diff --git a/.gitignore b/.gitignore index c9abc53..0d33b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,10 @@ docker-weewx.code-workspace /.vscode/ /log.txt /tmp/ +/public_html/ +/archive/ +/public_html/ +/dist/weewx-5.0.0/weewx.conf +/weewx.conf +/archive/weewx.sdb +/keys/ diff --git a/Dockerfile b/Dockerfile index d3df820..5c71599 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,73 @@ -FROM alpine:3.17.0 -MAINTAINER Tom Mitchell "tom@tom.org" +FROM debian:bookworm-slim +MAINTAINER Tom Mitchell "tom@tom.org" +ENV VERSION=5.1.0 +ENV TAG=v5.1.0 +ENV WEEWX_ROOT=/home/weewx/weewx-data ENV WEEWX_VERSION=4.10.0 ENV HOME=/home/weewx ENV TZ=America/New_York +ENV PATH=/usr/bin:$PATH + +# && apt-get install curl bash python3 python3-dev python3-pip python3-venv gcc libc-dev libffi-dev tzdata rsync openssh-client openssl git -y + +RUN apt-get update \ + && apt-get install wget unzip python3 python3-dev python3-pip python3-venv tzdata rsync openssh-client openssl git libffi-dev python3-setuptools libjpeg-dev -y +#RUN python3 -m pip install pip --upgrade \ +# && python3 -m pip install setuptools \ +# && python3 -m pip install cryptography \ +# && python3 -m pip install paho-mqtt -#wget http://www.weewx.com/downloads/released_versions/weewx-4.9.1.tar.gz -O /tmp/weewx.tgz \ -# && cd /tmp && tar zxvf /tmp/weewx*.tgz \ -# && cd weewx-* && python3 ./setup.py build && python3 ./setup.py install --no-prompt \ +RUN addgroup weewx \ + && useradd -m -g weewx weewx \ + && chown -R weewx:weewx /home/weewx \ + && chmod -R 755 /home/weewx -ADD dist/weewx-$WEEWX_VERSION /tmp/weewx/ -COPY conf-fragments/ /tmp/ -RUN apk add --update --no-cache --virtual deps gcc zlib-dev jpeg-dev python3-dev build-base linux-headers freetype-dev py3-pip alpine-conf \ - && apk add --no-cache python3 py3-pyserial py3-usb py3-pymysql sqlite wget rsync openssh tzdata \ - && ln -sf python3 /usr/bin/python \ - && pip3 install --no-cache --upgrade Cheetah3 Pillow image pyephem setuptools requests dnspython paho-mqtt configobj \ - && cd /tmp/weewx \ - && python3 ./setup.py build \ - && python3 ./setup.py install --no-prompt \ - && mkdir -p /var/log/weewx /tmp/weewx /home/weewx/public_html \ - && rm -rf /tmp/weewx \ - && apk del deps \ - && cat /tmp/*.conf >> /home/weewx/weewx.conf \ - && rm -rf /tmp/*.conf \ - && sed -i -e s:unspecified:Simulator: /home/weewx/weewx.conf -CMD ["/home/weewx/bin/weewxd", "/home/weewx/weewx.conf"] +USER weewx +RUN python3 -m venv /home/weewx/weewx-venv \ + && chmod -R 755 /home/weewx \ + && . /home/weewx/weewx-venv/bin/activate \ + && python3 -m pip install Pillow \ + && python3 -m pip install CT3 \ + && python3 -m pip install configobj \ + && python3 -m pip install paho-mqtt \ + # If your hardware uses a serial port + && python3 -m pip install pyserial \ + # If your hardware uses a USB port + && python3 -m pip install pyusb \ + # If you want extended celestial information: + && python3 -m pip install ephem \ + # If you use MySQL or Maria + && python3 -m pip install PyMySQL \ + # If you use sqlite + && python3 -m pip install db-sqlite3 -#wget http://www.weewx.com/downloads/released_versions/weewx-4.9.1.tar.gz -O /tmp/weewx.tgz \ -# && cd /tmp && tar zxvf /tmp/weewx*.tgz \ -# && cd weewx-* && python3 ./setup.py build && python3 ./setup.py install --no-prompt \ +RUN git clone https://github.com/weewx/weewx ~/weewx \ + && cd ~/weewx \ + && git checkout $TAG \ + && . /home/weewx/weewx-venv/bin/activate \ + && python3 ~/weewx/src/weectl.py station create --no-prompt +COPY conf-fragments/* /home/weewx/tmp/conf-fragments/ +RUN mkdir -p /home/weewx/tmp \ + && cat /home/weewx/tmp/conf-fragments/* >> /home/weewx/weewx-data/weewx.conf +## Belchertown extension +RUN cd /var/tmp \ + && . /home/weewx/weewx-venv/bin/activate \ + && wget https://github.com/poblabs/weewx-belchertown/releases/download/weewx-belchertown-1.3.1/weewx-belchertown-release.1.3.1.tar.gz \ + && tar zxvf weewx-belchertown-release.1.3.1.tar.gz \ + && cd weewx-belchertown-master \ + && python3 ~/weewx/src/weectl.py extension install -y . \ + && cd /var/tmp \ + && rm -rf weewx-belchertown-release.1.3.1.tar.gz weewx-belchertown-master \ +## MQTT extension + && wget -O weewx-mqtt.zip https://github.com/matthewwall/weewx-mqtt/archive/master.zip \ + && unzip weewx-mqtt.zip \ + && cd weewx-mqtt-master \ + && . /home/weewx/weewx-venv/bin/activate \ + && python3 ~/weewx/src/weectl.py extension install -y . \ + && cd /var/tmp \ + && rm -rf weewx-mqtt.zip weewx-mqtt-master +# +ADD ./bin/run.sh $WEEWX_ROOT/bin/run.sh +CMD $WEEWX_ROOT/bin/run.sh +WORKDIR $WEEWX_ROOT diff --git a/bin/run b/bin/run deleted file mode 100644 index 737c962..0000000 --- a/bin/run +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -HOME=/home/weewx -echo "using $CONF" - -# # TODO - check for existence of conf dir - exit(1) if not found - -# cp -rv $HOME/conf/$CONF/* /home/weewx/ - -# CONF_FILE=$HOME/weewx.conf - -cd $HOME - - -while true; do ./bin/weewxd $CONF_FILE > /dev/stdout; sleep 60; done diff --git a/bin/run.sh b/bin/run.sh new file mode 100755 index 0000000..4ddb272 --- /dev/null +++ b/bin/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh + +HOME=/home/weewx +WEEWX_ROOT=$HOME/weewx-data +CONF_FILE=$WEEWX_ROOT/weewx.conf + +echo "HOME=$HOME" +echo "using $CONF_FILE" +echo "weewx is in $WEEWX_ROOT" +echo "TZ=$TZ" +cd $WEEWX_ROOT + +while true; do + . /home/weewx/weewx-venv/bin/activate + python3 $HOME/weewx/src/weewxd.py $CONF_FILE > /dev/stdout + sleep 60 +done diff --git a/build.sh b/build.sh index e70f85f..56102c8 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash -VERSION=4.10.0-1 +REV=2 +WEEWX_VERSION=5.1.0 +IMAGE_VERSION=$WEEWX_VERSION-$REV +VERSION=5.1.0-2 #docker build --no-cache -t mitct02/weewx:$VERSION . -BUILDKIT_COLORS="run=123,20,245:error=yellow:cancel=blue:warning=white" docker buildx build --push --platform linux/amd64,linux/arm64,linux/arm/v7 -t mitct02/weewx:$VERSION . +BUILDKIT_COLORS="run=123,20,245:error=yellow:cancel=blue:warning=white" docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 -t mitct02/weewx:$IMAGE_VERSION . +#BUILDKIT_COLORS="run=123,20,245:error=yellow:cancel=blue:warning=white" docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 -t mitct02/weewx:$IMAGE_VERSION . +#docker pull mitct02/weewx:$IMAGE_VERSION diff --git a/conf-fragments/logging-stdout.conf b/conf-fragments/logging-stdout.conf index 4698665..47a0880 100644 --- a/conf-fragments/logging-stdout.conf +++ b/conf-fragments/logging-stdout.conf @@ -1,8 +1,4 @@ #----------------------- -# ref: https://github.com/weewx/weewx/wiki/WeeWX-v4-and-logging -# -# https://groups.google.com/g/weewx-user/c/rO01k9HYR8c/m/EGiwVTGVAQAJ -#----------------------- [Logging] version = 1 disable_existing_loggers = False @@ -14,16 +10,6 @@ [[loggers]] [[handlers]] - - # 10MB maxBytes - [[[rotate]]] - level = DEBUG - formatter = standard - class = logging.handlers.RotatingFileHandler - filename = /var/log/weewx.log - maxBytes = 10000000 - backupCount = 4 - # Log to console [[[console]]] level = INFO @@ -35,9 +21,9 @@ # How to format log messages [[formatters]] [[[simple]]] - format = "%(levelname)s %(message)s" + format = "%(asctime)s %(levelname)s %(message)s" [[[standard]]] - format = "{process_name}[%(process)d] %(levelname)s %(name)s: %(message)s" + format = "%(asctime)s {process_name}[%(process)d] %(levelname)s %(name)s: %(message)s" [[[verbose]]] format = "%(asctime)s {process_name}[%(process)d] %(levelname)s %(name)s: %(message)s" # Format to use for dates and times: diff --git a/dist/weewx-4.10.1/LICENSE.txt b/dist/weewx-4.10.1/LICENSE.txt new file mode 100644 index 0000000..94a0453 --- /dev/null +++ b/dist/weewx-4.10.1/LICENSE.txt @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/dist/weewx-4.10.1/PKG-INFO b/dist/weewx-4.10.1/PKG-INFO new file mode 100644 index 0000000..c9af98d --- /dev/null +++ b/dist/weewx-4.10.1/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: weewx +Version: 4.10.1 +Summary: The WeeWX weather software system +Home-page: http://www.weewx.com +Author: Tom Keffer +Author-email: tkeffer@gmail.com +License: GPLv3 +Description: WeeWX interacts with a weather station to produce graphs, reports, and HTML pages. WeeWX can upload data to services such as the WeatherUnderground, PWSweather.com, or CWOP. +Platform: UNKNOWN diff --git a/dist/weewx-4.10.1/README b/dist/weewx-4.10.1/README new file mode 100644 index 0000000..c650502 --- /dev/null +++ b/dist/weewx-4.10.1/README @@ -0,0 +1,9 @@ +weewx communicates with a weather station to produce graphs, reports, and HTML +pages. weewx can publish data to weather services such as WeatherUnderground, +PWSweather.com, or CWOP. + +weewx is licensed under the GNU Public License v3. + +Documentation: docs/readme.htm or http://weewx.com/docs.html +Wiki: https://github.com/weewx/weewx/wiki +Community support: https://groups.google.com/group/weewx-user diff --git a/dist/weewx-4.10.1/README.md b/dist/weewx-4.10.1/README.md new file mode 100644 index 0000000..b85c248 --- /dev/null +++ b/dist/weewx-4.10.1/README.md @@ -0,0 +1,107 @@ +# [WeeWX](http://www.weewx.com) +*Open source software for your weather station* + +## Description + +The WeeWX weather system is written in Python and runs on Linux, MacOSX, +Solaris, and *BSD. It runs exceptionally well on a Raspberry Pi. It generates +plots, HTML pages, and monthly and yearly summary reports, which can be +uploaded to a web server. Thousands of users worldwide! + +See the WeeWX website for [examples](http://weewx.com/showcase.html) of web +sites generated by WeeWX, and a [map](http://weewx.com/stations.html) of +stations using WeeWX. + +* Robust and hard-to-crash +* Designed with the enthusiast in mind +* Simple, easy to understand internal design that is easily extended (Python skills recommended) +* Python 2 or Python 3 +* Growing ecosystem of 3rd party extensions +* Internationalized language support +* Localized date/time support +* Support for US and metric units +* Support for multiple skins +* Support sqlite and MySQL +* Extensive almanac information +* Uploads to your website via FTP, FTPS, or rsync +* Uploads to online weather services + +Support for many online weather services, including: + +* The Weather Underground +* CWOP +* PWSweather +* WOW +* AWEKAS +* Open Weathermap +* WeatherBug +* Weather Cloud +* Wetter +* Windfinder + +Support for many data aggregation services, including: + +* EmonCMS +* Graphite +* InfluxDB +* MQTT +* Smart Energy Groups +* Thingspeak +* Twitter +* Xively + +Support for over 70 types of hardware including, but not limited to: + +* Davis Vantage Pro, Pro2, Vue, Envoy; +* Oregon Scientific WMR100, WMR300, WMR9x8, and other variants; +* Oregon Scientific LW300/LW301/LW302; +* Fine Offset WH10xx, WH20xx, and WH30xx series (including Ambient, Elecsa, Maplin, Tycon, Watson, and others); +* Fine Offset WH23xx, WH4000 (including Tycon TP2700, MiSol WH2310); +* Fine Offset WH2600, HP1000 (including Ambient Observer, Aercus WeatherSleuth, XC0422); +* LaCrosse WS-23XX and WS-28XX (including TFA); +* LaCrosse GW1000U bridge; +* Hideki TE923, TE831, TE838, DV928 (including TFA, Cresta, Honeywell, and others); +* PeetBros Ultimeter; +* RainWise CC3000 and MKIII; +* AcuRite 5-in-1 via USB console or bridge; +* Argent Data Systems WS1; +* KlimaLogg Pro; +* New Mountain; +* AirMar 150WX; +* Texas Weather Instruments; +* Dyacon; +* Meteostick; +* Ventus W820; +* Si1000 radio receiver; +* Software Defined Radio (SDR); +* One-wire (including Inspeed, ADS, AAG, Hobby-Boards). + +See the [hardware list](http://www.weewx.com/hardware.html) for a complete list +of supported stations, and for pictures to help identify your hardware! The +[hardware comparison](http://www.weewx.com/hwcmp.html) shows specifications for +many different types of hardware, including some not yet supported by WeeWX. + +## Downloads + +For current and previous releases: + +[http://weewx.com/downloads](http://weewx.com/downloads) + +## Documentation and Support + +Guides for installation, upgrading, and customization are in `docs/readme.htm` +or at: + +[http://weewx.com/docs.html](http://weewx.com/docs.html) + +The wiki includes user-contributed extensions and suggestions at: + +[https://github.com/weewx/weewx/wiki](https://github.com/weewx/weewx/wiki) + +Community support can be found at: + +[https://groups.google.com/group/weewx-user](https://groups.google.com/group/weewx-user) + +

Licensing

+ +WeeWX is licensed under the GNU Public License v3. diff --git a/dist/weewx-4.10.1/bin/daemon.py b/dist/weewx-4.10.1/bin/daemon.py new file mode 100644 index 0000000..4ffe63b --- /dev/null +++ b/dist/weewx-4.10.1/bin/daemon.py @@ -0,0 +1,82 @@ +# -*- coding: iso-8859-1 -*- +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +''' + This module is used to fork the current process into a daemon. + Almost none of this is necessary (or advisable) if your daemon + is being started by inetd. In that case, stdin, stdout and stderr are + all set up for you to refer to the network connection, and the fork()s + and session manipulation should not be done (to avoid confusing inetd). + Only the chdir() and umask() steps remain as useful. + References: + UNIX Programming FAQ + 1.7 How do I get my program to act like a daemon? + http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 + Advanced Programming in the Unix Environment + W. Richard Stevens, 1992, Addison-Wesley, ISBN 0-201-56317-7. + + History: + 2001/07/10 by Jürgen Hermann + 2002/08/28 by Noah Spurrier + 2003/02/24 by Clark Evans + + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012 +''' +import sys, os + +done = False + +def daemonize(stdout='/dev/null', stderr=None, stdin='/dev/null', + pidfile=None, startmsg = 'started with pid %s' ): + ''' + This forks the current process into a daemon. + The stdin, stdout, and stderr arguments are file names that + will be opened and be used to replace the standard file descriptors + in sys.stdin, sys.stdout, and sys.stderr. + These arguments are optional and default to /dev/null. + Note that stderr is opened unbuffered, so + if it shares a file with stdout then interleaved output + may not appear in the order that you expect. + ''' + global done + # Don't proceed if we have already daemonized. + if done: + return + # Do first fork. + try: + pid = os.fork() + if pid > 0: sys.exit(0) # Exit first parent. + except OSError as e: + sys.stderr.write("fork #1 failed: (%d) %s\n" % (e.errno, e.strerror)) + sys.exit(1) + + # Decouple from parent environment. + os.chdir("/") + os.umask(0o022) + os.setsid() + + # Do second fork. + try: + pid = os.fork() + if pid > 0: sys.exit(0) # Exit second parent. + except OSError as e: + sys.stderr.write("fork #2 failed: (%d) %s\n" % (e.errno, e.strerror)) + sys.exit(1) + + # Open file descriptors and print start message + if not stderr: stderr = stdout + si = open(stdin, 'r') + so = open(stdout, 'a+') + se = open(stderr, 'a+') + pid = str(os.getpid()) +# sys.stderr.write("\n%s\n" % startmsg % pid) +# sys.stderr.flush() + if pidfile: open(pidfile,'w+').write("%s\n" % pid) + # Redirect standard file descriptors. + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + done = True diff --git a/dist/weewx-4.10.1/bin/schemas/__init__.py b/dist/weewx-4.10.1/bin/schemas/__init__.py new file mode 100644 index 0000000..dc35849 --- /dev/null +++ b/dist/weewx-4.10.1/bin/schemas/__init__.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +""" +Package of schemas used by weewx. + +This package consists of a set of modules, each containing a schema. +""" diff --git a/dist/weewx-4.10.1/bin/schemas/wview.py b/dist/weewx-4.10.1/bin/schemas/wview.py new file mode 100644 index 0000000..89d0796 --- /dev/null +++ b/dist/weewx-4.10.1/bin/schemas/wview.py @@ -0,0 +1,75 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""The wview schema, which is also used by weewx.""" + +# ============================================================================= +# This is original schema for both weewx and wview, expressed using Python. +# It is only used for initialization --- afterwards, the schema is obtained +# dynamically from the database. +# +# Although a type may be listed here, it may not necessarily be supported by +# your weather station hardware. +# +# You may trim this list of any unused types if you wish, but it may not +# result in saving as much space as you might think --- most of the space is +# taken up by the primary key indexes (type "dateTime"). +# ============================================================================= +# NB: This schema is specified using the WeeWX V3 "old-style" schema. Starting +# with V4, a new style was added, which allows schema for the daily summaries +# to be expressed explicitly. +# ============================================================================= +schema = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('barometer', 'REAL'), + ('pressure', 'REAL'), + ('altimeter', 'REAL'), + ('inTemp', 'REAL'), + ('outTemp', 'REAL'), + ('inHumidity', 'REAL'), + ('outHumidity', 'REAL'), + ('windSpeed', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('rainRate', 'REAL'), + ('rain', 'REAL'), + ('dewpoint', 'REAL'), + ('windchill', 'REAL'), + ('heatindex', 'REAL'), + ('ET', 'REAL'), + ('radiation', 'REAL'), + ('UV', 'REAL'), + ('extraTemp1', 'REAL'), + ('extraTemp2', 'REAL'), + ('extraTemp3', 'REAL'), + ('soilTemp1', 'REAL'), + ('soilTemp2', 'REAL'), + ('soilTemp3', 'REAL'), + ('soilTemp4', 'REAL'), + ('leafTemp1', 'REAL'), + ('leafTemp2', 'REAL'), + ('extraHumid1', 'REAL'), + ('extraHumid2', 'REAL'), + ('soilMoist1', 'REAL'), + ('soilMoist2', 'REAL'), + ('soilMoist3', 'REAL'), + ('soilMoist4', 'REAL'), + ('leafWet1', 'REAL'), + ('leafWet2', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('txBatteryStatus', 'REAL'), + ('consBatteryVoltage', 'REAL'), + ('hail', 'REAL'), + ('hailRate', 'REAL'), + ('heatingTemp', 'REAL'), + ('heatingVoltage', 'REAL'), + ('supplyVoltage', 'REAL'), + ('referenceVoltage', 'REAL'), + ('windBatteryStatus', 'REAL'), + ('rainBatteryStatus', 'REAL'), + ('outTempBatteryStatus', 'REAL'), + ('inTempBatteryStatus', 'REAL')] diff --git a/dist/weewx-4.10.1/bin/schemas/wview_extended.py b/dist/weewx-4.10.1/bin/schemas/wview_extended.py new file mode 100644 index 0000000..20c3489 --- /dev/null +++ b/dist/weewx-4.10.1/bin/schemas/wview_extended.py @@ -0,0 +1,138 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""The extended wview schema.""" + +# ============================================================================= +# This is a list containing the default schema of the archive database. It is +# only used for initialization --- afterwards, the schema is obtained +# dynamically from the database. Although a type may be listed here, it may +# not necessarily be supported by your weather station hardware. +# ============================================================================= +# NB: This schema is specified using the WeeWX V4 "new-style" schema. +# ============================================================================= +table = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('altimeter', 'REAL'), + ('appTemp', 'REAL'), + ('appTemp1', 'REAL'), + ('barometer', 'REAL'), + ('batteryStatus1', 'REAL'), + ('batteryStatus2', 'REAL'), + ('batteryStatus3', 'REAL'), + ('batteryStatus4', 'REAL'), + ('batteryStatus5', 'REAL'), + ('batteryStatus6', 'REAL'), + ('batteryStatus7', 'REAL'), + ('batteryStatus8', 'REAL'), + ('cloudbase', 'REAL'), + ('co', 'REAL'), + ('co2', 'REAL'), + ('consBatteryVoltage', 'REAL'), + ('dewpoint', 'REAL'), + ('dewpoint1', 'REAL'), + ('ET', 'REAL'), + ('extraHumid1', 'REAL'), + ('extraHumid2', 'REAL'), + ('extraHumid3', 'REAL'), + ('extraHumid4', 'REAL'), + ('extraHumid5', 'REAL'), + ('extraHumid6', 'REAL'), + ('extraHumid7', 'REAL'), + ('extraHumid8', 'REAL'), + ('extraTemp1', 'REAL'), + ('extraTemp2', 'REAL'), + ('extraTemp3', 'REAL'), + ('extraTemp4', 'REAL'), + ('extraTemp5', 'REAL'), + ('extraTemp6', 'REAL'), + ('extraTemp7', 'REAL'), + ('extraTemp8', 'REAL'), + ('forecast', 'REAL'), + ('hail', 'REAL'), + ('hailBatteryStatus', 'REAL'), + ('hailRate', 'REAL'), + ('heatindex', 'REAL'), + ('heatindex1', 'REAL'), + ('heatingTemp', 'REAL'), + ('heatingVoltage', 'REAL'), + ('humidex', 'REAL'), + ('humidex1', 'REAL'), + ('inDewpoint', 'REAL'), + ('inHumidity', 'REAL'), + ('inTemp', 'REAL'), + ('inTempBatteryStatus', 'REAL'), + ('leafTemp1', 'REAL'), + ('leafTemp2', 'REAL'), + ('leafWet1', 'REAL'), + ('leafWet2', 'REAL'), + ('lightning_distance', 'REAL'), + ('lightning_disturber_count', 'REAL'), + ('lightning_energy', 'REAL'), + ('lightning_noise_count', 'REAL'), + ('lightning_strike_count', 'REAL'), + ('luminosity', 'REAL'), + ('maxSolarRad', 'REAL'), + ('nh3', 'REAL'), + ('no2', 'REAL'), + ('noise', 'REAL'), + ('o3', 'REAL'), + ('outHumidity', 'REAL'), + ('outTemp', 'REAL'), + ('outTempBatteryStatus', 'REAL'), + ('pb', 'REAL'), + ('pm10_0', 'REAL'), + ('pm1_0', 'REAL'), + ('pm2_5', 'REAL'), + ('pressure', 'REAL'), + ('radiation', 'REAL'), + ('rain', 'REAL'), + ('rainBatteryStatus', 'REAL'), + ('rainRate', 'REAL'), + ('referenceVoltage', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('signal1', 'REAL'), + ('signal2', 'REAL'), + ('signal3', 'REAL'), + ('signal4', 'REAL'), + ('signal5', 'REAL'), + ('signal6', 'REAL'), + ('signal7', 'REAL'), + ('signal8', 'REAL'), + ('snow', 'REAL'), + ('snowBatteryStatus', 'REAL'), + ('snowDepth', 'REAL'), + ('snowMoisture', 'REAL'), + ('snowRate', 'REAL'), + ('so2', 'REAL'), + ('soilMoist1', 'REAL'), + ('soilMoist2', 'REAL'), + ('soilMoist3', 'REAL'), + ('soilMoist4', 'REAL'), + ('soilTemp1', 'REAL'), + ('soilTemp2', 'REAL'), + ('soilTemp3', 'REAL'), + ('soilTemp4', 'REAL'), + ('supplyVoltage', 'REAL'), + ('txBatteryStatus', 'REAL'), + ('UV', 'REAL'), + ('uvBatteryStatus', 'REAL'), + ('windBatteryStatus', 'REAL'), + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windrun', 'REAL'), + ('windSpeed', 'REAL'), + ] + +day_summaries = [(e[0], 'scalar') for e in table + if e[0] not in ('dateTime', 'usUnits', 'interval')] + [('wind', 'VECTOR')] + +schema = { + 'table': table, + 'day_summaries' : day_summaries +} diff --git a/dist/weewx-4.10.1/bin/schemas/wview_small.py b/dist/weewx-4.10.1/bin/schemas/wview_small.py new file mode 100644 index 0000000..7ae21f7 --- /dev/null +++ b/dist/weewx-4.10.1/bin/schemas/wview_small.py @@ -0,0 +1,48 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""A very small wview schema.""" + +# ============================================================================= +# This is a very severely restricted schema that includes only the basics. It +# is useful for testing, and for very small installations. Like other WeeWX +# schemas, it is used only for initialization --- afterwards, the schema is obtained +# dynamically from the database. Although a type may be listed here, it may +# not necessarily be supported by your weather station hardware. +# ============================================================================= +# NB: This schema is specified using the WeeWX V4 "new-style" schema. +# ============================================================================= +table = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('altimeter', 'REAL'), + ('barometer', 'REAL'), + ('dewpoint', 'REAL'), + ('ET', 'REAL'), + ('heatindex', 'REAL'), + ('inHumidity', 'REAL'), + ('inTemp', 'REAL'), + ('outHumidity', 'REAL'), + ('outTemp', 'REAL'), + ('pressure', 'REAL'), + ('radiation', 'REAL'), + ('rain', 'REAL'), + ('rainRate', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('UV', 'REAL'), + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windSpeed', 'REAL'), + ] + +day_summaries = [(e[0], 'scalar') for e in table + if e[0] not in ('dateTime', 'usUnits', 'interval')] + [('wind', 'VECTOR')] + +schema = { + 'table': table, + 'day_summaries' : day_summaries +} diff --git a/dist/weewx-4.10.1/bin/six.py b/dist/weewx-4.10.1/bin/six.py new file mode 100644 index 0000000..d162d09 --- /dev/null +++ b/dist/weewx-4.10.1/bin/six.py @@ -0,0 +1,988 @@ +# Copyright (c) 2010-2020 Benjamin Peterson +# +# 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. + +"""Utilities for writing code that runs on Python 2 and 3""" + +from __future__ import absolute_import + +import functools +import itertools +import operator +import sys +import types + +__author__ = "Benjamin Peterson " +__version__ = "1.15.0" + + +# Useful for very coarse version differentiation. +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 +PY34 = sys.version_info[0:2] >= (3, 4) + +if PY3: + string_types = str, + integer_types = int, + class_types = type, + text_type = str + binary_type = bytes + + MAXSIZE = sys.maxsize +else: + string_types = basestring, + integer_types = (int, long) + class_types = (type, types.ClassType) + text_type = unicode + binary_type = str + + if sys.platform.startswith("java"): + # Jython always uses 32 bits. + MAXSIZE = int((1 << 31) - 1) + else: + # It's possible to have sizeof(long) != sizeof(Py_ssize_t). + class X(object): + + def __len__(self): + return 1 << 31 + try: + len(X()) + except OverflowError: + # 32-bit + MAXSIZE = int((1 << 31) - 1) + else: + # 64-bit + MAXSIZE = int((1 << 63) - 1) + del X + + +def _add_doc(func, doc): + """Add documentation to a function.""" + func.__doc__ = doc + + +def _import_module(name): + """Import module, returning the module after the last dot.""" + __import__(name) + return sys.modules[name] + + +class _LazyDescr(object): + + def __init__(self, name): + self.name = name + + def __get__(self, obj, tp): + result = self._resolve() + setattr(obj, self.name, result) # Invokes __set__. + try: + # This is a bit ugly, but it avoids running this again by + # removing this descriptor. + delattr(obj.__class__, self.name) + except AttributeError: + pass + return result + + +class MovedModule(_LazyDescr): + + def __init__(self, name, old, new=None): + super(MovedModule, self).__init__(name) + if PY3: + if new is None: + new = name + self.mod = new + else: + self.mod = old + + def _resolve(self): + return _import_module(self.mod) + + def __getattr__(self, attr): + _module = self._resolve() + value = getattr(_module, attr) + setattr(self, attr, value) + return value + + +class _LazyModule(types.ModuleType): + + def __init__(self, name): + super(_LazyModule, self).__init__(name) + self.__doc__ = self.__class__.__doc__ + + def __dir__(self): + attrs = ["__doc__", "__name__"] + attrs += [attr.name for attr in self._moved_attributes] + return attrs + + # Subclasses should override this + _moved_attributes = [] + + +class MovedAttribute(_LazyDescr): + + def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): + super(MovedAttribute, self).__init__(name) + if PY3: + if new_mod is None: + new_mod = name + self.mod = new_mod + if new_attr is None: + if old_attr is None: + new_attr = name + else: + new_attr = old_attr + self.attr = new_attr + else: + self.mod = old_mod + if old_attr is None: + old_attr = name + self.attr = old_attr + + def _resolve(self): + module = _import_module(self.mod) + return getattr(module, self.attr) + + +class _SixMetaPathImporter(object): + + """ + A meta path importer to import six.moves and its submodules. + + This class implements a PEP302 finder and loader. It should be compatible + with Python 2.5 and all existing versions of Python3 + """ + + def __init__(self, six_module_name): + self.name = six_module_name + self.known_modules = {} + + def _add_module(self, mod, *fullnames): + for fullname in fullnames: + self.known_modules[self.name + "." + fullname] = mod + + def _get_module(self, fullname): + return self.known_modules[self.name + "." + fullname] + + def find_module(self, fullname, path=None): + if fullname in self.known_modules: + return self + return None + + def __get_module(self, fullname): + try: + return self.known_modules[fullname] + except KeyError: + raise ImportError("This loader does not know module " + fullname) + + def load_module(self, fullname): + try: + # in case of a reload + return sys.modules[fullname] + except KeyError: + pass + mod = self.__get_module(fullname) + if isinstance(mod, MovedModule): + mod = mod._resolve() + else: + mod.__loader__ = self + sys.modules[fullname] = mod + return mod + + def is_package(self, fullname): + """ + Return true, if the named module is a package. + + We need this method to get correct spec objects with + Python 3.4 (see PEP451) + """ + return hasattr(self.__get_module(fullname), "__path__") + + def get_code(self, fullname): + """Return None + + Required, if is_package is implemented""" + self.__get_module(fullname) # eventually raises ImportError + return None + get_source = get_code # same as get_code + + def create_module(self, spec): + return self.load_module(spec.name) + + def exec_module(self, module): + pass + +_importer = _SixMetaPathImporter(__name__) + + +class _MovedItems(_LazyModule): + + """Lazy loading of moved objects""" + __path__ = [] # mark as package + + +_moved_attributes = [ + MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), + MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), + MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"), + MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), + MovedAttribute("intern", "__builtin__", "sys"), + MovedAttribute("map", "itertools", "builtins", "imap", "map"), + MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"), + MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"), + MovedAttribute("getoutput", "commands", "subprocess"), + MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"), + MovedAttribute("reduce", "__builtin__", "functools"), + MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), + MovedAttribute("StringIO", "StringIO", "io"), + MovedAttribute("UserDict", "UserDict", "collections"), + MovedAttribute("UserList", "UserList", "collections"), + MovedAttribute("UserString", "UserString", "collections"), + MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), + MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"), + MovedModule("builtins", "__builtin__"), + MovedModule("configparser", "ConfigParser"), + MovedModule("collections_abc", "collections", "collections.abc" if sys.version_info >= (3, 3) else "collections"), + MovedModule("copyreg", "copy_reg"), + MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), + MovedModule("dbm_ndbm", "dbm", "dbm.ndbm"), + MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread" if sys.version_info < (3, 9) else "_thread"), + MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), + MovedModule("http_cookies", "Cookie", "http.cookies"), + MovedModule("html_entities", "htmlentitydefs", "html.entities"), + MovedModule("html_parser", "HTMLParser", "html.parser"), + MovedModule("http_client", "httplib", "http.client"), + MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), + MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"), + MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), + MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"), + MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), + MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), + MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), + MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), + MovedModule("cPickle", "cPickle", "pickle"), + MovedModule("queue", "Queue"), + MovedModule("reprlib", "repr"), + MovedModule("socketserver", "SocketServer"), + MovedModule("_thread", "thread", "_thread"), + MovedModule("tkinter", "Tkinter"), + MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), + MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), + MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), + MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), + MovedModule("tkinter_tix", "Tix", "tkinter.tix"), + MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), + MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), + MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), + MovedModule("tkinter_colorchooser", "tkColorChooser", + "tkinter.colorchooser"), + MovedModule("tkinter_commondialog", "tkCommonDialog", + "tkinter.commondialog"), + MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), + MovedModule("tkinter_font", "tkFont", "tkinter.font"), + MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), + MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", + "tkinter.simpledialog"), + MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), + MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), + MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), + MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), + MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), + MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), +] +# Add windows specific modules. +if sys.platform == "win32": + _moved_attributes += [ + MovedModule("winreg", "_winreg"), + ] + +for attr in _moved_attributes: + setattr(_MovedItems, attr.name, attr) + if isinstance(attr, MovedModule): + _importer._add_module(attr, "moves." + attr.name) +del attr + +_MovedItems._moved_attributes = _moved_attributes + +moves = _MovedItems(__name__ + ".moves") +_importer._add_module(moves, "moves") + + +class Module_six_moves_urllib_parse(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_parse""" + + +_urllib_parse_moved_attributes = [ + MovedAttribute("ParseResult", "urlparse", "urllib.parse"), + MovedAttribute("SplitResult", "urlparse", "urllib.parse"), + MovedAttribute("parse_qs", "urlparse", "urllib.parse"), + MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), + MovedAttribute("urldefrag", "urlparse", "urllib.parse"), + MovedAttribute("urljoin", "urlparse", "urllib.parse"), + MovedAttribute("urlparse", "urlparse", "urllib.parse"), + MovedAttribute("urlsplit", "urlparse", "urllib.parse"), + MovedAttribute("urlunparse", "urlparse", "urllib.parse"), + MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), + MovedAttribute("quote", "urllib", "urllib.parse"), + MovedAttribute("quote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote", "urllib", "urllib.parse"), + MovedAttribute("unquote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes"), + MovedAttribute("urlencode", "urllib", "urllib.parse"), + MovedAttribute("splitquery", "urllib", "urllib.parse"), + MovedAttribute("splittag", "urllib", "urllib.parse"), + MovedAttribute("splituser", "urllib", "urllib.parse"), + MovedAttribute("splitvalue", "urllib", "urllib.parse"), + MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), + MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), + MovedAttribute("uses_params", "urlparse", "urllib.parse"), + MovedAttribute("uses_query", "urlparse", "urllib.parse"), + MovedAttribute("uses_relative", "urlparse", "urllib.parse"), +] +for attr in _urllib_parse_moved_attributes: + setattr(Module_six_moves_urllib_parse, attr.name, attr) +del attr + +Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes + +_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), + "moves.urllib_parse", "moves.urllib.parse") + + +class Module_six_moves_urllib_error(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_error""" + + +_urllib_error_moved_attributes = [ + MovedAttribute("URLError", "urllib2", "urllib.error"), + MovedAttribute("HTTPError", "urllib2", "urllib.error"), + MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), +] +for attr in _urllib_error_moved_attributes: + setattr(Module_six_moves_urllib_error, attr.name, attr) +del attr + +Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes + +_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), + "moves.urllib_error", "moves.urllib.error") + + +class Module_six_moves_urllib_request(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_request""" + + +_urllib_request_moved_attributes = [ + MovedAttribute("urlopen", "urllib2", "urllib.request"), + MovedAttribute("install_opener", "urllib2", "urllib.request"), + MovedAttribute("build_opener", "urllib2", "urllib.request"), + MovedAttribute("pathname2url", "urllib", "urllib.request"), + MovedAttribute("url2pathname", "urllib", "urllib.request"), + MovedAttribute("getproxies", "urllib", "urllib.request"), + MovedAttribute("Request", "urllib2", "urllib.request"), + MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), + MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), + MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), + MovedAttribute("BaseHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), + MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), + MovedAttribute("FileHandler", "urllib2", "urllib.request"), + MovedAttribute("FTPHandler", "urllib2", "urllib.request"), + MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), + MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), + MovedAttribute("urlretrieve", "urllib", "urllib.request"), + MovedAttribute("urlcleanup", "urllib", "urllib.request"), + MovedAttribute("URLopener", "urllib", "urllib.request"), + MovedAttribute("FancyURLopener", "urllib", "urllib.request"), + MovedAttribute("proxy_bypass", "urllib", "urllib.request"), + MovedAttribute("parse_http_list", "urllib2", "urllib.request"), + MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"), +] +for attr in _urllib_request_moved_attributes: + setattr(Module_six_moves_urllib_request, attr.name, attr) +del attr + +Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes + +_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), + "moves.urllib_request", "moves.urllib.request") + + +class Module_six_moves_urllib_response(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_response""" + + +_urllib_response_moved_attributes = [ + MovedAttribute("addbase", "urllib", "urllib.response"), + MovedAttribute("addclosehook", "urllib", "urllib.response"), + MovedAttribute("addinfo", "urllib", "urllib.response"), + MovedAttribute("addinfourl", "urllib", "urllib.response"), +] +for attr in _urllib_response_moved_attributes: + setattr(Module_six_moves_urllib_response, attr.name, attr) +del attr + +Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes + +_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), + "moves.urllib_response", "moves.urllib.response") + + +class Module_six_moves_urllib_robotparser(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_robotparser""" + + +_urllib_robotparser_moved_attributes = [ + MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), +] +for attr in _urllib_robotparser_moved_attributes: + setattr(Module_six_moves_urllib_robotparser, attr.name, attr) +del attr + +Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes + +_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), + "moves.urllib_robotparser", "moves.urllib.robotparser") + + +class Module_six_moves_urllib(types.ModuleType): + + """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" + __path__ = [] # mark as package + parse = _importer._get_module("moves.urllib_parse") + error = _importer._get_module("moves.urllib_error") + request = _importer._get_module("moves.urllib_request") + response = _importer._get_module("moves.urllib_response") + robotparser = _importer._get_module("moves.urllib_robotparser") + + def __dir__(self): + return ['parse', 'error', 'request', 'response', 'robotparser'] + +_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"), + "moves.urllib") + + +def add_move(move): + """Add an item to six.moves.""" + setattr(_MovedItems, move.name, move) + + +def remove_move(name): + """Remove item from six.moves.""" + try: + delattr(_MovedItems, name) + except AttributeError: + try: + del moves.__dict__[name] + except KeyError: + raise AttributeError("no such move, %r" % (name,)) + + +if PY3: + _meth_func = "__func__" + _meth_self = "__self__" + + _func_closure = "__closure__" + _func_code = "__code__" + _func_defaults = "__defaults__" + _func_globals = "__globals__" +else: + _meth_func = "im_func" + _meth_self = "im_self" + + _func_closure = "func_closure" + _func_code = "func_code" + _func_defaults = "func_defaults" + _func_globals = "func_globals" + + +try: + advance_iterator = next +except NameError: + def advance_iterator(it): + return it.next() +next = advance_iterator + + +try: + callable = callable +except NameError: + def callable(obj): + return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) + + +if PY3: + def get_unbound_function(unbound): + return unbound + + create_bound_method = types.MethodType + + def create_unbound_method(func, cls): + return func + + Iterator = object +else: + def get_unbound_function(unbound): + return unbound.im_func + + def create_bound_method(func, obj): + return types.MethodType(func, obj, obj.__class__) + + def create_unbound_method(func, cls): + return types.MethodType(func, None, cls) + + class Iterator(object): + + def next(self): + return type(self).__next__(self) + + callable = callable +_add_doc(get_unbound_function, + """Get the function out of a possibly unbound function""") + + +get_method_function = operator.attrgetter(_meth_func) +get_method_self = operator.attrgetter(_meth_self) +get_function_closure = operator.attrgetter(_func_closure) +get_function_code = operator.attrgetter(_func_code) +get_function_defaults = operator.attrgetter(_func_defaults) +get_function_globals = operator.attrgetter(_func_globals) + + +if PY3: + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") +else: + def iterkeys(d, **kw): + return d.iterkeys(**kw) + + def itervalues(d, **kw): + return d.itervalues(**kw) + + def iteritems(d, **kw): + return d.iteritems(**kw) + + def iterlists(d, **kw): + return d.iterlists(**kw) + + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + +_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") +_add_doc(itervalues, "Return an iterator over the values of a dictionary.") +_add_doc(iteritems, + "Return an iterator over the (key, value) pairs of a dictionary.") +_add_doc(iterlists, + "Return an iterator over the (key, [values]) pairs of a dictionary.") + + +if PY3: + def b(s): + return s.encode("latin-1") + + def u(s): + return s + unichr = chr + import struct + int2byte = struct.Struct(">B").pack + del struct + byte2int = operator.itemgetter(0) + indexbytes = operator.getitem + iterbytes = iter + import io + StringIO = io.StringIO + BytesIO = io.BytesIO + del io + _assertCountEqual = "assertCountEqual" + if sys.version_info[1] <= 1: + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" + else: + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" + _assertNotRegex = "assertNotRegex" +else: + def b(s): + return s + # Workaround for standalone backslash + + def u(s): + return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape") + unichr = unichr + int2byte = chr + + def byte2int(bs): + return ord(bs[0]) + + def indexbytes(buf, i): + return ord(buf[i]) + iterbytes = functools.partial(itertools.imap, ord) + import StringIO + StringIO = BytesIO = StringIO.StringIO + _assertCountEqual = "assertItemsEqual" + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" +_add_doc(b, """Byte literal""") +_add_doc(u, """Text literal""") + + +def assertCountEqual(self, *args, **kwargs): + return getattr(self, _assertCountEqual)(*args, **kwargs) + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + + +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) + + +def assertNotRegex(self, *args, **kwargs): + return getattr(self, _assertNotRegex)(*args, **kwargs) + + +if PY3: + exec_ = getattr(moves.builtins, "exec") + + def reraise(tp, value, tb=None): + try: + if value is None: + value = tp() + if value.__traceback__ is not tb: + raise value.with_traceback(tb) + raise value + finally: + value = None + tb = None + +else: + def exec_(_code_, _globs_=None, _locs_=None): + """Execute code in a namespace.""" + if _globs_ is None: + frame = sys._getframe(1) + _globs_ = frame.f_globals + if _locs_ is None: + _locs_ = frame.f_locals + del frame + elif _locs_ is None: + _locs_ = _globs_ + exec("""exec _code_ in _globs_, _locs_""") + + exec_("""def reraise(tp, value, tb=None): + try: + raise tp, value, tb + finally: + tb = None +""") + + +if sys.version_info[:2] > (3,): + exec_("""def raise_from(value, from_value): + try: + raise value from from_value + finally: + value = None +""") +else: + def raise_from(value, from_value): + raise value + + +print_ = getattr(moves.builtins, "print", None) +if print_ is None: + def print_(*args, **kwargs): + """The new-style print function for Python 2.4 and 2.5.""" + fp = kwargs.pop("file", sys.stdout) + if fp is None: + return + + def write(data): + if not isinstance(data, basestring): + data = str(data) + # If the file has an encoding, encode unicode with it. + if (isinstance(fp, file) and + isinstance(data, unicode) and + fp.encoding is not None): + errors = getattr(fp, "errors", None) + if errors is None: + errors = "strict" + data = data.encode(fp.encoding, errors) + fp.write(data) + want_unicode = False + sep = kwargs.pop("sep", None) + if sep is not None: + if isinstance(sep, unicode): + want_unicode = True + elif not isinstance(sep, str): + raise TypeError("sep must be None or a string") + end = kwargs.pop("end", None) + if end is not None: + if isinstance(end, unicode): + want_unicode = True + elif not isinstance(end, str): + raise TypeError("end must be None or a string") + if kwargs: + raise TypeError("invalid keyword arguments to print()") + if not want_unicode: + for arg in args: + if isinstance(arg, unicode): + want_unicode = True + break + if want_unicode: + newline = unicode("\n") + space = unicode(" ") + else: + newline = "\n" + space = " " + if sep is None: + sep = space + if end is None: + end = newline + for i, arg in enumerate(args): + if i: + write(sep) + write(arg) + write(end) +if sys.version_info[:2] < (3, 3): + _print = print_ + + def print_(*args, **kwargs): + fp = kwargs.get("file", sys.stdout) + flush = kwargs.pop("flush", False) + _print(*args, **kwargs) + if flush and fp is not None: + fp.flush() + +_add_doc(reraise, """Reraise an exception.""") + +if sys.version_info[0:2] < (3, 4): + # This does exactly the same what the :func:`py3:functools.update_wrapper` + # function does on Python versions after 3.2. It sets the ``__wrapped__`` + # attribute on ``wrapper`` object and it doesn't raise an error if any of + # the attributes mentioned in ``assigned`` and ``updated`` are missing on + # ``wrapped`` object. + def _update_wrapper(wrapper, wrapped, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES): + for attr in assigned: + try: + value = getattr(wrapped, attr) + except AttributeError: + continue + else: + setattr(wrapper, attr, value) + for attr in updated: + getattr(wrapper, attr).update(getattr(wrapped, attr, {})) + wrapper.__wrapped__ = wrapped + return wrapper + _update_wrapper.__doc__ = functools.update_wrapper.__doc__ + + def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES): + return functools.partial(_update_wrapper, wrapped=wrapped, + assigned=assigned, updated=updated) + wraps.__doc__ = functools.wraps.__doc__ + +else: + wraps = functools.wraps + + +def with_metaclass(meta, *bases): + """Create a base class with a metaclass.""" + # This requires a bit of explanation: the basic idea is to make a dummy + # metaclass for one level of class instantiation that replaces itself with + # the actual metaclass. + class metaclass(type): + + def __new__(cls, name, this_bases, d): + if sys.version_info[:2] >= (3, 7): + # This version introduced PEP 560 that requires a bit + # of extra care (we mimic what is done by __build_class__). + resolved_bases = types.resolve_bases(bases) + if resolved_bases is not bases: + d['__orig_bases__'] = bases + else: + resolved_bases = bases + return meta(name, resolved_bases, d) + + @classmethod + def __prepare__(cls, name, this_bases): + return meta.__prepare__(name, bases) + return type.__new__(metaclass, 'temporary_class', (), {}) + + +def add_metaclass(metaclass): + """Class decorator for creating a class with a metaclass.""" + def wrapper(cls): + orig_vars = cls.__dict__.copy() + slots = orig_vars.get('__slots__') + if slots is not None: + if isinstance(slots, str): + slots = [slots] + for slots_var in slots: + orig_vars.pop(slots_var) + orig_vars.pop('__dict__', None) + orig_vars.pop('__weakref__', None) + if hasattr(cls, '__qualname__'): + orig_vars['__qualname__'] = cls.__qualname__ + return metaclass(cls.__name__, cls.__bases__, orig_vars) + return wrapper + + +def ensure_binary(s, encoding='utf-8', errors='strict'): + """Coerce **s** to six.binary_type. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> encoded to `bytes` + - `bytes` -> `bytes` + """ + if isinstance(s, binary_type): + return s + if isinstance(s, text_type): + return s.encode(encoding, errors) + raise TypeError("not expecting type '%s'" % type(s)) + + +def ensure_str(s, encoding='utf-8', errors='strict'): + """Coerce *s* to `str`. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + # Optimization: Fast return for the common case. + if type(s) is str: + return s + if PY2 and isinstance(s, text_type): + return s.encode(encoding, errors) + elif PY3 and isinstance(s, binary_type): + return s.decode(encoding, errors) + elif not isinstance(s, (text_type, binary_type)): + raise TypeError("not expecting type '%s'" % type(s)) + return s + + +def ensure_text(s, encoding='utf-8', errors='strict'): + """Coerce *s* to six.text_type. + + For Python 2: + - `unicode` -> `unicode` + - `str` -> `unicode` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + if isinstance(s, binary_type): + return s.decode(encoding, errors) + elif isinstance(s, text_type): + return s + else: + raise TypeError("not expecting type '%s'" % type(s)) + + +def python_2_unicode_compatible(klass): + """ + A class decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if PY2: + if '__str__' not in klass.__dict__: + raise ValueError("@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % + klass.__name__) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode('utf-8') + return klass + + +# Complete the moves implementation. +# This code is at the end of this module to speed up module loading. +# Turn this module into a package. +__path__ = [] # required for PEP 302 and PEP 451 +__package__ = __name__ # see PEP 366 @ReservedAssignment +if globals().get("__spec__") is not None: + __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable +# Remove other six meta path importers, since they cause problems. This can +# happen if six is removed from sys.modules and then reloaded. (Setuptools does +# this for some reason.) +if sys.meta_path: + for i, importer in enumerate(sys.meta_path): + # Here's some real nastiness: Another "instance" of the six module might + # be floating around. Therefore, we can't use isinstance() to check for + # the six meta path importer, since the other six instance will have + # inserted an importer with different class. + if (type(importer).__name__ == "_SixMetaPathImporter" and + importer.name == __name__): + del sys.meta_path[i] + break + del i, importer +# Finally, add the importer to the meta path import hook. +sys.meta_path.append(_importer) diff --git a/dist/weewx-4.10.1/bin/user/__init__.py b/dist/weewx-4.10.1/bin/user/__init__.py new file mode 100644 index 0000000..2534c20 --- /dev/null +++ b/dist/weewx-4.10.1/bin/user/__init__.py @@ -0,0 +1,11 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +""" +Package of user extensions to weewx. + +This package is for your use. Generally, extensions to weewx go here. +Any modules you add to it will not be touched by the upgrade process. +""" diff --git a/dist/weewx-4.10.1/bin/user/extensions.py b/dist/weewx-4.10.1/bin/user/extensions.py new file mode 100644 index 0000000..df848ce --- /dev/null +++ b/dist/weewx-4.10.1/bin/user/extensions.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""User extensions module + +This module is imported from the main executable, so anything put here will be +executed before anything else happens. This makes it a good place to put user +extensions. +""" + +import locale +# This will use the locale specified by the environment variable 'LANG' +# Other options are possible. See: +# http://docs.python.org/2/library/locale.html#locale.setlocale +locale.setlocale(locale.LC_ALL, '') diff --git a/dist/weewx-4.10.1/bin/wee_config b/dist/weewx-4.10.1/bin/wee_config new file mode 100755 index 0000000..8d139d4 --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_config @@ -0,0 +1,128 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Configure the configuration file.""" +from __future__ import absolute_import +import sys +import optparse + +from weecfg.config import ConfigEngine, Logger + +usage = """Usage: wee_config --help + wee_config --version + wee_config --list-drivers + wee_config --reconfigure CONFIG_FILE|--config=CONFIG_FILE + [--driver=DRIVER] [--unit-system=(us|metric|metricwx)] + [--latitude=yy.y] [--longitude=xx.x] [--altitude=zz.z,(foot|meter)] + [--location="Home Sweet Home"] [--register=(y,n)] + [--output=OUT_CONFIG] [--no-prompt] [--no-backup] [--verbosity=N] + wee_config --install --dist-config=DIST_CONFIG --output=OUT_CONFIG + [--driver=DRIVER] [--unit-system=(us|metric|metricwx)] + [--latitude=yy.y] [--longitude=xx.x] [--altitude=zz.z,(foot|meter)] + [--location="Home Sweet Home"] [--register=(y,n)] + [--no-prompt] [--no-backup] [--verbosity=N] + wee_config --upgrade CONFIG_FILE|--config=CONFIG_FILE + --dist-config=DIST_CONFIG + [--output=OUT_CONFIG] [--no-prompt] [--no-backup] + [--warn-on-error] [--verbosity=N] + +User actions: + +--version Show the WeeWX version, then exit. +--list-drivers List the available weewx device drivers, then exit. +--reconfigure Modify an existing configuration file CONFIG_FILE with any + specified station parameters. Use this command with the + --driver option to change the device driver. + +System actions (not normally done by users): + +--install Install a new configuration file starting with the contents of + DIST_CONFIG, prompting for station parameters. +--upgrade Update the contents of configuration file CONFIG_FILE to the + installed version, then merge the result with the contents of + configuration file DIST_CONFIG. + +Station parameters: + + --driver --unit-system + --latitude --longitude + --altitude --location + --register +""" + + +def main(): + + # Create a command line parser: + parser = optparse.OptionParser(usage=usage) + + # Add the various options: + parser.add_option("--version", action="store_true", + help="Show the weewx version then exit.") + parser.add_option("--list-drivers", action="store_true", + help="List the available device drivers.") + parser.add_option("--reconfigure", action="store_true", + help="Reconfigure an existing configuration file.") + parser.add_option("--install", action="store_true", + help="Install a new configuration file.") + parser.add_option("--upgrade", action="store_true", + help="Update an existing configuration file, then merge " + "with contents of DIST_CONFIG.") + parser.add_option("--config", dest="config_path", metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + parser.add_option("--dist-config", + help="Use template configuration file DIST_CONFIG.") + parser.add_option("--output", metavar="OUT_CONFIG", + help="Save to configuration file OUT_CONFIG. If not " + "specified then replace existing configuration file.") + parser.add_option("--driver", metavar="DRIVER", + help="Use the driver DRIVER. " + "For example, weewx.drivers.vantage") + parser.add_option("--latitude", metavar="yy.y", + help="The station latitude in decimal degrees.") + parser.add_option("--longitude", metavar="xx.x", + help="The station longitude in decimal degrees.") + parser.add_option("--altitude", metavar="zz,(foot|meter)", + help="The station altitude in either feet or meters." + " For example, '750,foot' or '320,meter'") + parser.add_option("--location", + help="""A text description of the station.""" + """ For example, "Santa's workshop, North Pole" """) + parser.add_option("--unit-system", choices=["us", "metric", "metricwx"], + metavar="(us|metric|metricwx)", + help="Set display units to \n'us' (F, inHg, in, mph), " + "'metric' (C, mbar, cm, km/h), or 'metricwx' (C, mbar, mm, m/s).") + parser.add_option("--units", choices=["us", "metric", "metricwx"], + metavar="(us|metric|metricwx)", + help="DEPRECATED. Use option --unit-system instead.") + parser.add_option("--register", dest='register_this_station', choices=['y', 'n'], + metavar="(y/n)", + help="Register this station in the weewx registry?") + parser.add_option("--no-prompt", action="store_true", + help="Do not prompt. Use default or specified values.") + parser.add_option("--no-backup", action="store_true", default=False, + help="When replacing an existing configuration file, " + "do not create a backup copy.") + parser.add_option("--warn-on-error", action="store_true", default=False, + help="Only warn if an update is not possible. Default " + "behavior is to warn then exit.") + parser.add_option("--debug", action="store_true", + help="Show diagnostic information while running.") + parser.add_option('--verbosity', type=int, default=1, + metavar="N", help='How much status to display, 0-3') + + # Now we are ready to parse the command line: + (options, args) = parser.parse_args() + + config_mgr = ConfigEngine(logger=Logger(verbosity=options.verbosity)) + + config_mgr.run(args, options) + + sys.exit(0) + + +if __name__ == "__main__" : + main() diff --git a/dist/weewx-4.10.1/bin/wee_database b/dist/weewx-4.10.1/bin/wee_database new file mode 100755 index 0000000..7b5b6ff --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_database @@ -0,0 +1,1168 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Configure databases used by WeeWX""" +from __future__ import absolute_import +from __future__ import print_function +from __future__ import with_statement + +# python imports +import datetime +import logging +import optparse +import sys +import time + +import six +from six.moves import input + +# weewx imports +import user.extensions # @UnusedImport +import weecfg.database +import weedb +import weeutil.logger +import weewx.manager +import weewx.units +from weeutil.weeutil import timestamp_to_string, y_or_n, to_int + +log = logging.getLogger(__name__) + +usage = """wee_database --help + wee_database --create + wee_database --reconfigure + wee_database --transfer --dest-binding=BINDING_NAME [--dry-run] + wee_database --add-column=NAME [--type=(REAL|INTEGER)] + wee_database --rename-column=NAME --to-name=NEW_NAME + wee_database --drop-columns=NAME1,NAME2,... + wee_database --check + wee_database --update [--dry-run] + wee_database --drop-daily + wee_database --rebuild-daily [--date=YYYY-mm-dd | + [--from=YYYY-mm-dd] [--to=YYYY-mm-dd]] + [--dry-run] + wee_database --reweight [--date=YYYY-mm-dd | + [--from=YYYY-mm-dd] [--to=YYYY-mm-dd]] + [--dry-run] + wee_database --calc-missing [--date=YYYY-mm-dd | + [--from=YYYY-mm-dd[THH:MM]] [--to=YYYY-mm-dd[THH:MM]]] + wee_database --check-strings + wee_database --fix-strings [--dry-run] + +Description: + +Manipulate the WeeWX database. Most of these operations are handled +automatically by WeeWX, but they may be useful in special cases.""" + +epilog = """NOTE: MAKE A BACKUP OF YOUR DATABASE BEFORE USING THIS UTILITY! +Many of its actions are irreversible!""" + + +def main(): + # Create a command line parser: + parser = optparse.OptionParser(usage=usage, epilog=epilog) + + # Add the various verbs... + parser.add_option("--create", action='store_true', + help="Create the WeeWX database and initialize it with the" + " schema.") + parser.add_option("--reconfigure", action='store_true', + help="Create a new database using configuration" + " information found in the configuration file." + " The new database will have the same name as the old" + " database, with a '_new' on the end.") + parser.add_option("--transfer", action='store_true', + help="Transfer the WeeWX archive from source database " + "to destination database.") + parser.add_option("--add-column", type=str, metavar="NAME", + help="Add new column NAME to database.") + parser.add_option("--type", type=str, metavar="TYPE", + help="New database column type (INTEGER|REAL) " + "(option --add-column only). Default is 'REAL'.") + parser.add_option("--rename-column", type=str, metavar="NAME", + help="Rename the column with name NAME.") + parser.add_option("--to-name", type=str, metavar="NEW_NAME", + help="New name of the column (option --rename-column only).") + parser.add_option("--drop-columns", type=str, metavar="NAME1,NAME2,...", + help="Drop one or more columns. Names must be separated by commas, " + "with NO SPACES.") + parser.add_option("--check", action="store_true", + help="Check the calculations in the daily summary tables.") + parser.add_option("--update", action="store_true", + help="Update the daily summary tables if required and" + " recalculate the daily summary maximum windSpeed values.") + parser.add_option("--calc-missing", dest="calc_missing", action="store_true", + help="Calculate and store any missing derived observations.") + parser.add_option("--check-strings", action="store_true", + help="Check the archive table for null strings that may" + " have been introduced by a SQL editing program.") + parser.add_option("--fix-strings", action='store_true', + help="Fix any null strings in a SQLite database.") + parser.add_option("--drop-daily", action='store_true', + help="Drop the daily summary tables from a database.") + parser.add_option("--rebuild-daily", action='store_true', + help="Rebuild the daily summaries from data in the archive table.") + parser.add_option("--reweight", action="store_true", + help="Recalculate the weighted sums in the daily summaries.") + + # ... then add the various options: + parser.add_option("--config", dest="config_path", type=str, + metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + parser.add_option("--date", type=str, metavar="YYYY-mm-dd", + help="This date only (options --calc-missing and --rebuild-daily only).") + parser.add_option("--from", dest="from_date", type=str, metavar="YYYY-mm-dd[THH:MM]", + help="Start with this date or date-time" + " (options --calc-missing and --rebuild-daily only).") + parser.add_option("--to", dest="to_date", type=str, metavar="YYYY-mm-dd[THH:MM]", + help="End with this date or date-time" + " (options --calc-missing and --rebuild-daily only).") + parser.add_option("--binding", metavar="BINDING_NAME", default='wx_binding', + help="The data binding to use. Default is 'wx_binding'.") + parser.add_option("--dest-binding", metavar="BINDING_NAME", + help="The destination data binding (option --transfer only).") + parser.add_option('--dry-run', action='store_true', + default=False, + help="Print what would happen but do not do it. Default is False.") + + # Now we are ready to parse the command line: + options, args = parser.parse_args() + + if len(args) > 1: + print("wee_database takes at most a single argument (the path to the configuration file).", + file=sys.stderr) + print("You have %d: %s." % (len(args), args), file=sys.stderr) + sys.exit(2) + + # Do a check to see if the user used more than 1 'verb' + if sum(x is not None for x in [options.create, + options.reconfigure, + options.transfer, + options.add_column, + options.rename_column, + options.drop_columns, + options.check, + options.update, + options.calc_missing, + options.check_strings, + options.fix_strings, + options.drop_daily, + options.rebuild_daily, + options.reweight + ]) != 1: + sys.exit("Must specify one and only one verb.") + + # Check that the various options satisfy our rules + + if options.type and not options.add_column: + sys.exit("Option --type can only be used with option --add-column") + + if options.to_name and not options.rename_column: + sys.exit("Option --to-name can only be used with option --rename-column") + + if options.rename_column and not options.to_name: + sys.exit("Option --rename-column requires option --to-name") + + if options.date and not (options.calc_missing or options.rebuild_daily + or options.reweight): + sys.exit("Option --date can only be used with options " + "--calc-missing, --rebuild-daily, or --reweight") + + if options.from_date and not (options.calc_missing or options.rebuild_daily + or options.reweight): + sys.exit("Option --from can only be used with options " + "--calc-missing, --rebuild-daily, or --reweight") + + if options.to_date and not (options.calc_missing or options.rebuild_daily + or options.reweight): + sys.exit("Option --to can only be used with options " + "--calc-missing, --rebuild-daily, or --reweight") + + if options.dest_binding and not options.transfer: + sys.exit("Option --dest-binding can only be used with option --transfer") + + # get config_dict to use + config_path, config_dict = weecfg.read_config(options.config_path, args) + print("Using configuration file %s" % config_path) + + # Set weewx.debug as necessary: + weewx.debug = to_int(config_dict.get('debug', 0)) + + # Customize the logging with user settings. + weeutil.logger.setup('wee_database', config_dict) + + db_binding = options.binding + # Get the db name, be prepared to catch the error if the binding does not + # exist + try: + database = config_dict['DataBindings'][db_binding]['database'] + except KeyError: + # Couldn't find the database name, maybe the binding does not exist. + # Notify the user and exit. + print("Error obtaining database name from binding '%s'" % db_binding, file=sys.stderr) + sys.exit("Perhaps you need to specify a different binding using --binding") + else: + print("Using database binding '%s', which is bound to database '%s'" % (db_binding, + database)) + + if options.create: + createMainDatabase(config_dict, db_binding) + + elif options.reconfigure: + reconfigMainDatabase(config_dict, db_binding) + + elif options.transfer: + transferDatabase(config_dict, db_binding, options) + + elif options.add_column: + addColumn(config_dict, db_binding, options.add_column, options.type) + + elif options.rename_column: + renameColumn(config_dict, db_binding, options.rename_column, options.to_name) + + elif options.drop_columns: + dropColumns(config_dict, db_binding, options.drop_columns) + + elif options.check: + check(config_dict, db_binding, options) + + elif options.update: + update(config_dict, db_binding, options) + + elif options.calc_missing: + calc_missing(config_dict, db_binding, options) + + elif options.check_strings: + check_strings(config_dict, db_binding, options, fix=False) + + elif options.fix_strings: + check_strings(config_dict, db_binding, options, fix=True) + + elif options.drop_daily: + dropDaily(config_dict, db_binding) + + elif options.rebuild_daily: + rebuildDaily(config_dict, db_binding, options) + + elif options.reweight: + reweight(config_dict, db_binding, options) + + +def createMainDatabase(config_dict, db_binding): + """Create the WeeWX database""" + + # Try a simple open. If it succeeds, that means the database + # exists and is initialized. Otherwise, an exception will be thrown. + try: + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + print("Database '%s' already exists. Nothing done." % dbmanager.database_name) + except weedb.OperationalError: + # Database does not exist. Try again, but allow initialization: + with weewx.manager.open_manager_with_config(config_dict, + db_binding, initialize=True) as dbmanager: + print("Created database '%s'" % dbmanager.database_name) + + +def dropDaily(config_dict, db_binding): + """Drop the daily summaries from a WeeWX database""" + + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + db_binding) + database_name = manager_dict['database_dict']['database_name'] + + print("Proceeding will delete all your daily summaries from database '%s'" % database_name) + ans = y_or_n("Are you sure you want to proceed (y/n)? ") + if ans == 'y': + t1 = time.time() + print("Dropping daily summary tables from '%s' ... " % database_name) + try: + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + try: + dbmanager.drop_daily() + except weedb.OperationalError as e: + print("Error '%s'" % e, file=sys.stderr) + print("Drop daily summary tables failed for database '%s'" % database_name) + else: + tdiff = time.time() - t1 + print("Daily summary tables dropped from " + "database '%s' in %.2f seconds" % (database_name, tdiff)) + except weedb.OperationalError: + # No daily summaries. Nothing to be done. + print("No daily summaries found in database '%s'. Nothing done." % database_name) + else: + print("Nothing done.") + + +def rebuildDaily(config_dict, db_binding, options): + """Rebuild the daily summaries.""" + + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + db_binding) + database_name = manager_dict['database_dict']['database_name'] + + # get the first and last good timestamps from the archive, these represent + # our bounds for rebuilding + with weewx.manager.Manager.open(manager_dict['database_dict']) as dbmanager: + first_ts = dbmanager.firstGoodStamp() + first_d = datetime.date.fromtimestamp(first_ts) if first_ts is not None else None + last_ts = dbmanager.lastGoodStamp() + last_d = datetime.date.fromtimestamp(last_ts) if first_ts is not None else None + # determine the period over which we are rebuilding from any command line + # date parameters + from_dt, to_dt = _parse_dates(options) + # we have start and stop datetime objects but we work on whole days only, + # so need date object + from_d = from_dt.date() if from_dt is not None else None + to_d = to_dt.date() if to_dt is not None else None + # advise the user/log what we will do + if from_d is None and to_d is None: + _msg = "All daily summaries will be rebuilt." + elif from_d and not to_d: + _msg = "Daily summaries from %s through the end (%s) will be rebuilt." % (from_d, + last_d) + elif not from_d and to_d: + _msg = "Daily summaries from the beginning (%s) through %s will be rebuilt." % (first_d, + to_d) + elif from_d == to_d: + _msg = "Daily summary for %s will be rebuilt." % from_d + else: + _msg = "Daily summaries from %s through %s inclusive will be rebuilt." % (from_d, + to_d) + log.info(_msg) + print(_msg) + ans = y_or_n("Proceed (y/n)? ") + if ans == 'n': + _msg = "Nothing done." + log.info(_msg) + print(_msg) + return + + t1 = time.time() + + # Open up the database. This will create the tables necessary for the daily + # summaries if they don't already exist: + with weewx.manager.open_manager_with_config(config_dict, + db_binding, initialize=True) as dbmanager: + + log.info("Rebuilding daily summaries in database '%s' ..." % database_name) + print("Rebuilding daily summaries in database '%s' ..." % database_name) + if options.dry_run: + print("Dry run. Nothing done.") + return + else: + # now do the actual rebuild + nrecs, ndays = dbmanager.backfill_day_summary(start_d=from_d, + stop_d=to_d, + trans_days=20) + tdiff = time.time() - t1 + # advise the user/log what we did + log.info("Rebuild of daily summaries in database '%s' complete" % database_name) + if nrecs: + sys.stdout.flush() + # fix a bit of formatting inconsistency if less than 1000 records + # processed + if nrecs >= 1000: + print() + if ndays == 1: + _msg = "Processed %d records to rebuild 1 daily summary in %.2f seconds" % (nrecs, + tdiff) + else: + _msg = ("Processed %d records to rebuild %d daily summaries in %.2f seconds" % (nrecs, + ndays, + tdiff)) + print(_msg) + print("Rebuild of daily summaries in database '%s' complete" % database_name) + else: + print("Daily summaries up to date in '%s'" % database_name) + + +def reweight(config_dict, db_binding, options): + """Recalculate the weighted sums in the daily summaries.""" + + # Determine the period over which we are rebuilding from any command line date parameters + from_dt, to_dt = _parse_dates(options) + # Convert from Datetime to Date objects + from_d = from_dt.date() if from_dt is not None else None + to_d = to_dt.date() if to_dt is not None else None + + # advise the user/log what we will do + if from_d is None and to_d is None: + msg = "The weighted sums in all the daily summaries will be recalculated." + elif from_d and not to_d: + msg = "The weighted sums in the daily summaries from %s through the end " \ + "will be recalculated." % from_d + elif not from_d and to_d: + msg = "The weighted sums in the daily summaries from the beginning through %s" \ + "will be recalculated." % to_d + elif from_d == to_d: + msg = "The weighted sums in the daily summary for %s will be recalculated." % from_d + else: + msg = "The weighted sums in the daily summaries from %s through %s, " \ + "inclusive, will be recalculated." % (from_d, to_d) + + log.info(msg) + print(msg) + ans = y_or_n("Proceed (y/n)? ") + if ans == 'n': + msg = "Nothing done." + log.info(msg) + print(msg) + return + + t1 = time.time() + + # Open up the database. + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + database_name = manager_dict['database_dict']['database_name'] + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + + log.info("Recalculating the weighted summaries in database '%s' ..." % database_name) + print("Recalculating the weighted summaries in database '%s' ..." % database_name) + if options.dry_run: + print("Dry run. Nothing done.") + else: + # Do the actual recalculations + dbmanager.recalculate_weights(start_d=from_d, stop_d=to_d) + + msg = "Finished reweighting in %.1f seconds." % (time.time() - t1) + log.info(msg) + print(msg) + + +def reconfigMainDatabase(config_dict, db_binding): + """Create a new database, then populate it with the contents of an old database""" + + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + db_binding) + # Make a copy for the new database (we will be modifying it) + new_database_dict = dict(manager_dict['database_dict']) + + # Now modify the database name + new_database_dict['database_name'] = manager_dict['database_dict']['database_name'] + '_new' + + # First check and see if the new database already exists. If it does, check + # with the user whether it's ok to delete it. + try: + weedb.create(new_database_dict) + except weedb.DatabaseExists: + ans = y_or_n("New database '%s' already exists. " + "Delete it first (y/n)? " % new_database_dict['database_name']) + if ans == 'y': + weedb.drop(new_database_dict) + else: + print("Nothing done.") + return + + # Get the unit system of the old archive: + with weewx.manager.Manager.open(manager_dict['database_dict']) as old_dbmanager: + old_unit_system = old_dbmanager.std_unit_system + + if old_unit_system is None: + print("Old database has not been initialized. Nothing to be done.") + return + + # Get the unit system of the new archive: + try: + target_unit_nickname = config_dict['StdConvert']['target_unit'] + except KeyError: + target_unit_system = None + else: + target_unit_system = weewx.units.unit_constants[target_unit_nickname.upper()] + + print("Copying database '%s' to '%s'" % (manager_dict['database_dict']['database_name'], + new_database_dict['database_name'])) + if target_unit_system is None or old_unit_system == target_unit_system: + print("The new database will use the same unit system as the old ('%s')." % + weewx.units.unit_nicknames[old_unit_system]) + else: + print("Units will be converted from the '%s' system to the '%s' system." % + (weewx.units.unit_nicknames[old_unit_system], + weewx.units.unit_nicknames[target_unit_system])) + + ans = y_or_n("Are you sure you wish to proceed (y/n)? ") + if ans == 'y': + t1 = time.time() + weewx.manager.reconfig(manager_dict['database_dict'], + new_database_dict, + new_unit_system=target_unit_system, + new_schema=manager_dict['schema']) + tdiff = time.time() - t1 + print("Database '%s' copied to '%s' in %.2f seconds." + % (manager_dict['database_dict']['database_name'], + new_database_dict['database_name'], + tdiff)) + else: + print("Nothing done.") + + +def transferDatabase(config_dict, db_binding, options): + """Transfer 'archive' data from one database to another""" + + # do we have enough to go on, must have a dest binding + if not options.dest_binding: + print("Destination binding not specified. Nothing Done. Aborting.", file=sys.stderr) + return + # get manager dict for our source binding + src_manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + db_binding) + # get manager dict for our dest binding + try: + dest_manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + options.dest_binding) + except weewx.UnknownBinding: + # if we can't find the binding display a message then return + print("Unknown destination binding '%s', confirm destination binding." + % options.dest_binding, file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + except weewx.UnknownDatabase: + # if we can't find the database display a message then return + print("Error accessing destination database, " + "confirm destination binding and/or database.", file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + except (ValueError, AttributeError): + # maybe a schema issue + print("Error accessing destination database.", file=sys.stderr) + print("Maybe the destination schema is incorrectly specified " + "in binding '%s' in weewx.conf?" % options.dest_binding, file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + except weewx.UnknownDatabaseType: + # maybe a [Databases] issue + print("Error accessing destination database.", file=sys.stderr) + print("Maybe the destination database is incorrectly defined in weewx.conf?", + file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + # get a manager for our source + with weewx.manager.Manager.open(src_manager_dict['database_dict']) as src_manager: + # How many source records? + num_recs = src_manager.getSql("SELECT COUNT(dateTime) from %s;" + % src_manager.table_name)[0] + if not num_recs: + # we have no source records to transfer so abort with a message + print("No records found in source database '%s'." + % src_manager.database_name) + print("Nothing done. Aborting.") + exit() + + if not options.dry_run: # is it a dry run ? + # not a dry run, actually do the transfer + ans = y_or_n("Transfer %s records from source database '%s' " + "to destination database '%s' (y/n)? " + % (num_recs, src_manager.database_name, + dest_manager_dict['database_dict']['database_name'])) + if ans == 'y': + t1 = time.time() + # wrap in a try..except in case we have an error + try: + with weewx.manager.Manager.open_with_create( + dest_manager_dict['database_dict'], + table_name=dest_manager_dict['table_name'], + schema=dest_manager_dict['schema']) as dest_manager: + print("Transferring, this may take a while.... ") + sys.stdout.flush() + + # This could generate a *lot* of log entries. Temporarily disable logging + # for events at or below INFO + logging.disable(logging.INFO) + + # do the transfer, should be quick as it's done as a + # single transaction + nrecs = dest_manager.addRecord(src_manager.genBatchRecords(), + progress_fn=weewx.manager.show_progress) + + # Remove the temporary restriction + logging.disable(logging.NOTSET) + + tdiff = time.time() - t1 + print("\nCompleted.") + if nrecs: + print("%s records transferred from source database '%s' to " + "destination database '%s' in %.2f seconds." + % (nrecs, src_manager.database_name, + dest_manager.database_name, tdiff)) + else: + print("Error. No records were transferred from source " + "database '%s' to destination database '%s'." + % (src_manager.database_name, dest_manager.database_name), + file=sys.stderr) + except ImportError: + # Probably when trying to load db driver + print("Error accessing destination database '%s'." + % (dest_manager_dict['database_dict']['database_name'],), + file=sys.stderr) + print("Nothing done. Aborting.", file=sys.stderr) + raise + except (OSError, weedb.OperationalError): + # probably a weewx.conf typo or MySQL db not created + print("Error accessing destination database '%s'." + % dest_manager_dict['database_dict']['database_name'], file=sys.stderr) + print("Maybe it does not exist (MySQL) or is incorrectly " + "defined in weewx.conf?", file=sys.stderr) + print("Nothing done. Aborting.", file=sys.stderr) + return + + else: + # we decided not to do the transfer + print("Nothing done.") + return + else: + # it's a dry run so say what we would have done then return + print("Transfer %s records from source database '%s' " + "to destination database '%s'." + % (num_recs, src_manager.database_name, + dest_manager_dict['database_dict']['database_name'])) + print("Dry run, nothing done.") + + +def addColumn(config_dict, db_binding, column_name, column_type): + """Add a single column to the database. + column_name: The name of the new column. + column_type: The type ("REAL"|"INTEGER|) of the new column. + """ + column_type = column_type or 'REAL' + ans = y_or_n( + "Add new column '%s' of type '%s' to database (y/n)? " % (column_name, column_type)) + if ans == 'y': + dbm = weewx.manager.open_manager_with_config(config_dict, db_binding) + dbm.add_column(column_name, column_type) + print("New column %s of type %s added to database." % (column_name, column_type)) + else: + print("Nothing done.") + + +def renameColumn(config_dict, db_binding, old_column_name, new_column_name): + """Rename a column in the database. """ + ans = y_or_n("Rename column '%s' to '%s' (y/n)? " % (old_column_name, new_column_name)) + if ans == 'y': + dbm = weewx.manager.open_manager_with_config(config_dict, db_binding) + dbm.rename_column(old_column_name, new_column_name) + print("Column '%s' renamed to '%s'." % (old_column_name, new_column_name)) + else: + print("Nothing done.") + + +def dropColumns(config_dict, db_binding, drop_columns): + """Drop a set of columns from the database""" + drop_list = drop_columns.split(',') + # In case the user ended the list of columns to be dropped with a comma, search for an + # empty column name + try: + drop_list.remove('') + except ValueError: + pass + ans = y_or_n("Drop column(s) '%s' from the database (y/n)? " % ", ".join(drop_list)) + if ans == 'y': + drop_set = set(drop_list) + dbm = weewx.manager.open_manager_with_config(config_dict, db_binding) + # Now drop the columns. If one is missing, a NoColumnError will be raised. Be prepared + # to catch it. + try: + print("This may take a while...") + dbm.drop_columns(drop_set) + except weedb.NoColumnError as e: + print(e, file=sys.stderr) + print("Nothing done.") + else: + print("Column(s) '%s' dropped from the database" % ", ".join(drop_list)) + else: + print("Nothing done.") + + +def check(config_dict, db_binding, options): + """Check database and report outstanding fixes/issues. + + Performs the following checks: + - checks database version + - checks for null strings in SQLite database + """ + + t1 = time.time() + + # Check interval weighting + print("Checking daily summary tables version...") + + # Get a database manager object + dbm = weewx.manager.open_manager_with_config(config_dict, db_binding) + + # check the daily summary version + _daily_summary_version = dbm._read_metadata('Version') + msg = "Daily summary tables are at version %s" % _daily_summary_version + log.info(msg) + print(msg) + + if _daily_summary_version is not None and _daily_summary_version >= '2.0': + # interval weighting fix has been applied + msg = "Interval Weighting Fix is not required." + log.info(msg) + print(msg) + else: + print("Recommend running --update to recalculate interval weightings.") + print("Daily summary tables version check completed in %0.2f seconds." % (time.time() - t1)) + + # now check for null strings + check_strings(config_dict, db_binding, options, fix=False) + + +def update(config_dict, db_binding, options): + """Apply any required database fixes. + + Applies the following fixes: + - checks if database version is 3.0, if not interval weighting fix is + applied + - recalculates windSpeed daily summary max and maxtime fields from + archive + """ + + # prompt for confirmation if it is not a dry run + ans = 'y' if options.dry_run else None + while ans not in ['y', 'n']: + ans = input("The update process does not affect archive data, " + "but does alter the database.\nContinue (y/n)? ") + if ans == 'n': + # we decided not to update the summary tables + msg = "Update cancelled" + log.info(msg) + print(msg) + return + + msg = "Preparing interval weighting fix..." + log.info(msg) + print(msg) + + # Get a database manager object + dbm = weewx.manager.open_manager_with_config(config_dict, db_binding) + + # check the daily summary version + msg = "Daily summary tables are at version %s" % dbm.version + log.info(msg) + print(msg) + + if dbm.version is not None and dbm.version >= '4.0': + # interval weighting fix has been applied + msg = "Interval weighting fix is not required." + log.info(msg) + print(msg) + else: + # apply the interval weighting + msg = "Calculating interval weights..." + log.info(msg) + print(msg) + t1 = time.time() + if options.dry_run: + print("Dry run. Nothing done") + else: + dbm.update() + msg = "Interval Weighting Fix completed in %0.2f seconds." % (time.time() - t1) + log.info(msg) + print(msg) + + # recalc the max/maxtime windSpeed values + _fix_wind(config_dict, db_binding, options) + + +def calc_missing(config_dict, db_binding, options): + """Calculate any missing derived observations and save to database.""" + + msg = "Preparing to calculate missing derived observations..." + log.info(msg) + + # get a db manager dict given the config dict and binding + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + db_binding) + # Get the table_name used by the binding, it could be different to the + # default 'archive'. If for some reason it is not specified then fail hard. + table_name = manager_dict['table_name'] + # get the first and last good timestamps from the archive, these represent + # our overall bounds for calculating missing derived obs + with weewx.manager.Manager.open(manager_dict['database_dict'], + table_name=table_name) as dbmanager: + first_ts = dbmanager.firstGoodStamp() + last_ts = dbmanager.lastGoodStamp() + # process any command line options that may limit the period over which + # missing derived obs are calculated + start_dt, stop_dt = _parse_dates(options) + # we now have a start and stop date for processing, we need to obtain those + # as epoch timestamps, if we have no start and/or stop date then use the + # first or last good timestamp instead + start_ts = time.mktime(start_dt.timetuple()) if start_dt is not None else first_ts - 1 + stop_ts = time.mktime(stop_dt.timetuple()) if stop_dt is not None else last_ts + # notify if this is a dry run + if options.dry_run: + msg = "This is a dry run, missing derived observations will be calculated but not saved" + log.info(msg) + print(msg) + _head = "Missing derived observations will be calculated " + # advise the user/log what we will do + if start_dt is None and stop_dt is None: + _tail = "for all records." + elif start_dt and not stop_dt: + _tail = "from %s through to the end (%s)." % (timestamp_to_string(start_ts), + timestamp_to_string(stop_ts)) + elif not start_dt and stop_dt: + _tail = "from the beginning (%s) through to %s." % (timestamp_to_string(start_ts), + timestamp_to_string(stop_ts)) + else: + _tail = "from %s through to %s inclusive." % (timestamp_to_string(start_ts), + timestamp_to_string(stop_ts)) + _msg = "%s%s" % (_head, _tail) + log.info(_msg) + print(_msg) + ans = y_or_n("Proceed (y/n)? ") + if ans == 'n': + _msg = "Nothing done." + log.info(_msg) + print(_msg) + return + + t1 = time.time() + + # construct a CalcMissing config dict + calc_missing_config_dict = {'name': 'Calculate Missing Derived Observations', + 'binding': db_binding, + 'start_ts': start_ts, + 'stop_ts': stop_ts, + 'trans_days': 20, + 'dry_run': options.dry_run} + + # obtain a CalcMissing object + calc_missing_obj = weecfg.database.CalcMissing(config_dict, + calc_missing_config_dict) + msg = "Calculating missing derived observations..." + log.info(msg) + print(msg) + # Calculate and store any missing observations. Be prepared to + # catch any exceptions from CalcMissing. + try: + calc_missing_obj.run() + except weewx.UnknownBinding as e: + # We have an unknown binding, this could occur if we are using a + # non-default binding and StdWXCalculate has not been told (via + # weewx.conf) to use the same binding. Log it and notify the user then + # exit. + _msg = "Error: '%s'" % e + print(_msg) + log.error(_msg) + print("Perhaps StdWXCalculate is using a different binding. Check " + "configuration file [StdWXCalculate] stanza") + sys.exit("Nothing done. Aborting.") + else: + msg = "Missing derived observations calculated in %0.2f seconds" % (time.time() - t1) + log.info(msg) + print(msg) + + +def _fix_wind(config_dict, db_binding, options): + """Recalculate the windSpeed daily summary max and maxtime fields. + + Create a WindSpeedRecalculation object and call its run() method to + recalculate the max and maxtime fields from archive data. This process is + idempotent so it can be called repeatedly with no ill effect. + """ + + t1 = time.time() + msg = "Preparing maximum windSpeed fix..." + log.info(msg) + print(msg) + + # notify if this is a dry run + if options.dry_run: + print("This is a dry run: maximum windSpeed will be calculated but not saved.") + + # construct a windSpeed recalculation config dict + wind_config_dict = {'name': 'Maximum windSpeed fix', + 'binding': db_binding, + 'trans_days': 100, + 'dry_run': options.dry_run} + + # create a windSpeedRecalculation object + wind_obj = weecfg.database.WindSpeedRecalculation(config_dict, + wind_config_dict) + # perform the recalculation, wrap in a try..except to catch any db errors + try: + wind_obj.run() + except weedb.NoTableError: + msg = "Maximum windSpeed fix applied: no windSpeed found" + log.info(msg) + print(msg) + else: + msg = "Maximum windSpeed fix completed in %0.2f seconds" % (time.time() - t1) + log.info(msg) + print(msg) + + +# These functions are necessary because Python 3 does not allow you to +# parameterize types. So, we use a big if-else. + +def check_type(val, expected): + if expected == 'INTEGER': + return isinstance(val, six.integer_types) + elif expected == 'REAL': + return isinstance(val, float) + elif expected == 'STR' or expected == 'TEXT': + return isinstance(val, six.string_types) + else: + raise ValueError("Unknown type %s" % expected) + + +def set_type(val, target): + if target == 'INTEGER': + return int(val) + elif target == 'REAL': + return float(val) + elif target == 'STR' or target == 'TEXT': + return six.ensure_str(val) + else: + raise ValueError("Unknown type %s" % target) + + +def check_strings(config_dict, db_binding, options, fix=False): + """Scan the archive table for null strings. + + Identifies and lists any null string occurrences in the archive table. If + fix is True then any null strings that are found are fixed. + """ + + t1 = time.time() + if options.dry_run or not fix: + logging.disable(logging.INFO) + + print("Preparing Null String Fix, this may take a while...") + + if fix: + log.info("Preparing Null String Fix") + # notify if this is a dry run + if options.dry_run: + print("This is a dry run: null strings will be detected but not fixed") + + # open up the main database archive table + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + + obs_list = [] + obs_type_list = [] + + # get the schema and extract the Python type each observation type should be + for column in dbmanager.connection.genSchemaOf('archive'): + # Save the observation name for this column (eg, 'outTemp'): + obs_list.append(column[1]) + # And its type + obs_type_list.append(column[2]) + + records = 0 + _found = [] + # cycle through each row in the database + for record in dbmanager.genBatchRows(): + records += 1 + # now examine each column + for icol in range(len(record)): + # check to see if this column is an instance of the correct + # Python type + if record[icol] is not None and not check_type(record[icol], obs_type_list[icol]): + # Oops. Found a bad one. Print it out. + if fix: + log.info("Timestamp = %s; record['%s']= %r; ... " + % (record[0], obs_list[icol], record[icol])) + + if fix: + # coerce to the correct type. If it can't be done, then + # set it to None. + try: + corrected_value = set_type(record[icol], obs_type_list[icol]) + except ValueError: + corrected_value = None + # update the database with the new value but only if + # it's not a dry run + if not options.dry_run: + dbmanager.updateValue(record[0], obs_list[icol], corrected_value) + _found.append((record[0], obs_list[icol], record[icol], corrected_value)) + # log it + log.info(" changed to %r\n" % corrected_value) + else: + _found.append((record[0], obs_list[icol], record[icol])) + # notify the user of progress + if records % 1000 == 0: + print("Checking record: %d; Timestamp: %s\r" + % (records, timestamp_to_string(record[0])), end=' ') + sys.stdout.flush() + # update our final count now that we have finished + if records: + print("Checking record: %d; Timestamp: %s\r" + % (records, timestamp_to_string(record[0])), end=' ') + print() + else: + print("No records in database.") + tdiff = time.time() - t1 + # now display details of what we found if we found any null strings + if len(_found): + print("The following null strings were found:") + for item in _found: + if len(item) == 4: + print("Timestamp = %s; record['%s'] = %r; ... changed to %r" % item) + else: + print("Timestamp = %s; record['%s'] = %r; ... ignored" % item) + # how many did we fix? + fixed = len([a for a in _found if len(a) == 4]) + # summarise our results + if len(_found) == 0: + # found no null strings, log it and display on screen + log.info("No null strings found.") + print("No null strings found.") + elif fixed == len(_found): + # fixed all we found + if options.dry_run: + # its a dry run so display to screen but not to log + print("%d of %d null strings found would have been fixed." % (fixed, len(_found))) + else: + # really did fix so log and display to screen + log.info("%d of %d null strings found were fixed." % (fixed, len(_found))) + print("%d of %d null strings found were fixed." % (fixed, len(_found))) + elif fix: + # this should never occur - found some but didn't fix them all when we + # should have + if options.dry_run: + # its a dry run so say what would have happened + print("Could not fix all null strings. " + "%d of %d null strings found would have been fixed." % (fixed, + len(_found))) + else: + # really did fix so log and display to screen + log.info("Could not fix all null strings. " + "%d of %d null strings found were fixed." % (fixed, + len(_found))) + print("Could not fix all null strings. " + "%d of %d null strings found were fixed." % (fixed, + len(_found))) + else: + # found some null string but it was only a check not a fix, just + # display to screen + print("%d null strings were found.\r\n" + "Recommend running --fix-strings to fix these strings." % len(_found)) + + # and finally details on time taken + if fix: + log.info("Applied Null String Fix in %0.2f seconds." % tdiff) + print("Applied Null String Fix in %0.2f seconds." % tdiff) + else: + # it was a check not a fix so just display to screen + print("Completed Null String Check in %0.2f seconds." % tdiff) + # just in case, set the syslog level back where we found it + if options.dry_run or not fix: + logging.disable(logging.NOTSET) + + +def _parse_dates(options): + """Parse --date, --from and --to command line options. + + Parses --date or --from and --to to determine a date-time span to be + used. --to and --from in the format y-m-dTHH:MM precisely define a + date-time but anything in the format y-m-d does not. When rebuilding + the daily summaries this imprecision is not import as we merely need a + date-time somewhere within the day being rebuilt. When calculating + missing fields we need date-times for the span over which the + calculations are to be performed. + + Inputs: + options: the optparse options + + Returns: A two-way tuple (from_dt, to_dt) representing the from and to + date-times derived from the --date or --to and --from command line + options where + from_dt: A datetime.datetime object holding the from date-time. May + be None + to_dt: A datetime.datetime object holding the to date-time. May be + None + """ + + # default is None, unless user has specified an option + _from_dt = None + _to_dt = None + + # first look for --date + if options.date: + # we have a --date option, make sure we are not over specified + if options.from_date or options.to_date: + raise ValueError("Specify either --date or a --from and --to combination; not both") + + # there is a --date but is it valid + try: + # this will give a datetime object representing midnight at the + # start of the day + _from_dt = datetime.datetime.strptime(options.date, "%Y-%m-%d") + except ValueError: + raise ValueError("Invalid --date option specified.") + else: + # we have the from date-time, for a --date option our final results + # depend on the action we are to perform + if options.rebuild_daily or options.reweight: + # The daily summaries are stamped with the midnight timestamp + # for each day, so our from and to results need to be within the + # same calendar day else we will rebuild more than just one day. + # For simplicity make them the same. + _to_dt = _from_dt + elif options.calc_missing: + # On the other hand calc missing will be dealing with archive + # records which are epoch timestamped. The midnight stamped + # record is part of the previous day so make our from result + # one second after midnight. THe to result must be midnight at + # the end of the day. + _to_dt = _from_dt + datetime.timedelta(days=1) + _from_dt = _from_dt + datetime.timedelta(seconds=1) + else: + # nothing else uses from and to (yet) but just in case return + # midnight to midnight as the default + _to_dt = _from_dt + datetime.timedelta(days=1) + # we have our results so we can return + return _from_dt, _to_dt + + # we don't have --date so now look for --from and --to + if options.from_date: + # we have a --from but is it valid + try: + if 'T' in options.from_date: + # we have a time so we can precisely determine a date-time + _from_dt = datetime.datetime.strptime(options.from_date, "%Y-%m-%dT%H:%M") + else: + # we have a date only, so use midnight at the start of the day + _from_dt = datetime.datetime.strptime(options.from_date, "%Y-%m-%d") + except ValueError: + raise ValueError("Invalid --from option specified.") + + if options.to_date: + # we have a --to but is it valid + try: + if 'T' in options.to_date: + # we have a time so decode and use that + _to_dt = datetime.datetime.strptime(options.to_date, "%Y-%m-%dT%H:%M") + else: + # we have a date, first obtain a datetime object for midnight + # at the start of the day specified + _to_dt = datetime.datetime.strptime(options.to_date, "%Y-%m-%d") + # since we have a date the result we want depends on what action + # we are to complete + if options.rebuild_daily or options.reweight: + # for a rebuild the to date-time must be within the date + # specified date, which it already is so leave it + pass + elif options.calc_missing: + # for calc missing we want midnight at the end of the day + _to_dt = _to_dt + datetime.timedelta(days=1) + else: + # nothing else uses from and to (yet) but just in case + # return midnight at the end of the day + _to_dt = _to_dt + datetime.timedelta(days=1) + except ValueError: + raise ValueError("Invalid --to option specified.") + + # if we have both from and to date-times make sure from is no later than to + if _from_dt and _to_dt and _to_dt < _from_dt: + raise weewx.ViolatedPrecondition("--from value is later than --to value.") + # we have our results so we can return + return _from_dt, _to_dt + + +if __name__ == "__main__": + main() diff --git a/dist/weewx-4.10.1/bin/wee_debug b/dist/weewx-4.10.1/bin/wee_debug new file mode 100755 index 0000000..9d06131 --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_debug @@ -0,0 +1,431 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009-2021 Gary Roderick, Tom Keffer , and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +""" Generate weewx debug info """ + +from __future__ import absolute_import +from __future__ import print_function + +import optparse +import os +import platform +import sys + +import six +from configobj import ConfigObj + +# weewx imports +import weecfg +import weedb +import weewx.manager +import weewx.units +import weeutil.config +from weecfg.extension import ExtensionEngine +from weeutil.weeutil import TimeSpan, timestamp_to_string +from weewx.manager import DaySummaryManager + +VERSION = weewx.__version__ +WEE_DEBUG_VERSION = '0.9' + +# keys/setting names to obfuscate in weewx.conf, key value will be obfuscated +# if the key starts any element in the list. Can add additional string elements +# to list if required +OBFUSCATE_MAP = { + "obfuscate": [ + "apiKey", "api_key", "app_key", "archive_security_key", "id", "key", + "oauth_token", "password", "raw_security_key", "token", "user", + "server_url", "station"], + "do_not_obfuscate": [ + "station_type"] + } + +# weewx archive field to use as the basis of counting number of archive records +# Can be any field but dateTime is preferred as it should be in every weewx +# archive +COUNT_FIELD = 'dateTime' + +# Redirect the import of setup (needed to get extension info) +sys.modules['setup'] = weecfg.extension + +usage = """wee_debug --help + wee_debug --info + [CONFIG_FILE|--config=CONFIG_FILE] + [--output|--output DEBUG_PATH] + [--verbosity=0|1|2] + wee_debug --version + +Description: + +Generate a standard suite of system/weewx information to aid in remote +debugging. The wee_debug output consists of two parts, the first part containing +a snapshot of relevant system/weewx information and the second part a parsed and +obfuscated copy of weewx.conf. This output can be redirected to file and posted +when seeking assistance via forums or email. + +Actions: + +--info Generate a debug report.""" + +epilog = """wee_debug will attempt to obfuscate obvious personal/private +information in weewx.conf such as user names, passwords and API keys; however, +the user should thoroughly check the generated output for personal/private +information before posting the information publicly.""" + +def main(): + + # Create a command line parser: + parser = optparse.OptionParser(usage = usage, + epilog = epilog) + + # Add the various options: + parser.add_option("--config", dest="config_path", type=str, + metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + parser.add_option("--info", dest="info", action='store_true', + help="Generate weewx debug output.") + parser.add_option('--output', action='callback', + callback=optional_arg('/var/tmp/weewx.debug'), + dest='debug_file', metavar="DEBUG_PATH", + help="Write wee_debug output to DEBUG_PATH. DEBUG_PATH " + "includes path and file name. Default is " + "/var/tmp/weewx.debug.") + parser.add_option('--verbosity', type=int, default=1, + metavar="N", help="How much detail to display, " + "0-2, default=1.") + parser.add_option('--version', dest='version', action='store_true', + help='Display wee_debug version number.') + + # Now we are ready to parse the command line: + (options, args) = parser.parse_args() # @UnusedVariable + + # check weewx version number for compatibility + if int(VERSION[0]) < 3: + # incompatible version of weewx + print("Incompatible version of weewx detected (%s). " + "Weewx v3.0.0 or greater required." % VERSION) + print("Nothing done, exiting.") + exit(1) + + # get config_dict to use + config_path, config_dict = weecfg.read_config(options.config_path, args) + + # display wee_debug version info + if options.version: + print("wee_debug version: %s" % WEE_DEBUG_VERSION) + exit(1) + + # display debug info + if options.info: + # first a message re verbosity + if options.verbosity == 0: + print("Using verbosity=0, displaying minimal info") + elif options.verbosity == 1: + print("Using verbosity=1, displaying most info") + else: + print("Using verbosity=2, displaying all info") + print() + # then a message re output destination + if options.debug_file is not None: + print("wee_debug output will be written to %s" % options.debug_file) + else: + print("wee_debug output will be sent to stdout(console)") + print() + + # get some key weewx parameters + db_binding_wx = get_binding(config_dict) + database_wx = config_dict['DataBindings'][db_binding_wx]['database'] + + # generate our debug info sending it to file or console + if options.debug_file is not None: + # save stdout for when we clean up + old_stdout = sys.stdout + # open our debug file for writing + _debug_file = open(options.debug_file, 'w') + # redirect stdout to our file + sys.stdout = _debug_file + print("Using configuration file %s" % config_path) + print("Using database binding '%s', which is bound to database '%s'" % (db_binding_wx, + database_wx)) + print() + # generate our debug info + generateDebugInfo(config_dict, + config_path, + db_binding_wx, + options.verbosity) + print() + print("Parsed and obfuscated weewx.conf") + # generate our obfuscated weewx.conf + generateDebugConf(config_dict) + if options.debug_file is not None: + # close our file + _debug_file.close() + # revert stdout + sys.stdout = old_stdout + print("wee_debug output successfully written to %s" % options.debug_file) + else: + print() + print("wee_debug report successfully generated") + exit(1) + + # if we have a compatible weewx version but did not use --version or --info + # then show the wee_debug help + parser.print_help() + +def optional_arg(arg_default): + """ Callback function to implement optparse command line parameters that + support default values and optional parameters. + + http://stackoverflow.com/questions/1229146/parsing-empty-options-in-python + """ + + def func(option, opt_str, value, parser): # @UnusedVariable + if parser.rargs and not parser.rargs[0].startswith('-'): + val = parser.rargs[0] + parser.rargs.pop(0) + else: + val = arg_default + setattr(parser.values, option.dest, val) + + return func + +def generateDebugInfo(config_dict, config_path, db_binding_wx, verbosity): + """ Generate system/weewx debug info """ + + # system/OS info + generateSysInfo(verbosity) + + # weewx version info + print("General Weewx info") + print(" Weewx version %s detected." % VERSION) + print() + + # station info + print("Station info") + stationType = config_dict['Station']['station_type'] + print(" Station type: %s" % stationType) + print(" Driver: %s" % config_dict[stationType]['driver']) + print() + + # driver info + if verbosity > 0: + print("Driver info") + driver_dict = {stationType: config_dict[stationType]} + _config = ConfigObj(driver_dict) + if six.PY2: + _config.write(sys.stdout) + else: + # The ConfigObj.write() method always writes in binary, + # which is not accepted by Python 3 for output to stdout + # So write to sys.stdout.buffer + _config.write(sys.stdout.buffer) + print() + + # installed extensions info + print("Currently installed extensions") + ext = ExtensionEngine(config_path=config_path, + config_dict=config_dict) + ext.enumerate_extensions() + print() + + # weewx archive info + try: + manager_info_dict = getManagerInfo(config_dict, db_binding_wx) + except weedb.CannotConnect as e: + print("Unable to connect to database:", e) + print() + except weedb.OperationalError as e: + print("Error hitting database. It may not be properly initialized:") + print(e) + print() + else: + units_nickname = weewx.units.unit_nicknames.get(manager_info_dict['units'], "Unknown unit constant") + print("Archive info") + print(" Database name: %s" % manager_info_dict['db_name']) + print(" Table name: %s" % manager_info_dict['table_name']) + print(" Version %s" % manager_info_dict['version']) + print(" Unit system: %s (%s)" % (manager_info_dict['units'], + units_nickname)) + print(" First good timestamp: %s" % timestamp_to_string(manager_info_dict['first_ts'])) + print(" Last good timestamp: %s" % timestamp_to_string(manager_info_dict['last_ts'])) + if manager_info_dict['ts_count']: + print(" Number of records: %s" % manager_info_dict['ts_count'].value) + else: + print(" Number of records: %s (no archive records found)" % \ + manager_info_dict['ts_count']) + # if we have a database and a table but no start or stop ts and no records + # inform the user that the database/table exists but appears empty + if (manager_info_dict['db_name'] and manager_info_dict['table_name']) and \ + not (manager_info_dict['ts_count'] or manager_info_dict['units'] or \ + manager_info_dict['first_ts'] or manager_info_dict['last_ts']): + print(" It is likely that the database (%s) archive table (%s)" % \ + (manager_info_dict['db_name'], manager_info_dict['table_name'])) + print(" exists but contains no data.") + print(" weewx (weewx.conf) is set to use an archive interval of %s seconds." % \ + config_dict['StdArchive']['archive_interval']) + print(" The station hardware was not interrogated in determining archive interval.") + print() + + # weewx database info + if verbosity > 0: + print("Databases configured in weewx.conf") + for db_keys in config_dict['Databases']: + database_dict = weewx.manager.get_database_dict_from_config(config_dict, + db_keys) + _ = sorted(database_dict.keys()) + print(" Database name: %s" % database_dict['database_name']) + print(" Database driver: %s" % database_dict['driver']) + if 'host' in database_dict: + print(" Database host: %s" % database_dict['host']) + print() + + # sqlkeys/obskeys info + if verbosity > 1: + print("Supported SQL keys") + formatListCols(manager_info_dict['sqlkeys'], 3) + print() + print("Supported observation keys") + formatListCols(manager_info_dict['obskeys'], 3) + print() + +def generateDebugConf(config_dict): + """ Generate a parsed and obfuscated weewx.conf and write to sys.stdout """ + + # Make a deep copy first, as we may be altering values. + config_dict_copy = weeutil.config.deep_copy(config_dict) + + # Turn off interpolation on the copy, so it doesn't interfere with faithful representation of + # the values + config_dict_copy.interpolation = False + + # Now obfuscate any sensitive keys + obfuscate_dict(config_dict_copy, + OBFUSCATE_MAP['obfuscate'], + OBFUSCATE_MAP['do_not_obfuscate']) + + # put obfuscated config_dict into weewx.conf form + if six.PY2: + config_dict_copy.write(sys.stdout) + else: + # The ConfigObj.write() method always writes in binary, + # which is not accepted by Python 3 for output to stdout. + # So write to sys.stdout.buffer + config_dict_copy.write(sys.stdout.buffer) + + +def generateSysInfo(verbosity): + # % string operator deprecated Python 3.1 and up. + print("System info") + + if verbosity > 0: + print(" Platform: " + platform.platform()) + print(" Python Version: " + platform.python_version()) + + # environment + if verbosity > 1: + print("\nEnvironment") + for n in os.environ: + print(" %s=%s" % (n, os.environ[n])) + + # load info + try: + loadavg = '%.2f %.2f %.2f' % os.getloadavg() + (load1, load5, load15) = loadavg.split(" ") + + print("\nLoad Information\n 1 minute load average: " + load1) + print(" 5 minute load average: " + load5) + print(" 15 minute load average: " + load15) + except OSError: + print("Sorry, the load average not available on this platform") + + print() + + +def obfuscate_dict(src_dict, obfuscate_list, retain_list): + """ Obfuscate any dictionary items whose key is contained in passed list. """ + + # We need a function to be passed on to the 'walk()' function. + def obfuscate_value(section, key): + # Check to see if the key is in the obfuscation list. If so, then obfuscate it. + if any(key.startswith(k) for k in obfuscate_list) and key not in retain_list: + section[key]= "XXX obfuscated by wee_debug XXX" + + # Now walk the configuration dictionary, using the function + src_dict.walk(obfuscate_value) + + +def get_binding(config_dict): + """ Get db_binding for the weewx database """ + + # Extract our binding from the StdArchive section of the config file. If + # it's missing, return None. + if 'StdArchive' in config_dict: + db_binding_wx = config_dict['StdArchive'].get('data_binding', 'wx_binding') + else: + db_binding_wx = None + + return db_binding_wx + +def getManagerInfo(config_dict, db_binding_wx): + """ Get info from the manager of a weewx archive for inclusion in debug + report + """ + + with weewx.manager.open_manager_with_config(config_dict, db_binding_wx) as dbmanager_wx: + info = { + 'db_name' : dbmanager_wx.database_name, + 'table_name' : dbmanager_wx.table_name, + 'version' : getattr(dbmanager_wx, 'version', 'unknown'), + 'units' : dbmanager_wx.std_unit_system, + 'first_ts' : dbmanager_wx.first_timestamp, + 'last_ts' : dbmanager_wx.last_timestamp, + 'sqlkeys' : dbmanager_wx.sqlkeys, + 'obskeys' : dbmanager_wx.obskeys + } + # do we have any records in our archive? + if info['first_ts'] and info['last_ts']: + # We have some records so proceed to count them. + # Since we are (more than likely) using archive field 'dateTime' for + # our record count we need to call the getAggregate() method from our + # parent class. Note that if we change to some other field the 'count' + # might take a while longer depending on the archive size. + info['ts_count'] = super(DaySummaryManager, dbmanager_wx).getAggregate(TimeSpan(info['first_ts'], info['last_ts']), + COUNT_FIELD, + 'count') + else: + info['ts_count'] = None + return info + +def _readproc_dict(filename): + """ Read proc file that has 'name:value' format for each line """ + + info = {} + with open(filename) as fp: + for line in fp: + if line.find(':') >= 0: + (n,v) = line.split(':',1) + info[n.strip()] = v.strip() + return info + +def _readproc_line(filename): + """ Read single line proc file, return the string """ + + with open(filename) as fp: + info = fp.read() + return info + +def formatListCols(the_list, cols): + """ Format a list of strings into a given number of columns respecting the + width of the largest list entry + """ + + max_width = max([len(x) for x in the_list]) + justifyList = [x.ljust(max_width) for x in the_list] + lines = (' '.join(justifyList[i:i + cols]) + for i in range(0, len(justifyList), cols)) + print("\n".join(lines)) + +if __name__=="__main__" : + main() diff --git a/dist/weewx-4.10.1/bin/wee_device b/dist/weewx-4.10.1/bin/wee_device new file mode 100755 index 0000000..6155f40 --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_device @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# +# Copyright (c) 2013-2021 Matthew Wall and Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Command line utility for configuring devices.""" + +from __future__ import absolute_import +from __future__ import print_function +import logging +import sys + +import weecfg +import weeutil.logger +import weewx +from weeutil.weeutil import to_int + +log = logging.getLogger(__name__) + +def main(): + + # Load the configuration file + config_fn, config_dict = weecfg.read_config(None, sys.argv[1:]) + print('Using configuration file %s' % config_fn) + + # Set weewx.debug as necessary: + weewx.debug = to_int(config_dict.get('debug', 0)) + + # Customize the logging with user settings. + weeutil.logger.setup('wee_device', config_dict) + + try: + # Find the device type + device_type = config_dict['Station']['station_type'] + driver = config_dict[device_type]['driver'] + except KeyError as e: + sys.exit("Unable to determine driver: %s" % e) + + # Try to load the driver + device = None + try: + __import__(driver) + driver_module = sys.modules[driver] + loader_function = getattr(driver_module, 'configurator_loader') + except ImportError as e: + msg = "Unable to import driver %s: %s" % (driver, e) + log.error(msg) + sys.exit(msg) + except AttributeError as e: + msg = "The driver %s does not include a configuration tool" % driver + log.info("%s: %s" % (msg, e)) + sys.exit(0) + except Exception as e: + msg = "Cannot load configurator for %s" % device_type + log.error("%s: %s" % (msg, e)) + sys.exit(msg) + + device = loader_function(config_dict) + + # Try to determine driver name and version. + try: + driver_name = driver_module.DRIVER_NAME + except AttributeError: + driver_name = '?' + try: + driver_vers = driver_module.DRIVER_VERSION + except AttributeError: + driver_vers = '?' + print('Using %s driver version %s (%s)' % (driver_name, driver_vers, driver)) + + device.configure(config_dict) + + +if __name__ == "__main__": + main() diff --git a/dist/weewx-4.10.1/bin/wee_extension b/dist/weewx-4.10.1/bin/wee_extension new file mode 100755 index 0000000..091928b --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_extension @@ -0,0 +1,93 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009-2021 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Install and remove extensions.""" +from __future__ import absolute_import +import optparse +import sys + +import weewx +import weecfg.extension +import weeutil.logger +from weecfg import Logger +from weecfg.extension import ExtensionEngine +from weeutil.weeutil import to_int + +# Redirect the import of setup: +sys.modules['setup'] = weecfg.extension + +usage = """wee_extension --help + wee_extension --list + [CONFIG_FILE|--config=CONFIG_FILE] + wee_extension --install=(filename|directory) + [CONFIG_FILE|--config=CONFIG_FILE] + [--tmpdir==DIR] [--dry-run] [--verbosity=N] + wee_extension --uninstall=EXTENSION + [CONFIG_FILE|--config=CONFIG_FILE] + [--verbosity=N] + +Install, list, and uninstall extensions to weewx. + +Actions: + +--list: Show installed extensions. +--install: Install the specified extension. +--uninstall: Uninstall the specified extension.""" + +def main(): + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--list', action="store_true", dest="list_extensions", + help="Show installed extensions.") + parser.add_option('--install', metavar="FILENAME|DIRECTORY", + help="Install an extension contained in FILENAME " + " (such as pmon.tar.gz), or from a DIRECTORY in which " + "it has been unpacked.") + parser.add_option('--uninstall', metavar="EXTENSION", + help="Uninstall the extension with name EXTENSION.") + parser.add_option("--config", metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + parser.add_option('--tmpdir', default='/var/tmp', + metavar="DIR", help='Use temporary directory DIR.') + parser.add_option('--bin-root', metavar="BIN_ROOT", + help="Look in BIN_ROOT for weewx executables.") + parser.add_option('--dry-run', action='store_true', + help='Print what would happen but do not do it.') + parser.add_option('--verbosity', type=int, default=1, + metavar="N", help='How much status to display, 0-3') + + # Now we are ready to parse the command line: + (options, _args) = parser.parse_args() + + config_path, config_dict = weecfg.read_config(options.config, _args) + + # Set weewx.debug as necessary: + weewx.debug = to_int(config_dict.get('debug', 0)) + + # Customize the logging with user settings. + weeutil.logger.setup('wee_extension', config_dict) + + ext = ExtensionEngine(config_path=config_path, + config_dict=config_dict, + tmpdir=options.tmpdir, + bin_root=options.bin_root, + dry_run=options.dry_run, + logger=Logger(verbosity=options.verbosity)) + + if options.list_extensions: + ext.enumerate_extensions() + + if options.install: + ext.install_extension(options.install) + + if options.uninstall: + ext.uninstall_extension(options.uninstall) + + return 0 + +if __name__=="__main__" : + main() + diff --git a/dist/weewx-4.10.1/bin/wee_import b/dist/weewx-4.10.1/bin/wee_import new file mode 100755 index 0000000..3a5cc32 --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_import @@ -0,0 +1,899 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009-2021 Tom Keffer and Gary Roderick +# +# See the file LICENSE.txt for your rights. +# +"""Import WeeWX observation data from an external source. + +Compatibility: + + wee_import can import from: + - a Comma Separated Values (CSV) format file + - the historical records of a Weather Underground Personal + Weather Station + - one or more Cumulus monthly log files + - one or more Weather Display monthly log files + +Design + + wee_import utilises an import config file and a number of command line + options to control the import. The import config file defines the type of + input to be performed and the import data source as well as more advanced + options such as field maps etc. Details of the supported command line + parameters/options can be viewed by entering wee_import --help at the + command line. Details of the wee_import import config file settings can be + found in the example import config files distributed in the + weewx/util/import directory. + + wee_import utilises an abstract base class Source that defines the majority + of the wee_import functionality. The abstract base class and other + supporting structures are in bin/weeimport/weeimport.py. Child classes are + created from the base class for each different import type supported by + wee_import. The child classes set a number of import type specific + properties as well as defining getData() and period_generator() methods that + read the raw data to be imported and generates a sequence of objects to be + imported (eg monthly log files) respectively. This way wee_import can be + extended to support other sources by defining a new child class, its + specific properties as well as getData() and period_generator() methods. The + child class for a given import type are defined in the + bin/weeimport/xxximport.py files. + + As with other WeeWX utilities, wee_import advises the user of basic + configuration, action taken and results via the console. However, since + wee_import can make substantial changes to the WeeWX archive, wee_import + also logs to WeeWX log system file. Console and log output can be controlled + via a number of command line options. + +Prerequisites + + wee_import uses a number of WeeWX API calls and therefore must have a + functional WeeWX installation. wee_import requires WeeWX 4.0.0 or later. + +Configuration + + A number of parameters can be defined in the import config file as follows: + +# EXAMPLE WEE_IMPORT CONFIGURATION FILE +# +# Copyright (c) 2009-2016 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# Format is: +# source = (CSV | WU | Cumulus | WD) +source = CSV + +############################################################################## + +[CSV] + # Parameters used when importing from a CSV file + + # Path and name of our CSV source file. Format is: + # file = full path and filename + file = /var/tmp/data.csv + + # If there is no mapped interval field how will the interval field be + # determined for the imported records. Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when the imported records + # are equally spaced in time and there are no missing records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX using the same archive interval as set in weewx.conf on + # this machine. + # x - Use a fixed interval of x minutes for every record. This + # setting is best used if the records to be imported are + # equally based in time but there are some missing records. + # + # Note: If there is a mapped interval field then this setting will be + # ignored. + # Format is: + # interval = (derive | conf | x) + interval = derive + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Specify whether a UV sensor was used to produce any UV observations. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV fields will be set to None/NULL. + # For a CSV import UV_sensor should be set to False if a UV sensor was + # NOT present when the import data was created. Otherwise it may be set to + # True or omitted. Format is: + # UV_sensor = (True | False) + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce any solar + # radiation observations. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation fields will be set to + # None/NULL. + # For a CSV import solar_sensor should be set to False if a solar radiation + # sensor was NOT present when the import data was created. Otherwise it may + # be set to True or omitted. Format is: + # solar_sensor = (True | False) + solar_sensor = True + + # Date-time format of CSV field from which the WeeWX archive record + # dateTime field is to be extracted. wee_import first attempts to interpret + # date/time info in this format, if this fails it then attempts to + # interpret it as a timestamp and if this fails it then raises an error. + # Uses Python strptime() format codes. + # raw_datetime_format = Python strptime() format string + raw_datetime_format = %Y-%m-%d %H:%M:%S + + # Does the imported rain field represent the total rainfall since the last + # record or a cumulative value. Available options are: + # discrete - rain field represents total rainfall since last record + # cumulative - rain field represents a cumulative rainfall reset at + # midnight + # rain = (discrete | cumulative) + rain = cumulative + + # Lower and upper bounds for imported wind direction. It is possible, + # particularly for a calculated direction, to have a value (eg -45) outside + # of the WeeWX limits (0 to 360 inclusive). Format is: + # + # wind_direction = lower,upper + # + # where : + # lower is the lower limit of acceptable wind direction in degrees + # (may be negative) + # upper is the upper limit of acceptable wind direction in degrees + # + # Imported values from lower to upper will be normalised to the range 0 to + # 360. Values outside of the parameter range will be stored as None. Default + # is -360,360. + wind_direction = -360,360 + + # Map CSV record fields to WeeWX archive fields. Format is: + # + # weewx_archive_field_name = csv_field_name, weewx_unit_name + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # csv_field_name - The name of a field from the CSV file. + # weewx_unit_name - The name of the units, as defined in WeeWX, + # used by csv_field_name. This value represents + # the units used for this field in the CSV + # file, wee_import will do the necessary + # conversions to the unit system used by the + # WeeWX archive. + # For example, + # outTemp = Temp, degree_C + # would map the CSV field Temp, in degrees C, to the archive field outTemp. + # + # If a field mapping exists for the WeeWX usUnits archive field then the + # units option may be omitted for each mapped field. + # + # WeeWX archive fields that do not exist in the CSV data may be omitted. Any + # omitted fields that are derived (eg dewpoint) may be calculated during + # import using the equivalent of the WeeWX StdWXCalculate service through + # setting the calc-missing parameter above. + [[FieldMap]] + dateTime = timestamp, unix_epoch + usUnits = + interval = + barometer = barometer, inHg + pressure = + altimeter = + inTemp = + outTemp = Temp, degree_F + inHumidity = + outHumidity = humidity, percent + windSpeed = windspeed, mile_per_hour + windDir = wind, degree_compass + windGust = gust, mile_per_hour + windGustDir = gustDir, degree_compass + rainRate = rate, inch_per_hour + rain = dayrain, inch + dewpoint = + windchill = + heatindex = + ET = + radiation = + UV = + +############################################################################## + +[WU] + # Parameters used when importing from a WU PWS + + # WU PWS Station ID to be used for import. + station_id = XXXXXXXX123 + + # WU API key to be used for import. + api_key = XXXXXXXXXXXXXXXXXXXXXX1234567890 + + # + # When importing WU data the following WeeWX database fields will be + # populated directly by the imported data (provided the corresponding data + # exists on WU): + # barometer + # dateTime + # dewpoint + # heatindex + # outHumidity + # outTemp + # radiation + # rain + # rainRate + # windchill + # windDir + # windGust + # windSpeed + # UV + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # pressure + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + # windrun + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when the imported records + # are equally spaced in time and there are no missing records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX using the same archive interval as set in weewx.conf on + # this machine. + # x - Use a fixed interval of x minutes for every record. This + # setting is best used if the records to be imported are + # equally based in time but there are some missing records. + # This setting is recommended for WU imports. + # Due to WU frequently missing uploaded records, use of 'derive' may give + # incorrect or inconsistent interval values. Better results may be + # achieved by using the 'conf' setting (if WeeWX has been doing the WU + # uploading and the WeeWX archive_interval matches the WU observation + # spacing in time) or setting the interval to a fixed value (eg 5). The + # most appropriate setting will depend on the completeness and (time) + # accuracy of the WU data being imported. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. This is particularly useful + # for WU imports where WU often records clearly erroneous values against + # obs that are not reported. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Lower and upper bounds for imported wind direction. It is possible, + # particularly for a calculated direction, to have a value (eg -45) outside + # of the WeeWX limits (0 to 360 inclusive). Format is: + # + # wind_direction = lower,upper + # + # where : + # lower is the lower limit of acceptable wind direction in degrees + # (may be negative) + # upper is the upper limit of acceptable wind direction in degrees + # + # WU has at times been known to store large values (eg -9999) for wind + # direction, often no wind direction was uploaded to WU. The wind_direction + # parameter sets a lower and upper bound for valid wind direction values. + # Values inside these bounds are normalised to the range 0 to 360. Values + # outside of the bounds will be stored as None. Default is 0,360 + wind_direction = 0,360 + +############################################################################## + +[Cumulus] + # Parameters used when importing Cumulus monthly log files + # + # Directory containing Cumulus monthly log files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp/cumulus + + # When importing Cumulus monthly log file data the following WeeWX database + # fields will be populated directly by the imported data: + # barometer + # dateTime + # dewpoint + # heatindex + # inHumidity + # inTemp + # outHumidity + # outTemp + # radiation (if Cumulus data available) + # rain (requires Cumulus 1.9.4 or later) + # rainRate + # UV (if Cumulus data available) + # windDir + # windGust + # windSpeed + # windchill + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # pressure + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + # windrun + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when the imported records + # are equally spaced in time and there are no missing records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX using the same archive interval as set in weewx.conf on + # this machine. + # x - Use a fixed interval of x minutes for every record. This + # setting is best used if the records to be imported are + # equally based in time but there are some missing records. + # This setting is recommended for WU imports. + # To import Cumulus records it is recommended that the interval setting + # be set to the value used in Cumulus as the 'data log interval'. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify the character used as the field delimiter as Cumulus monthly log + # files may not always use a comma to delimit fields in the monthly log + # files. The character must be enclosed in quotes. Must not be the same + # as the decimal setting below. Format is: + # delimiter = ',' + delimiter = ',' + + # Specify the character used as the decimal point. Cumulus monthly log + # files may not always use a fullstop character as the decimal point. The + # character must be enclosed in quotes. Must not be the same as the + # delimiter setting. Format is: + # decimal = '.' + decimal = '.' + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Specify whether a UV sensor was used to produce any UV observations. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV fields will be set to None/NULL. + # For a Cumulus monthly log file import UV_sensor should be set to False if + # a UV sensor was NOT present when the import data was created. Otherwise + # it may be set to True or omitted. Format is: + # UV_sensor = (True | False) + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce any solar + # radiation observations. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation fields will be set to + # None/NULL. + # For a Cumulus monthly log file import solar_sensor should be set to False + # if a solar radiation sensor was NOT present when the import data was + # created. Otherwise it may be set to True or omitted. Format is: + # solar_sensor = (True | False) + solar_sensor = True + + # For correct import of the monthly logs wee_import needs to know what + # units are used in the imported data. The units used for temperature, + # pressure, rain and windspeed related observations in the Cumulus monthly + # logs are set at the Cumulus Station Configuration Screen. The + # [[Units]] settings below should be set to the WeeWX equivalent of the + # units of measure used by Cumulus (eg if Cumulus used 'C' for temperature, + # temperature should be set to 'degree_C'). Note that Cumulus does not + # support all units used by WeeWX (eg 'mmHg') so not all WeeWX unit are + # available options. + [[Units]] + temperature = degree_C # options are 'degree_F' or 'degree_C' + pressure = hPa # options are 'inHg', 'mbar' or 'hPa' + rain = mm # options are 'inch' or 'mm' + speed = km_per_hour # options are 'mile_per_hour', + # 'km_per_hour', 'knot' or + # 'meter_per_second' + +############################################################################## + +[WD] + # Parameters used when importing Weather Display monthly log files + # + # Directory containing Weather Display monthly log files to be imported. + # Format is: + # directory = full path without trailing / + directory = /var/tmp/WD + + # When importing Weather Display monthly log file data the following WeeWX + # database fields will be populated directly by the imported data: + # barometer + # dateTime + # dewpoint + # heatindex + # inHumidity + # inTemp + # outHumidity + # outTemp + # radiation (if WD radiation data available) + # rain + # rainRate + # UV (if WD UV data available) + # windDir + # windGust + # windSpeed + # windchill + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # pressure + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + # windrun + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when the imported records + # are equally spaced in time and there are no missing records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX using the same archive interval as set in weewx.conf on + # this machine. + # x - Use a fixed interval of x minutes for every record. This + # setting is best used if the records to be imported are + # equally based in time but there are some missing records. + # This setting is recommended for WU imports. + # To import WD records it is recommended that the interval setting be set to + # the value used in WD as the 'data log interval'. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify the character used as the field delimiter as WD monthly log files + # may not always use a comma to delimit fields in the monthly log files. The + # character must be enclosed in quotes. Must not be the same as the decimal + # setting below. Format is: + # delimiter = ',' + delimiter = ',' + + # Specify the character used as the decimal point. WD monthly log file may + # not always use a full stop character as the decimal point. The character + # must be enclosed in quotes. Must not be the same as the delimiter setting. + # Format is: + # decimal = '.' + decimal = '.' + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Specify whether a UV sensor was used to produce any UV observations. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV fields will be set to None/NULL. + # For a Cumulus monthly log file import UV_sensor should be set to False if + # a UV sensor was NOT present when the import data was created. Otherwise + # it may be set to True or omitted. Format is: + # UV_sensor = (True | False) + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce any solar + # radiation observations. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation fields will be set to + # None/NULL. + # For a Cumulus monthly log file import solar_sensor should be set to False + # if a solar radiation sensor was NOT present when the import data was + # created. Otherwise it may be set to True or omitted. Format is: + # solar_sensor = (True | False) + solar_sensor = True + + # For correct import of the monthly logs wee_import needs to know what + # units are used in the imported data. The units used for temperature, + # pressure, rain and wind speed related observations in the Cumulus monthly + # logs are set at the Cumulus Station Configuration Screen. The + # [[Units]] settings below should be set to the WeeWX equivalent of the + # units of measure used by Cumulus (eg if Cumulus used 'C' for temperature, + # temperature should be set to 'degree_C'). Note that Cumulus does not + # support all units used by WeeWX (eg 'mmHg') so not all WeeWX units are + # available options. + [[Units]] + temperature = degree_C # options are 'degree_F' or 'degree_C' + pressure = hPa # options are 'inHg', 'mbar' or 'hPa' + rain = mm # options are 'inch' or 'mm' + speed = km_per_hour # options are 'mile_per_hour', + # 'km_per_hour', 'knot' or + # 'meter_per_second' + + +Adding a New Import Source + + To add a new import source: + + - Create a new file bin/weeimport/xxximport.py that defines a new class + for the xxx source that is a child of class Source. The new class must + meet the following minimum requirements: + + - __init__() must define: + + - self.raw_datetime_format: Format of date time data field from + which observation timestamp is to be + derived. String comprising Python + strptime() format codes. + - self.rain: Whether imported rainfall field contains the + rainfall since the last record or a cumulative value. + String 'discrete' or 'cumulative' + - self.wind_dir: The range of values in degrees that will be + accepted as a valid wind direction. Two way + tuple of the format (lower, upper) where lower + is the lower inclusive limit and upper is the + upper inclusive limit. + + - Define a period_generator() method that: + + - Accepts no parameters and generates (yields) a sequence of + objects (eg file names, dates for a http request etc) that are + passed to the getRawData() method to obtain a sequence of raw + data records. + + - Define a getRawdata() method that: + + - Accepts a single parameter 'period' that is provided by the + period_generator() method. + + - Returns an iterable of raw source data records. + + - Creates the source data field-to-WeeWX archive field map and + saves the map to the class object map property (the map may be + created using the Source.parseMap() method). Refer to + getRawData() methods in csvimport.py and wuimport.py. + + - Modify bin/weeimport/weeimport.py as follows: + + - Add a new entry to the list of supported services defined in + SUPPORTED_SERVICES. + + - Create a wee_import import config file for importing from the new + source. The import config file must: + + - Add a new source name to the source parameter. The new source name + must be the same (case sensitive) as the entry added to + weeimport.py SUPPORTED_SERVICES. + + - Define a stanza for the new import type. The stanza must be the + same (case sensitive) as the entry added to weeimport.py + SUPPORTED_SERVICES. +""" + +# Python imports +from __future__ import absolute_import +from __future__ import print_function + +import logging +import optparse + +from distutils.version import StrictVersion + +# WeeWX imports +import weecfg +import weewx +import weeimport +import weeimport.weeimport +import weeutil.logger +import weeutil.weeutil + + +log = logging.getLogger(__name__) + +# wee_import version number +WEE_IMPORT_VERSION = '0.7' +# minimum WeeWX version required for this version of wee_import +REQUIRED_WEEWX = "4.0.0" + +description = """Import observation data into a WeeWX archive.""" + +usage = """wee_import --help + wee_import --version + wee_import --import-config=IMPORT_CONFIG_FILE + [--config=CONFIG_FILE] + [--date=YYYY-mm-dd | --from=YYYY-mm-dd[THH:MM] --to=YYYY-mm-dd[THH:MM]] + [--dry-run] + [--verbose] + [--no-prompt] + [--suppress-warnings] +""" + +epilog = """wee_import will import data from an external source into a WeeWX + archive. Daily summaries are updated as each archive record is + imported so there should be no need to separately rebuild the daily + summaries using the wee_database utility.""" + + +def main(): + """The main routine that kicks everything off.""" + + # Create a command line parser: + parser = optparse.OptionParser(description=description, + usage=usage, + epilog=epilog) + + # Add the various options: + parser.add_option("--config", dest="config_path", type=str, + metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + parser.add_option("--import-config", dest="import_config_path", type=str, + metavar="IMPORT_CONFIG_FILE", + help="Use import configuration file IMPORT_CONFIG_FILE.") + parser.add_option("--dry-run", dest="dry_run", action="store_true", + help="Print what would happen but do not do it.") + parser.add_option("--date", dest="date", type=str, metavar="YYYY-mm-dd", + help="Import data for this date. Format is YYYY-mm-dd.") + parser.add_option("--from", dest="date_from", type=str, metavar="YYYY-mm-dd[THH:MM]", + help="Import data starting at this date or date-time. " + "Format is YYYY-mm-dd[THH:MM].") + parser.add_option("--to", dest="date_to", type=str, metavar="YYYY-mm-dd[THH:MM]", + help="Import data up until this date or date-time. Format " + "is YYYY-mm-dd[THH:MM].") + parser.add_option("--verbose", action="store_true", dest="verbose", + help="Print and log useful extra output.") + parser.add_option("--no-prompt", action="store_true", dest="no_prompt", + help="Do not prompt. Accept relevant defaults and all y/n prompts.") + parser.add_option("--suppress-warnings", action="store_true", dest="suppress", + help="Suppress warnings to stdout. Warnings are still logged.") + parser.add_option("--version", dest="version", action="store_true", + help="Display wee_import version number.") + + # Now we are ready to parse the command line: + (options, args) = parser.parse_args() + + # check WeeWX version number for compatibility + if StrictVersion(weewx.__version__) < StrictVersion(REQUIRED_WEEWX): + print("WeeWX %s or greater is required, found %s. Nothing done, exiting." % (REQUIRED_WEEWX, + weewx.__version__)) + exit(1) + + # get config_dict to use + config_path, config_dict = weecfg.read_config(options.config_path, args) + print("Using WeeWX configuration file %s" % config_path) + + # Set weewx.debug as necessary: + weewx.debug = weeutil.weeutil.to_int(config_dict.get('debug', 0)) + + # Set up any customized logging: + weeutil.logger.setup('wee_import', config_dict) + + # display wee_import version info + if options.version: + print("wee_import version: %s" % WEE_IMPORT_VERSION) + exit(0) + + # to do anything more we need an import config file, check if one was + # provided + if options.import_config_path: + # we have something so try to start + + # advise the user we are starting up + print("Starting wee_import...") + log.info("Starting wee_import...") + + # If we got this far we must want to import something so get a Source + # object from our factory and try to import. Be prepared to catch any + # errors though. + try: + source_obj = weeimport.weeimport.Source.sourceFactory(options, + args) + source_obj.run() + except weeimport.weeimport.WeeImportOptionError as e: + print("**** Command line option error.") + log.info("**** Command line option error.") + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except weeimport.weeimport.WeeImportIOError as e: + print("**** Unable to load source data.") + log.info("**** Unable to load source data.") + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except weeimport.weeimport.WeeImportFieldError as e: + print("**** Unable to map source data.") + log.info("**** Unable to map source data.") + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except weeimport.weeimport.WeeImportMapError as e: + print("**** Unable to parse source-to-WeeWX field map.") + log.info("**** Unable to parse source-to-WeeWX field map.") + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except (weewx.ViolatedPrecondition, weewx.UnsupportedFeature) as e: + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + print() + parser.print_help() + exit(1) + except SystemExit as e: + print(e) + exit(0) + except (ValueError, weewx.UnitError) as e: + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except IOError as e: + print("**** Unable to load config file.") + log.info("**** Unable to load config file.") + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + else: + # we have no import config file so display a suitable message followed + # by the help text then exit + print("**** No import config file specified.") + print("**** Nothing done.") + print() + parser.print_help() + exit(1) + + +# execute our main code +if __name__ == "__main__": + main() diff --git a/dist/weewx-4.10.1/bin/wee_reports b/dist/weewx-4.10.1/bin/wee_reports new file mode 100755 index 0000000..f44b596 --- /dev/null +++ b/dist/weewx-4.10.1/bin/wee_reports @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Executable that can run all reports.""" + +from __future__ import absolute_import +from __future__ import print_function + +import argparse +import socket +import sys +import time + +# Although 'user.extensions' is not used, it's important to execute any user extensions +# before starting. +# noinspection PyUnresolvedReferences +import user.extensions +import weecfg +import weeutil.logger +import weewx.engine +import weewx.manager +import weewx.reportengine +import weewx.station +from weeutil.weeutil import timestamp_to_string + +description = """Run all reports defined in the specified configuration file. +Use this utility to run reports immediately instead of waiting for the end of +an archive interval.""" + +usage = """%(prog)s --help + %(prog)s [CONFIG_FILE | --config=CONFIG_FILE] + %(prog)s [CONFIG_FILE | --config=CONFIG_FILE] --epoch=TIMESTAMP + %(prog)s [CONFIG_FILE | --config=CONFIG_FILE] --date=YYYY-MM-DD --time=HH:MM""" + +epilog = "Specify either the positional argument CONFIG_FILE, " \ + "or the optional argument --config, but not both." + + +def disable_timing(section, key): + """Function to effectively disable report_timing option""" + if key == 'report_timing': + section['report_timing'] = "* * * * *" + + +def main(): + # Create a command line parser: + parser = argparse.ArgumentParser(description=description, usage=usage, epilog=epilog, + prog='wee_reports') + + # Add the various options: + parser.add_argument("--config", dest="config_option", metavar="CONFIG_FILE", + help="Use the configuration file CONFIG_FILE") + parser.add_argument("--epoch", metavar="EPOCH_TIME", + help="Time of the report in unix epoch time") + parser.add_argument("--date", metavar="YYYY-MM-DD", + type=lambda d: time.strptime(d, '%Y-%m-%d'), + help="Date for the report") + parser.add_argument("--time", metavar="HH:MM", + type=lambda t: time.strptime(t, '%H:%M'), + help="Time of day for the report") + parser.add_argument("config_arg", nargs='?', metavar="CONFIG_FILE") + + # Now we are ready to parse the command line: + namespace = parser.parse_args() + + # User can specify the config file as either a positional argument, or as an option + # argument, but not both. + if namespace.config_option and namespace.config_arg: + sys.exit(epilog) + # Presence of --date requires --time and v.v. + if namespace.date and not namespace.time or namespace.time and not namespace.date: + sys.exit("Must specify both --date and --time.") + # Can specify the time as either unix epoch time, or explicit date and time, but not both + if namespace.epoch and namespace.date: + sys.exit("The time of the report must be specified either as unix epoch time, " + "or with an explicit date and time, but not both.") + + # If the user specified a time, retrieve it. Otherwise, set to None + if namespace.epoch: + gen_ts = int(namespace.epoch) + elif namespace.date: + gen_ts = get_epoch_time(namespace.date, namespace.time) + else: + gen_ts = None + + if gen_ts is None: + print("Generating as of last timestamp in the database.") + else: + print("Generating for requested time %s" % timestamp_to_string(gen_ts)) + + # Fetch the config file + config_path, config_dict = weecfg.read_config(namespace.config_arg, [namespace.config_option]) + print("Using configuration file %s" % config_path) + + # Look for the debug flag. If set, ask for extra logging + weewx.debug = int(config_dict.get('debug', 0)) + + # Set logging configuration: + weeutil.logger.setup('wee_reports', config_dict) + + # For wee_reports we want to generate all reports irrespective of any + # report_timing settings that may exist. The easiest way to do this is walk + # the config dict resetting any report_timing settings found. + config_dict.walk(disable_timing) + + socket.setdefaulttimeout(10) + + # Instantiate the dummy engine. This will cause services to get loaded, which will make + # the type extensions (xtypes) system available. + engine = weewx.engine.DummyEngine(config_dict) + + stn_info = weewx.station.StationInfo(**config_dict['Station']) + + try: + binding = config_dict['StdArchive']['data_binding'] + except KeyError: + binding = 'wx_binding' + + # Retrieve the appropriate record from the database + with weewx.manager.DBBinder(config_dict) as db_binder: + db_manager = db_binder.get_manager(binding) + if gen_ts: + ts = gen_ts + else: + ts = db_manager.lastGoodStamp() + + record = db_manager.getRecord(ts) + + # Instantiate the report engine with the retrieved record and required timestamp + t = weewx.reportengine.StdReportEngine(config_dict, stn_info, record=record, gen_ts=ts) + + # Although the report engine inherits from Thread, we can just run it in the main thread: + t.run() + + # Shut down any running services, + engine.shutDown() + + +def get_epoch_time(d_tt, t_tt): + tt = (d_tt.tm_year, d_tt.tm_mon, d_tt.tm_mday, + t_tt.tm_hour, t_tt.tm_min, 0, 0, 0, -1) + return time.mktime(tt) + + +if __name__ == "__main__": + main() diff --git a/dist/weewx-4.10.1/bin/weecfg/__init__.py b/dist/weewx-4.10.1/bin/weecfg/__init__.py new file mode 100644 index 0000000..a34ca86 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weecfg/__init__.py @@ -0,0 +1,2004 @@ +# coding: utf-8 +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Utilities used by the setup and configure programs""" + +from __future__ import print_function +from __future__ import with_statement +from __future__ import absolute_import + +import errno +import glob +import os.path +import shutil +import sys +import tempfile + +import six +from six.moves import StringIO, input + +import configobj + +import weeutil.weeutil +import weeutil.config +from weeutil.weeutil import to_bool + +major_comment_block = ["", "##############################################################################", ""] + +DEFAULT_URL = 'http://acme.com' + + +class ExtensionError(IOError): + """Errors when installing or uninstalling an extension""" + + +class Logger(object): + def __init__(self, verbosity=0): + self.verbosity = verbosity + + def log(self, msg, level=0): + if self.verbosity >= level: + print("%s%s" % (' ' * (level - 1), msg)) + + def set_verbosity(self, verbosity): + self.verbosity = verbosity + + +# ============================================================================== +# Utilities that find and save ConfigObj objects +# ============================================================================== + +DEFAULT_LOCATIONS = ['../..', '/etc/weewx', '/home/weewx'] + + +def find_file(file_path=None, args=None, locations=DEFAULT_LOCATIONS, + file_name='weewx.conf'): + """Find and return a path to a file, looking in "the usual places." + + General strategy: + + First, file_path is tried. If not found there, then the first element of + args is tried. + + If those fail, try a path based on where the application is running. + + If that fails, then the list of directory locations is searched, + looking for a file with file name file_name. + + If after all that, the file still cannot be found, then an IOError + exception will be raised. + + Args: + file_path (str): A candidate path to the file. + args (list[str]): command-line arguments. If the file cannot be found in file_path, + then the members of args will be tried. + locations (list[str]): A list of directories to be searched. If they do not + start with a slash ('/'), then they will be treated as relative to + this file (bin/weecfg/__init__.py). + Default is ['../..', '/etc/weewx', '/home/weewx']. + file_name (str): The name of the file to be found. This is used + only if the directories must be searched. Default is 'weewx.conf'. + + Returns: + str: full path to the file + + Raises: + IOError: If the configuration file cannot be found, or is not a file. + """ + + # Start by searching args (if available) + if file_path is None and args: + for i in range(len(args)): + # Ignore empty strings and None values: + if not args[i]: + continue + if not args[i].startswith('-'): + file_path = args[i] + del args[i] + break + + if file_path is None: + for directory in locations: + # If this is a relative path, then prepend with the + # directory this file is in: + if not directory.startswith('/'): + directory = os.path.join(os.path.dirname(__file__), directory) + candidate = os.path.abspath(os.path.join(directory, file_name)) + if os.path.isfile(candidate): + return candidate + + if file_path is None: + raise IOError("Unable to find file '%s'. Tried directories %s" + % (file_name, locations)) + elif not os.path.isfile(file_path): + raise IOError("%s is not a file" % file_path) + + return file_path + + +def read_config(config_path, args=None, locations=DEFAULT_LOCATIONS, + file_name='weewx.conf', interpolation='ConfigParser'): + """Read the specified configuration file, return an instance of ConfigObj + with the file contents. If no file is specified, look in the standard + locations for weewx.conf. Returns the filename of the actual configuration + file, as well as the ConfigObj. + + Args: + + config_path (str): configuration filename. + args (list[str]): command-line arguments. + locations (list[str]): A list of directories to search. + file_name (str): The name of the config file. Default is 'weewx.conf' + interpolation (str): The type of interpolation to use when reading the config file. + Default is 'ConfigParser'. See the ConfigObj documentation https://bit.ly/3L593vH + + Returns: + (str, configobj.ConfigObj): path-to-file, instance-of-ConfigObj + + Raises: + SyntaxError: If there is a syntax error in the file + IOError: If the file cannot be found + """ + # Find and open the config file: + config_path = find_file(config_path, args, + locations=locations, file_name=file_name) + try: + # Now open it up and parse it. + config_dict = configobj.ConfigObj(config_path, + interpolation=interpolation, + file_error=True, + encoding='utf-8', + default_encoding='utf-8') + except configobj.ConfigObjError as e: + # Add on the path of the offending file, then reraise. + e.msg += ' File %s' % config_path + raise + + # Remember where we found the config file + config_dict['config_path'] = os.path.realpath(config_path) + + return config_path, config_dict + + +def save_with_backup(config_dict, config_path): + return save(config_dict, config_path, backup=True) + + +def save(config_dict, config_path, backup=False): + """Save the config file, backing up as necessary. + + Args: + config_dict(dict): A configuration dictionary. + config_path(str): Path to where the dictionary should be saved. + backup(bool): True to save a timestamped version of the old config file, False otherwise. + Returns: + str|None: The path to the backed up old config file. None otherwise + """ + + # We need to pop 'config_path' off the dictionary before writing. WeeWX v4.9.1 wrote + # 'entry_path' to the config file as well, so we need to get rid of that in case it snuck in. + # Make a deep copy first --- we're going to be modifying the dictionary. + write_dict = weeutil.config.deep_copy(config_dict) + write_dict.pop('config_path', None) + write_dict.pop('entry_path', None) + + # Check to see if the file exists, and we are supposed to make backup: + if os.path.exists(config_path) and backup: + + # Yes. We'll have to back it up. + backup_path = weeutil.weeutil.move_with_timestamp(config_path) + + # Now we can save the file. Get a temporary file: + with tempfile.NamedTemporaryFile() as tmpfile: + # Write the configuration dictionary to it: + write_dict.write(tmpfile) + tmpfile.flush() + + # Now move the temporary file into the proper place: + shutil.copyfile(tmpfile.name, config_path) + + else: + + # No existing file or no backup required. Just write. + with open(config_path, 'wb') as fd: + write_dict.write(fd) + backup_path = None + + return backup_path + + +# ============================================================================== +# Utilities that modify ConfigObj objects +# ============================================================================== + +def modify_config(config_dict, stn_info, logger, debug=False): + """This function is responsible for creating or modifying the driver stanza. + + If a driver has a configuration editor, then use that to insert the + stanza for the driver in the config_dict. If there is no configuration + editor, then inject a generic configuration, i.e., just the driver name + with a single 'driver' element that points to the driver file. + + Args: + config_dict(configobj.ConfigObj): The configuration dictionary + stn_info(dict): Dictionary containing station information. Typical entries: + location: "My Little Town, Oregon" + latitude: "45.0" + longitude: "-122.0" + altitude: ["700", "foot"] + station_type: "Vantage" + lang: "en" + unit_system: "us" + register_this_station: "False" + driver: "weewx.drivers.vantage" + logger (Logger): For logging + debug (bool): For additional debug information + """ + driver_editor = None + driver_name = None + driver_version = None + + # Get the driver editor, name, and version: + driver = stn_info.get('driver') + if driver: + try: + # Look up driver info: + driver_editor, driver_name, driver_version = load_driver_editor(driver) + except Exception as e: + sys.exit("Driver %s failed to load: %s" % (driver, e)) + stn_info['station_type'] = driver_name + if debug: + logger.log('Using %s version %s (%s)' + % (driver_name, driver_version, driver), level=1) + + # Get a driver stanza, if possible + stanza = None + if driver_name is not None: + if driver_editor is not None: + # if a previous stanza exists for this driver, grab it + if driver_name in config_dict: + orig_stanza = configobj.ConfigObj(interpolation=False) + orig_stanza[driver_name] = config_dict[driver_name] + orig_stanza_text = '\n'.join(orig_stanza.write()) + else: + orig_stanza_text = None + + # let the driver process the stanza or give us a new one + stanza_text = driver_editor.get_conf(orig_stanza_text) + stanza = configobj.ConfigObj(stanza_text.splitlines()) + + # let the driver modify other parts of the configuration + driver_editor.modify_config(config_dict) + else: + stanza = configobj.ConfigObj(interpolation=False) + stanza[driver_name] = config_dict.get(driver_name, {}) + + # If we have a stanza, inject it into the configuration dictionary + if stanza is not None and driver_name is not None: + # Ensure that the driver field matches the path to the actual driver + stanza[driver_name]['driver'] = driver + # Insert the stanza in the configuration dictionary: + config_dict[driver_name] = stanza[driver_name] + # Add a major comment deliminator: + config_dict.comments[driver_name] = major_comment_block + # If we have a [Station] section, move the new stanza to just after it + if 'Station' in config_dict: + reorder_sections(config_dict, driver_name, 'Station', after=True) + # make the stanza the station type + config_dict['Station']['station_type'] = driver_name + + # Apply any overrides from the stn_info + if stn_info: + # Update driver stanza with any overrides from stn_info + if driver_name is not None and driver_name in stn_info: + for k in stn_info[driver_name]: + config_dict[driver_name][k] = stn_info[driver_name][k] + # Update station information with stn_info overrides + for p in ['location', 'latitude', 'longitude', 'altitude']: + if p in stn_info: + if debug: + logger.log("Using %s for %s" % (stn_info[p], p), level=2) + config_dict['Station'][p] = stn_info[p] + + if 'StdReport' in config_dict \ + and 'unit_system' in stn_info \ + and stn_info['unit_system'] != 'custom': + # Make sure the default unit system sits under [[Defaults]]. First, get rid of anything + # under [StdReport] + config_dict['StdReport'].pop('unit_system', None) + # Then add it under [[Defaults]] + config_dict['StdReport']['Defaults']['unit_system'] = stn_info['unit_system'] + + if 'register_this_station' in stn_info \ + and 'StdRESTful' in config_dict \ + and 'StationRegistry' in config_dict['StdRESTful']: + config_dict['StdRESTful']['StationRegistry']['register_this_station'] \ + = stn_info['register_this_station'] + + if 'station_url' in stn_info and 'Station' in config_dict: + if 'station_url' in config_dict['Station']: + config_dict['Station']['station_url'] = stn_info['station_url'] + else: + inject_station_url(config_dict, stn_info['station_url']) + + +def inject_station_url(config_dict, url): + """Inject the option station_url into the [Station] section""" + + if 'station_url' in config_dict['Station']: + # Already injected. Done. + return + + # Isolate just the [Station] section. This simplifies what follows + station_dict = config_dict['Station'] + + # First search for any existing comments that mention 'station_url' + for scalar in station_dict.scalars: + for ilist, comment in enumerate(station_dict.comments[scalar]): + if comment.find('station_url') != -1: + # This deletes the (up to) three lines related to station_url that ships + # with the standard distribution + del station_dict.comments[scalar][ilist] + if ilist and station_dict.comments[scalar][ilist - 1].find('specify an URL') != -1: + del station_dict.comments[scalar][ilist - 1] + if ilist > 1 and station_dict.comments[scalar][ilist - 2].strip() == '': + del station_dict.comments[scalar][ilist - 2] + + # Add the new station_url, plus comments + station_dict['station_url'] = url + station_dict.comments['station_url'] \ + = ['', ' # If you have a website, you may specify an URL'] + + # Reorder to match the canonical ordering. + reorder_scalars(station_dict.scalars, 'station_url', 'rain_year_start') + +# ============================================================================== +# Utilities that update and merge ConfigObj objects +# ============================================================================== + + +def update_and_merge(config_dict, template_dict): + """First update a configuration file, then merge it with the distribution template""" + + update_config(config_dict) + merge_config(config_dict, template_dict) + + +def update_config(config_dict): + """Update a (possibly old) configuration dictionary to the latest format. + + Raises exception of type ValueError if it cannot be done. + """ + + major, minor = get_version_info(config_dict) + + # I don't know how to merge older, V1.X configuration files, only + # newer V2.X ones. + if major == '1': + raise ValueError("Cannot update version V%s.%s. Too old" % (major, minor)) + + update_to_v25(config_dict) + + update_to_v26(config_dict) + + update_to_v30(config_dict) + + update_to_v32(config_dict) + + update_to_v36(config_dict) + + update_to_v39(config_dict) + + update_to_v40(config_dict) + + update_to_v42(config_dict) + + update_to_v43(config_dict) + + +def merge_config(config_dict, template_dict): + """Merge the template (distribution) dictionary into the user's dictionary. + + config_dict: An existing, older configuration dictionary. + + template_dict: A newer dictionary supplied by the installer. + """ + + # All we need to do is update the version number: + config_dict['version'] = template_dict['version'] + + +def update_to_v25(config_dict): + """Major changes for V2.5: + + - Option webpath is now station_url + - Drivers are now in their own package + - Introduction of the station registry + + """ + major, minor = get_version_info(config_dict) + + if major + minor >= '205': + return + + try: + # webpath is now station_url + webpath = config_dict['Station'].get('webpath') + station_url = config_dict['Station'].get('station_url') + if webpath is not None and station_url is None: + config_dict['Station']['station_url'] = webpath + config_dict['Station'].pop('webpath', None) + except KeyError: + pass + + # Drivers are now in their own Python package. Change the names. + + # --- Davis Vantage series --- + try: + if config_dict['Vantage']['driver'].strip() == 'weewx.VantagePro': + config_dict['Vantage']['driver'] = 'weewx.drivers.vantage' + except KeyError: + pass + + # --- Oregon Scientific WMR100 --- + + # The section name has changed from WMR-USB to WMR100 + if 'WMR-USB' in config_dict: + if 'WMR100' in config_dict: + sys.exit("\n*** Configuration file has both a 'WMR-USB' " + "section and a 'WMR100' section. Aborting ***\n\n") + config_dict.rename('WMR-USB', 'WMR100') + # If necessary, reflect the section name in the station type: + try: + if config_dict['Station']['station_type'].strip() == 'WMR-USB': + config_dict['Station']['station_type'] = 'WMR100' + except KeyError: + pass + # Finally, the name of the driver has been changed + try: + if config_dict['WMR100']['driver'].strip() == 'weewx.wmrx': + config_dict['WMR100']['driver'] = 'weewx.drivers.wmr100' + except KeyError: + pass + + # --- Oregon Scientific WMR9x8 series --- + + # The section name has changed from WMR-918 to WMR9x8 + if 'WMR-918' in config_dict: + if 'WMR9x8' in config_dict: + sys.exit("\n*** Configuration file has both a 'WMR-918' " + "section and a 'WMR9x8' section. Aborting ***\n\n") + config_dict.rename('WMR-918', 'WMR9x8') + # If necessary, reflect the section name in the station type: + try: + if config_dict['Station']['station_type'].strip() == 'WMR-918': + config_dict['Station']['station_type'] = 'WMR9x8' + except KeyError: + pass + # Finally, the name of the driver has been changed + try: + if config_dict['WMR9x8']['driver'].strip() == 'weewx.WMR918': + config_dict['WMR9x8']['driver'] = 'weewx.drivers.wmr9x8' + except KeyError: + pass + + # --- Fine Offset instruments --- + + try: + if config_dict['FineOffsetUSB']['driver'].strip() == 'weewx.fousb': + config_dict['FineOffsetUSB']['driver'] = 'weewx.drivers.fousb' + except KeyError: + pass + + # --- The weewx Simulator --- + + try: + if config_dict['Simulator']['driver'].strip() == 'weewx.simulator': + config_dict['Simulator']['driver'] = 'weewx.drivers.simulator' + except KeyError: + pass + + if 'StdArchive' in config_dict: + # Option stats_types is no longer used. Get rid of it. + config_dict['StdArchive'].pop('stats_types', None) + + try: + # V2.5 saw the introduction of the station registry: + if 'StationRegistry' not in config_dict['StdRESTful']: + stnreg_dict = weeutil.config.config_from_str("""[StdRESTful] + + [[StationRegistry]] + # Uncomment the following line to register this weather station. + #register_this_station = True + + # Specify a station URL, otherwise the station_url from [Station] + # will be used. + #station_url = http://example.com/weather/ + + # Specify a description of the station, otherwise the location from + # [Station] will be used. + #description = The greatest station on earth + + driver = weewx.restful.StationRegistry + + """) + config_dict.merge(stnreg_dict) + except KeyError: + pass + + config_dict['version'] = '2.5.0' + + +def update_to_v26(config_dict): + """Update a configuration diction to V2.6. + + Major changes: + + - Addition of "model" option for WMR100, WMR200, and WMR9x8 + - New option METRICWX + - Engine service list now broken up into separate sublists + - Introduction of 'log_success' and 'log_failure' options + - Introduction of rapidfire + - Support of uploaders for WOW and AWEKAS + - CWOP option 'interval' changed to 'post_interval' + - CWOP option 'server' changed to 'server_list' (and is not in default weewx.conf) + """ + + major, minor = get_version_info(config_dict) + + if major + minor >= '206': + return + + try: + if 'model' not in config_dict['WMR100']: + config_dict['WMR100']['model'] = 'WMR100' + config_dict['WMR100'].comments['model'] = \ + ["", " # The station model, e.g., WMR100, WMR100N, WMRS200"] + except KeyError: + pass + + try: + if 'model' not in config_dict['WMR200']: + config_dict['WMR200']['model'] = 'WMR200' + config_dict['WMR200'].comments['model'] = \ + ["", " # The station model, e.g., WMR200, WMR200A, Radio Shack W200"] + except KeyError: + pass + + try: + if 'model' not in config_dict['WMR9x8']: + config_dict['WMR9x8']['model'] = 'WMR968' + config_dict['WMR9x8'].comments['model'] = \ + ["", " # The station model, e.g., WMR918, Radio Shack 63-1016"] + except KeyError: + pass + + # Option METRICWX was introduced. Include it in the inline comment + try: + config_dict['StdConvert'].inline_comments['target_unit'] = "# Options are 'US', 'METRICWX', or 'METRIC'" + except KeyError: + pass + + # New default values for inHumidity, rain, and windSpeed Quality Controls + try: + if 'inHumidity' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['inHumidity'] = [0, 100] + if 'rain' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['rain'] = [0, 60, "inch"] + if 'windSpeed' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['windSpeed'] = [0, 120, "mile_per_hour"] + if 'inTemp' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['inTemp'] = [10, 20, "degree_F"] + except KeyError: + pass + + service_map_v2 = {'weewx.wxengine.StdTimeSynch': 'prep_services', + 'weewx.wxengine.StdConvert': 'process_services', + 'weewx.wxengine.StdCalibrate': 'process_services', + 'weewx.wxengine.StdQC': 'process_services', + 'weewx.wxengine.StdArchive': 'archive_services', + 'weewx.wxengine.StdPrint': 'report_services', + 'weewx.wxengine.StdReport': 'report_services'} + + # See if the engine configuration section has the old-style "service_list": + if 'Engines' in config_dict and 'service_list' in config_dict['Engines']['WxEngine']: + # It does. Break it up into five, smaller lists. If a service + # does not appear in the dictionary "service_map_v2", meaning we + # do not know what it is, then stick it in the last group we + # have seen. This should get its position about right. + last_group = 'prep_services' + + # Set up a bunch of empty groups in the right order. Option 'data_services' was actually introduced + # in v3.0, but it can be included without harm here. + for group in ['prep_services', 'data_services', 'process_services', 'archive_services', + 'restful_services', 'report_services']: + config_dict['Engines']['WxEngine'][group] = list() + + # Add a helpful comment + config_dict['Engines']['WxEngine'].comments['prep_services'] = \ + ['', ' # The list of services the main weewx engine should run:'] + + # Now map the old service names to the right group + for _svc_name in config_dict['Engines']['WxEngine']['service_list']: + svc_name = _svc_name.strip() + # Skip the no longer needed StdRESTful service: + if svc_name == 'weewx.wxengine.StdRESTful': + continue + # Do we know about this service? + if svc_name in service_map_v2: + # Yes. Get which group it belongs to, and put it there + group = service_map_v2[svc_name] + config_dict['Engines']['WxEngine'][group].append(svc_name) + last_group = group + else: + # No. Put it in the last group. + config_dict['Engines']['WxEngine'][last_group].append(svc_name) + + # Now add the restful services, using the old driver name to help us + for section in config_dict['StdRESTful'].sections: + svc = config_dict['StdRESTful'][section]['driver'] + # weewx.restful has changed to weewx.restx + if svc.startswith('weewx.restful'): + svc = 'weewx.restx.Std' + section + # awekas is in weewx.restx since 2.6 + if svc.endswith('AWEKAS'): + svc = 'weewx.restx.AWEKAS' + config_dict['Engines']['WxEngine']['restful_services'].append(svc) + + # Depending on how old a version the user has, the station registry + # may have to be included: + if 'weewx.restx.StdStationRegistry' not in config_dict['Engines']['WxEngine']['restful_services']: + config_dict['Engines']['WxEngine']['restful_services'].append('weewx.restx.StdStationRegistry') + + # Get rid of the no longer needed service_list: + config_dict['Engines']['WxEngine'].pop('service_list', None) + + # V2.6 introduced "log_success" and "log_failure" options. + # The "driver" option was removed. + for section in config_dict['StdRESTful']: + # Save comments before popping driver + comments = config_dict['StdRESTful'][section].comments.get('driver', []) + if 'log_success' not in config_dict['StdRESTful'][section]: + config_dict['StdRESTful'][section]['log_success'] = True + if 'log_failure' not in config_dict['StdRESTful'][section]: + config_dict['StdRESTful'][section]['log_failure'] = True + config_dict['StdRESTful'][section].comments['log_success'] = comments + config_dict['StdRESTful'][section].pop('driver', None) + + # Option 'rapidfire' was new: + try: + if 'rapidfire' not in config_dict['StdRESTful']['Wunderground']: + config_dict['StdRESTful']['Wunderground']['rapidfire'] = False + config_dict['StdRESTful']['Wunderground'].comments['rapidfire'] = \ + ['', + ' # Set the following to True to have weewx use the WU "Rapidfire"', + ' # protocol'] + except KeyError: + pass + + # Support for the WOW uploader was introduced + try: + if 'WOW' not in config_dict['StdRESTful']: + config_dict.merge(weeutil.config.config_from_str("""[StdRESTful] + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + log_success = True + log_failure = True + + """)) + config_dict['StdRESTful'].comments['WOW'] = [''] + except KeyError: + pass + + # Support for the AWEKAS uploader was introduced + try: + if 'AWEKAS' not in config_dict['StdRESTful']: + config_dict.merge(weeutil.config.config_from_str("""[StdRESTful] + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + log_success = True + log_failure = True + + """)) + config_dict['StdRESTful'].comments['AWEKAS'] = [''] + except KeyError: + pass + + # The CWOP option "interval" has changed to "post_interval" + try: + if 'interval' in config_dict['StdRESTful']['CWOP']: + comment = config_dict['StdRESTful']['CWOP'].comments['interval'] + config_dict['StdRESTful']['CWOP']['post_interval'] = \ + config_dict['StdRESTful']['CWOP']['interval'] + config_dict['StdRESTful']['CWOP'].pop('interval') + config_dict['StdRESTful']['CWOP'].comments['post_interval'] = comment + except KeyError: + pass + + try: + if 'server' in config_dict['StdRESTful']['CWOP']: + # Save the old comments, as they are useful for setting up CWOP + comments = [c for c in config_dict['StdRESTful']['CWOP'].comments.get('server') if 'Comma' not in c] + # Option "server" has become "server_list". It is also no longer + # included in the default weewx.conf, so just pop it. + config_dict['StdRESTful']['CWOP'].pop('server', None) + # Put the saved comments in front of the first scalar. + key = config_dict['StdRESTful']['CWOP'].scalars[0] + config_dict['StdRESTful']['CWOP'].comments[key] = comments + except KeyError: + pass + + config_dict['version'] = '2.6.0' + + +def update_to_v30(config_dict): + """Update a configuration file to V3.0 + + - Introduction of the new database structure + - Introduction of StdWXCalculate + """ + + major, minor = get_version_info(config_dict) + + if major + minor >= '300': + return + + old_database = None + + if 'StdReport' in config_dict: + # The key "data_binding" is now used instead of these: + config_dict['StdReport'].pop('archive_database', None) + config_dict['StdReport'].pop('stats_database', None) + if 'data_binding' not in config_dict['StdReport']: + config_dict['StdReport']['data_binding'] = 'wx_binding' + config_dict['StdReport'].comments['data_binding'] = \ + ['', " # The database binding indicates which data should be used in reports"] + + if 'Databases' in config_dict: + # The stats database no longer exists. Remove it from the [Databases] + # section: + config_dict['Databases'].pop('stats_sqlite', None) + config_dict['Databases'].pop('stats_mysql', None) + # The key "database" changed to "database_name" + for stanza in config_dict['Databases']: + if 'database' in config_dict['Databases'][stanza]: + config_dict['Databases'][stanza].rename('database', + 'database_name') + + if 'StdArchive' in config_dict: + # Save the old database, if it exists + old_database = config_dict['StdArchive'].pop('archive_database', None) + # Get rid of the no longer needed options + config_dict['StdArchive'].pop('stats_database', None) + config_dict['StdArchive'].pop('archive_schema', None) + config_dict['StdArchive'].pop('stats_schema', None) + # Add the data_binding option + if 'data_binding' not in config_dict['StdArchive']: + config_dict['StdArchive']['data_binding'] = 'wx_binding' + config_dict['StdArchive'].comments['data_binding'] = \ + ['', " # The data binding to be used"] + + if 'DataBindings' not in config_dict: + # Insert a [DataBindings] section. First create it + c = weeutil.config.config_from_str("""[DataBindings] + # This section binds a data store to a database + + [[wx_binding]] + # The database must match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + + """) + # Now merge it in: + config_dict.merge(c) + # For some reason, ConfigObj strips any leading comments. Put them back: + config_dict.comments['DataBindings'] = major_comment_block + # Move the new section to just before [Databases] + reorder_sections(config_dict, 'DataBindings', 'Databases') + # No comments between the [DataBindings] and [Databases] sections: + config_dict.comments['Databases'] = [""] + config_dict.inline_comments['Databases'] = [] + + # If there was an old database, add it in the new, correct spot: + if old_database: + try: + config_dict['DataBindings']['wx_binding']['database'] = old_database + except KeyError: + pass + + # StdWXCalculate is new + if 'StdWXCalculate' not in config_dict: + c = weeutil.config.config_from_str("""[StdWXCalculate] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware""") + # Now merge it in: + config_dict.merge(c) + # For some reason, ConfigObj strips any leading comments. Put them back: + config_dict.comments['StdWXCalculate'] = major_comment_block + # Move the new section to just before [StdArchive] + reorder_sections(config_dict, 'StdWXCalculate', 'StdArchive') + + # Section ['Engines'] got renamed to ['Engine'] + if 'Engine' not in config_dict and 'Engines' in config_dict: + config_dict.rename('Engines', 'Engine') + # Subsection [['WxEngine']] got renamed to [['Services']] + if 'WxEngine' in config_dict['Engine']: + config_dict['Engine'].rename('WxEngine', 'Services') + + # Finally, module "wxengine" got renamed to "engine". Go through + # each of the service lists, making the change + for list_name in config_dict['Engine']['Services']: + service_list = config_dict['Engine']['Services'][list_name] + # If service_list is not already a list (it could be just a + # single name), then make it a list: + if not isinstance(service_list, (tuple, list)): + service_list = [service_list] + config_dict['Engine']['Services'][list_name] = \ + [this_item.replace('wxengine', 'engine') for this_item in service_list] + try: + # Finally, make sure the new StdWXCalculate service is in the list: + if 'weewx.wxservices.StdWXCalculate' not in config_dict['Engine']['Services']['process_services']: + config_dict['Engine']['Services']['process_services'].append('weewx.wxservices.StdWXCalculate') + except KeyError: + pass + + config_dict['version'] = '3.0.0' + + +def update_to_v32(config_dict): + """Update a configuration file to V3.2 + + - Introduction of section [DatabaseTypes] + - New option in [Databases] points to DatabaseType + """ + + major, minor = get_version_info(config_dict) + + if major + minor >= '302': + return + + # For interpolation to work, it's critical that WEEWX_ROOT not end + # with a trailing slash ('/'). Convert it to the normative form: + config_dict['WEEWX_ROOT'] = os.path.normpath(config_dict['WEEWX_ROOT']) + + # Add a default database-specific top-level stanzas if necessary + if 'DatabaseTypes' not in config_dict: + # Do SQLite first. Start with a sanity check: + try: + assert (config_dict['Databases']['archive_sqlite']['driver'] == 'weedb.sqlite') + except KeyError: + pass + # Set the default [[SQLite]] section. Turn off interpolation first, so the + # symbol for WEEWX_ROOT does not get lost. + save, config_dict.interpolation = config_dict.interpolation, False + # The section must be built step by step so we get the order of the entries correct + config_dict['DatabaseTypes'] = {} + config_dict['DatabaseTypes']['SQLite'] = {} + config_dict['DatabaseTypes']['SQLite']['driver'] = 'weedb.sqlite' + config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] = '%(WEEWX_ROOT)s/archive' + config_dict['DatabaseTypes'].comments['SQLite'] = \ + ['', ' # Defaults for SQLite databases'] + config_dict['DatabaseTypes']['SQLite'].comments['SQLITE_ROOT'] \ + = " # Directory in which the database files are located" + config_dict.interpolation = save + try: + root = config_dict['Databases']['archive_sqlite']['root'] + database_name = config_dict['Databases']['archive_sqlite']['database_name'] + fullpath = os.path.join(root, database_name) + dirname = os.path.dirname(fullpath) + # By testing to see if they end up resolving to the same thing, + # we can keep the interpolation used to specify SQLITE_ROOT above. + if dirname != config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT']: + config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] = dirname + config_dict['DatabaseTypes']['SQLite'].comments['SQLITE_ROOT'] = \ + [' # Directory in which the database files are located'] + config_dict['Databases']['archive_sqlite']['database_name'] = os.path.basename(fullpath) + config_dict['Databases']['archive_sqlite']['database_type'] = 'SQLite' + config_dict['Databases']['archive_sqlite'].pop('root', None) + config_dict['Databases']['archive_sqlite'].pop('driver', None) + except KeyError: + pass + + # Now do MySQL. Start with a sanity check: + try: + assert (config_dict['Databases']['archive_mysql']['driver'] == 'weedb.mysql') + except KeyError: + pass + config_dict['DatabaseTypes']['MySQL'] = {} + config_dict['DatabaseTypes'].comments['MySQL'] = ['', ' # Defaults for MySQL databases'] + try: + config_dict['DatabaseTypes']['MySQL']['host'] = \ + config_dict['Databases']['archive_mysql'].get('host', 'localhost') + config_dict['DatabaseTypes']['MySQL']['user'] = \ + config_dict['Databases']['archive_mysql'].get('user', 'weewx') + config_dict['DatabaseTypes']['MySQL']['password'] = \ + config_dict['Databases']['archive_mysql'].get('password', 'weewx') + config_dict['DatabaseTypes']['MySQL']['driver'] = 'weedb.mysql' + config_dict['DatabaseTypes']['MySQL'].comments['host'] = [ + " # The host where the database is located"] + config_dict['DatabaseTypes']['MySQL'].comments['user'] = [ + " # The user name for logging into the host"] + config_dict['DatabaseTypes']['MySQL'].comments['password'] = [ + " # The password for the user name"] + config_dict['Databases']['archive_mysql'].pop('host', None) + config_dict['Databases']['archive_mysql'].pop('user', None) + config_dict['Databases']['archive_mysql'].pop('password', None) + config_dict['Databases']['archive_mysql'].pop('driver', None) + config_dict['Databases']['archive_mysql']['database_type'] = 'MySQL' + config_dict['Databases'].comments['archive_mysql'] = [''] + except KeyError: + pass + + # Move the new section to just before [Engine] + reorder_sections(config_dict, 'DatabaseTypes', 'Engine') + # Add a major comment deliminator: + config_dict.comments['DatabaseTypes'] = \ + major_comment_block + \ + ['# This section defines defaults for the different types of databases', ''] + + # Version 3.2 introduces the 'enable' keyword for RESTful protocols. Set + # it appropriately + def set_enable(c, service, keyword): + # Check to see whether this config file has the service listed + try: + c['StdRESTful'][service] + except KeyError: + # It does not. Nothing to do. + return + + # Now check to see whether it already has the option 'enable': + if 'enable' in c['StdRESTful'][service]: + # It does. No need to proceed + return + + # The option 'enable' is not present. Add it, + # and set based on whether the keyword is present: + if keyword in c['StdRESTful'][service]: + c['StdRESTful'][service]['enable'] = 'true' + else: + c['StdRESTful'][service]['enable'] = 'false' + # Add a comment for it + c['StdRESTful'][service].comments['enable'] = ['', ' # Set to true to enable this uploader'] + + set_enable(config_dict, 'AWEKAS', 'username') + set_enable(config_dict, 'CWOP', 'station') + set_enable(config_dict, 'PWSweather', 'station') + set_enable(config_dict, 'WOW', 'station') + set_enable(config_dict, 'Wunderground', 'station') + + config_dict['version'] = '3.2.0' + + +def update_to_v36(config_dict): + """Update a configuration file to V3.6 + + - New subsection [[Calculations]] + """ + + major, minor = get_version_info(config_dict) + + if major + minor >= '306': + return + + # Perform the following only if the dictionary has a StdWXCalculate section + if 'StdWXCalculate' in config_dict: + # No need to update if it already has a 'Calculations' section: + if 'Calculations' not in config_dict['StdWXCalculate']: + # Save the comment attached to the first scalar + try: + first = config_dict['StdWXCalculate'].scalars[0] + comment = config_dict['StdWXCalculate'].comments[first] + config_dict['StdWXCalculate'].comments[first] = '' + except IndexError: + comment = """ # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx""" + # Create a new 'Calculations' section: + config_dict['StdWXCalculate']['Calculations'] = {} + # Now transfer over the options. Make a copy of them first: we will be + # deleting some of them. + scalars = list(config_dict['StdWXCalculate'].scalars) + for scalar in scalars: + # These scalars don't get moved: + if not scalar in ['ignore_zero_wind', 'rain_period', + 'et_period', 'wind_height', 'atc', + 'nfac', 'max_delta_12h']: + config_dict['StdWXCalculate']['Calculations'][scalar] = config_dict['StdWXCalculate'][scalar] + config_dict['StdWXCalculate'].pop(scalar) + # Insert the old comment at the top of the new stanza: + try: + first = config_dict['StdWXCalculate']['Calculations'].scalars[0] + config_dict['StdWXCalculate']['Calculations'].comments[first] = comment + except IndexError: + pass + + config_dict['version'] = '3.6.0' + + +def update_to_v39(config_dict): + """Update a configuration file to V3.9 + + - New top-level options log_success and log_failure + - New subsections [[SeasonsReport]], [[SmartphoneReport]], and [[MobileReport]] + - New section [StdReport][[Defaults]]. Prior to V4.6, it had lots of entries. With the + introduction of V4.6, it has been pared back to the minimum. + """ + + major, minor = get_version_info(config_dict) + + if major + minor >= '309': + return + + # Add top-level log_success and log_failure if missing + if 'log_success' not in config_dict: + config_dict['log_success'] = True + config_dict.comments['log_success'] = ['', '# Whether to log successful operations'] + reorder_scalars(config_dict.scalars, 'log_success', 'socket_timeout') + if 'log_failure' not in config_dict: + config_dict['log_failure'] = True + config_dict.comments['log_failure'] = ['', '# Whether to log unsuccessful operations'] + reorder_scalars(config_dict.scalars, 'log_failure', 'socket_timeout') + + if 'StdReport' in config_dict: + + # + # The logic below will put the subsections in the following order: + # + # [[StandardReport]] + # [[SeasonsReport]] + # [[SmartphoneReport]] + # [[MobileReport]] + # [[FTP]] + # [[RSYNC] + # [[Defaults]] + # + # NB: For an upgrade, we want StandardReport first, because that's + # what the user is already using. + # + + # Work around a ConfigObj limitation that can cause comments to be dropped. + # Save the original comment, then restore it later. + std_report_comment = config_dict.comments['StdReport'] + + if 'Defaults' not in config_dict['StdReport']: + defaults_dict = weeutil.config.config_from_str(DEFAULTS) + weeutil.config.merge_config(config_dict, defaults_dict) + reorder_sections(config_dict['StdReport'], 'Defaults', 'RSYNC', after=True) + + if 'SeasonsReport' not in config_dict['StdReport']: + seasons_options_dict = weeutil.config.config_from_str(SEASONS_REPORT) + weeutil.config.merge_config(config_dict, seasons_options_dict) + reorder_sections(config_dict['StdReport'], 'SeasonsReport', 'FTP') + + if 'SmartphoneReport' not in config_dict['StdReport']: + smartphone_options_dict = weeutil.config.config_from_str(SMARTPHONE_REPORT) + weeutil.config.merge_config(config_dict, smartphone_options_dict) + reorder_sections(config_dict['StdReport'], 'SmartphoneReport', 'FTP') + + if 'MobileReport' not in config_dict['StdReport']: + mobile_options_dict = weeutil.config.config_from_str(MOBILE_REPORT) + weeutil.config.merge_config(config_dict, mobile_options_dict) + reorder_sections(config_dict['StdReport'], 'MobileReport', 'FTP') + + if 'StandardReport' in config_dict['StdReport'] \ + and 'enable' not in config_dict['StdReport']['StandardReport']: + config_dict['StdReport']['StandardReport']['enable'] = True + + # Put the comment for [StdReport] back in + config_dict.comments['StdReport'] = std_report_comment + + # Remove all comments before each report section + for report in config_dict['StdReport'].sections: + if report == 'Defaults': + continue + config_dict['StdReport'].comments[report] = [''] + + # Special comment for the first report section: + first_section_name = config_dict['StdReport'].sections[0] + config_dict['StdReport'].comments[first_section_name] \ + = ['', + '####', + '', + '# Each of the following subsections defines a report that will be run.', + '# See the customizing guide to change the units, plot types and line', + '# colors, modify the fonts, display additional sensor data, and other', + '# customizations. Many of those changes can be made here by overriding', + '# parameters, or by modifying templates within the skin itself.', + '' + ] + + config_dict['version'] = '3.9.0' + + +def update_to_v40(config_dict): + """Update a configuration file to V4.0 + + - Add option loop_request for Vantage users. + - Fix problems with DegreeDays and Trend in weewx.conf + - Add new option growing_base + - Add new option WU api_key + - Add options to [StdWXCalculate] that were formerly defaults + """ + + # No need to check for the version of weewx for these changes. + + if 'Vantage' in config_dict \ + and 'loop_request' not in config_dict['Vantage']: + config_dict['Vantage']['loop_request'] = 1 + config_dict['Vantage'].comments['loop_request'] = \ + ['', 'The type of LOOP packet to request: 1 = LOOP1; 2 = LOOP2; 3 = both'] + reorder_scalars(config_dict['Vantage'].scalars, 'loop_request', 'iss_id') + + if 'StdReport' in config_dict \ + and 'Defaults' in config_dict['StdReport'] \ + and 'Units' in config_dict['StdReport']['Defaults']: + + # Both the DegreeDays and Trend subsections accidentally ended up + # in the wrong section + for key in ['DegreeDays', 'Trend']: + + # Proceed only if the key has not already been moved, and exists in the incorrect spot: + if key not in config_dict['StdReport']['Defaults']['Units'] \ + and 'Ordinates' in config_dict['StdReport']['Defaults']['Units'] \ + and key in config_dict['StdReport']['Defaults']['Units']['Ordinates']: + # Save the old comment + old_comment = config_dict['StdReport']['Defaults']['Units']['Ordinates'].comments[key] + + # Shallow copy the sub-section + config_dict['StdReport']['Defaults']['Units'][key] = \ + config_dict['StdReport']['Defaults']['Units']['Ordinates'][key] + # Delete it in from its old location + del config_dict['StdReport']['Defaults']['Units']['Ordinates'][key] + + # Unfortunately, ConfigObj can't fix these things when doing a shallow copy: + config_dict['StdReport']['Defaults']['Units'][key].depth = \ + config_dict['StdReport']['Defaults']['Units'].depth + 1 + config_dict['StdReport']['Defaults']['Units'][key].parent = \ + config_dict['StdReport']['Defaults']['Units'] + config_dict['StdReport']['Defaults']['Units'].comments[key] = old_comment + + # Now add the option "growing_base" if it hasn't already been added: + if 'StdReport' in config_dict \ + and 'Defaults' in config_dict['StdReport'] \ + and 'Units' in config_dict['StdReport']['Defaults'] \ + and 'DegreeDays' in config_dict['StdReport']['Defaults']['Units'] \ + and 'growing_base' not in config_dict['StdReport']['Defaults']['Units']['DegreeDays']: + config_dict['StdReport']['Defaults']['Units']['DegreeDays']['growing_base'] = [50.0, 'degree_F'] + config_dict['StdReport']['Defaults']['Units']['DegreeDays'].comments['growing_base'] = \ + ["Base temperature for growing days, with unit:"] + + # Add the WU API key if it hasn't already been added + if 'StdRESTful' in config_dict \ + and 'Wunderground' in config_dict['StdRESTful'] \ + and 'api_key' not in config_dict['StdRESTful']['Wunderground']: + config_dict['StdRESTful']['Wunderground']['api_key'] = 'replace_me' + config_dict['StdRESTful']['Wunderground'].comments['api_key'] = \ + ["", "If you plan on using wunderfixer, set the following", "to your API key:"] + + # The following types were never listed in weewx.conf and, instead, depended on defaults. + if 'StdWXCalculate' in config_dict \ + and 'Calculations' in config_dict['StdWXCalculate']: + config_dict['StdWXCalculate']['Calculations'].setdefault('maxSolarRad', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('cloudbase', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('humidex', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('appTemp', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('ET', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('windrun', 'prefer_hardware') + + # This section will inject a [Logging] section. Leave it commented out for now, + # until we gain more experience with it. + + # if 'Logging' not in config_dict: + # logging_dict = configobj.ConfigObj(StringIO(weeutil.logger.LOGGING_STR), interpolation=False) + # + # # Delete some not needed (and dangerous) entries + # try: + # del logging_dict['Logging']['version'] + # del logging_dict['Logging']['disable_existing_loggers'] + # except KeyError: + # pass + # + # config_dict.merge(logging_dict) + # + # # Move the new section to just before [Engine] + # reorder_sections(config_dict, 'Logging', 'Engine') + # config_dict.comments['Logging'] = \ + # major_comment_block + \ + # ['# This section customizes logging', ''] + + # Make sure the version number is at least 4.0 + major, minor = get_version_info(config_dict) + + if major + minor < '400': + config_dict['version'] = '4.0.0' + + +def update_to_v42(config_dict): + """Update a configuration file to V4.2 + + - Add new engine service group xtype_services + """ + + if 'Engine' in config_dict and 'Services' in config_dict['Engine']: + # If it's not there already, inject 'xtype_services' + if 'xtype_services' not in config_dict['Engine']['Services']: + config_dict['Engine']['Services']['xtype_services'] = \ + ['weewx.wxxtypes.StdWXXTypes', + 'weewx.wxxtypes.StdPressureCooker', + 'weewx.wxxtypes.StdRainRater', + 'weewx.wxxtypes.StdDelta'] + + # V4.2.0 neglected to include StdDelta. If necessary, add it: + if 'weewx.wxxtypes.StdDelta' not in config_dict['Engine']['Services']['xtype_services']: + config_dict['Engine']['Services']['xtype_services'].append('weewx.wxxtypes.StdDelta') + + # Make sure xtype_services are located just before the 'archive_services' + reorder_scalars(config_dict['Engine']['Services'].scalars, + 'xtype_services', + 'archive_services') + config_dict['Engine']['Services'].comments['prep_services'] = [] + config_dict['Engine']['Services'].comments['xtype_services'] = [] + config_dict['Engine'].comments['Services'] = ['The following section specifies which ' + 'services should be run and in what order.'] + config_dict['version'] = '4.2.0' + + +def update_to_v43(config_dict): + """Update a configuration file to V4.3 + + - Set [StdReport] / log_failure to True + """ + if 'StdReport' in config_dict and 'log_failure' in config_dict['StdReport']: + config_dict['StdReport']['log_failure'] = True + + config_dict['version'] = '4.3.0' + + +# ============================================================================== +# Utilities that extract from ConfigObj objects +# ============================================================================== + +def get_version_info(config_dict): + # Get the version number. If it does not appear at all, then + # assume a very old version: + config_version = config_dict.get('version') or '1.0.0' + + # Updates only care about the major and minor numbers + parts = config_version.split('.') + major = parts[0] + minor = parts[1] + + # Take care of the collation problem when comparing things like + # version '1.9' to '1.10' by prepending a '0' to the former: + if len(minor) < 2: + minor = '0' + minor + + return major, minor + + +def get_station_info_from_config(config_dict): + """Extract station info from config dictionary. + + Returns: + A station_info structure. If a key is missing in the structure, that means no + information is available about it. + """ + stn_info = dict() + if config_dict: + if 'Station' in config_dict: + if 'location' in config_dict['Station']: + stn_info['location'] \ + = weeutil.weeutil.list_as_string(config_dict['Station']['location']) + if 'latitude' in config_dict['Station']: + stn_info['latitude'] = config_dict['Station']['latitude'] + if 'longitude' in config_dict['Station']: + stn_info['longitude'] = config_dict['Station']['longitude'] + if 'altitude' in config_dict['Station']: + stn_info['altitude'] = config_dict['Station']['altitude'] + if 'station_type' in config_dict['Station']: + stn_info['station_type'] = config_dict['Station']['station_type'] + if stn_info['station_type'] in config_dict: + stn_info['driver'] = config_dict[stn_info['station_type']]['driver'] + + try: + stn_info['lang'] = config_dict['StdReport']['lang'] + except KeyError: + try: + stn_info['lang'] = config_dict['StdReport']['Defaults']['lang'] + except KeyError: + pass + try: + # Look for option 'unit_system' in [StdReport] + stn_info['unit_system'] = config_dict['StdReport']['unit_system'] + except KeyError: + try: + stn_info['unit_system'] = config_dict['StdReport']['Defaults']['unit_system'] + except KeyError: + # Not there. It's a custom system + stn_info['unit_system'] = 'custom' + try: + stn_info['register_this_station'] \ + = config_dict['StdRESTful']['StationRegistry']['register_this_station'] + except KeyError: + pass + try: + stn_info['station_url'] = config_dict['Station']['station_url'] + except KeyError: + pass + + return stn_info + + +# ============================================================================== +# Utilities that manipulate ConfigObj objects +# ============================================================================== + +def reorder_sections(config_dict, src, dst, after=False): + """Move the section with key src to just before (after=False) or after + (after=True) the section with key dst. """ + bump = 1 if after else 0 + # We need both keys to procede: + if src not in config_dict.sections or dst not in config_dict.sections: + return + # If index raises an exception, we want to fail hard. + # Find the source section (the one we intend to move): + src_idx = config_dict.sections.index(src) + # Save the key + src_key = config_dict.sections[src_idx] + # Remove it + config_dict.sections.pop(src_idx) + # Find the destination + dst_idx = config_dict.sections.index(dst) + # Now reorder the attribute 'sections', putting src just before dst: + config_dict.sections = config_dict.sections[:dst_idx + bump] + [src_key] + \ + config_dict.sections[dst_idx + bump:] + + +def reorder_scalars(scalars, src, dst): + """Reorder so the src item is just before the dst item""" + try: + src_index = scalars.index(src) + except ValueError: + return + scalars.pop(src_index) + # If the destination cannot be found, but the src object at the end + try: + dst_index = scalars.index(dst) + except ValueError: + dst_index = len(scalars) + + scalars.insert(dst_index, src) + + +# def reorder(name_list, ref_list): +# """Reorder the names in name_list, according to a reference list.""" +# result = [] +# # Use the ordering in ref_list, to reassemble the name list: +# for name in ref_list: +# # These always come at the end +# if name in ['FTP', 'RSYNC']: +# continue +# if name in name_list: +# result.append(name) +# # Finally, add these, so they are at the very end +# for name in ref_list: +# if name in name_list and name in ['FTP', 'RSYNC']: +# result.append(name) +# +# return result +# + +def remove_and_prune(a_dict, b_dict): + """Remove fields from a_dict that are present in b_dict""" + for k in b_dict: + if isinstance(b_dict[k], dict): + if k in a_dict and type(a_dict[k]) is configobj.Section: + remove_and_prune(a_dict[k], b_dict[k]) + if not a_dict[k].sections: + a_dict.pop(k) + elif k in a_dict: + a_dict.pop(k) + + +def prepend_path(a_dict, label, value): + """Prepend the value to every instance of the label in dict a_dict""" + for k in a_dict: + if isinstance(a_dict[k], dict): + prepend_path(a_dict[k], label, value) + elif k == label: + a_dict[k] = os.path.join(value, a_dict[k]) + + +# def replace_string(a_dict, label, value): +# for k in a_dict: +# if isinstance(a_dict[k], dict): +# replace_string(a_dict[k], label, value) +# else: +# a_dict[k] = a_dict[k].replace(label, value) + +# ============================================================================== +# Utilities that work on drivers +# ============================================================================== + +def get_all_driver_infos(): + # first look in the drivers directory + infos = get_driver_infos() + # then add any drivers in the user directory + infos.update(get_driver_infos('user')) + return infos + + +def get_driver_infos(driver_pkg_name='weewx.drivers', excludes=['__init__.py']): + """Scan the drivers folder, extracting information about each available + driver. Return as a dictionary, keyed by the driver module name. + + Valid drivers must be importable, and must have attribute "DRIVER_NAME" + defined. + """ + + __import__(driver_pkg_name) + driver_package = sys.modules[driver_pkg_name] + driver_pkg_directory = os.path.dirname(os.path.abspath(driver_package.__file__)) + driver_list = [os.path.basename(f) for f in glob.glob(os.path.join(driver_pkg_directory, "*.py"))] + + driver_info_dict = {} + for filename in driver_list: + if filename in excludes: + continue + + # Get the driver module name. This will be something like + # 'weewx.drivers.fousb' + driver_module_name = os.path.splitext("%s.%s" % (driver_pkg_name, + filename))[0] + + try: + # Try importing the module + __import__(driver_module_name) + driver_module = sys.modules[driver_module_name] + + # A valid driver will define the attribute "DRIVER_NAME" + if hasattr(driver_module, 'DRIVER_NAME'): + # A driver might define the attribute DRIVER_VERSION + driver_module_version = getattr(driver_module, 'DRIVER_VERSION', '?') + # Create an entry for it, keyed by the driver module name + driver_info_dict[driver_module_name] = { + 'module_name': driver_module_name, + 'driver_name': driver_module.DRIVER_NAME, + 'version': driver_module_version, + 'status': ''} + except (SyntaxError, ImportError) as e: + # If the import fails, report it in the status + driver_info_dict[driver_module_name] = { + 'module_name': driver_module_name, + 'driver_name': '?', + 'version': '?', + 'status': e} + + return driver_info_dict + + +def print_drivers(): + """Get information about all the available drivers, then print it out.""" + driver_info_dict = get_all_driver_infos() + keys = sorted(driver_info_dict) + print("%-25s%-15s%-9s%-25s" % ("Module name", "Driver name", "Version", "Status")) + for d in keys: + print(" %(module_name)-25s%(driver_name)-15s%(version)-9s%(status)-25s" % driver_info_dict[d]) + + +def load_driver_editor(driver_module_name): + """Load the configuration editor from the driver file + + driver_module_name: A string holding the driver name. + E.g., 'weewx.drivers.fousb' + """ + __import__(driver_module_name) + driver_module = sys.modules[driver_module_name] + editor = None + driver_name = None + driver_version = 'undefined' + if hasattr(driver_module, 'confeditor_loader'): + loader_function = getattr(driver_module, 'confeditor_loader') + editor = loader_function() + if hasattr(driver_module, 'DRIVER_NAME'): + driver_name = driver_module.DRIVER_NAME + if hasattr(driver_module, 'DRIVER_VERSION'): + driver_version = driver_module.DRIVER_VERSION + return editor, driver_name, driver_version + + +# ============================================================================== +# Utilities that seek info from the command line +# ============================================================================== + +def prompt_for_info(location=None, latitude='0.000', longitude='0.000', + altitude=['0', 'meter'], unit_system='metricwx', + register_this_station='false', + station_url=DEFAULT_URL, **kwargs): + stn_info = {} + # + # Description + # + print("Enter a brief description of the station, such as its location. For example:") + print("Santa's Workshop, North Pole") + stn_info['location'] = prompt_with_options("description", location) + + # + # Altitude + # + print("\nSpecify altitude, with units 'foot' or 'meter'. For example:") + print("35, foot") + print("12, meter") + msg = "altitude [%s]: " % weeutil.weeutil.list_as_string(altitude) if altitude else "altitude: " + alt = None + while alt is None: + ans = input(msg).strip() + if ans: + parts = ans.split(',') + if len(parts) == 2: + try: + # Test whether the first token can be converted into a + # number. If not, an exception will be raised. + float(parts[0]) + if parts[1].strip() in ['foot', 'meter']: + alt = [parts[0].strip(), parts[1].strip()] + except (ValueError, TypeError): + pass + elif altitude: + alt = altitude + + if not alt: + print("Unrecognized response. Try again.") + stn_info['altitude'] = alt + + # + # Latitude & Longitude + # + print("\nSpecify latitude in decimal degrees, negative for south.") + stn_info['latitude'] = prompt_with_limits("latitude", latitude, -90, 90) + print("Specify longitude in decimal degrees, negative for west.") + stn_info['longitude'] = prompt_with_limits("longitude", longitude, -180, 180) + + # + # Include in station registry? + # + default = 'y' if to_bool(register_this_station) else 'n' + print("\nYou can register your station on weewx.com, where it will be included") + print("in a map. You will need a unique URL to identify your station (such as a") + print("website, or WeatherUnderground link).") + registry = prompt_with_options("Include station in the station registry (y/n)?", + default, + ['y', 'n']) + if registry.lower() == 'y': + stn_info['register_this_station'] = 'true' + while True: + station_url = prompt_with_options("Unique URL:", station_url) + if station_url == DEFAULT_URL: + print("Unique please!") + else: + stn_info['station_url'] = station_url + break + else: + stn_info['register_this_station'] = 'false' + + # Get what unit system the user wants + options = ['us', 'metric', 'metricwx'] + print("\nIndicate the preferred units for display: %s" % options) + uni = prompt_with_options("unit system", unit_system, options) + stn_info['unit_system'] = uni + + return stn_info + + +def prompt_for_driver(dflt_driver=None): + """Get the information about each driver, return as a dictionary.""" + if dflt_driver is None: + dflt_driver = 'weewx.drivers.simulator' + infos = get_all_driver_infos() + keys = sorted(infos) + dflt_idx = None + print("\nInstalled drivers include:") + for i, d in enumerate(keys): + print(" %2d) %-15s %-25s %s" % (i, infos[d].get('driver_name', '?'), + "(%s)" % d, infos[d].get('status', ''))) + if dflt_driver == d: + dflt_idx = i + msg = "choose a driver [%d]: " % dflt_idx if dflt_idx is not None else "choose a driver: " + idx = 0 + ans = None + while ans is None: + ans = input(msg).strip() + if not ans: + ans = dflt_idx + try: + idx = int(ans) + if not 0 <= idx < len(keys): + ans = None + except (ValueError, TypeError): + ans = None + return keys[idx] + + +def prompt_for_driver_settings(driver, config_dict): + """Let the driver prompt for any required settings. If the driver does + not define a method for prompting, return an empty dictionary.""" + settings = dict() + try: + __import__(driver) + driver_module = sys.modules[driver] + loader_function = getattr(driver_module, 'confeditor_loader') + editor = loader_function() + editor.existing_options = config_dict.get(driver_module.DRIVER_NAME, {}) + settings[driver_module.DRIVER_NAME] = editor.prompt_for_settings() + except AttributeError: + pass + return settings + + +def get_languages(skin_dir): + """ Return all languages supported by the skin + + Args: + skin_dir (str): The path to the skin subdirectory. + + Returns: + (dict): A dictionary where the key is the language code, and the value is the natural + language name of the language. The value 'None' is returned if skin_dir does not exist. + """ + # Get the path to the "./lang" subdirectory + lang_dir = os.path.join(skin_dir, './lang') + # Get all the files in the subdirectory. If the subdirectory does not exist, an exception + # will be raised. Be prepared to catch it. + try: + lang_files = os.listdir(lang_dir) + except OSError: + # No 'lang' subdirectory. Return None + return None + + languages = {} + + # Go through the files... + for lang_file in lang_files: + # ... get its full path ... + lang_full_path = os.path.join(lang_dir, lang_file) + # ... make sure it's a file ... + if os.path.isfile(lang_full_path): + # ... then get the language code for that file. + code = lang_file.split('.')[0] + # Retrieve the ConfigObj for this language + lang_dict = configobj.ConfigObj(lang_full_path, encoding='utf-8') + # See if it has a natural language version of the language code: + try: + language = lang_dict['Texts']['Language'] + except KeyError: + # It doesn't. Just label it 'Unknown' + language = 'Unknown' + # Add the code, plus the language + languages[code] = language + return languages + + +def pick_language(languages, default='en'): + """ + Given a choice of languages, pick one. + + Args: + languages (list): As returned by function get_languages() above + default (str): The language code of the default + + Returns: + (str): The chosen language code + """ + keys = sorted(languages.keys()) + if default not in keys: + default = None + msg = "Available languages\nCode | Language\n" + for code in keys: + msg += "%4s | %-20s\n" % (code, languages[code]) + msg += "Pick a code" + value = prompt_with_options(msg, default, keys) + + return value + + +def prompt_with_options(prompt, default=None, options=None): + """Ask the user for an input with an optional default value. + + prompt: A string to be used for a prompt. + + default: A default value. If the user simply hits , this + is the value returned. Optional. + + options: A list of possible choices. The returned value must be in + this list. Optional.""" + + msg = "%s [%s]: " % (prompt, default) if default is not None else "%s: " % prompt + value = None + while value is None: + value = input(six.ensure_str(msg)).strip() + if value: + if options and value not in options: + value = None + elif default is not None: + value = default + + return value + + +def prompt_with_limits(prompt, default=None, low_limit=None, high_limit=None): + """Ask the user for an input with an optional default value. The + returned value must lie between optional upper and lower bounds. + + prompt: A string to be used for a prompt. + + default: A default value. If the user simply hits , this + is the value returned. Optional. + + low_limit: The value must be equal to or greater than this value. + Optional. + + high_limit: The value must be less than or equal to this value. + Optional. + """ + msg = "%s [%s]: " % (prompt, default) if default is not None else "%s: " % prompt + value = None + while value is None: + value = input(msg).strip() + if value: + try: + v = float(value) + if (low_limit is not None and v < low_limit) or \ + (high_limit is not None and v > high_limit): + value = None + except (ValueError, TypeError): + value = None + elif default is not None: + value = default + + return value + + +# ============================================================================== +# Miscellaneous utilities +# ============================================================================== + +def extract_roots(config_path, config_dict, bin_root): + """Get the location of the various root directories used by weewx.""" + + root_dict = {'WEEWX_ROOT': config_dict['WEEWX_ROOT'], + 'CONFIG_ROOT': os.path.dirname(config_path)} + # If bin_root has not been defined, then figure out where it is using + # the location of this file: + if bin_root: + root_dict['BIN_ROOT'] = bin_root + else: + root_dict['BIN_ROOT'] = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..')) + # The user subdirectory: + root_dict['USER_ROOT'] = os.path.join(root_dict['BIN_ROOT'], 'user') + # The extensions directory is in the user directory: + root_dict['EXT_ROOT'] = os.path.join(root_dict['USER_ROOT'], 'installer') + # Add SKIN_ROOT if it can be found: + try: + root_dict['SKIN_ROOT'] = os.path.abspath(os.path.join( + root_dict['WEEWX_ROOT'], + config_dict['StdReport']['SKIN_ROOT'])) + except KeyError: + pass + + return root_dict + + +def extract_tar(filename, target_dir, logger=None): + """Extract files from a tar archive into a given directory + + Returns: A list of the extracted files + """ + logger = logger or Logger() + import tarfile + logger.log("Extracting from tar archive %s" % filename, level=1) + tar_archive = None + try: + tar_archive = tarfile.open(filename, mode='r') + tar_archive.extractall(target_dir) + member_names = [os.path.normpath(x.name) for x in tar_archive.getmembers()] + return member_names + finally: + if tar_archive is not None: + tar_archive.close() + + +def extract_zip(filename, target_dir, logger=None): + """Extract files from a zip archive into the specified directory. + + Returns: a list of the extracted files + """ + logger = logger or Logger() + import zipfile + logger.log("Extracting from zip archive %s" % filename, level=1) + + zip_archive = zipfile.ZipFile(filename) + + try: + member_names = zip_archive.namelist() + + # manually extract files since extractall is only in python 2.6+ + # zip_archive.extractall(target_dir) + for f in member_names: + if f.endswith('/'): + dst = "%s/%s" % (target_dir, f) + mkdir_p(dst) + for f in member_names: + if not f.endswith('/'): + path = "%s/%s" % (target_dir, f) + with open(path, 'wb') as dest_file: + dest_file.write(zip_archive.read(f)) + return member_names + finally: + zip_archive.close() + + +def mkdir_p(path): + """equivalent to 'mkdir -p'""" + try: + os.makedirs(path) + except OSError as e: + if e.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise + + +def get_extension_installer(extension_installer_dir): + """Get the installer in the given extension installer subdirectory""" + old_path = sys.path + try: + # Inject the location of the installer directory into the path + sys.path.insert(0, extension_installer_dir) + try: + # Now I can import the extension's 'install' module: + __import__('install') + except ImportError: + raise ExtensionError("Cannot find 'install' module in %s" % extension_installer_dir) + install_module = sys.modules['install'] + loader = getattr(install_module, 'loader') + # Get rid of the module: + sys.modules.pop('install', None) + installer = loader() + finally: + # Restore the path + sys.path = old_path + + return (install_module.__file__, installer) + + +# ============================================================================== +# Various config sections +# ============================================================================== + + +SEASONS_REPORT = """[StdReport] + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = false""" + +SMARTPHONE_REPORT = """[StdReport] + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone""" + +MOBILE_REPORT = """[StdReport] + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile""" + +DEFAULTS = """[StdReport] + + #### + + # Options in the [[Defaults]] section below will apply to all reports. + # What follows are a few of the more popular options you may want to + # uncomment, then change. + [[Defaults]] + + # Which language to use for all reports. Not all skins support all languages. + # You can override this for individual reports. + lang = en + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us + + [[[Units]]] + # Option "unit_system" above sets the general unit system, but overriding specific unit + # groups is possible. These are popular choices. Uncomment and set as appropriate. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_pressure = mbar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + # Uncommenting the following section frequently results in more + # attractive formatting of times and dates, but may not work in + # your locale. + [[[[TimeFormats]]]] + # day = %H:%M + # week = %H:%M on %A + # month = %d-%b-%Y %H:%M + # year = %d-%b-%Y %H:%M + # rainyear = %d-%b-%Y %H:%M + # current = %d-%b-%Y %H:%M + # ephem_day = %H:%M + # ephem_year = %d-%b-%Y %H:%M + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + [[[Labels]]] + # Users frequently change the labels for these observation types + [[[[Generic]]]] + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # extraTemp1 = Temperature1 + # extraTemp2 = Temperature2 + # extraTemp3 = Temperature3 + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + +""" diff --git a/dist/weewx-4.10.1/bin/weecfg/config.py b/dist/weewx-4.10.1/bin/weecfg/config.py new file mode 100644 index 0000000..5ba62cd --- /dev/null +++ b/dist/weewx-4.10.1/bin/weecfg/config.py @@ -0,0 +1,166 @@ +# +# Copyright (c) 2009-2021 Tom Keffer and +# Matthew Wall +# +# See the file LICENSE.txt for your full rights. +# +"""Utilities for managing the config file""" + +from __future__ import print_function +from __future__ import absolute_import +import sys + +import configobj + +import weecfg +import weewx +from weecfg import Logger + +# The default station information: +stn_info_defaults = { + 'location' : "My Home Town", + 'latitude' : "0.0", + 'longitude': "0.0", + 'altitude': "0, meter", + 'unit_system': 'metricwx', + 'register_this_station': 'false', + 'station_type': 'Simulator', + 'driver': 'weewx.drivers.simulator', + 'lang' : 'en', +} + + +class ConfigEngine(object): + """Install, upgrade, or reconfigure the configuration file, weewx.conf""" + + def __init__(self, logger=None): + self.logger = logger or Logger() + + def run(self, args, options): + if options.version: + print(weewx.__version__) + sys.exit(0) + + if options.list_drivers: + weecfg.print_drivers() + sys.exit(0) + + # + # If we got to this point, the verb must be --install, --upgrade, or --reconfigure. + # Check for errors in the options. + # + + # There must be one, and only one, of options install, upgrade, and reconfigure + if sum([options.install is not None, + options.upgrade is not None, + options.reconfigure is not None]) != 1: + sys.exit("Must specify one and only one of --install, --upgrade, or --reconfigure.") + + # Check for missing --dist-config + if (options.install or options.upgrade) and not options.dist_config: + sys.exit("The commands --install and --upgrade require option --dist-config.") + + # Check for missing config file + if options.upgrade and not (options.config_path or args): + sys.exit("The command --upgrade requires an existing configuration file.") + + if options.install and not options.output: + sys.exit("The --install command requires option --output.") + + # The install option does not take an old config file + if options.install and (options.config_path or args): + sys.exit("A configuration file cannot be used with the --install command.") + + # + # Now run the commands. + # + + # First, fiddle with option --altitude to convert it into a list: + if options.altitude: + options.altitude = options.altitude.split(",") + + # Option "--unit-system" used to be called "--units". For backwards compatibility, allow + # both. + if options.units: + if options.unit_system: + sys.exit("Specify either option --units or option --unit-system, but not both") + options.unit_system = options.units + delattr(options, "units") + + if options.install or options.upgrade: + # These options require a distribution config file. + # Open it up and parse it: + try: + dist_config_dict = configobj.ConfigObj(options.dist_config, + file_error=True, + encoding='utf-8') + except IOError as e: + sys.exit("Unable to open distribution configuration file: %s" % e) + except SyntaxError as e: + sys.exit("Syntax error in distribution configuration file '%s': %s" + % (options.dist_config, e)) + + # The install command uses the distribution config file as its input. + # Other commands use an existing config file. + if options.install: + config_dict = dist_config_dict + else: + try: + config_path, config_dict = weecfg.read_config(options.config_path, args) + except SyntaxError as e: + sys.exit("Syntax error in configuration file: %s" % e) + except IOError as e: + sys.exit("Unable to open configuration file: %s" % e) + self.logger.log("Using configuration file %s" % config_path) + + if options.upgrade: + # Update the config dictionary, then merge it with the distribution + # dictionary + weecfg.update_and_merge(config_dict, dist_config_dict) + + elif options.install or options.reconfigure: + # Extract stn_info from the config_dict and command-line options: + stn_info = self.get_stn_info(config_dict, options) + # Use it to modify the configuration file. + weecfg.modify_config(config_dict, stn_info, self.logger, options.debug) + + else: + sys.exit("Internal logic error in config.py") + + # For the path to the final file, use whatever was specified by --output, + # or the original path if that wasn't specified + output_path = options.output or config_path + + # Save weewx.conf, backing up any old file. + backup_path = weecfg.save(config_dict, output_path, not options.no_backup) + if backup_path: + self.logger.log("Saved backup to %s" % backup_path) + + def get_stn_info(self, config_dict, options): + """Build the stn_info structure. Extract first from the config_dict object, + then from any command-line overrides, then use defaults, then prompt the user + for values.""" + + # Start with values from the config file: + stn_info = weecfg.get_station_info_from_config(config_dict) + + # Get command line overrides, and apply them to stn_info. If that leaves a value + # unspecified, then get it from the defaults. + for k in stn_info_defaults: + # Override only if the option exists and is not None: + if hasattr(options, k) and getattr(options, k) is not None: + stn_info[k] = getattr(options, k) + elif k not in stn_info: + # Value is still not specified. Get a default value + stn_info[k] = stn_info_defaults[k] + + # Unless --no-prompt has been specified, give the user a chance + # to change things: + if not options.no_prompt: + prompt_info = weecfg.prompt_for_info(**stn_info) + stn_info.update(prompt_info) + driver = weecfg.prompt_for_driver(stn_info.get('driver')) + stn_info['driver'] = driver + stn_info.update(weecfg.prompt_for_driver_settings(driver, config_dict)) + + return stn_info diff --git a/dist/weewx-4.10.1/bin/weecfg/database.py b/dist/weewx-4.10.1/bin/weecfg/database.py new file mode 100644 index 0000000..9b566c4 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weecfg/database.py @@ -0,0 +1,583 @@ +# +# Copyright (c) 2009-2022 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Classes to support fixes or other bulk corrections of weewx data.""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +# standard python imports +import datetime +import logging +import sys +import time + +# weewx imports +import weedb +import weeutil.weeutil +import weewx.engine +import weewx.manager +import weewx.units +import weewx.wxservices +from weeutil.weeutil import timestamp_to_string, startOfDay, to_bool, option_as_list + +log = logging.getLogger(__name__) + +# ============================================================================ +# class DatabaseFix +# ============================================================================ + + +class DatabaseFix(object): + """Base class for fixing bulk data in the weewx database. + + Classes for applying different fixes the weewx database data should be + derived from this class. Derived classes require: + + run() method: The entry point to apply the fix. + fix config dict: Dictionary containing config data specific to + the fix. Minimum fields required are: + + name. The name of the fix. String. + """ + + def __init__(self, config_dict, fix_config_dict): + """A generic initialisation.""" + + # save our weewx config dict + self.config_dict = config_dict + # save our fix config dict + self.fix_config_dict = fix_config_dict + # get our name + self.name = fix_config_dict['name'] + # is this a dry run + self.dry_run = to_bool(fix_config_dict.get('dry_run', True)) + # Get the binding for the archive we are to use. If we received an + # explicit binding then use that otherwise use the binding that + # StdArchive uses. + try: + db_binding = fix_config_dict['binding'] + except KeyError: + if 'StdArchive' in config_dict: + db_binding = config_dict['StdArchive'].get('data_binding', + 'wx_binding') + else: + db_binding = 'wx_binding' + self.binding = db_binding + # get a database manager object + self.dbm = weewx.manager.open_manager_with_config(config_dict, + self.binding) + + def run(self): + raise NotImplementedError("Method 'run' not implemented") + + def genSummaryDaySpans(self, start_ts, stop_ts, obs='outTemp'): + """Generator to generate a sequence of daily summary day TimeSpans. + + Given an observation that has a daily summary table, generate a + sequence of TimeSpan objects for each row in the daily summary table. + In this way the generated sequence includes only rows included in the + daily summary rather than any 'missing' rows. + + Args: + start_ts (float): Include daily summary rows with a dateTime >= start_ts. + stop_ts (float): Include daily summary rows with a dateTime <= start_ts. + obs (str): The weewx observation whose daily summary table is to be + used as the source of the TimeSpan objects + + Yields: + weeutil.weeutil.TimeSpan: A sequence with the start of each day. + """ + + _sql = "SELECT dateTime FROM %s_day_%s " \ + " WHERE dateTime >= ? AND dateTime <= ?" % (self.dbm.table_name, obs) + + _cursor = self.dbm.connection.cursor() + try: + for _row in _cursor.execute(_sql, (start_ts, stop_ts)): + yield weeutil.weeutil.daySpan(_row[0]) + finally: + _cursor.close() + + def first_summary_ts(self, obs_type): + """Obtain the timestamp of the earliest daily summary entry for an + observation type. + + Imput: + obs_type: The observation type whose daily summary is to be checked. + + Returns: + The timestamp of the earliest daily summary entry for obs_tpye + observation. None is returned if no record culd be found. + """ + + _sql_str = "SELECT MIN(dateTime) FROM %s_day_%s" % (self.dbm.table_name, + obs_type) + _row = self.dbm.getSql(_sql_str) + if _row: + return _row[0] + return None + + @staticmethod + def _progress(record, ts): + """Utility function to show our progress while processing the fix. + + Override in derived class to provide a different progress display. + To do nothing override with a pass statement. + """ + + _msg = "Fixing database record: %d; Timestamp: %s\r" % (record, timestamp_to_string(ts)) + print(_msg, end='', file=sys.stdout) + sys.stdout.flush() + + +# ============================================================================ +# class WindSpeedRecalculation +# ============================================================================ + + +class WindSpeedRecalculation(DatabaseFix): + """Class to recalculate windSpeed daily maximum value. To recalculate the + windSpeed daily maximum values: + + 1. Create a dictionary of parameters required by the fix. The + WindSpeedRecalculation class uses the following parameters as indicated: + + name: Name of the fix, for the windSpeed recalculation fix + this is 'windSpeed Recalculation'. String. Mandatory. + + binding: The binding of the database to be fixed. Default is + the binding specified in weewx.conf [StdArchive]. + String, eg 'binding_name'. Optional. + + trans_days: Number of days of data used in each database + transaction. Integer, default is 50. Optional. + + dry_run: Process the fix as if it was being applied but do not + write to the database. Boolean, default is True. + Optional. + + 2. Create an WindSpeedRecalculation object passing it a weewx config dict + and a fix config dict. + + 3. Call the resulting object's run() method to apply the fix. + """ + + def __init__(self, config_dict, fix_config_dict): + """Initialise our WindSpeedRecalculation object.""" + + # call our parents __init__ + super(WindSpeedRecalculation, self).__init__(config_dict, fix_config_dict) + + # log if a dry run + if self.dry_run: + log.info("maxwindspeed: This is a dry run. " + "Maximum windSpeed will be recalculated but not saved.") + + log.debug("maxwindspeed: Using database binding '%s', " + "which is bound to database '%s'." % + (self.binding, self.dbm.database_name)) + # number of days per db transaction, default to 50. + self.trans_days = int(fix_config_dict.get('trans_days', 50)) + log.debug("maxwindspeed: Database transactions will use %s days of data." % self.trans_days) + + def run(self): + """Main entry point for applying the windSpeed Calculation fix. + + Recalculating the windSpeed daily summary max field from archive data + is idempotent so there is no need to check whether the fix has already + been applied. Just go ahead and do it catching any exceptions we know + may be raised. + """ + + # apply the fix but be prepared to catch any exceptions + try: + self.do_fix() + except weedb.NoTableError: + raise + except weewx.ViolatedPrecondition as e: + log.error("maxwindspeed: %s not applied: %s" % (self.name, e)) + # raise the error so caller can deal with it if they want + raise + + def do_fix(self): + """Recalculate windSpeed daily summary max field from archive data. + + Step through each row in the windSpeed daily summary table and replace + the max field with the max value for that day based on archive data. + Database transactions are done in self.trans_days days at a time. + """ + + t1 = time.time() + log.info("maxwindspeed: Applying %s..." % self.name) + # get the start and stop Gregorian day number + start_ts = self.first_summary_ts('windSpeed') + if not start_ts: + print("Database empty. Nothing done.") + return + start_greg = weeutil.weeutil.toGregorianDay(start_ts) + stop_greg = weeutil.weeutil.toGregorianDay(self.dbm.last_timestamp) + # initialise a few things + day = start_greg + n_days = 0 + last_start = None + while day <= stop_greg: + # get the start and stop timestamps for this tranche + tr_start_ts = weeutil.weeutil.startOfGregorianDay(day) + tr_stop_ts = weeutil.weeutil.startOfGregorianDay(day + self.trans_days - 1) + # start the transaction + with weedb.Transaction(self.dbm.connection) as _cursor: + # iterate over the rows in the windSpeed daily summary table + for day_span in self.genSummaryDaySpans(tr_start_ts, tr_stop_ts, 'windSpeed'): + # get the days max windSpeed and the time it occurred from + # the archive + (day_max_ts, day_max) = self.get_archive_span_max(day_span, 'windSpeed') + # now save the value and time in the applicable row in the + # windSpeed daily summary, but only if its not a dry run + if not self.dry_run: + self.write_max('windSpeed', day_span.start, + day_max, day_max_ts) + # increment our days done counter + n_days += 1 + # give the user some information on progress + if n_days % 50 == 0: + self._progress(n_days, day_span.start) + last_start = day_span.start + # advance to the next tranche + day += self.trans_days + + # we have finished, give the user some final information on progress, + # mainly so the total tallies with the log + self._progress(n_days, last_start) + print(file=sys.stdout) + tdiff = time.time() - t1 + # We are done so log and inform the user + log.info("maxwindspeed: Maximum windSpeed calculated " + "for %s days in %0.2f seconds." % (n_days, tdiff)) + if self.dry_run: + log.info("maxwindspeed: This was a dry run. %s was not applied." % self.name) + + def get_archive_span_max(self, span, obs): + """Find the max value of an obs and its timestamp in a span based on + archive data. + + Gets the max value of an observation and the timestamp at which it + occurred from a TimeSpan of archive records. Raises a + weewx.ViolatedPrecondition error if the max value of the observation + field could not be determined. + + Input parameters: + span: TimesSpan object of the period from which to determine + the interval value. + obs: The observation to be used. + + Returns: + A tuple of the format: + + (timestamp, value) + + where: + timestamp is the epoch timestamp when the max value occurred + value is the max value of the observation over the time span + + If no observation field values are found then a + weewx.ViolatedPrecondition error is raised. + """ + + select_str = "SELECT dateTime, %(obs_type)s FROM %(table_name)s " \ + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND " \ + "%(obs_type)s = (SELECT MAX(%(obs_type)s) FROM %(table_name)s " \ + "WHERE dateTime > %(start)s and dateTime <= %(stop)s) AND " \ + "%(obs_type)s IS NOT NULL" + interpolate_dict = {'obs_type': obs, + 'table_name': self.dbm.table_name, + 'start': span.start, + 'stop': span.stop} + + _row = self.dbm.getSql(select_str % interpolate_dict) + if _row: + try: + return _row[0], _row[1] + except IndexError: + _msg = "'%s' field not found in archive day %s." % (obs, span) + raise weewx.ViolatedPrecondition(_msg) + else: + return None, None + + def write_max(self, obs, row_ts, value, when_ts, cursor=None): + """Update the max and maxtime fields in an existing daily summary row. + + Updates the max and maxtime fields in a row in a daily summary table. + + Input parameters: + obs: The observation to be used. the daily summary updated will + be xxx_day_obs where xxx is the database archive table name. + row_ts: Timestamp of the row to be updated. + value: The value to be saved in field max + when_ts: The timestamp to be saved in field maxtime + cursor: Cursor object for the database connection being used. + + Returns: + Nothing. + """ + + _cursor = cursor or self.dbm.connection.cursor() + + max_update_str = "UPDATE %s_day_%s SET %s=?,%s=? " \ + "WHERE datetime=?" % (self.dbm.table_name, obs, 'max', 'maxtime') + _cursor.execute(max_update_str, (value, when_ts, row_ts)) + if cursor is None: + _cursor.close() + + @staticmethod + def _progress(ndays, last_time): + """Utility function to show our progress while processing the fix.""" + + _msg = "Updating 'windSpeed' daily summary: %d; " \ + "Timestamp: %s\r" % (ndays, timestamp_to_string(last_time, format_str="%Y-%m-%d")) + print(_msg, end='', file=sys.stdout) + sys.stdout.flush() + + +# ============================================================================ +# class CalcMissing +# ============================================================================ + +class CalcMissing(DatabaseFix): + """Class to calculate and store missing derived observations. + + The following algorithm is used to calculate and store missing derived + observations: + + 1. Obtain a wxservices.WXCalculate() object to calculate the derived obs + fields for each record + 2. Iterate over each day and record in the period concerned augmenting + each record with derived fields. Any derived fields that are missing + or == None are calculated. Days are processed in tranches and each + updated derived fields for each tranche are processed as a single db + transaction. + 4. Once all days/records have been processed the daily summaries for the + period concerned are recalculated. + """ + + def __init__(self, config_dict, calc_missing_config_dict): + """Initialise a CalcMissing object. + + config_dict: WeeWX config file as a dict + calc_missing_config_dict: A config dict with the following structure: + name: A descriptive name for the class + binding: data binding to use + start_ts: start ts of timespan over which missing derived fields + will be calculated + stop_ts: stop ts of timespan over which missing derived fields + will be calculated + trans_days: number of days of records per db transaction + dry_run: is this a dry run (boolean) + """ + + # call our parents __init__ + super(CalcMissing, self).__init__(config_dict, calc_missing_config_dict) + + # the start timestamp of the period to calc missing + self.start_ts = int(calc_missing_config_dict.get('start_ts')) + # the stop timestamp of the period to calc missing + self.stop_ts = int(calc_missing_config_dict.get('stop_ts')) + # number of days per db transaction, default to 50. + self.trans_days = int(calc_missing_config_dict.get('trans_days', 10)) + # is this a dry run, default to true + self.dry_run = to_bool(calc_missing_config_dict.get('dry_run', True)) + + self.config_dict = config_dict + + def run(self): + """Main entry point for calculating missing derived fields. + + Calculate the missing derived fields for the timespan concerned, save + the calculated data to archive and recalculate the daily summaries. + """ + + # record the current time + t1 = time.time() + + # Instantiate a dummy engine, to be used to calculate derived variables. This will + # cause all the xtype services to get loaded. + engine = weewx.engine.DummyEngine(self.config_dict) + # While the above instantiated an instance of StdWXCalculate, we have no way of + # retrieving it. So, instantiate another one, then use that to calculate derived types. + wxcalculate = weewx.wxservices.StdWXCalculate(engine, self.config_dict) + + # initialise some counters so we know what we have processed + days_updated = 0 + days_processed = 0 + total_records_processed = 0 + total_records_updated = 0 + + # obtain gregorian days for our start and stop timestamps + start_greg = weeutil.weeutil.toGregorianDay(self.start_ts) + stop_greg = weeutil.weeutil.toGregorianDay(self.stop_ts) + # start at the first day + day = start_greg + while day <= stop_greg: + # get the start and stop timestamps for this tranche + tr_start_ts = weeutil.weeutil.startOfGregorianDay(day) + tr_stop_ts = min(weeutil.weeutil.startOfGregorianDay(stop_greg + 1), + weeutil.weeutil.startOfGregorianDay(day + self.trans_days)) + # start the transaction + with weedb.Transaction(self.dbm.connection) as _cursor: + # iterate over each day in the tranche we are to work in + for tranche_day in weeutil.weeutil.genDaySpans(tr_start_ts, tr_stop_ts): + # initialise a counter for records processed on this day + records_updated = 0 + # iterate over each record in this day + for record in self.dbm.genBatchRecords(startstamp=tranche_day.start, + stopstamp=tranche_day.stop): + # but we are only concerned with records after the + # start and before or equal to the stop timestamps + if self.start_ts < record['dateTime'] <= self.stop_ts: + # first obtain a list of the fields that may be calculated + extras_list = [] + for obs in wxcalculate.calc_dict: + directive = wxcalculate.calc_dict[obs] + if directive == 'software' \ + or directive == 'prefer_hardware' \ + and (obs not in record or record[obs] is None): + extras_list.append(obs) + + # calculate the missing derived fields for the record + wxcalculate.do_calculations(record) + + # Obtain a new record dictionary that contains only those items + # that wxcalculate calculated. Use dictionary comprehension. + extras_dict = {k:v for (k,v) in record.items() if k in extras_list} + + # update the archive with the calculated data + records_updated += self.update_record_fields(record['dateTime'], + extras_dict) + # update the total records processed + total_records_processed += 1 + # Give the user some information on progress + if total_records_processed % 1000 == 0: + p_msg = "Processing record: %d; Last record: %s" % (total_records_processed, + timestamp_to_string(record['dateTime'])) + self._progress(p_msg) + # update the total records updated + total_records_updated += records_updated + # if we updated any records on this day increment the count + # of days updated + days_updated += 1 if records_updated > 0 else 0 + days_processed += 1 + # advance to the next tranche + day += self.trans_days + # finished, so give the user some final information on progress, mainly + # so the total tallies with the log + p_msg = "Processing record: %d; Last record: %s" % (total_records_processed, + timestamp_to_string(tr_stop_ts)) + self._progress(p_msg, overprint=False) + # now update the daily summaries, but only if this is not a dry run + if not self.dry_run: + print("Recalculating daily summaries...") + # first we need a start and stop date object + start_d = datetime.date.fromtimestamp(self.start_ts) + # Since each daily summary is identified by the midnight timestamp + # for that day we need to make sure we our stop timestamp is not on + # a midnight boundary or we will rebuild the following days sumamry + # as well. if it is on a midnight boundary just subtract 1 second + # and use that. + summary_stop_ts = self.stop_ts + if weeutil.weeutil.isMidnight(self.stop_ts): + summary_stop_ts -= 1 + stop_d = datetime.date.fromtimestamp(summary_stop_ts) + # do the update + self.dbm.backfill_day_summary(start_d=start_d, stop_d=stop_d) + print(file=sys.stdout) + print("Finished recalculating daily summaries") + else: + # it's a dry run so say the rebuild was skipped + print("This is a dry run, recalculation of daily summaries was skipped") + tdiff = time.time() - t1 + # we are done so log and inform the user + _day_processed_str = "day" if days_processed == 1 else "days" + _day_updated_str = "day" if days_updated == 1 else "days" + if not self.dry_run: + log.info("Processed %d %s consisting of %d records. " + "%d %s consisting of %d records were updated " + "in %0.2f seconds." % (days_processed, + _day_processed_str, + total_records_processed, + days_updated, + _day_updated_str, + total_records_updated, + tdiff)) + else: + # this was a dry run + log.info("Processed %d %s consisting of %d records. " + "%d %s consisting of %d records would have been updated " + "in %0.2f seconds." % (days_processed, + _day_processed_str, + total_records_processed, + days_updated, + _day_updated_str, + total_records_updated, + tdiff)) + + def update_record_fields(self, ts, record, cursor=None): + """Update multiple fields in a given archive record. + + Updates multiple fields in an archive record via an update query. + + Inputs: + ts: epoch timestamp of the record to be updated + record: dict containing the updated data in field name-value pairs + cursor: sqlite cursor + """ + + # Only data types that appear in the database schema can be + # updated. To find them, form the intersection between the set of + # all record keys and the set of all sql keys + record_key_set = set(record.keys()) + update_key_set = record_key_set.intersection(self.dbm.sqlkeys) + # only update if we have data for at least one field that is in the schema + if len(update_key_set) > 0: + # convert to an ordered list + key_list = list(update_key_set) + # get the values in the same order + value_list = [record[k] for k in key_list] + + # Construct the SQL update statement. First construct the 'SET' + # argument, we want a string of comma separated `field_name`=? + # entries. Each ? will be replaced by a value from update value list + # when the SQL statement is executed. We should not see any field + # names that are SQLite/MySQL reserved words (eg interval) but just + # in case enclose field names in backquotes. + set_str = ','.join(["`%s`=?" % k for k in key_list]) + # form the SQL update statement + sql_update_stmt = "UPDATE %s SET %s WHERE dateTime=%s" % (self.dbm.table_name, + set_str, + ts) + # obtain a cursor if we don't have one + _cursor = cursor or self.dbm.connection.cursor() + # execute the update statement but only if its not a dry run + if not self.dry_run: + _cursor.execute(sql_update_stmt, value_list) + # close the cursor is we opened one + if cursor is None: + _cursor.close() + # if we made it here the record was updated so return the number of + # records updated which will always be 1 + return 1 + # there were no fields to update so return 0 + return 0 + + @staticmethod + def _progress(message, overprint=True): + """Utility function to show our progress.""" + + if overprint: + print(message + "\r", end='') + else: + print(message) + sys.stdout.flush() diff --git a/dist/weewx-4.10.1/bin/weecfg/extension.py b/dist/weewx-4.10.1/bin/weecfg/extension.py new file mode 100644 index 0000000..94c70ff --- /dev/null +++ b/dist/weewx-4.10.1/bin/weecfg/extension.py @@ -0,0 +1,452 @@ +# +# Copyright (c) 2009-2021 Tom Keffer and +# Matthew Wall +# +# See the file LICENSE.txt for your full rights. +# +"""Utilities for installing and removing extensions""" + +# As an example, here are the names of some reference directories for the +# extension pmon (process monitor): +# -user/ # The USER_ROOT subdirectory +# -user/installer/ # The EXT_ROOT subdirectory +# -user/installer/pmon/ # The extension's installer subdirectory +# -user/installer/pmon/install.py # The copy of the installer for the extension + +from __future__ import absolute_import +import glob +import os +import shutil +import sys + +import configobj + +import weecfg +from weecfg import Logger, prompt_with_options +from weewx import all_service_groups +import weeutil.config +import weeutil.weeutil + + +class InstallError(Exception): + """Exception raised when installing an extension.""" + + +class ExtensionInstaller(dict): + """Base class for extension installers.""" + + def configure(self, engine): + """Can be overridden by installers. It should return True if the installer modifies + the configuration dictionary.""" + return False + + +class ExtensionEngine(object): + """Engine that manages extensions.""" + # Extension components can be installed to these locations + target_dirs = { + 'bin': 'BIN_ROOT', + 'skins': 'SKIN_ROOT'} + + def __init__(self, config_path, config_dict, tmpdir=None, bin_root=None, + dry_run=False, logger=None): + """ + Initializer for ExtensionEngine. + + Args: + + config_path (str): Path to the configuration file. For example, something + like /home/weewx/weewx.conf) + + config_dict (str): The configuration dictionary, i.e., the contents of the + file at config_path. + + tmpdir (str): A temporary directory to be used for extracting tarballs and + the like [Optional] + + bin_root (str): Path to the location of the weewx binary files. For example, + something like /home/weewx/bin. Optional. If not specified, + it will be guessed based on the location of this file. + + dry_run (bool): If Truthy, all the steps will be printed out, but nothing will + actually be done. + + logger (weecfg.Logger): An instance of weecfg.Logger. This will be used to print + things to the console. + """ + self.config_path = config_path + self.config_dict = config_dict + self.logger = logger or Logger() + self.tmpdir = tmpdir or '/var/tmp' + self.dry_run = dry_run + + self.root_dict = weecfg.extract_roots(self.config_path, self.config_dict, bin_root) + self.logger.log("root dictionary: %s" % self.root_dict, 4) + + def enumerate_extensions(self): + """Print info about all installed extensions to the logger.""" + ext_root = self.root_dict['EXT_ROOT'] + try: + exts = os.listdir(ext_root) + if exts: + self.logger.log("%-18s%-10s%s" % ("Extension Name", "Version", "Description"), + level=0) + for f in exts: + info = self.get_extension_info(f) + msg = "%(name)-18s%(version)-10s%(description)s" % info + self.logger.log(msg, level=0) + else: + self.logger.log("Extension cache is '%s'" % ext_root, level=2) + self.logger.log("No extensions installed", level=0) + except OSError: + self.logger.log("No extension cache '%s'" % ext_root, level=2) + self.logger.log("No extensions installed", level=0) + + def get_extension_info(self, ext_name): + ext_cache_dir = os.path.join(self.root_dict['EXT_ROOT'], ext_name) + _, installer = weecfg.get_extension_installer(ext_cache_dir) + return installer + + def install_extension(self, extension_path): + """Install the extension from the file or directory extension_path""" + self.logger.log("Request to install '%s'" % extension_path) + if os.path.isfile(extension_path): + # It is a file. If it ends with .zip, assume it is a zip archive. + # Otherwise, assume it is a tarball. + extension_dir = None + try: + if extension_path[-4:] == '.zip': + member_names = weecfg.extract_zip(extension_path, + self.tmpdir, self.logger) + else: + member_names = weecfg.extract_tar(extension_path, + self.tmpdir, self.logger) + extension_reldir = os.path.commonprefix(member_names) + if extension_reldir == '': + raise InstallError("Unable to install from '%s': no common path " + "(the extension archive contains more than a " + "single root directory)" % extension_path) + extension_dir = os.path.join(self.tmpdir, extension_reldir) + self.install_from_dir(extension_dir) + finally: + if extension_dir: + shutil.rmtree(extension_dir, ignore_errors=True) + elif os.path.isdir(extension_path): + # It's a directory, presumably containing the extension components. + # Install directly + self.install_from_dir(extension_path) + else: + raise InstallError("Extension '%s' not found." % extension_path) + + self.logger.log("Finished installing extension '%s'" % extension_path) + + def install_from_dir(self, extension_dir): + """Install the extension whose components are in extension_dir""" + self.logger.log("Request to install extension found in directory %s" % + extension_dir, level=2) + + # The "installer" is actually a dictionary containing what is to be installed and where. + # The "installer_path" is the path to the file containing that dictionary. + installer_path, installer = weecfg.get_extension_installer(extension_dir) + extension_name = installer.get('name', 'Unknown') + self.logger.log("Found extension with name '%s'" % extension_name, + level=2) + + # Go through all the files used by the extension. A "source tuple" is something like + # (bin, [user/myext.py, user/otherext.py]). The first element is the directory the files go + # in, the second element is a list of files to be put in that directory + self.logger.log("Copying new files", level=2) + N = 0 + for source_tuple in installer['files']: + # For each set of sources, see if it's a type we know about + for directory in ExtensionEngine.target_dirs: + # This will be something like 'bin', or 'skins': + source_type = os.path.commonprefix((source_tuple[0], directory)) + # If there is a match, source_type will be something other than an empty string: + if source_type: + # This will be something like 'BIN_ROOT' or 'SKIN_ROOT': + root_type = ExtensionEngine.target_dirs[source_type] + # Now go through all the files of the source tuple + for install_file in source_tuple[1]: + source_path = os.path.join(extension_dir, install_file) + dst_file = ExtensionEngine._strip_leading_dir(install_file) + destination_path = os.path.abspath(os.path.join(self.root_dict[root_type], + dst_file)) + self.logger.log("Copying from '%s' to '%s'" + % (source_path, destination_path), + level=3) + if not self.dry_run: + try: + os.makedirs(os.path.dirname(destination_path)) + except OSError: + pass + shutil.copy(source_path, destination_path) + N += 1 + # We've completed at least one destination directory that we recognized. + break + else: + # No 'break' occurred, meaning that we didn't recognize any target directories. + sys.exit("Unknown destination directory %s. Skipped file(s) %s" + % (source_tuple[0], source_tuple[1])) + self.logger.log("Copied %d files" % N, level=2) + + save_config = False + + # Go through all the possible service groups and see if the extension + # includes any services that belong in any of them. + self.logger.log("Adding services to service lists", level=2) + for service_group in all_service_groups: + if service_group in installer: + extension_svcs = weeutil.weeutil.option_as_list(installer[service_group]) + # Be sure that the leaf node is actually a list + svc_list = weeutil.weeutil.option_as_list( + self.config_dict['Engine']['Services'][service_group]) + for svc in extension_svcs: + # See if this service is already in the service group + if svc not in svc_list: + if not self.dry_run: + # Add the new service into the appropriate service group + svc_list.append(svc) + self.config_dict['Engine']['Services'][service_group] = svc_list + save_config = True + self.logger.log("Added new service %s to %s" + % (svc, service_group), level=3) + + # Give the installer a chance to do any customized configuration + save_config |= installer.configure(self) + + # Look for options that have to be injected into the configuration file + if 'config' in installer: + save_config |= self._inject_config(installer['config'], extension_name) + + # Save the extension's install.py file in the extension's installer + # directory for later use enumerating and uninstalling + extension_installer_dir = os.path.join(self.root_dict['EXT_ROOT'], extension_name) + self.logger.log("Saving installer file to %s" % extension_installer_dir) + if not self.dry_run: + try: + os.makedirs(os.path.join(extension_installer_dir)) + except OSError: + pass + shutil.copy2(installer_path, extension_installer_dir) + + if save_config: + backup_path = weecfg.save_with_backup(self.config_dict, self.config_path) + self.logger.log("Saved configuration dictionary. Backup copy at %s" % backup_path) + + def get_lang_code(self, skin, default_code): + """Convenience function for picking a language code""" + skin_path = os.path.join(self.root_dict['SKIN_ROOT'], skin) + languages = weecfg.get_languages(skin_path) + code = weecfg.pick_language(languages, default_code) + return code + + def _inject_config(self, extension_config, extension_name): + """Injects any additions to the configuration file that the extension might have. + + Returns True if it modified the config file, False otherwise. + """ + self.logger.log("Adding sections to configuration file", level=2) + # Make a copy so we can modify the sections to fit the existing configuration + if isinstance(extension_config, configobj.Section): + cfg = weeutil.config.deep_copy(extension_config) + else: + cfg = dict(extension_config) + + save_config = False + + # Prepend any html paths with HTML_ROOT from existing configuration + weecfg.prepend_path(cfg, 'HTML_ROOT', self.config_dict['StdReport']['HTML_ROOT']) + + # If the extension uses a database, massage it so it's compatible with the new V3.2 way of + # specifying database options + if 'Databases' in cfg: + for db in cfg['Databases']: + db_dict = cfg['Databases'][db] + # Does this extension use the V3.2+ 'database_type' option? + if 'database_type' not in db_dict: + # There is no database type specified. In this case, the driver type better + # appear. Fail hard, with a KeyError, if it does not. Also, if the driver is + # not for sqlite or MySQL, then we don't know anything about it. Assume the + # extension author knows what s/he is doing, and leave it be. + if db_dict['driver'] == 'weedb.sqlite': + db_dict['database_type'] = 'SQLite' + db_dict.pop('driver') + elif db_dict['driver'] == 'weedb.mysql': + db_dict['database_type'] = 'MySQL' + db_dict.pop('driver') + + if not self.dry_run: + # Inject any new config data into the configuration file + weeutil.config.conditional_merge(self.config_dict, cfg) + + self._reorder(cfg) + save_config = True + + self.logger.log("Merged extension settings into configuration file", level=3) + return save_config + + def _reorder(self, cfg): + """Reorder the resultant config_dict""" + # Patch up the location of any reports so they appear before FTP/RSYNC + + # First, find the FTP or RSYNC reports. This has to be done on the basis of the skin type, + # rather than the report name, in case there are multiple FTP or RSYNC reports to be run. + try: + for report in self.config_dict['StdReport'].sections: + if self.config_dict['StdReport'][report]['skin'] in ['Ftp', 'Rsync']: + target_name = report + break + else: + # No FTP or RSYNC. Nothing to do. + return + except KeyError: + return + + # Now shuffle things so any reports that appear in the extension appear just before FTP (or + # RSYNC) and in the same order they appear in the extension manifest. + try: + for report in cfg['StdReport']: + weecfg.reorder_sections(self.config_dict['StdReport'], report, target_name) + except KeyError: + pass + + def uninstall_extension(self, extension_name): + """Uninstall the extension with name extension_name""" + + self.logger.log("Request to remove extension '%s'" % extension_name) + + # Find the subdirectory containing this extension's installer + extension_installer_dir = os.path.join(self.root_dict['EXT_ROOT'], extension_name) + try: + # Retrieve it + _, installer = weecfg.get_extension_installer(extension_installer_dir) + except weecfg.ExtensionError: + sys.exit("Unable to find extension %s" % extension_name) + + # Remove any files that were added: + self.uninstall_files(installer) + + save_config = False + + # Remove any services we added + for service_group in all_service_groups: + if service_group in installer: + new_list = [x for x in self.config_dict['Engine']['Services'][service_group] \ + if x not in installer[service_group]] + if not self.dry_run: + self.config_dict['Engine']['Services'][service_group] = new_list + save_config = True + + # Remove any sections we added + if 'config' in installer and not self.dry_run: + weecfg.remove_and_prune(self.config_dict, installer['config']) + save_config = True + + if not self.dry_run: + # Finally, remove the extension's installer subdirectory: + shutil.rmtree(extension_installer_dir) + + if save_config: + weecfg.save_with_backup(self.config_dict, self.config_path) + + self.logger.log("Finished removing extension '%s'" % extension_name) + + def uninstall_files(self, installer): + """Delete files that were installed for this extension""" + + directory_list = [] + + self.logger.log("Removing files.", level=2) + N = 0 + for source_tuple in installer['files']: + # For each set of sources, see if it's a type we know about + for directory in ExtensionEngine.target_dirs: + # This will be something like 'bin', or 'skins': + source_type = os.path.commonprefix((source_tuple[0], directory)) + # If there is a match, source_type will be something other than an empty string: + if source_type: + # This will be something like 'BIN_ROOT' or 'SKIN_ROOT': + root_type = ExtensionEngine.target_dirs[source_type] + # Now go through all the files of the source tuple + for install_file in source_tuple[1]: + dst_file = ExtensionEngine._strip_leading_dir(install_file) + destination_path = os.path.abspath(os.path.join(self.root_dict[root_type], + dst_file)) + file_name = os.path.basename(destination_path) + # There may be a versioned skin.conf. Delete it by adding a wild card. + # Similarly, be sure to delete Python files with .pyc or .pyo extensions. + if file_name == 'skin.conf' or file_name.endswith('py'): + destination_path += "*" + N += self.delete_file(destination_path) + # Accumulate all directories under 'skins' + if root_type == 'SKIN_ROOT': + dst_dir = ExtensionEngine._strip_leading_dir(source_tuple[0]) + directory = os.path.abspath(os.path.join(self.root_dict[root_type], + dst_dir)) + directory_list.append(directory) + break + else: + sys.exit("Skipped file %s: Unknown destination directory %s" + % (source_tuple[1], source_tuple[0])) + self.logger.log("Removed %d files" % N, level=2) + + # Now delete all the empty skin directories. Start by finding the directory closest to root + most_root = os.path.commonprefix(directory_list) + # Now delete the directories under it, from the bottom up. + for dirpath, _, _ in os.walk(most_root, topdown=False): + if dirpath in directory_list: + self.delete_directory(dirpath) + + def delete_file(self, filename, report_errors=True): + """ + Delete files from the file system. + + Args: + filename (str): The path to the file(s) to be deleted. Can include wildcards. + + report_errors (bool): If truthy, report an error if the file is missing or cannot be + deleted. Otherwise don't. In neither case will an exception be raised. + Returns: + int: The number of files deleted + """ + n_deleted = 0 + for fn in glob.glob(filename): + self.logger.log("Deleting file %s" % fn, level=2) + if not self.dry_run: + try: + os.remove(fn) + n_deleted += 1 + except OSError as e: + if report_errors: + self.logger.log("Delete failed: %s" % e, level=4) + return n_deleted + + def delete_directory(self, directory, report_errors=True): + """ + Delete the given directory from the file system. + + Args: + + directory (str): The path to the directory to be deleted. If the directory is not + empty, nothing is done. + + report_errors (bool); If truthy, report an error. Otherwise don't. In neither case will + an exception be raised. """ + try: + if os.listdir(directory): + self.logger.log("Directory '%s' not empty" % directory, level=2) + else: + self.logger.log("Deleting directory %s" % directory, level=2) + if not self.dry_run: + shutil.rmtree(directory) + except OSError as e: + if report_errors: + self.logger.log("Delete failed on directory '%s': %s" + % (directory, e), level=2) + + @staticmethod + def _strip_leading_dir(path): + idx = path.find('/') + if idx >= 0: + return path[idx + 1:] diff --git a/dist/weewx-4.10.1/bin/weedb/__init__.py b/dist/weewx-4.10.1/bin/weedb/__init__.py new file mode 100644 index 0000000..3da6a59 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weedb/__init__.py @@ -0,0 +1,209 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Middleware that sits above DBAPI and makes it a little more database independent. + +Weedb generally follows the MySQL exception model. Specifically: + - Operations on a non-existent database result in a weedb.OperationalError exception + being raised. + - Operations on a non-existent table result in a weedb.ProgrammingError exception + being raised. + - Select statements requesting non-existing columns result in a weedb.OperationalError + exception being raised. + - Attempt to add a duplicate key results in a weedb.IntegrityError exception + being raised. +""" + +import sys + +# The exceptions that the weedb package can raise: +class DatabaseError(Exception): + """Base class of all weedb exceptions.""" + +class IntegrityError(DatabaseError): + """Operation attempted involving the relational integrity of the database.""" + +class ProgrammingError(DatabaseError): + """SQL or other programming error.""" + +class DatabaseExistsError(ProgrammingError): + """Attempt to create a database that already exists""" + +class TableExistsError(ProgrammingError): + """Attempt to create a table that already exists.""" + +class NoTableError(ProgrammingError): + """Attempt to operate on a non-existing table.""" + +class OperationalError(DatabaseError): + """Runtime database errors.""" + +class NoDatabaseError(OperationalError): + """Operation attempted on a database that does not exist.""" + +class CannotConnectError(OperationalError): + """Unable to connect to the database server.""" + +class DisconnectError(OperationalError): + """Database went away.""" + +class NoColumnError(OperationalError): + """Attempt to operate on a column that does not exist.""" + +class BadPasswordError(OperationalError): + """Bad or missing password.""" + +class PermissionError(OperationalError): + """Lacking necessary permissions.""" + +# For backwards compatibility: +DatabaseExists = DatabaseExistsError +NoDatabase = NoDatabaseError +CannotConnect = CannotConnectError + +# In what follows, the test whether a database dictionary has function "dict" is +# to get around a bug in ConfigObj. It seems to be unable to unpack (using the +# '**' notation) a ConfigObj dictionary into a function. By calling .dict() a +# regular dictionary is returned, which can be unpacked. + +def create(db_dict): + """Create a database. If it already exists, an exception of type + weedb.DatabaseExistsError will be raised.""" + __import__(db_dict['driver']) + driver_mod = sys.modules[db_dict['driver']] + # See note above + if hasattr(db_dict, "dict"): + return driver_mod.create(**db_dict.dict()) + else: + return driver_mod.create(**db_dict) + + +def connect(db_dict): + """Return a connection to a database. If the database does not + exist, an exception of type weedb.NoDatabaseError will be raised.""" + __import__(db_dict['driver']) + driver_mod = sys.modules[db_dict['driver']] + # See note above + if hasattr(db_dict, "dict"): + return driver_mod.connect(**db_dict.dict()) + else: + return driver_mod.connect(**db_dict) + + +def drop(db_dict): + """Drop (delete) a database. If the database does not exist, + the exception weedb.NoDatabaseError will be raised.""" + __import__(db_dict['driver']) + driver_mod = sys.modules[db_dict['driver']] + # See note above + if hasattr(db_dict, "dict"): + return driver_mod.drop(**db_dict.dict()) + else: + return driver_mod.drop(**db_dict) + + +class Connection(object): + """Abstract base class, representing a connection to a database.""" + + def __init__(self, connection, database_name, dbtype): + """Superclass should raise exception of type weedb.OperationalError + if the database does not exist.""" + self.connection = connection + self.database_name = database_name + self.dbtype = dbtype + + def cursor(self): + """Returns an appropriate database cursor.""" + raise NotImplementedError + + def execute(self, sql_string, sql_tuple=()): + """Execute a sql statement. This version does not return a cursor, + so it can only be used for statements that do not return a result set.""" + + cursor = self.cursor() + try: + cursor.execute(sql_string, sql_tuple) + finally: + cursor.close() + + def tables(self): + """Returns a list of the tables in the database. + Returns an empty list if the database has no tables in it.""" + raise NotImplementedError + + def genSchemaOf(self, table): + """Generator function that returns a summary of the table's schema. + It returns a 6-way tuple: + (number, column_name, column_type, can_be_null, default_value, is_primary) + + Example: + (2, 'mintime', 'INTEGER', True, None, False)""" + raise NotImplementedError + + def columnsOf(self, table): + """Returns a list of the column names in the specified table. Implementers + should raise an exception of type weedb.ProgrammingError if the table does not exist.""" + raise NotImplementedError + + def get_variable(self, var_name): + """Return a database specific operational variable. Generally, things like + pragmas, or optimization-related variables. + + It returns a 2-way tuple: + (variable-name, variable-value) + If the variable does not exist, it returns None. + """ + raise NotImplementedError + + @property + def has_math(self): + """Returns True if the database supports math functions such as cos() and sin(). + False otherwise.""" + return True + + def begin(self): + raise NotImplementedError + + def commit(self): + raise NotImplementedError + + def rollback(self): + raise NotImplementedError + + def close(self): + try: + self.connection.close() + except DatabaseError: + pass + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + try: + self.close() + except DatabaseError: + pass + +class Transaction(object): + """Class to be used to wrap transactions in a 'with' clause.""" + def __init__(self, connection): + self.connection = connection + self.cursor = self.connection.cursor() + + def __enter__(self): + self.connection.begin() + return self.cursor + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + if etyp is None: + self.connection.commit() + else: + self.connection.rollback() + try: + self.cursor.close() + except DatabaseError: + pass + diff --git a/dist/weewx-4.10.1/bin/weedb/mysql.py b/dist/weewx-4.10.1/bin/weedb/mysql.py new file mode 100644 index 0000000..0ab3a56 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weedb/mysql.py @@ -0,0 +1,338 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""weedb driver for the MySQL database""" + +import decimal +import six + +try: + import MySQLdb +except ImportError: + # Some installs use 'pymysql' instead of 'MySQLdb' + import pymysql as MySQLdb + from pymysql import DatabaseError as MySQLDatabaseError +else: + try: + from MySQLdb import DatabaseError as MySQLDatabaseError + except ImportError: + from _mysql_exceptions import DatabaseError as MySQLDatabaseError + +from weeutil.weeutil import to_bool +import weedb + +DEFAULT_ENGINE = 'INNODB' + +exception_map = { + 1007: weedb.DatabaseExistsError, + 1008: weedb.NoDatabaseError, + 1044: weedb.PermissionError, + 1045: weedb.BadPasswordError, + 1049: weedb.NoDatabaseError, + 1050: weedb.TableExistsError, + 1054: weedb.NoColumnError, + 1091: weedb.NoColumnError, + 1062: weedb.IntegrityError, + 1146: weedb.NoTableError, + 1927: weedb.CannotConnectError, + 2002: weedb.CannotConnectError, + 2003: weedb.CannotConnectError, + 2005: weedb.CannotConnectError, + 2006: weedb.DisconnectError, + 2013: weedb.DisconnectError, + None: weedb.DatabaseError +} + + +def guard(fn): + """Decorator function that converts MySQL exceptions into weedb exceptions.""" + + def guarded_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except MySQLDatabaseError as e: + # Get the MySQL exception number out of e: + try: + errno = e.args[0] + except (IndexError, AttributeError): + errno = None + # Default exception is weedb.DatabaseError + klass = exception_map.get(errno, weedb.DatabaseError) + raise klass(e) + + return guarded_fn + + +def connect(host='localhost', user='', password='', database_name='', + driver='', port=3306, engine=DEFAULT_ENGINE, autocommit=True, **kwargs): + """Connect to the specified database""" + return Connection(host=host, port=int(port), user=user, password=password, + database_name=database_name, engine=engine, autocommit=autocommit, **kwargs) + + +def create(host='localhost', user='', password='', database_name='', + driver='', port=3306, engine=DEFAULT_ENGINE, autocommit=True, **kwargs): + """Create the specified database. If it already exists, + an exception of type weedb.DatabaseExistsError will be thrown.""" + # Open up a connection w/o specifying the database. + connect = Connection(host=host, + port=int(port), + user=user, + password=password, + autocommit=autocommit, + **kwargs) + cursor = connect.cursor() + + try: + # Now create the database. + cursor.execute("CREATE DATABASE %s" % (database_name,)) + finally: + cursor.close() + connect.close() + + +def drop(host='localhost', user='', password='', database_name='', + driver='', port=3306, engine=DEFAULT_ENGINE, autocommit=True, + **kwargs): # @UnusedVariable + """Drop (delete) the specified database.""" + # Open up a connection + connect = Connection(host=host, + port=int(port), + user=user, + password=password, + autocommit=autocommit, + **kwargs) + cursor = connect.cursor() + + try: + cursor.execute("DROP DATABASE %s" % database_name) + finally: + cursor.close() + connect.close() + + +class Connection(weedb.Connection): + """A wrapper around a MySQL connection object.""" + + @guard + def __init__(self, host='localhost', user='', password='', database_name='', + port=3306, engine=DEFAULT_ENGINE, autocommit=True, **kwargs): + """Initialize an instance of Connection. + + Parameters: + + host: IP or hostname with the mysql database (required) + user: User name (required) + password: The password for the username (required) + database_name: The database to be used. (required) + port: Its port number (optional; default is 3306) + engine: The MySQL database engine to use (optional; default is 'INNODB') + autocommit: If True, autocommit is enabled (default is True) + kwargs: Any extra arguments you may wish to pass on to MySQL + connect statement. See the file MySQLdb/connections.py for a list (optional). + """ + connection = MySQLdb.connect(host=host, port=int(port), user=user, passwd=password, + db=database_name, **kwargs) + + weedb.Connection.__init__(self, connection, database_name, 'mysql') + + # Set the storage engine to be used + set_engine(self.connection, engine) + + # Set the transaction isolation level. + self.connection.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") + self.connection.autocommit(to_bool(autocommit)) + + def cursor(self): + """Return a cursor object.""" + # The implementation of the MySQLdb cursor is lame enough that we are + # obliged to include a wrapper around it: + return Cursor(self) + + @guard + def tables(self): + """Returns a list of tables in the database.""" + + table_list = list() + # Get a cursor directly from MySQL + cursor = self.connection.cursor() + try: + cursor.execute("""SHOW TABLES;""") + while True: + row = cursor.fetchone() + if row is None: break + # Extract the table name. In case it's in unicode, convert to a regular string. + table_list.append(str(row[0])) + finally: + cursor.close() + return table_list + + @guard + def genSchemaOf(self, table): + """Return a summary of the schema of the specified table. + + If the table does not exist, an exception of type weedb.OperationalError is raised.""" + + # Get a cursor directly from MySQL: + cursor = self.connection.cursor() + try: + # If the table does not exist, this will raise a MySQL ProgrammingError exception, + # which gets converted to a weedb.OperationalError exception by the guard decorator + cursor.execute("""SHOW COLUMNS IN %s;""" % table) + irow = 0 + while True: + row = cursor.fetchone() + if row is None: break + # Append this column to the list of columns. + colname = str(row[0]) + if row[1].upper() == 'DOUBLE': + coltype = 'REAL' + elif row[1].upper().startswith('INT'): + coltype = 'INTEGER' + elif row[1].upper().startswith('CHAR'): + coltype = 'STR' + else: + coltype = str(row[1]).upper() + is_primary = True if row[3] == 'PRI' else False + can_be_null = False if row[2] == '' else to_bool(row[2]) + yield (irow, colname, coltype, can_be_null, row[4], is_primary) + irow += 1 + finally: + cursor.close() + + @guard + def columnsOf(self, table): + """Return a list of columns in the specified table. + + If the table does not exist, an exception of type weedb.OperationalError is raised.""" + column_list = [row[1] for row in self.genSchemaOf(table)] + return column_list + + @guard + def get_variable(self, var_name): + cursor = self.connection.cursor() + try: + cursor.execute("SHOW VARIABLES LIKE '%s';" % var_name) + row = cursor.fetchone() + # This is actually a 2-way tuple (variable-name, variable-value), + # or None, if the variable does not exist. + return row + finally: + cursor.close() + + @guard + def begin(self): + """Begin a transaction.""" + self.connection.query("START TRANSACTION") + + @guard + def commit(self): + self.connection.commit() + + @guard + def rollback(self): + self.connection.rollback() + + +class Cursor(object): + """A wrapper around the MySQLdb cursor object""" + + @guard + def __init__(self, connection): + """Initialize a Cursor from a connection. + + connection: An instance of db.mysql.Connection""" + + # Get the MySQLdb cursor and store it internally: + self.cursor = connection.connection.cursor() + + @guard + def execute(self, sql_string, sql_tuple=()): + """Execute a SQL statement on the MySQL server. + + sql_string: A SQL statement to be executed. It should use ? as + a placeholder. + + sql_tuple: A tuple with the values to be used in the placeholders.""" + + # MySQL uses '%s' as placeholders, so replace the ?'s with %s + mysql_string = sql_string.replace('?', '%s') + + # Convert sql_tuple to a plain old tuple, just in case it actually + # derives from tuple, but overrides the string conversion (as is the + # case with a TimeSpan object): + self.cursor.execute(mysql_string, tuple(sql_tuple)) + + return self + + def fetchone(self): + # Get a result from the MySQL cursor, then run it through the _massage + # filter below + return _massage(self.cursor.fetchone()) + + def drop_columns(self, table, column_names): + """Drop the set of 'column_names' from table 'table'. + + table: The name of the table from which the column(s) are to be dropped. + + column_names: A set (or list) of column names to be dropped. It is not an error to try to drop + a non-existent column. + """ + for column_name in column_names: + self.execute("ALTER TABLE %s DROP COLUMN %s;" % (table, column_name)) + + def close(self): + try: + self.cursor.close() + del self.cursor + except AttributeError: + pass + + # + # Supplying functions __iter__ and next allows the cursor to be used as an iterator. + # + def __iter__(self): + return self + + def __next__(self): + result = self.fetchone() + if result is None: + raise StopIteration + return result + + # For Python 2 compatibility: + next = __next__ + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.close() + + +# +# This is a utility function for converting a result set that might contain +# longs or decimal.Decimals (which MySQLdb uses) to something containing just ints. +# +def _massage(seq): + # Return the _massaged sequence if it exists, otherwise, return None + if seq is not None: + return [int(i) if isinstance(i, (six.integer_types, decimal.Decimal)) else i for i in seq] + + +def set_engine(connect, engine): + """Set the default MySQL storage engine.""" + try: + server_version = connect._server_version + except AttributeError: + server_version = connect.server_version + # Some servers return lists of ints, some lists of strings, some a string. + # Try to normalize: + if isinstance(server_version, (tuple, list)): + server_version = '%s.%s' % server_version[:2] + if server_version >= '5.5': + connect.query("SET default_storage_engine=%s" % engine) + else: + connect.query("SET storage_engine=%s;" % engine) diff --git a/dist/weewx-4.10.1/bin/weedb/sqlite.py b/dist/weewx-4.10.1/bin/weedb/sqlite.py new file mode 100644 index 0000000..08adced --- /dev/null +++ b/dist/weewx-4.10.1/bin/weedb/sqlite.py @@ -0,0 +1,297 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""weedb driver for sqlite""" + +from __future__ import with_statement +import os.path + +# Import sqlite3. If it does not support the 'with' statement, then +# import pysqlite2, which might... +import sqlite3 + +if not hasattr(sqlite3.Connection, "__exit__"): # @UndefinedVariable + del sqlite3 + from pysqlite2 import dbapi2 as sqlite3 # @Reimport @UnresolvedImport + +# Test to see whether this version of SQLite has math functions. An explicit test is required +# (rather than just check version numbers) because the SQLite library may or may not have been +# compiled with the DSQLITE_ENABLE_MATH_FUNCTIONS option. +try: + with sqlite3.connect(":memory:") as conn: + conn.execute("SELECT RADIANS(0.0), SIN(0.0), COS(0.0);") +except sqlite3.OperationalError: + has_math = False +else: + has_math = True + +import weedb +from weeutil.weeutil import to_int, to_bool + + +def guard(fn): + """Decorator function that converts sqlite exceptions into weedb exceptions.""" + + def guarded_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except sqlite3.IntegrityError as e: + raise weedb.IntegrityError(e) + except sqlite3.OperationalError as e: + msg = str(e).lower() + if msg.startswith("unable to open"): + raise weedb.PermissionError(e) + elif msg.startswith("no such table"): + raise weedb.NoTableError(e) + elif msg.endswith("already exists"): + raise weedb.TableExistsError(e) + elif msg.startswith("no such column"): + raise weedb.NoColumnError(e) + else: + raise weedb.OperationalError(e) + except sqlite3.ProgrammingError as e: + raise weedb.ProgrammingError(e) + + return guarded_fn + + +def connect(database_name='', SQLITE_ROOT='', driver='', **argv): # @UnusedVariable + """Factory function, to keep things compatible with DBAPI. """ + return Connection(database_name=database_name, SQLITE_ROOT=SQLITE_ROOT, **argv) + + +@guard +def create(database_name='', SQLITE_ROOT='', driver='', **argv): # @UnusedVariable + """Create the database specified by the db_dict. If it already exists, + an exception of type DatabaseExistsError will be thrown.""" + file_path = _get_filepath(SQLITE_ROOT, database_name, **argv) + # Check whether the database file exists: + if os.path.exists(file_path): + raise weedb.DatabaseExistsError("Database %s already exists" % (file_path,)) + else: + if file_path != ':memory:': + # If it doesn't exist, create the parent directories + fileDirectory = os.path.dirname(file_path) + if not os.path.exists(fileDirectory): + try: + os.makedirs(fileDirectory) + except OSError: + raise weedb.PermissionError("No permission to create %s" % fileDirectory) + timeout = to_int(argv.get('timeout', 5)) + isolation_level = argv.get('isolation_level') + # Open, then immediately close the database. + connection = sqlite3.connect(file_path, timeout=timeout, isolation_level=isolation_level) + connection.close() + + +def drop(database_name='', SQLITE_ROOT='', driver='', **argv): # @UnusedVariable + file_path = _get_filepath(SQLITE_ROOT, database_name, **argv) + try: + os.remove(file_path) + except OSError as e: + errno = getattr(e, 'errno', 2) + if errno == 13: + raise weedb.PermissionError("No permission to drop database %s" % file_path) + else: + raise weedb.NoDatabaseError("Attempt to drop non-existent database %s" % file_path) + + +def _get_filepath(SQLITE_ROOT, database_name, **argv): + """Utility function to calculate the path to the sqlite database file.""" + if database_name == ':memory:': + return database_name + # For backwards compatibility, allow the keyword 'root', if 'SQLITE_ROOT' is + # not defined: + root_dir = SQLITE_ROOT or argv.get('root', '') + return os.path.join(root_dir, database_name) + + +class Connection(weedb.Connection): + """A wrapper around a sqlite3 connection object.""" + + @guard + def __init__(self, database_name='', SQLITE_ROOT='', pragmas=None, **argv): + """Initialize an instance of Connection. + + Parameters: + + database_name: The name of the Sqlite database. This is generally the file name + SQLITE_ROOT: The path to the directory holding the database. Joining "SQLITE_ROOT" with + "database_name" results in the full path to the sqlite file. + pragmas: Any pragma statements, in the form of a dictionary. + timeout: The amount of time, in seconds, to wait for a lock to be released. + Optional. Default is 5. + isolation_level: The type of isolation level to use. One of None, + DEFERRED, IMMEDIATE, or EXCLUSIVE. Default is None (autocommit mode). + + If the operation fails, an exception of type weedb.OperationalError will be raised. + """ + + self.file_path = _get_filepath(SQLITE_ROOT, database_name, **argv) + if self.file_path != ':memory:' and not os.path.exists(self.file_path): + raise weedb.NoDatabaseError("Attempt to open a non-existent database %s" + % self.file_path) + timeout = to_int(argv.get('timeout', 5)) + isolation_level = argv.get('isolation_level') + connection = sqlite3.connect(self.file_path, timeout=timeout, + isolation_level=isolation_level) + + if pragmas is not None: + for pragma in pragmas: + connection.execute("PRAGMA %s=%s;" % (pragma, pragmas[pragma])) + weedb.Connection.__init__(self, connection, database_name, 'sqlite') + + @guard + def cursor(self): + """Return a cursor object.""" + return self.connection.cursor(Cursor) + + @guard + def execute(self, sql_string, sql_tuple=()): + """Execute a sql statement. This specialized version takes advantage + of sqlite's ability to do an execute without a cursor.""" + + with self.connection: + self.connection.execute(sql_string, sql_tuple) + + @guard + def tables(self): + """Returns a list of tables in the database.""" + + table_list = list() + for row in self.connection.execute("SELECT tbl_name FROM sqlite_master " + "WHERE type='table';"): + # Extract the table name. Sqlite returns unicode, so always + # convert to a regular string: + table_list.append(str(row[0])) + return table_list + + @guard + def genSchemaOf(self, table): + """Return a summary of the schema of the specified table. + + If the table does not exist, an exception of type weedb.OperationalError is raised.""" + for row in self.connection.execute("""PRAGMA table_info(%s);""" % table): + if row[2].upper().startswith('CHAR'): + coltype = 'STR' + else: + coltype = str(row[2]).upper() + yield (row[0], str(row[1]), coltype, not to_bool(row[3]), row[4], to_bool(row[5])) + + def columnsOf(self, table): + """Return a list of columns in the specified table. If the table does not exist, + None is returned.""" + + column_list = [row[1] for row in self.genSchemaOf(table)] + + # If there are no columns (which means the table did not exist) raise an exceptional + if not column_list: + raise weedb.ProgrammingError("No such table %s" % table) + return column_list + + @guard + def get_variable(self, var_name): + cursor = self.connection.cursor() + try: + cursor.execute("PRAGMA %s;" % var_name) + row = cursor.fetchone() + return None if row is None else (var_name, row[0]) + finally: + cursor.close() + + @property + def has_math(self): + global has_math + return has_math + + @guard + def begin(self): + self.connection.execute("BEGIN TRANSACTION") + + @guard + def commit(self): + self.connection.commit() + + @guard + def rollback(self): + self.connection.rollback() + + @guard + def close(self): + self.connection.close() + + +class Cursor(sqlite3.Cursor): + """A wrapper around the sqlite cursor object""" + + # The sqlite3 cursor object is very full featured. We need only turn + # the sqlite exceptions into weedb exceptions. + @guard + def execute(self, *args, **kwargs): + return sqlite3.Cursor.execute(self, *args, **kwargs) + + @guard + def fetchone(self): + return sqlite3.Cursor.fetchone(self) + + @guard + def fetchall(self): + return sqlite3.Cursor.fetchall(self) + + @guard + def fetchmany(self, size=None): + if size is None: size = self.arraysize + return sqlite3.Cursor.fetchmany(self, size) + + def drop_columns(self, table, column_names): + """Drop the set of 'column_names' from table 'table'. + + table: The name of the table from which the column(s) are to be dropped. + + column_names: A set (or list) of column names to be dropped. It is not an error to try to + drop a non-existent column. + """ + + existing_column_set = set() + create_list = [] + insert_list = [] + + self.execute("""PRAGMA table_info(%s);""" % table) + + for row in self.fetchall(): + # Unpack the row + row_no, obs_name, obs_type, no_null, default, pk = row + existing_column_set.add(obs_name) + # Search through the target columns. + if obs_name in column_names: + continue + no_null_str = " NOT NULL" if no_null else "" + pk_str = " UNIQUE PRIMARY KEY" if pk else "" + default_str = " DEFAULT %s" % default if default is not None else "" + create_list.append("`%s` %s%s%s%s" % (obs_name, obs_type, no_null_str, + pk_str, default_str)) + insert_list.append(obs_name) + + for column in column_names: + if column not in existing_column_set: + raise weedb.NoColumnError("Cannot DROP '%s'; column does not exist." % column) + + create_str = ", ".join(create_list) + insert_str = ", ".join(insert_list) + + self.execute("CREATE TEMPORARY TABLE %s_temp (%s);" % (table, create_str)) + self.execute("INSERT INTO %s_temp SELECT %s FROM %s;" % (table, insert_str, table)) + self.execute("DROP TABLE %s;" % table) + self.execute("CREATE TABLE %s (%s);" % (table, create_str)) + self.execute("INSERT INTO %s SELECT %s FROM %s_temp;" % (table, insert_str, table)) + self.execute("DROP TABLE %s_temp;" % table) + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + # It is not an error to close a sqlite3 cursor multiple times, + # so there's no reason to guard it with a "try" clause: + self.close() diff --git a/dist/weewx-4.10.1/bin/weeimport/__init__.py b/dist/weewx-4.10.1/bin/weeimport/__init__.py new file mode 100644 index 0000000..d333804 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/__init__.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2009-2016 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +""" +Package weeimport. A set of modules for importing observational data into WeeWX. + +""" diff --git a/dist/weewx-4.10.1/bin/weeimport/csvimport.py b/dist/weewx-4.10.1/bin/weeimport/csvimport.py new file mode 100644 index 0000000..2b4ac74 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/csvimport.py @@ -0,0 +1,277 @@ +# +# Copyright (c) 2009-2019 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# + +"""Module to interact with a CSV file and import raw observational data for +use with wee_import. +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +# Python imports +import csv +import io +import logging +import os + + +# WeeWX imports +from . import weeimport +import weewx + +from weeutil.weeutil import timestamp_to_string, option_as_list +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class CSVSource +# ============================================================================ + + +class CSVSource(weeimport.Source): + """Class to interact with a CSV format text file. + + Handles the import of data from a CSV format data file with known field + names. + """ + + # Define a dict to map CSV fields to WeeWX archive fields. For a CSV import + # these details are specified by the user in the wee_import config file. + _header_map = None + # define a dict to map cardinal, intercardinal and secondary intercardinal + # directions to degrees + wind_dir_map = {'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5, + 'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5, + 'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5, + 'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5, + 'NORTH': 0.0, 'NORTHNORTHEAST': 22.5, + 'NORTHEAST': 45.0, 'EASTNORTHEAST': 67.5, + 'EAST': 90.0, 'EASTSOUTHEAST': 112.5, + 'SOUTHEAST': 135.0, 'SOUTHSOUTHEAST': 157.5, + 'SOUTH': 180.0, 'SOUTHSOUTHWEST': 202.5, + 'SOUTHWEST': 225.0, 'WESTSOUTHWEST': 247.5, + 'WEST': 270.0, 'WESTNORTHWEST': 292.5, + 'NORTHWEST': 315.0, 'NORTHNORTHWEST': 337.5 + } + + def __init__(self, config_dict, config_path, csv_config_dict, import_config_path, options): + + # call our parents __init__ + super(CSVSource, self).__init__(config_dict, + csv_config_dict, + options) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.csv_config_dict = csv_config_dict + + # get a few config settings from our CSV config dict + # csv field delimiter + self.delimiter = str(self.csv_config_dict.get('delimiter', ',')) + # string format used to decode the imported field holding our dateTime + self.raw_datetime_format = self.csv_config_dict.get('raw_datetime_format', + '%Y-%m-%d %H:%M:%S') + # is our rain discrete or cumulative + self.rain = self.csv_config_dict.get('rain', 'cumulative') + # determine valid range for imported wind direction + _wind_direction = option_as_list(self.csv_config_dict.get('wind_direction', + '0,360')) + try: + if float(_wind_direction[0]) <= float(_wind_direction[1]): + self.wind_dir = [float(_wind_direction[0]), + float(_wind_direction[1])] + else: + self.wind_dir = [-360, 360] + except (KeyError, ValueError): + self.wind_dir = [-360, 360] + # get our source file path + try: + self.source = csv_config_dict['file'] + except KeyError: + raise weewx.ViolatedPrecondition("CSV source file not specified " + "in '%s'." % import_config_path) + # get the source file encoding, default to utf-8-sig + self.source_encoding = self.csv_config_dict.get('source_encoding', + 'utf-8-sig') + # initialise our import field-to-WeeWX archive field map + self.map = None + # initialise some other properties we will need + self.start = 1 + self.end = 1 + self.increment = 1 + + # tell the user/log what we intend to do + _msg = "A CSV import from source file '%s' has been requested." % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if options.date: + _msg = " source=%s, date=%s" % (self.source, options.date) + else: + # we must have --from and --to + _msg = " source=%s, from=%s, to=%s" % (self.source, + options.date_from, + options.date_to) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc_missing=%s, " \ + "ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s, " \ + "date/time_string_format=%s" % (self.tranche, + self.interval, + self.raw_datetime_format) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " delimiter='%s', rain=%s, wind_direction=%s" % (self.delimiter, + self.rain, + self.wind_dir) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s" % (self.UV_sensor, self.solar_sensor) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound to " \ + "database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + if self.calc_missing: + _msg = "Missing derived observations will be calculated." + print(_msg) + log.info(_msg) + + if not self.UV_sensor: + _msg = "All WeeWX UV fields will be set to None." + print(_msg) + log.info(_msg) + if not self.solar_sensor: + _msg = "All WeeWX radiation fields will be set to None." + print(_msg) + log.info(_msg) + if options.date or options.date_from: + _msg = "Observations timestamped after %s and " \ + "up to and" % timestamp_to_string(self.first_ts) + print(_msg) + log.info(_msg) + _msg = "including %s will be imported." % timestamp_to_string(self.last_ts) + print(_msg) + log.info(_msg) + if self.dry_run: + _msg = "This is a dry run, imported data will not be saved to archive." + print(_msg) + log.info(_msg) + + def getRawData(self, period): + """Obtain an iterable containing the raw data to be imported. + + Raw data is read and any clean-up/pre-processing carried out before the + iterable is returned. In this case we will use csv.Dictreader(). The + iterable should be of a form where the field names in the field map can + be used to map the data to the WeeWX archive record format. + + Input parameters: + + period: a simple counter that is unused but retained to keep the + getRawData() signature the same across all classes. + """ + + # does our source exist? + if os.path.isfile(self.source): + # It exists. The source file may use some encoding, if we can't + # decode it raise a WeeImportDecodeError. + try: + with io.open(self.source, mode='r', encoding=self.source_encoding) as f: + _raw_data = f.readlines() + except UnicodeDecodeError as e: + # not a utf-8 based encoding, so raise a WeeImportDecodeError + raise weeimport.WeeImportDecodeError(e) + else: + # if it doesn't we can't go on so raise it + raise weeimport.WeeImportIOError( + "CSV source file '%s' could not be found." % self.source) + + # just in case the data has been sourced from the web we will remove + # any HTML tags and blank lines that may exist + _clean_data = [] + for _row in _raw_data: + # check for and remove any null bytes + clean_row = _row + if "\x00" in _row: + clean_row = clean_row.replace("\x00", "") + _msg = "One or more null bytes found in and removed " \ + "from file '%s'" % self.source + print(_msg) + log.info(_msg) + # get rid of any HTML tags + _line = ''.join(CSVSource._tags.split(clean_row)) + if _line != "\n": + # save anything that is not a blank line + _clean_data.append(_line) + + # create a dictionary CSV reader, using the first line as the set of keys + _csv_reader = csv.DictReader(_clean_data, delimiter=self.delimiter) + + # finally, get our source-to-database mapping + self.map = self.parseMap('CSV', _csv_reader, self.csv_config_dict) + + # return our CSV dict reader + return _csv_reader + + @staticmethod + def period_generator(): + """Generator function to control CSV import processing loop. + + Since CSV imports import from a single file this generator need only + return a single value before it is exhausted. + """ + + yield 1 + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + For CSV imports there is only one period so it is always the first. + """ + + return True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + For CSV imports there is only one period so it is always the last. + """ + + return True diff --git a/dist/weewx-4.10.1/bin/weeimport/cumulusimport.py b/dist/weewx-4.10.1/bin/weeimport/cumulusimport.py new file mode 100644 index 0000000..ddea209 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/cumulusimport.py @@ -0,0 +1,437 @@ +# +# Copyright (c) 2009-2019 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Module to interact with Cumulus monthly log files and import raw +observational data for use with weeimport. +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +# Python imports +import csv +import glob +import io +import logging +import os +import time + +# WeeWX imports +from . import weeimport +import weewx + +from weeutil.weeutil import timestamp_to_string +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + +# Dict to lookup rainRate units given rain units +rain_units_dict = {'inch': 'inch_per_hour', 'mm': 'mm_per_hour'} + + +# ============================================================================ +# class CumulusSource +# ============================================================================ + + +class CumulusSource(weeimport.Source): + """Class to interact with a Cumulus generated monthly log files. + + Handles the import of data from Cumulus monthly log files.Cumulus stores + observation data in monthly log files. Each log file contains a month of + data in CSV format. The format of the CSV data (eg date separator, field + delimiter, decimal point character) depends upon the settings used in + Cumulus. + + Data is imported from all month log files found in the source directory one + log file at a time. Units of measure are not specified in the monthly log + files so the units of measure must be specified in the wee_import config + file. Whilst the Cumulus monthly log file format is well defined, some + pre-processing of the data is required to provide data in a format the + suitable for use in the wee_import mapping methods. + """ + + # List of field names used during import of Cumulus log files. These field + # names are for internal wee_import use only as Cumulus monthly log files + # do not have a header line with defined field names. Cumulus monthly log + # field 0 and field 1 are date and time fields respectively. getRawData() + # combines these fields to return a formatted date-time string that is later + # converted into a unix epoch timestamp. + _field_list = ['datetime', 'cur_out_temp', 'cur_out_hum', + 'cur_dewpoint', 'avg_wind_speed', 'gust_wind_speed', + 'avg_wind_bearing', 'cur_rain_rate', 'day_rain', 'cur_slp', + 'rain_counter', 'curr_in_temp', 'cur_in_hum', + 'lastest_wind_gust', 'cur_windchill', 'cur_heatindex', + 'cur_uv', 'cur_solar', 'cur_et', 'annual_et', + 'cur_app_temp', 'cur_tmax_solar', 'day_sunshine_hours', + 'cur_wind_bearing', 'day_rain_rg11', 'midnight_rain'] + # Dict to map all possible Cumulus field names (refer _field_list) to WeeWX + # archive field names and units. + _header_map = {'datetime': {'units': 'unix_epoch', 'map_to': 'dateTime'}, + 'cur_out_temp': {'map_to': 'outTemp'}, + 'curr_in_temp': {'map_to': 'inTemp'}, + 'cur_dewpoint': {'map_to': 'dewpoint'}, + 'cur_slp': {'map_to': 'barometer'}, + 'avg_wind_bearing': {'units': 'degree_compass', + 'map_to': 'windDir'}, + 'avg_wind_speed': {'map_to': 'windSpeed'}, + 'cur_heatindex': {'map_to': 'heatindex'}, + 'gust_wind_speed': {'map_to': 'windGust'}, + 'cur_windchill': {'map_to': 'windchill'}, + 'cur_out_hum': {'units': 'percent', 'map_to': 'outHumidity'}, + 'cur_in_hum': {'units': 'percent', 'map_to': 'inHumidity'}, + 'midnight_rain': {'map_to': 'rain'}, + 'cur_rain_rate': {'map_to': 'rainRate'}, + 'cur_solar': {'units': 'watt_per_meter_squared', + 'map_to': 'radiation'}, + 'cur_uv': {'units': 'uv_index', 'map_to': 'UV'}, + 'cur_app_temp': {'map_to': 'appTemp'} + } + + def __init__(self, config_dict, config_path, cumulus_config_dict, import_config_path, options): + + # call our parents __init__ + super(CumulusSource, self).__init__(config_dict, + cumulus_config_dict, + options) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.cumulus_config_dict = cumulus_config_dict + + # wind dir bounds + self.wind_dir = [0, 360] + + # field delimiter used in monthly log files, default to comma + self.delimiter = str(cumulus_config_dict.get('delimiter', ',')) + + # date separator used in monthly log files, default to solidus + separator = cumulus_config_dict.get('separator', '/') + # we combine Cumulus date and time fields to give a fixed format + # date-time string + self.raw_datetime_format = separator.join(('%d', '%m', '%y %H:%M')) + + # Cumulus log files provide a number of cumulative rainfall fields. We + # cannot use the daily rainfall as this may reset at some time of day + # other than midnight (as required by WeeWX). So we use field 26, total + # rainfall since midnight and treat it as a cumulative value. + self.rain = 'cumulative' + + # initialise our import field-to-WeeWX archive field map + self.map = None + + # Cumulus log files have a number of 'rain' fields that can be used to + # derive the WeeWX rain field. Which one is available depends on the + # Cumulus version that created the logs. The preferred field is field + # 26(AA) - total rainfall since midnight but it is only available in + # Cumulus v1.9.4 or later. If that field is not available then the + # preferred field in field 09(J) - total rainfall today then field + # 11(L) - total rainfall counter. Initialise the rain_source_confirmed + # property now and we will deal with it later when we have some source + # data. + self.rain_source_confirmed = None + + # Units of measure for some obs (eg temperatures) cannot be derived from + # the Cumulus monthly log files. These units must be specified by the + # user in the import config file. Read these units and fill in the + # missing unit data in the header map. Do some basic error checking and + # validation, if one of the fields is missing or invalid then we need + # to catch the error and raise it as we can't go on. + # Temperature + try: + temp_u = cumulus_config_dict['Units'].get('temperature') + except KeyError: + _msg = "No units specified for Cumulus temperature " \ + "fields in %s." % (self.import_config_path, ) + raise weewx.UnitError(_msg) + else: + # temperature units vary between unit systems so we can verify a + # valid temperature unit simply by checking for membership of + # weewx.units.conversionDict keys + if temp_u in weewx.units.conversionDict.keys(): + self._header_map['cur_out_temp']['units'] = temp_u + self._header_map['curr_in_temp']['units'] = temp_u + self._header_map['cur_dewpoint']['units'] = temp_u + self._header_map['cur_heatindex']['units'] = temp_u + self._header_map['cur_windchill']['units'] = temp_u + self._header_map['cur_app_temp']['units'] = temp_u + else: + _msg = "Unknown units '%s' specified for Cumulus " \ + "temperature fields in %s." % (temp_u, + self.import_config_path) + raise weewx.UnitError(_msg) + # Pressure + try: + press_u = cumulus_config_dict['Units'].get('pressure') + except KeyError: + _msg = "No units specified for Cumulus pressure " \ + "fields in %s." % (self.import_config_path, ) + raise weewx.UnitError(_msg) + else: + if press_u in ['inHg', 'mbar', 'hPa']: + self._header_map['cur_slp']['units'] = press_u + else: + _msg = "Unknown units '%s' specified for Cumulus " \ + "pressure fields in %s." % (press_u, + self.import_config_path) + raise weewx.UnitError(_msg) + # Rain + try: + rain_u = cumulus_config_dict['Units'].get('rain') + except KeyError: + _msg = "No units specified for Cumulus " \ + "rain fields in %s." % (self.import_config_path, ) + raise weewx.UnitError(_msg) + else: + if rain_u in rain_units_dict: + self._header_map['midnight_rain']['units'] = rain_u + self._header_map['cur_rain_rate']['units'] = rain_units_dict[rain_u] + + else: + _msg = "Unknown units '%s' specified for Cumulus " \ + "rain fields in %s." % (rain_u, + self.import_config_path) + raise weewx.UnitError(_msg) + # Speed + try: + speed_u = cumulus_config_dict['Units'].get('speed') + except KeyError: + _msg = "No units specified for Cumulus " \ + "speed fields in %s." % (self.import_config_path, ) + raise weewx.UnitError(_msg) + else: + # speed units vary between unit systems so we can verify a valid + # speed unit simply by checking for membership of + # weewx.units.conversionDict keys + if speed_u in weewx.units.conversionDict.keys(): + self._header_map['avg_wind_speed']['units'] = speed_u + self._header_map['gust_wind_speed']['units'] = speed_u + else: + _msg = "Unknown units '%s' specified for Cumulus " \ + "speed fields in %s." % (speed_u, + self.import_config_path) + raise weewx.UnitError(_msg) + + # get our source file path + try: + self.source = cumulus_config_dict['directory'] + except KeyError: + _msg = "Cumulus monthly logs directory not specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # get the source file encoding, default to utf-8-sig + self.source_encoding = self.cumulus_config_dict.get('source_encoding', + 'utf-8-sig') + + # property holding the current log file name being processed + self.file_name = None + + # Now get a list on monthly log files sorted from oldest to newest + month_log_list = glob.glob(self.source + '/?????log.txt') + _temp = [(fn, fn[-9:-7], time.strptime(fn[-12:-9], '%b').tm_mon) for fn in month_log_list] + self.log_list = [a[0] for a in sorted(_temp, + key=lambda el: (el[1], el[2]))] + if len(self.log_list) == 0: + raise weeimport.WeeImportIOError( + "No Cumulus monthly logs found in directory '%s'." % self.source) + + # tell the user/log what we intend to do + _msg = "Cumulus monthly log files in the '%s' directory will be imported" % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if options.date: + _msg = " date=%s" % options.date + else: + # we must have --from and --to + _msg = " from=%s, to=%s" % (options.date_from, options.date_to) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc_missing=%s, " \ + "ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s" % (self.tranche, + self.interval) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s" % (self.UV_sensor, self.solar_sensor) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound " \ + "to database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + if self.calc_missing: + print("Missing derived observations will be calculated.") + if not self.UV_sensor: + print("All WeeWX UV fields will be set to None.") + if not self.solar_sensor: + print("All WeeWX radiation fields will be set to None.") + if options.date or options.date_from: + print("Observations timestamped after %s and " + "up to and" % timestamp_to_string(self.first_ts)) + print("including %s will be imported." % timestamp_to_string(self.last_ts)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def getRawData(self, period): + """Get raw observation data and construct a map from Cumulus monthly + log fields to WeeWX archive fields. + + Obtain raw observational data from Cumulus monthly logs. This raw data + needs to be cleaned of unnecessary characters/codes, a date-time field + generated for each row and an iterable returned. + + Input parameters: + + period: the file name, including path, of the Cumulus monthly log + file from which raw obs data will be read. + """ + + # period holds the filename of the monthly log file that contains our + # data. Does our source exist? + if os.path.isfile(period): + # It exists. The source file may use some encoding, if we can't + # decode it raise a WeeImportDecodeError. + try: + with io.open(period, mode='r', encoding=self.source_encoding) as f: + _raw_data = f.readlines() + except UnicodeDecodeError as e: + # not a utf-8 based encoding, so raise a WeeImportDecodeError + raise weeimport.WeeImportDecodeError(e) + else: + # If it doesn't we can't go on so raise it + raise weeimport.WeeImportIOError( + "Cumulus monthly log file '%s' could not be found." % period) + + # Our raw data needs a bit of cleaning up before we can parse/map it. + _clean_data = [] + for _row in _raw_data: + # check for and remove any null bytes + clean_row = _row + if "\x00" in _row: + clean_row = clean_row.replace("\x00", "") + _msg = "One or more null bytes found in and removed " \ + "from monthly log file '%s'" % (period, ) + print(_msg) + log.info(_msg) + # make sure we have full stops as decimal points + _line = clean_row.replace(self.decimal_sep, '.') + # ignore any blank lines + if _line != "\n": + # Cumulus has separate date and time fields as the first 2 + # fields of a row. It is easier to combine them now into a + # single date-time field that we can parse later when we map the + # raw data. + _datetime_line = _line.replace(self.delimiter, ' ', 1) + # Save what's left + _clean_data.append(_datetime_line) + + # if we haven't confirmed our source for the WeeWX rain field we need + # to do so now + if self.rain_source_confirmed is None: + # The Cumulus source field depends on the Cumulus version that + # created the log files. Unfortunately, we can only determine + # which field to use by looking at the mapped Cumulus data. If we + # look at our DictReader we have no way to reset it, so we create + # a one off DictReader to use instead. + _rain_reader = csv.DictReader(_clean_data, fieldnames=self._field_list, + delimiter=self.delimiter) + # now that we know what Cumulus fields are available we can set our + # rain source appropriately + self.set_rain_source(_rain_reader) + + # Now create a dictionary CSV reader + _reader = csv.DictReader(_clean_data, fieldnames=self._field_list, + delimiter=self.delimiter) + # Finally, get our database-source mapping + self.map = self.parseMap('Cumulus', _reader, self.cumulus_config_dict) + # Return our dict reader + return _reader + + def period_generator(self): + """Generator function yielding a sequence of monthly log file names. + + This generator controls the FOR statement in the parents run() method + that loops over the monthly log files to be imported. The generator + yields a monthly log file name from the list of monthly log files to + be imported until the list is exhausted. + """ + + # step through each of our file names + for self.file_name in self.log_list: + # yield the file name + yield self.file_name + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list or it is None (the initialisation value). + """ + + return self.file_name == self.log_list[0] if self.file_name is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current file name being processed is the last in + the list. + """ + + return self.file_name == self.log_list[-1] + + def set_rain_source(self, _data): + """Set the Cumulus field to be used as the WeeWX rain field source. + """ + + _row = next(_data) + if _row['midnight_rain'] is not None: + # we have data in midnight_rain, our default source, so leave + # things as they are and return + pass + elif _row['day_rain'] is not None: + # we have data in day_rain so use that as our rain source + self._header_map['day_rain'] = self._header_map['midnight_rain'] + del self._header_map['midnight_rain'] + elif _row['rain_counter'] is not None: + # we have data in rain_counter so use that as our rain source + self._header_map['rain_counter'] = self._header_map['midnight_rain'] + del self._header_map['midnight_rain'] + else: + # We should never end up in this state but.... + # We have no suitable rain source so we can't import so remove the + # rain field entry from the header map. + del self._header_map['midnight_rain'] + # we only need to do this once so set our flag to True + self.rain_source_confirmed = True + return diff --git a/dist/weewx-4.10.1/bin/weeimport/wdimport.py b/dist/weewx-4.10.1/bin/weeimport/wdimport.py new file mode 100644 index 0000000..d3a0e6d --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/wdimport.py @@ -0,0 +1,694 @@ +# +# Copyright (c) 2009-2019 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Module for use with weeimport to import observational data from Weather +Display monthly log files. +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +# Python imports +import collections +import csv +import datetime +import glob +import io +import logging +import operator +import os +import time + +# WeeWX imports +from . import weeimport +import weeutil.weeutil +import weewx + +from weeutil.weeutil import timestamp_to_string +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + +# ============================================================================ +# class WDSource +# ============================================================================ + + +class WDSource(weeimport.Source): + """Class to interact with a Weather Display generated monthly log files. + + Handles the import of data from WD monthly log files. WD stores observation + data across a number of monthly log files. Each log file contains one month + of minute separated data in structured text or csv format. + + Data is imported from all monthly log files found in the source directory + one set of monthly log files at a time. Units of measure are not specified + in the monthly log files so the units of measure must be specified in the + wee_import config file. Whilst the WD monthly log file formats are well + defined, some pre-processing of the data is required to provide data in a + format suitable for use in the wee_import mapping methods. + + WD log file units are set to either Metric or US in WD via the 'Log File' + setting under 'Units' on the 'Units/Wind Chill' tab of the universal setup. + The units used in each log file in each case are: + + Log File Field Metric US + Units Units + MMYYYYlg.txt day + MMYYYYlg.txt month + MMYYYYlg.txt year + MMYYYYlg.txt hour + MMYYYYlg.txt minute + MMYYYYlg.txt temperature C F + MMYYYYlg.txt humidity % % + MMYYYYlg.txt dewpoint C F + MMYYYYlg.txt barometer hPa inHg + MMYYYYlg.txt windspeed knots mph + MMYYYYlg.txt gustspeed knots mph + MMYYYYlg.txt direction degrees degrees + MMYYYYlg.txt rainlastmin mm inch + MMYYYYlg.txt dailyrain mm inch + MMYYYYlg.txt monthlyrain mm inch + MMYYYYlg.txt yearlyrain mm inch + MMYYYYlg.txt heatindex C F + MMYYYYvantagelog.txt Solar radiation W/sqm W/sqm + MMYYYYvantagelog.txt UV index index + MMYYYYvantagelog.txt Daily ET mm inch + MMYYYYvantagelog.txt soil moist cb cb + MMYYYYvantagelog.txt soil temp C F + MMYYYYvantageextrasensrslog.txt temp1-temp7 C F + MMYYYYvantageextrasensrslog.txt hum1-hum7 % % + """ + + # Dict of log files and field names that we know how to process. Field + # names would normally be derived from the first line of log file but + # inconsistencies in field naming in the log files make this overly + # complicated and difficult. 'fields' entry can be overridden from import + # config if required. + logs = {'lg.txt': {'fields': ['day', 'month', 'year', 'hour', 'minute', + 'temperature', 'humidity', 'dewpoint', + 'barometer', 'windspeed', 'gustspeed', + 'direction', 'rainlastmin', 'dailyrain', + 'monthlyrain', 'yearlyrain', 'heatindex'] + }, + 'lgcsv.csv': {'fields': ['day', 'month', 'year', 'hour', 'minute', + 'temperature', 'humidity', 'dewpoint', + 'barometer', 'windspeed', 'gustspeed', + 'direction', 'rainlastmin', 'dailyrain', + 'monthlyrain', 'yearlyrain', 'heatindex'] + }, + 'vantageextrasensorslog.csv': {'fields': ['day', 'month', 'year', + 'hour', 'minute', 'temp1', + 'temp2', 'temp3', 'temp4', + 'temp5', 'temp6', 'temp7', + 'hum1', 'hum2', 'hum3', + 'hum4', 'hum5', 'hum6', + 'hum7'] + }, + 'vantagelog.txt': {'fields': ['day', 'month', 'year', 'hour', + 'minute', 'radiation', 'UV', + 'dailyet', 'soilmoist', + 'soiltemp'] + }, + 'vantagelogcsv.csv': {'fields': ['day', 'month', 'year', 'hour', + 'minute', 'radiation', 'UV', + 'dailyet', 'soilmoist', + 'soiltemp'] + } + } + + # dict to map WD log field units based on metric or US + wd_unit_sys = {'temperature': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'dewpoint': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'barometer': {'METRIC': 'hPa', 'US': 'inHg'}, + 'direction': {'METRIC': 'degree_compass', + 'US': 'degree_compass'}, + 'windspeed': {'METRIC': 'knot', 'US': 'mile_per_hour'}, + 'heatindex': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'gustspeed': {'METRIC': 'knot', 'US': 'mile_per_hour'}, + 'humidity': {'METRIC': 'percent', 'US': 'percent'}, + 'rainlastmin': {'METRIC': 'mm', 'US': 'inch'}, + 'radiation': {'METRIC': 'watt_per_meter_squared', + 'US': 'watt_per_meter_squared'}, + 'uv': {'METRIC': 'uv_index', 'US': 'uv_index'}, + 'soilmoist': {'METRIC': 'centibar', 'US': 'centibar'}, + 'soiltemp': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'temp1': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum1': {'METRIC': 'percent', 'US': 'percent'}, + 'temp2': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum2': {'METRIC': 'percent', 'US': 'percent'}, + 'temp3': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum3': {'METRIC': 'percent', 'US': 'percent'}, + 'temp4': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum4': {'METRIC': 'percent', 'US': 'percent'}, + 'temp5': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum5': {'METRIC': 'percent', 'US': 'percent'}, + 'temp6': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum6': {'METRIC': 'percent', 'US': 'percent'}, + 'temp7': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'hum7': {'METRIC': 'percent', 'US': 'percent'} + } + + # dict to map all possible WD field names (refer _field_list) to WeeWX + # archive field names and units + _header_map = {'datetime': {'units': 'unix_epoch', 'map_to': 'dateTime'}, + 'temperature': {'map_to': 'outTemp'}, + 'dewpoint': {'map_to': 'dewpoint'}, + 'barometer': {'map_to': 'barometer'}, + 'direction': {'units': 'degree_compass', + 'map_to': 'windDir'}, + 'windspeed': {'map_to': 'windSpeed'}, + 'heatindex': {'map_to': 'heatindex'}, + 'gustspeed': {'map_to': 'windGust'}, + 'humidity': {'units': 'percent', 'map_to': 'outHumidity'}, + 'rainlastmin': {'map_to': 'rain'}, + 'radiation': {'units': 'watt_per_meter_squared', + 'map_to': 'radiation'}, + 'uv': {'units': 'uv_index', 'map_to': 'UV'}, + 'soilmoist': {'units': 'centibar', 'map_to': 'soilMoist1'}, + 'soiltemp': {'map_to': 'soilTemp1'}, + 'temp1': {'map_to': 'extraTemp1'}, + 'hum1': {'units': 'percent', 'map_to': 'extraHumid1'}, + 'temp2': {'map_to': 'extraTemp2'}, + 'hum2': {'units': 'percent', 'map_to': 'extraHumid2'}, + 'temp3': {'map_to': 'extraTemp3'}, + 'hum3': {'units': 'percent', 'map_to': 'extraHumid3'}, + 'temp4': {'map_to': 'extraTemp4'}, + 'hum4': {'units': 'percent', 'map_to': 'extraHumid4'}, + 'temp5': {'map_to': 'extraTemp5'}, + 'hum5': {'units': 'percent', 'map_to': 'extraHumid5'}, + 'temp6': {'map_to': 'extraTemp6'}, + 'hum6': {'units': 'percent', 'map_to': 'extraHumid6'}, + 'temp7': {'map_to': 'extraTemp7'}, + 'hum7': {'units': 'percent', 'map_to': 'extraHumid7'} + } + + def __init__(self, config_dict, config_path, wd_config_dict, import_config_path, options): + + # call our parents __init__ + super(WDSource, self).__init__(config_dict, wd_config_dict, options) + + # save the import config path + self.import_config_path = import_config_path + # save the import config dict + self.wd_config_dict = wd_config_dict + + # our parent uses 'derive' as the default interval setting, for WD the + # default should be 1 (minute) so redo the interval setting with our + # default + self.interval = wd_config_dict.get('interval', 1) + + # wind dir bounds + self.wind_dir = [0, 360] + + # How the WeeWX field 'rain' is populated depends on the source rain + # data. If the only data available is cumulative then the WeeWX rain + # field is calculated as the difference between successive cumulative + # values. WD provides a rain per interval field so that data can be + # used to map directly to the WeeWX rain field. If rain is to be + # calculated from a cumulative value then self.rain must be set to + # 'cumulative', to map directly to the WeeWX rain field self.rain must + # be set to None. + self.rain = None + + # field delimiter used in text format monthly log files, default to + # space + self.txt_delimiter = str(wd_config_dict.get('txt_delimiter', ' ')) + # field delimiter used in csv format monthly log files, default to + # comma + self.csv_delimiter = str(wd_config_dict.get('csv_delimiter', ',')) + + # ignore extreme > 255.0 values for temperature and humidity fields + self.ignore_extr_th = weeutil.weeutil.tobool(wd_config_dict.get('ignore_extreme_temp_hum', + True)) + + # initialise the import field-to-WeeWX archive field map + self.map = None + + # property holding the current log file name being processed + self.file_name = None + + # WD logs use either US or Metric units. The units used in each case + # are: + # Metric units: C, knots, hPa, mm + # US units: F, mph, inHg, inch + # + # The user must specify the units to be used in the import config file. + # This can be by either by specifying the log units as Metric or US + # using the 'units' config option. Alternatively temperature, pressure, + # rainfall and speed units can be specified individually under the + # [Units] stanza. First check for a valid 'units' config option then + # check for individual group units. Do some basic error checking and + # validation, if one of the fields is missing or invalid then we need + # to catch the error and raise it as we can't go on. + log_unit_config = wd_config_dict.get('Units') + if log_unit_config is not None: + # get the units config option + log_unit_sys = wd_config_dict['Units'].get('units') + # accept any capitalization of USA as == US + log_unit_sys = log_unit_sys if log_unit_sys.upper() != 'USA' else 'US' + # does the units config option specify a valid log unit system + if log_unit_sys is None or log_unit_sys.upper() not in ['METRIC', 'US']: + # log unit system not specified look for individual entries + # temperature + temp_u = wd_config_dict['Units'].get('temperature') + if temp_u is not None: + # temperature units vary between unit systems so we can verify a + # valid temperature unit simply by checking for membership of + # weewx.units.conversionDict keys + if temp_u in weewx.units.conversionDict.keys(): + self._header_map['temperature']['units'] = temp_u + self._header_map['dewpoint']['units'] = temp_u + self._header_map['heatindex']['units'] = temp_u + self._header_map['soiltemp']['units'] = temp_u + self._header_map['temp1']['units'] = temp_u + self._header_map['temp2']['units'] = temp_u + self._header_map['temp3']['units'] = temp_u + self._header_map['temp4']['units'] = temp_u + self._header_map['temp5']['units'] = temp_u + self._header_map['temp6']['units'] = temp_u + self._header_map['temp7']['units'] = temp_u + else: + _msg = "Unknown units '%s' specified for Weather Display " \ + "temperature fields in %s." % (temp_u, self.import_config_path) + raise weewx.UnitError(_msg) + else: + _msg = "No units specified for Weather Display temperature " \ + "fields in %s." % (self.import_config_path,) + raise weewx.UnitError(_msg) + + # pressure + press_u = wd_config_dict['Units'].get('pressure') + if press_u is not None: + if press_u in ['inHg', 'hPa']: + self._header_map['barometer']['units'] = press_u + else: + _msg = "Unknown units '%s' specified for Weather Display " \ + "pressure fields in %s." % (press_u, self.import_config_path) + raise weewx.UnitError(_msg) + else: + _msg = "No units specified for Weather Display pressure " \ + "fields in %s." % (self.import_config_path,) + raise weewx.UnitError(_msg) + + # rain + rain_u = wd_config_dict['Units'].get('rain') + if rain_u is not None: + if rain_u in ['inch', 'mm']: + self._header_map['rainlastmin']['units'] = rain_u + self._header_map['dailyrain']['units'] = rain_u + self._header_map['monthlyrain']['units'] = rain_u + self._header_map['yearlyrain']['units'] = rain_u + self._header_map['dailyet']['units'] = rain_u + else: + _msg = "Unknown units '%s' specified for Weather Display " \ + "rain fields in %s." % (rain_u, self.import_config_path) + raise weewx.UnitError(_msg) + else: + _msg = "No units specified for Weather Display rain fields " \ + "in %s." % (self.import_config_path,) + raise weewx.UnitError(_msg) + + # speed + speed_u = wd_config_dict['Units'].get('speed') + if speed_u is not None: + if speed_u in ['inch', 'mm']: + self._header_map['windspeed']['units'] = speed_u + self._header_map['gustspeed']['units'] = speed_u + else: + _msg = "Unknown units '%s' specified for Weather Display " \ + "speed fields in %s." % (speed_u, self.import_config_path) + raise weewx.UnitError(_msg) + else: + _msg = "No units specified for Weather Display speed fields " \ + "in %s." % (self.import_config_path,) + raise weewx.UnitError(_msg) + + else: + # log unit system specified + _unit_sys = log_unit_sys.upper() + # do we have a valid log unit system + if _unit_sys in ['METRIC', 'US']: + # valid log unit system so assign units as applicable + self._header_map['temperature']['units'] = self.wd_unit_sys['temperature'][_unit_sys] + self._header_map['dewpoint']['units'] = self.wd_unit_sys['temperature'][_unit_sys] + self._header_map['heatindex']['units'] = self.wd_unit_sys['temperature'][_unit_sys] + self._header_map['barometer']['units'] = self.wd_unit_sys['barometer'][_unit_sys] + self._header_map['windspeed']['units'] = self.wd_unit_sys['windspeed'][_unit_sys] + self._header_map['gustspeed']['units'] = self.wd_unit_sys['gustspeed'][_unit_sys] + self._header_map['rainlastmin']['units'] = self.wd_unit_sys['rainlastmin'][_unit_sys] + self._header_map['soiltemp']['units'] = self.wd_unit_sys['soiltemp'][_unit_sys] + for _num in range(1, 8): + _temp = 'temp%s' % _num + self._header_map[_temp]['units'] = self.wd_unit_sys[_temp][_unit_sys] + else: + # no valid Units config found, we can't go on so raise an error + raise weewx.UnitError("Invalid setting for 'units' config option.") + else: + # there is no Units config, we can't go on so raise an error + raise weewx.UnitError("No Weather Display units config found.") + + # obtain a list of logs files to be processed + _to_process = wd_config_dict.get('logs_to_process', list(self.logs.keys())) + self.logs_to_process = weeutil.weeutil.option_as_list(_to_process) + + # can missing log files be ignored + self.ignore_missing_log = weeutil.weeutil.to_bool(wd_config_dict.get('ignore_missing_log', + True)) + + # get our source file path + try: + self.source = wd_config_dict['directory'] + except KeyError: + _msg = "Weather Display monthly logs directory not " \ + "specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # get the source file encoding, default to utf-8-sig + self.source_encoding = self.wd_config_dict.get('source_encoding', + 'utf-8-sig') + + # Now get a list on monthly log files sorted from oldest to newest. + # This is complicated by the log file naming convention used by WD. + # first the 1 digit months + _lg_5_list = glob.glob(self.source + '/' + '[0-9]' * 5 + 'lg.txt') + # and the 2 digit months + _lg_6_list = glob.glob(self.source + '/' + '[0-9]' * 6 + 'lg.txt') + # concatenate the two lists to get the complete list + month_lg_list = _lg_5_list + _lg_6_list + # create a list of log files in chronological order (month, year) + _temp = [] + # create a list of log files, adding year and month fields for sorting + for p in month_lg_list: + # obtain the file name + fn = os.path.split(p)[1] + # obtain the numeric part of the file name + _digits = ''.join(c for c in fn if c.isdigit()) + # append a list of format [path+file name, month, year] + _temp.append([p, int(_digits[:-4]), int(_digits[-4:])]) + # now sort the list keeping just the log file path and name + self.log_list = [a[0] for a in sorted(_temp, key=lambda el: (el[2], el[1]))] + # if there are no log files then there is nothing to be done + if len(self.log_list) == 0: + raise weeimport.WeeImportIOError("No Weather Display monthly logs " + "found in directory '%s'." % self.source) + # Some log files have entries that belong in a different month. + # Initialise a list to hold these extra records for processing during + # the appropriate month + self.extras = {} + for log_to_process in self.logs_to_process: + self.extras[log_to_process] = [] + + # tell the user/log what we intend to do + _msg = "Weather Display monthly log files in the '%s' " \ + "directory will be imported" % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if options.date: + _msg = " date=%s" % options.date + else: + # we must have --from and --to + _msg = " from=%s, to=%s" % (options.date_from, options.date_to) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc_missing=%s, " \ + "ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + if log_unit_sys is not None and log_unit_sys.upper() in ['METRIC', 'US']: + # valid unit system specified + _msg = " monthly logs are in %s units" % log_unit_sys.upper() + if self.verbose: + print(_msg) + log.debug(_msg) + else: + # group units specified + _msg = " monthly logs use the following units:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " temperature=%s pressure=%s" % (temp_u, press_u) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " rain=%s speed=%s" % (rain_u, speed_u) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s" % (self.tranche, + self.interval) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s ignore extreme temperature " \ + "and humidity=%s" % (self.UV_sensor, + self.solar_sensor, + self.ignore_extr_th) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound to " \ + "database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + if self.calc_missing: + print("Missing derived observations will be calculated.") + if not self.UV_sensor: + print("All WeeWX UV fields will be set to None.") + if not self.solar_sensor: + print("All WeeWX radiation fields will be set to None.") + if options.date or options.date_from: + print("Observations timestamped after %s and " + "up to and" % timestamp_to_string(self.first_ts)) + print("including %s will be imported." % timestamp_to_string(self.last_ts)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def getRawData(self, period): + """ Obtain raw observation data from a log file. + + The getRawData() method must return a single iterable containing the + raw observational data for the period. Since Weather Display uses more + than one log file per month the data from all relevant log files needs + to be combined into a single dict. A date-time field must be generated + for each row and the resulting raw data dict returned. + + Input parameters: + + period: the file name, including path, of the Weather Display monthly + log file from which raw obs data will be read. + """ + + # since we may have multiple log files to parse for a given period + # strip out the month-year portion of the log file + _path, _file = os.path.split(period) + _prefix = _file[:-6] + _month = _prefix[:-4] + _year = _prefix[-4:] + + # initialise a list to hold the list of dicts read from each log file + _data_list = [] + # iterate over the log files to processed + for lg in self.logs_to_process: + # obtain the path and file name of the log we are to process + _fn = '%s%s' % (_prefix, lg) + _path_file_name = os.path.join(_path, _fn) + + # check that the log file exists + if os.path.isfile(_path_file_name): + # It exists. The source file may use some encoding, if we can't + # decode it raise a WeeImportDecodeError. + try: + with io.open(_path_file_name, mode='r', encoding=self.source_encoding) as f: + _raw_data = f.readlines() + except UnicodeDecodeError as e: + # not a utf-8 based encoding, so raise a WeeImportDecodeError + raise weeimport.WeeImportDecodeError(e) + else: + # log file does not exist ignore it if we are allowed else + # raise it + if self.ignore_missing_log: + pass + else: + _msg = "Weather Display monthly log file '%s' could " \ + "not be found." % _path_file_name + raise weeimport.WeeImportIOError(_msg) + + # Determine delimiter to use. This is a simple check, if 'csv' + # exists anywhere in the file name then assume a csv and use the + # csv delimiter otherwise use the txt delimiter + _del = self.csv_delimiter if 'csv' in lg.lower() else self.txt_delimiter + + # the raw data needs a bit of cleaning up before we can parse/map it + _clean_data = [] + for i, _row in enumerate(_raw_data): + # do a crude check of the expected v actual number of columns + # since we are not aware whether the WD log files structure may + # have changed over time + + # ignore the first line, it will likely be header info + if i == 2 and \ + len(" ".join(_row.split()).split(_del)) != len(self.logs[lg]['fields']): + _msg = "Unexpected number of columns found in '%s': " \ + "%s v %s" % (_fn, + len(_row.split(_del)), + len(self.logs[lg]['fields'])) + print(_msg) + log.info(_msg) + # check for and remove any null bytes + clean_row = _row + if "\x00" in _row: + clean_row = clean_row.replace("\x00", "") + _msg = "One or more null bytes found in and removed " \ + "from row %d in file '%s'" % (i, _fn) + print(_msg) + log.info(_msg) + # make sure we have full stops as decimal points + _clean_data.append(clean_row.replace(self.decimal_sep, '.')) + + # initialise a list to hold our processed data for this log file + _data = [] + # obtain the field names to be used for this log file + _field_names = self.logs[lg].get('fields') + # create a CSV dictionary reader, use skipinitialspace=True to skip + # any extra whitespace between columns and fieldnames to specify + # the field names to be used + _reader = csv.DictReader(_clean_data, + delimiter=_del, + skipinitialspace=True, + fieldnames=_field_names) + # skip the header line since we are using our own field names + next(_reader) + # iterate over the records and calculate a unix timestamp for each + # record + for rec in _reader: + # first get a datetime object from the individual date-time + # fields + _dt = datetime.datetime(int(rec['year']), int(rec['month']), + int(rec['day']), int(rec['hour']), + int(rec['minute'])) + # now as a timetuple + _tt = _dt.timetuple() + # and finally a timestamp but as a string like the rest of our + # data + _ts = "%s" % int(time.mktime(_tt)) + # add the timestamp to our record + _ts_rec = dict(rec, **{'datetime': _ts}) + # some WD log files contain records from another month so check + # year and month and if the record belongs to another month + # store it for use later otherwise add it to this months data + if _ts_rec['year'] == _year and _ts_rec['month'] == _month: + # add the timestamped record to our data list + _data.append(_ts_rec) + else: + # add the record to the list for later processing + self.extras[lg].append(_ts_rec) + # now add any extras that may belong in this month + for e_rec in self.extras[lg]: + if e_rec['year'] == _year and e_rec['month'] == _month: + # add the record + _data.append(e_rec) + # now update our extras and remove any records we added + self.extras[lg][:] = [x for x in self.extras[lg] if + not (x['year'] == _year and x['month'] == _month)] + + # There may be duplicate timestamped records in the data. We will + # keep the first encountered duplicate and discard the latter ones + # but we also need to keep track of the duplicate timestamps for + # later reporting. + + # initialise a set to hold the timestamps we have seen + _timestamps = set() + # initialise a list to hold the unique timestamped records + unique_data = [] + # iterate over each record in the list of records + for item in _data: + # has this timestamp been seen before + if item['datetime'] not in _timestamps: + # no it hasn't, so keep the record and add the timestamp + # to the list of timestamps seen + unique_data.append(item) + _timestamps.add(item['datetime']) + else: + # yes it has been seen, so add the timestamp to the list of + # duplicates for later reporting + self.period_duplicates.add(int(item['datetime'])) + + # add the data (list of dicts) to the list of processed log file + # data + _data_list.append(unique_data) + + # we have all our data so now combine the data for each timestamp into + # a common record, this gives us a single list of dicts + d = collections.defaultdict(dict) + for _list in _data_list: + for elm in _list: + d[elm['datetime']].update(elm) + + # The combined data will likely not be in dateTime order, WD logs can + # be imported in a dateTime unordered state but the user will be + # presented with a more legible display as the import progresses if + # the data is in ascending dateTime order. + _sorted = sorted(list(d.values()), key=operator.itemgetter('datetime')) + # finally, get our database-source mapping + self.map = self.parseMap('WD', _sorted, self.wd_config_dict) + # return our sorted data + return _sorted + + def period_generator(self): + """Generator function yielding a sequence of monthly log file names. + + This generator controls the FOR statement in the parents run() method + that loops over the monthly log files to be imported. The generator + yields a monthly log file name from the list of monthly log files to + be imported until the list is exhausted. + """ + + # Step through each of our file names + for self.file_name in self.log_list: + # yield the file name + yield self.file_name + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list or it is None (the initialisation value). + """ + + return self.file_name == self.log_list[0] if self.file_name is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current file name being processed is the last in + the list. + """ + + return self.file_name == self.log_list[-1] diff --git a/dist/weewx-4.10.1/bin/weeimport/weathercatimport.py b/dist/weewx-4.10.1/bin/weeimport/weathercatimport.py new file mode 100644 index 0000000..36054ef --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/weathercatimport.py @@ -0,0 +1,387 @@ +# +# Copyright (c) 2009-2020 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Module for use with wee_import to import observational data from WeatherCat +monthly .cat files. +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +# Python imports +import glob +import logging +import os +import shlex +import time + +# python 2/3 compatibility shims +import six + +# WeeWX imports +from . import weeimport +import weewx + +from weeutil.weeutil import timestamp_to_string +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class WeatherCatSource +# ============================================================================ + + +class WeatherCatSource(weeimport.Source): + """Class to interact with a WeatherCat monthly data (.cat) files. + + Handles the import of data from WeatherCat monthly data files. WeatherCat + stores formatted observation data in text files using the .cat extension. + Each file contains a month of data with one record per line. Fields in each + record are space delimited with field names and field data separated by a + colon (:). + + Data files are named 'x_WeatherCatData.cat' where x is the month + number (1..12). Each year's monthly data files are located in a directory + named 'YYYY' where YYYY is the year number. wee_import relies on this + directory structure for successful import of WeatherCat data. + + Data is imported from all monthly data files found in the source directory + one file at a time. Units of measure are not specified in the monthly data + files so the units of measure must be specified in the import config file + being used. + """ + + # dict to map all possible WeatherCat .cat file field names to WeeWX + # archive field names and units + default_header_map = {'dateTime': {'field_name': 'dateTime', + 'units': 'unix_epoch'}, + 'usUnits': {'units': None}, + 'interval': {'units': 'minute'}, + 'outTemp': {'field_name': 'T', + 'units': 'degree_C'}, + 'inTemp': {'field_name': 'Ti', + 'units': 'degree_C'}, + 'extraTemp1': {'field_name': 'T1', + 'units': 'degree_C'}, + 'extraTemp2': {'field_name': 'T2', + 'units': 'degree_C'}, + 'extraTemp3': {'field_name': 'T3', + 'units': 'degree_C'}, + 'dewpoint': {'field_name': 'D', + 'units': 'degree_C'}, + 'barometer': {'field_name': 'Pr', + 'units': 'mbar'}, + 'windSpeed': {'field_name': 'W', + 'units': 'km_per_hour'}, + 'windDir': {'field_name': 'Wd', + 'units': 'degree_compass'}, + 'windchill': {'field_name': 'Wc', + 'units': 'degree_C'}, + 'windGust': {'field_name': 'Wg', + 'units': 'km_per_hour'}, + 'rainRate': {'field_name': 'Ph', + 'units': 'mm_per_hour'}, + 'rain': {'field_name': 'P', + 'units': 'mm'}, + 'outHumidity': {'field_name': 'H', + 'units': 'percent'}, + 'inHumidity': {'field_name': 'Hi', + 'units': 'percent'}, + 'extraHumid1': {'field_name': 'H1', + 'units': 'percent'}, + 'extraHumid2': {'field_name': 'H2', + 'units': 'percent'}, + 'radiation': {'field_name': 'S', + 'units': 'watt_per_meter_squared'}, + 'soilMoist1': {'field_name': 'Sm1', + 'units': 'centibar'}, + 'soilMoist2': {'field_name': 'Sm2', + 'units': 'centibar'}, + 'soilMoist3': {'field_name': 'Sm3', + 'units': 'centibar'}, + 'soilMoist4': {'field_name': 'Sm4', + 'units': 'centibar'}, + 'leafWet1': {'field_name': 'Lw1', + 'units': 'count'}, + 'leafWet2': {'field_name': 'Lw2', + 'units': 'count'}, + 'soilTemp1': {'field_name': 'St1', + 'units': 'degree_C'}, + 'soilTemp2': {'field_name': 'St2', + 'units': 'degree_C'}, + 'soilTemp3': {'field_name': 'St3', + 'units': 'degree_C'}, + 'soilTemp4': {'field_name': 'St4', + 'units': 'degree_C'}, + 'leafTemp1': {'field_name': 'Lt1', + 'units': 'degree_C'}, + 'leafTemp2': {'field_name': 'Lt2', + 'units': 'degree_C'}, + 'UV': {'field_name': 'U', + 'units': 'uv_index'} + } + + weathercat_unit_groups = {'temperature': ('outTemp', 'inTemp', + 'extraTemp1', 'extraTemp2', + 'extraTemp3', 'windchill', + 'soilTemp1', 'soilTemp2', + 'soilTemp3', 'soilTemp4', + 'leafTemp1', 'leafTemp2'), + 'dewpoint': ('dewpoint',), + 'pressure': ('barometer',), + 'windspeed': ('windSpeed', 'windGust'), + 'precipitation': ('rain',) + } + + def __init__(self, config_dict, config_path, weathercat_config_dict, import_config_path, + options): + + # call our parents __init__ + super(WeatherCatSource, self).__init__(config_dict, + weathercat_config_dict, + options) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.weathercat_config_dict = weathercat_config_dict + + # wind dir bounds + self.wind_dir = [0, 360] + + # WeatherCat data files provide a number of cumulative rainfall fields. + # We use field 'P' the total precipitation and treat it as a cumulative + # value. + self.rain = 'cumulative' + + # The WeatherCatData.cat file structure is well-defined, so we can + # construct our import field-to-WeeWX archive field map now. The user + # can specify the units used in the monthly data files, so first + # construct a default field map then go through and adjust the units + # where necessary. + # first initialise with a default field map + self.map = self.default_header_map + # now check the [[Units]] stanza in the import config file and adjust + # any units as required + if 'Units' in weathercat_config_dict and len(weathercat_config_dict['Units']) > 0: + # we have [[Units]] settings so iterate over each + for group, value in six.iteritems(weathercat_config_dict['Units']): + # is this group (eg 'temperature', 'rain', etc) one that we know + # about + if group in self.weathercat_unit_groups: + # it is, so iterate over each import field that could be affected by + # this unit setting + for field in self.weathercat_unit_groups[group]: + # set the units field accordingly + self.map[field]['units'] = value + # We have one special 'derived' unit setting, rainRate. The + # rainRate units are derived from the rain units by simply + # appending '_per_hour' + if group == 'precipitation': + self.map['rainRate']['units'] = ''.join([value, '_per_hour']) + + # property holding the current log file name being processed + self.file_name = None + + # get our source file path + try: + self.source = weathercat_config_dict['directory'] + except KeyError: + raise weewx.ViolatedPrecondition( + "WeatherCat directory not specified in '%s'." % import_config_path) + + # Get a list of monthly data files sorted from oldest to newest. + # Remember the files are in 'year' folders. + # first get a list of all the 'year' folders including path + _y_list = [os.path.join(self.source, d) for d in os.listdir(self.source) + if os.path.isdir(os.path.join(self.source, d))] + # initialise our list of monthly data files + f_list = [] + # iterate over the 'year' directories + for _dir in _y_list: + # find any monthly data files in the 'year' directory and add them + # to the file list + f_list += glob.glob(''.join([_dir, '/*[0-9]_WeatherCatData.cat'])) + # now get an intermediate list that we can use to sort the file list + # from oldest to newest + _temp = [(fn, + os.path.basename(os.path.dirname(fn)), + os.path.basename(fn).split('_')[0].zfill(2)) for fn in f_list] + # now do the sorting + self.cat_list = [a[0] for a in sorted(_temp, + key=lambda el: (el[1], el[2]))] + + if len(self.cat_list) == 0: + raise weeimport.WeeImportIOError( + "No WeatherCat monthly .cat files found in directory '%s'." % self.source) + + # tell the user/log what we intend to do + _msg = "WeatherCat monthly .cat files in the '%s' directory " \ + "will be imported" % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if options.date: + _msg = " date=%s" % options.date + else: + # we must have --from and --to + _msg = " from=%s, to=%s" % (options.date_from, options.date_to) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc-missing=%s" % (self.dry_run, + self.calc_missing) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s" % (self.tranche, + self.interval) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s" % (self.UV_sensor, self.solar_sensor) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is " \ + "bound to database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + if self.calc_missing: + print("Missing derived observations will be calculated.") + if not self.UV_sensor: + print("All WeeWX UV fields will be set to None.") + if not self.solar_sensor: + print("All WeeWX radiation fields will be set to None.") + if options.date or options.date_from: + print("Observations timestamped after %s and " + "up to and" % (timestamp_to_string(self.first_ts),)) + print("including %s will be imported." % (timestamp_to_string(self.last_ts),)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def getRawData(self, period): + """Get the raw data and create WeatherCat to WeeWX archive field map. + + Create a WeatherCat to WeeWX archive field map and instantiate a + generator to yield one row of raw observation data at a time from the + monthly data file. + + Field names and units are fixed for a WeatherCat monthly data file, so + we can use a fixed field map. + + Calculating a date-time for each row requires obtaining the year from + the monthly data file directory, month from the monthly data file name + and day, hour and minute from the individual rows in the monthly data + file. This calculation could be performed in the mapRawData() method, + but it is convenient to do it here as all the source data is readily + available plus it maintains simplicity in the mapRawData() method. + + Input parameter: + + period: The file name, including path, of the WeatherCat monthly + data file from which raw obs data will be read. + """ + + # confirm the source exists + if os.path.isfile(period): + # set WeatherCat to WeeWX archive field map + self.map = dict(self.default_header_map) + # obtain year from the directory containing the monthly data file + _year = os.path.basename(os.path.dirname(period)) + # obtain the month number from the monthly data filename, we need + # to zero pad to ensure we get a two character month + _month = os.path.basename(period).split('_')[0].zfill(2) + # read the monthly data file line by line + with open(period, 'r') as f: + for _raw_line in f: + # the line is a data line if it has a t and V key + if 't:' in _raw_line and 'V:' in _raw_line: + # we have a data line + _row = {} + # check for and remove any null bytes and strip any + # whitespace + if "\x00" in _raw_line: + _line = _raw_line.replace("\x00", "").strip() + _msg = "One or more null bytes found in and removed " \ + "from month '%d' year '%d'" % (int(_month), _year) + print(_msg) + log.info(_msg) + else: + # strip any whitespace + _line = _raw_line.strip() + # iterate over the key-value pairs on the line + for pair in shlex.split(_line): + _split_pair = pair.split(":", 1) + # if we have a key-value pair save the data in the + # row dict + if len(_split_pair) > 1: + _row[_split_pair[0]] = _split_pair[1] + # calculate an epoch timestamp for the row + if 't' in _row: + _ymt = ''.join([_year, _month, _row['t']]) + try: + _datetm = time.strptime(_ymt, "%Y%m%d%H%M") + _row['dateTime'] = str(int(time.mktime(_datetm))) + except ValueError: + raise ValueError("Cannot convert '%s' to timestamp." % _ymt) + yield _row + else: + # if it doesn't we can't go on so raise it + _msg = "WeatherCat monthly .cat file '%s' could not be found." % period + raise weeimport.WeeImportIOError(_msg) + + def period_generator(self): + """Generator yielding a sequence of WeatherCat monthly data file names. + + This generator controls the FOR statement in the parent's run() method + that iterates over the monthly data files to be imported. The generator + yields a monthly data file name from the sorted list of monthly data + files to be imported until the list is exhausted. The generator also + sets the first_period and last_period properties.""" + + # step through each of our file names + for self.file_name in self.cat_list: + # yield the file name + yield self.file_name + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list or if the current period is None (the initialisation value). + """ + + return self.file_name == self.cat_list[0] if self.file_name is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current file name being processed is the last in + the list. + """ + + return self.file_name == self.cat_list[-1] diff --git a/dist/weewx-4.10.1/bin/weeimport/weeimport.py b/dist/weewx-4.10.1/bin/weeimport/weeimport.py new file mode 100644 index 0000000..94cc496 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/weeimport.py @@ -0,0 +1,1369 @@ +# +# Copyright (c) 2009-2022 Tom Keffer and Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# + +"""Module providing the base classes and API for importing observational data +into WeeWX. +""" + +from __future__ import with_statement +from __future__ import print_function +from __future__ import absolute_import + +# Python imports +import datetime +import logging +import numbers +import re +import sys +import time +from datetime import datetime as dt + +import six +from six.moves import input + +# WeeWX imports +import weecfg +import weecfg.database +import weewx +import weewx.accum +import weewx.qc +import weewx.wxservices + +from weewx.manager import open_manager_with_config +from weewx.units import unit_constants, unit_nicknames, convertStd, to_std_system, ValueTuple +from weeutil.weeutil import timestamp_to_string, option_as_list, to_int, tobool, get_object, max_with_none + +log = logging.getLogger(__name__) + +# List of sources we support +SUPPORTED_SOURCES = ['CSV', 'WU', 'Cumulus', 'WD', 'WeatherCat'] + +# Minimum requirements in any explicit or implicit WeeWX field-to-import field +# map +MINIMUM_MAP = {'dateTime': {'units': 'unix_epoch'}, + 'usUnits': {'units': None}, + 'interval': {'units': 'minute'}} + + +# ============================================================================ +# Error Classes +# ============================================================================ + + +class WeeImportOptionError(Exception): + """Base class of exceptions thrown when encountering an error with a + command line option. + """ + + +class WeeImportMapError(Exception): + """Base class of exceptions thrown when encountering an error with an + external source-to-WeeWX field map. + """ + + +class WeeImportIOError(Exception): + """Base class of exceptions thrown when encountering an I/O error with an + external source. + """ + + +class WeeImportFieldError(Exception): + """Base class of exceptions thrown when encountering an error with a field + from an external source. + """ + + +class WeeImportDecodeError(Exception): + """Base class of exceptions thrown when encountering a decode error with an + external source. + """ + +# ============================================================================ +# class Source +# ============================================================================ + + +class Source(object): + """ Abstract base class used for interacting with an external data source + to import records into the WeeWX archive. + + __init__() must define the following properties: + dry_run - Is this a dry run (ie do not save imported records + to archive). [True|False]. + calc_missing - Calculate any missing derived observations. + [True|False]. + ignore_invalid_data - Ignore any invalid data found in a source field. + [True|False]. + tranche - Number of records to be written to archive in a + single transaction. Integer. + interval - Method of determining interval value if interval + field not included in data source. + ['config'|'derive'|x] where x is an integer. + + Child classes are used to interact with a specific source (eg CSV file, + WU). Any such child classes must define a getRawData() method which: + - gets the raw observation data and returns an iterable yielding data + dicts whose fields can be mapped to a WeeWX archive field + - defines an import data field-to-WeeWX archive field map (self.map) + + self.raw_datetime_format - Format of date time data field from which + observation timestamp is to be derived. A + string in Python datetime string format such + as '%Y-%m-%d %H:%M:%S'. If the date time + data field cannot be interpreted as a string + wee_import attempts to interpret the field + as a unix timestamp. If the field is not a + valid unix timestamp an error is raised. + """ + + # reg expression to match any HTML tag of the form <...> + _tags = re.compile(r'\<.*\>') + + def __init__(self, config_dict, import_config_dict, options): + """A generic initialisation. + + Set some realistic default values for options read from the import + config file. Obtain objects to handle missing derived obs (if required) + and QC on imported data. Parse any --date command line option, so we + know what records to import. + """ + + # save our WeeWX config dict + self.config_dict = config_dict + + # get our import config dict settings + # interval, default to 'derive' + self.interval = import_config_dict.get('interval', 'derive') + # do we ignore invalid data, default to True + self.ignore_invalid_data = tobool(import_config_dict.get('ignore_invalid_data', + True)) + # tranche, default to 250 + self.tranche = to_int(import_config_dict.get('tranche', 250)) + # apply QC, default to True + self.apply_qc = tobool(import_config_dict.get('qc', True)) + # calc-missing, default to True + self.calc_missing = tobool(import_config_dict.get('calc_missing', True)) + # decimal separator, default to period '.' + self.decimal_sep = import_config_dict.get('decimal', '.') + + # Some sources include UV index and solar radiation values even if no + # sensor was present. The WeeWX convention is to store the None value + # when a sensor or observation does not exist. Record whether UV and/or + # solar radiation sensor was present. + # UV, default to True + self.UV_sensor = tobool(import_config_dict.get('UV_sensor', True)) + # solar, default to True + self.solar_sensor = tobool(import_config_dict.get('solar_sensor', True)) + + # initialise ignore extreme > 255.0 values for temperature and + # humidity fields for WD imports + self.ignore_extr_th = False + + self.db_binding_wx = get_binding(config_dict) + self.dbm = open_manager_with_config(config_dict, self.db_binding_wx, + initialize=True, + default_binding_dict={'table_name': 'archive', + 'manager': 'weewx.wxmanager.DaySummaryManager', + 'schema': 'schemas.wview_extended.schema'}) + # get the unit system used in our db + if self.dbm.std_unit_system is None: + # we have a fresh archive (ie no records) so cannot deduce + # the unit system in use, so go to our config_dict + self.archive_unit_sys = unit_constants[self.config_dict['StdConvert'].get('target_unit', + 'US')] + else: + # get our unit system from the archive db + self.archive_unit_sys = self.dbm.std_unit_system + + # initialise the accum dict with any Accumulator config in the config + # dict + weewx.accum.initialize(self.config_dict) + + # get ourselves a QC object to do QC on imported records + try: + mm_dict = config_dict['StdQC']['MinMax'] + except KeyError: + mm_dict = {} + self.import_QC = weewx.qc.QC(mm_dict) + + # Process our command line options + self.dry_run = options.dry_run + self.verbose = options.verbose + self.no_prompt = options.no_prompt + self.suppress = options.suppress + + # By processing any --date, --from and --to options we need to derive + # self.first_ts and self.last_ts; the earliest (exclusive) and latest + # (inclusive) timestamps of data to be imported. If we have no --date, + # --from or --to then set both to None (we then get the default action + # for each import type). + # First we see if we have a valid --date, if not then we look for + # --from and --to. + if options.date or options.date == "": + # there is a --date but is it valid + try: + _first_dt = dt.strptime(options.date, "%Y-%m-%d") + except ValueError: + # Could not convert --date. If we have a --date it must be + # valid otherwise we can't continue so raise it. + _msg = "Invalid --date option specified." + raise WeeImportOptionError(_msg) + else: + # we have a valid date so do some date arithmetic + _last_dt = _first_dt + datetime.timedelta(days=1) + self.first_ts = time.mktime(_first_dt.timetuple()) + self.last_ts = time.mktime(_last_dt.timetuple()) + elif options.date_from or options.date_to or options.date_from == '' or options.date_to == '': + # There is a --from and/or a --to, but do we have both and are + # they valid. + # try --from first + try: + if 'T' in options.date_from: + _from_dt = dt.strptime(options.date_from, "%Y-%m-%dT%H:%M") + else: + _from_dt = dt.strptime(options.date_from, "%Y-%m-%d") + _from_ts = time.mktime(_from_dt.timetuple()) + except TypeError: + # --from not specified we can't continue so raise it + _msg = "Missing --from option. Both --from and --to must be specified." + raise WeeImportOptionError(_msg) + except ValueError: + # could not convert --from, we can't continue so raise it + _msg = "Invalid --from option." + raise WeeImportOptionError(_msg) + # try --to + try: + if 'T' in options.date_to: + _to_dt = dt.strptime(options.date_to, "%Y-%m-%dT%H:%M") + else: + _to_dt = dt.strptime(options.date_to, "%Y-%m-%d") + # since it is just a date we want the end of the day + _to_dt += datetime.timedelta(days=1) + _to_ts = time.mktime(_to_dt.timetuple()) + except TypeError: + # --to not specified , we can't continue so raise it + _msg = "Missing --to option. Both --from and --to must be specified." + raise WeeImportOptionError(_msg) + except ValueError: + # could not convert --to, we can't continue so raise it + _msg = "Invalid --to option." + raise WeeImportOptionError(_msg) + # If we made it here we have a _from_ts and _to_ts. Do a simple + # error check first. + if _from_ts > _to_ts: + # from is later than to, raise it + _msg = "--from value is later than --to value." + raise WeeImportOptionError(_msg) + self.first_ts = _from_ts + self.last_ts = _to_ts + else: + # no --date or --from/--to so we take the default, set all to None + self.first_ts = None + self.last_ts = None + + # initialise a few properties we will need during the import + # answer flags + self.ans = None + self.interval_ans = None + # properties to help with processing multi-period imports + self.period_no = None + # total records processed + self.total_rec_proc = 0 + # total unique records identified + self.total_unique_rec = 0 + # total duplicate records identified + self.total_duplicate_rec = 0 + # time we started to first save + self.t1 = None + # time taken to process + self.tdiff = None + # earliest timestamp imported + self.earliest_ts = None + # latest timestamp imported + self.latest_ts = None + + # initialise two sets to hold timestamps of records for which we + # encountered duplicates + + # duplicates seen over all periods + self.duplicates = set() + # duplicates seen over the current period + self.period_duplicates = set() + + @staticmethod + def sourceFactory(options, args): + """Factory to produce a Source object. + + Returns an appropriate object depending on the source type. Raises a + weewx.UnsupportedFeature error if an object could not be created. + """ + + # get some key WeeWX parameters + # first the config dict to use + config_path, config_dict = weecfg.read_config(options.config_path, args) + # get wee_import config dict if it exists + import_config_path, import_config_dict = weecfg.read_config(None, + args, + file_name=options.import_config_path) + # we should have a source parameter at the root of out import config + # file, try to get it but be prepared to catch the error. + try: + source = import_config_dict['source'] + except KeyError: + # we have no source parameter so check if we have a single source + # config stanza, if we do then proceed using that + _source_keys = [s for s in SUPPORTED_SOURCES if s in import_config_dict.keys] + if len(_source_keys) == 1: + # we have a single source config stanza so use that + source = _source_keys[0] + else: + # there is no source parameter and we do not have a single + # source config stanza so raise an error + _msg = "Invalid 'source' parameter or no 'source' parameter specified in %s" % import_config_path + raise weewx.UnsupportedFeature(_msg) + # if we made it this far we have all we need to create an object + module_class = '.'.join(['weeimport', + source.lower() + 'import', + source + 'Source']) + return get_object(module_class)(config_dict, + config_path, + import_config_dict.get(source, {}), + import_config_path, + options) + + def run(self): + """Main entry point for importing from an external source. + + Source data may be provided as a group of records over a single period + (eg a single CSV file) or as a number of groups of records covering + multiple periods(eg a WU multi-day import). Step through each group of + records, getting the raw data, mapping the data and saving the data for + each period. + """ + + # setup a counter to count the periods of records + self.period_no = 1 + # obtain the lastUpdate metadata value before we import anything + last_update = to_int(self.dbm._read_metadata('lastUpdate')) + with self.dbm as archive: + if self.first_period: + # collect the time for some stats reporting later + self.t1 = time.time() + # it's convenient to give this message now + if self.dry_run: + print('Starting dry run import ...') + else: + print('Starting import ...') + + if self.first_period and not self.last_period: + # there are more periods so say so + print("Records covering multiple periods have been identified for import.") + + # step through our periods of records until we reach the end. A + # 'period' of records may comprise the contents of a file, a day + # of WU obs or a month of Cumulus obs + for period in self.period_generator(): + + # if we are importing multiple periods of data then tell the + # user what period we are up to + if not (self.first_period and self.last_period): + print("Period %d ..." % self.period_no) + + # get the raw data + _msg = 'Obtaining raw import data for period %d ...' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + try: + _raw_data = self.getRawData(period) + except WeeImportIOError as e: + print("**** Unable to load source data for period %d." % self.period_no) + log.info("**** Unable to load source data for period %d." % self.period_no) + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + log.info("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + # increment our period counter + self.period_no += 1 + continue + except WeeImportDecodeError as e: + print("**** Unable to decode source data for period %d." % self.period_no) + log.info("**** Unable to decode source data for period %d." % self.period_no) + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + log.info("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + print("**** Consider specifying the source file encoding " + "using the 'source_encoding' config option.") + log.info("**** Consider specifying the source file encoding " + "using the 'source_encoding' config option.") + # increment our period counter + self.period_no += 1 + continue + _msg = 'Raw import data read successfully for period %d.' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + + # map the raw data to a WeeWX archive compatible dictionary + _msg = 'Mapping raw import data for period %d ...' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + _mapped_data = self.mapRawData(_raw_data, self.archive_unit_sys) + _msg = 'Raw import data mapped successfully for period %d.' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + + # save the mapped data to archive + # first advise the user and log, but only if it's not a dry run + if not self.dry_run: + _msg = 'Saving mapped data to archive for period %d ...' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + self.saveToArchive(archive, _mapped_data) + # advise the user and log, but only if it's not a dry run + if not self.dry_run: + _msg = 'Mapped data saved to archive successfully "' \ + '"for period %d.' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + # increment our period counter + self.period_no += 1 + # The source data has been processed and any records saved to + # archive (except if it was a dry run). + + # now update the lastUpdate metadata field, set it to the max of + # the timestamp of the youngest record imported and the value of + # lastUpdate from before we started + new_last_update = max_with_none((last_update, self.latest_ts)) + if new_last_update is not None: + self.dbm._write_metadata('lastUpdate', str(int(new_last_update))) + # If necessary, calculate any missing derived fields and provide + # the user with suitable summary output. + if self.total_rec_proc == 0: + # nothing was imported so no need to calculate any missing + # fields just inform the user what was done + _msg = 'No records were identified for import. Exiting. Nothing done.' + print(_msg) + log.info(_msg) + else: + # We imported something, but was it a dry run or not? + total_rec = self.total_rec_proc + self.total_duplicate_rec + if self.dry_run: + # It was a dry run. Skip calculation of missing derived + # fields (since there are no archive records to process), + # just provide the user with a summary of what we did. + _msg = "Finished dry run import" + print(_msg) + log.info(_msg) + _msg = "%d records were processed and %d unique records would "\ + "have been imported." % (total_rec, + self.total_rec_proc) + print(_msg) + log.info(_msg) + if self.total_duplicate_rec > 1: + _msg = "%d duplicate records were ignored." % self.total_duplicate_rec + print(_msg) + log.info(_msg) + elif self.total_duplicate_rec == 1: + _msg = "1 duplicate record was ignored." + print(_msg) + log.info(_msg) + else: + # It was not a dry run so calculate any missing derived + # fields and provide the user with a summary of what we did. + if self.calc_missing: + # We were asked to calculate missing derived fields, so + # get a CalcMissing object. + # First construct a CalcMissing config dict + # (self.dry_run will never be true). Subtract 0.5 + # seconds from the earliest timestamp as calc_missing + # only calculates missing derived obs for records + # timestamped after start_ts. + calc_missing_config_dict = {'name': 'Calculate Missing Derived Observations', + 'binding': self.db_binding_wx, + 'start_ts': self.earliest_ts-0.5, + 'stop_ts': self.latest_ts, + 'trans_days': 1, + 'dry_run': self.dry_run is True} + # now obtain a CalcMissing object + self.calc_missing_obj = weecfg.database.CalcMissing(self.config_dict, + calc_missing_config_dict) + _msg = "Calculating missing derived observations ..." + print(_msg) + log.info(_msg) + # do the calculations + self.calc_missing_obj.run() + _msg = "Finished calculating missing derived observations" + print(_msg) + log.info(_msg) + # now provide the summary report + _msg = "Finished import" + print(_msg) + log.info(_msg) + _msg = "%d records were processed and %d unique records " \ + "imported in %.2f seconds." % (total_rec, + self.total_rec_proc, + self.tdiff) + print(_msg) + log.info(_msg) + if self.total_duplicate_rec > 1: + _msg = "%d duplicate records were ignored." % self.total_duplicate_rec + print(_msg) + log.info(_msg) + elif self.total_duplicate_rec == 1: + _msg = "1 duplicate record was ignored." + print(_msg) + log.info(_msg) + print("Those records with a timestamp already " + "in the archive will not have been") + print("imported. Confirm successful import in the WeeWX log file.") + + def parseMap(self, source_type, source, import_config_dict): + """Produce a source field-to-WeeWX archive field map. + + Data from an external source can be mapped to the WeeWX archive using: + - a fixed field map (WU), + - a fixed field map with user specified source units (Cumulus), or + - a user defined field/units map. + + All user defined mapping is specified in the import config file. + + To generate the field map first look to see if we have a fixed map, if + we do validate it and return the resulting map. Otherwise, look for + user specified mapping in the import config file, construct the field + map and return it. If there is neither a fixed map nor a user specified + mapping then raise an error. + + Input parameters: + + source_type: String holding name of the section in + import_config_dict that holds config details for the + source being used. + + source: Iterable holding the source data. Used if import field + names are included in the source data (eg CSV). + + import_config_dict: config dict from import config file. + + Returns a map as a dictionary of elements with each element structured + as follows: + + 'archive_field_name': {'field_name': 'source_field_name', + 'units': 'unit_name'} + + where: + + - archive_field_name is an observation name in the WeeWX + database schema + - source_field_name is the name of a field from the external + source + - unit_name is the WeeWX unit name of the units used by + source_field_name + """ + + # start with the minimum map + _map = dict(MINIMUM_MAP) + + # Do the easy one first, do we have a fixed mapping, if so validate it + if self._header_map: + # We have a static map that maps header fields to WeeWX (eg WU). + # Our static map may have entries for fields that don't exist in our + # source data so step through each field name in our source data and + # only add those that exist to our resulting map. + + # first get a list of fields, source could be a DictReader object + # or a list of dicts, a DictReader will have a fieldnames property + try: + _field_names = source.fieldnames + except AttributeError: + # Not a DictReader so need to obtain the dict keys, could just + # pick a record and extract its keys but some records may have + # different keys to others. Use sets and a generator + # comprehension. + _field_names = set().union(*(list(d.keys()) for d in source)) + # now iterate over the field names + for _key in _field_names: + # if we know about the field name add it to our map + if _key in self._header_map: + _map[self._header_map[_key]['map_to']] = {'field_name': _key, + 'units': self._header_map[_key]['units']} + # Do we have a user specified map, if so construct our field map + elif 'FieldMap' in import_config_dict: + # we have a user specified map so construct our map dict + for _key, _item in six.iteritems(import_config_dict['FieldMap']): + _entry = option_as_list(_item) + # expect 2 parameters for each option: source field, units + if len(_entry) == 2: + # we have 2 parameter so that's field and units + _map[_key] = {'field_name': _entry[0], + 'units': _entry[1]} + # if the entry is not empty then it might be valid ie just a + # field name (eg if usUnits is specified) + elif _entry != [''] and len(_entry) == 1: + # we have 1 parameter so it must be just name + _map[_key] = {'field_name': _entry[0]} + else: + # otherwise it's invalid so ignore it + pass + + # now do some crude error checking + + # dateTime. We must have a dateTime mapping. Check for a + # 'field_name' field under 'dateTime' and be prepared to catch the + # error if it does not exist. + try: + if _map['dateTime']['field_name']: + # we have a 'field_name' entry so continue + pass + else: + # something is wrong; we have a 'field_name' entry, but it + # is not valid so raise an error + _msg = "Invalid mapping specified in '%s' for " \ + "field 'dateTime'." % self.import_config_path + raise WeeImportMapError(_msg) + except KeyError: + _msg = "No mapping specified in '%s' for field " \ + "'dateTime'." % self.import_config_path + raise WeeImportMapError(_msg) + + # usUnits. We don't have to have a mapping for usUnits but if we + # don't then we must have 'units' specified for each field mapping. + if 'usUnits' not in _map or _map['usUnits'].get('field_name') is None: + # no unit system mapping do we have units specified for + # each individual field + for _key, _val in six.iteritems(_map): + # we don't need to check dateTime and usUnits + if _key not in ['dateTime', 'usUnits']: + if 'units' in _val: + # we have a units field, do we know about it + if _val['units'] not in weewx.units.conversionDict \ + and _val['units'] not in weewx.units.USUnits.values() \ + and _val['units'] != 'text': + # we have an invalid unit string so tell the + # user and exit + _msg = "Unknown units '%s' specified for " \ + "field '%s' in %s." % (_val['units'], + _key, + self.import_config_path) + raise weewx.UnitError(_msg) + else: + # we don't have a units field, that's not allowed + # so raise an error + _msg = "No units specified for source field " \ + "'%s' in %s." % (_key, + self.import_config_path) + raise WeeImportMapError(_msg) + + # if we got this far we have a usable map, advise the user what we + # will use + _msg = "The following imported field-to-WeeWX field map will be used:" + if self.verbose: + print(_msg) + log.info(_msg) + for _key, _val in six.iteritems(_map): + if 'field_name' in _val: + _units_msg = "" + if 'units' in _val: + if _val['units'] == 'text': + _units_msg = " as text" + else: + _units_msg = " in units '%s'" % _val['units'] + _msg = " source field '%s'%s --> WeeWX field '%s'" % (_val['field_name'], + _units_msg, + _key) + if self.verbose: + print(_msg) + log.info(_msg) + else: + # no [[FieldMap]] stanza and no _header_map so raise an error as we + # don't know what to map + _msg = "No '%s' field map found in %s." % (source_type, + self.import_config_path) + raise WeeImportMapError(_msg) + return _map + + def mapRawData(self, data, unit_sys=weewx.US): + """Maps raw data to WeeWX archive record compatible dictionaries. + + Takes an iterable source of raw data observations, maps the fields of + each row to a list of WeeWX compatible archive records and performs any + necessary unit conversion. + + Input parameters: + + data: iterable that yields the data records to be processed. + + unit_sys: WeeWX unit system in which the generated records will be + provided. Omission will result in US customary (weewx.US) + being used. + + Returns a list of dicts of WeeWX compatible archive records. + """ + + # initialise our list of mapped records + _records = [] + # initialise some rain variables + _last_ts = None + _last_rain = None + # list of fields we have given the user a warning over, prevents us + # giving multiple warnings for the same field. + _warned = [] + # step through each row in our data + for _row in data: + _rec = {} + # first off process the fields that require special processing + # dateTime + if 'field_name' in self.map['dateTime']: + # we have a map for dateTime + try: + _raw_dateTime = _row[self.map['dateTime']['field_name']] + except KeyError: + _msg = "Field '%s' not found in source "\ + "data." % self.map['dateTime']['field_name'] + raise WeeImportFieldError(_msg) + # now process the raw date time data + if isinstance(_raw_dateTime, numbers.Number) or _raw_dateTime.isdigit(): + # Our dateTime is a number, is it a timestamp already? + # Try to use it and catch the error if there is one and + # raise it higher. + try: + _rec_dateTime = int(_raw_dateTime) + except ValueError: + _msg = "Invalid '%s' field. Cannot convert '%s' to " \ + "timestamp." % (self.map['dateTime']['field_name'], + _raw_dateTime) + raise ValueError(_msg) + else: + # it's a non-numeric string so try to parse it and catch + # the error if there is one and raise it higher + try: + _datetm = time.strptime(_raw_dateTime, + self.raw_datetime_format) + _rec_dateTime = int(time.mktime(_datetm)) + except ValueError: + _msg = "Invalid '%s' field. Cannot convert '%s' to " \ + "timestamp." % (self.map['dateTime']['field_name'], + _raw_dateTime) + raise ValueError(_msg) + # if we have a timeframe of concern does our record fall within + # it + if (self.first_ts is None and self.last_ts is None) or \ + self.first_ts < _rec_dateTime <= self.last_ts: + # we have no timeframe or if we do it falls within it so + # save the dateTime + _rec['dateTime'] = _rec_dateTime + # update earliest and latest record timestamps + if self.earliest_ts is None or _rec_dateTime < self.earliest_ts: + self.earliest_ts = _rec_dateTime + if self.latest_ts is None or _rec_dateTime > self.earliest_ts: + self.latest_ts = _rec_dateTime + else: + # it is not so skip to the next record + continue + else: + # there is no mapped field for dateTime so raise an error + raise ValueError("No mapping for WeeWX field 'dateTime'.") + # usUnits + _units = None + if 'field_name' in self.map['usUnits']: + # we have a field map for a unit system + try: + # The mapped field is in _row so try to get the raw data. + # If it's not there then raise an error. + _raw_units = int(_row[self.map['usUnits']['field_name']]) + except KeyError: + _msg = "Field '%s' not found in "\ + "source data." % self.map['usUnits']['field_name'] + raise WeeImportFieldError(_msg) + # we have a value but is it valid + if _raw_units in unit_nicknames: + # it is valid so use it + _units = _raw_units + else: + # the units value is not valid so raise an error + _msg = "Invalid unit system '%s'(0x%02x) mapped from data source. " \ + "Check data source or field mapping." % (_raw_units, + _raw_units) + raise weewx.UnitError(_msg) + # interval + if 'field_name' in self.map['interval']: + # We have a map for interval so try to get the raw data. If + # it's not there raise an error. + try: + _tfield = _row[self.map['interval']['field_name']] + except KeyError: + _msg = "Field '%s' not found in "\ + "source data." % self.map['interval']['field_name'] + raise WeeImportFieldError(_msg) + # now process the raw interval data + if _tfield is not None and _tfield != '': + try: + _rec['interval'] = int(_tfield) + except ValueError: + _msg = "Invalid '%s' field. Cannot convert '%s' to " \ + "an integer." % (self.map['interval']['field_name'], + _tfield) + raise ValueError(_msg) + else: + # if it happens to be None then raise an error + _msg = "Invalid value '%s' for mapped field '%s' at " \ + "timestamp '%s'." % (_tfield, + self.map['interval']['field_name'], + timestamp_to_string(_rec['dateTime'])) + raise ValueError(_msg) + else: + # we have no mapping so calculate it, wrap in a try..except in + # case it cannot be calculated + try: + _rec['interval'] = self.getInterval(_last_ts, _rec['dateTime']) + except WeeImportFieldError as e: + # We encountered a WeeImportFieldError, which means we + # cannot calculate the interval value, possibly because + # this record is out of date-time order. We cannot use this + # record so skip it, advise the user (via console and log) + # and move to the next record. + _msg = "Record discarded: %s" % e + print(_msg) + log.info(_msg) + continue + # now step through the rest of the fields in our map and process + # the fields that don't require special processing + for _field in self.map: + # skip those that have had special processing + if _field in MINIMUM_MAP: + continue + # process everything else + else: + # is our mapped field in the record + if self.map[_field]['field_name'] in _row: + # yes it is + # first check to see if this is a text field + if 'units' in self.map[_field] and self.map[_field]['units'] == 'text': + # we have a text field, so accept the field + # contents as is + _rec[_field] = _row[self.map[_field]['field_name']] + else: + # we have a non-text field so try to get a value + # for the obs but if we can't, catch the error + try: + _value = float(_row[self.map[_field]['field_name']].strip()) + except AttributeError: + # the data has no strip() attribute so chances + # are it's a number already, or it could + # (somehow ?) be None + if _row[self.map[_field]['field_name']] is None: + _value = None + else: + try: + _value = float(_row[self.map[_field]['field_name']]) + except TypeError: + # somehow we have data that is not a + # number or a string + _msg = "%s: cannot convert '%s' to float at " \ + "timestamp '%s'." % (_field, + _row[self.map[_field]['field_name']], + timestamp_to_string(_rec['dateTime'])) + raise TypeError(_msg) + except ValueError: + # A ValueError means that float() could not + # convert the string or number to a float, most + # likely because we have non-numeric, non-None + # data. We have some other possibilities to + # work through before we give up. + + # start by setting our result to None. + _value = None + + # perhaps it is numeric data but with something + # other that a period as decimal separator, try + # using float() again after replacing the + # decimal seperator + if self.decimal_sep is not None: + _data = _row[self.map[_field]['field_name']].replace(self.decimal_sep, + '.') + try: + _value = float(_data) + except ValueError: + # still could not convert it so pass + pass + + # If this is a csv import and we are mapping to + # a direction field, perhaps we have a string + # representation of a cardinal, inter-cardinal + # or secondary inter-cardinal direction that we + # can convert to degrees + + if _value is None and hasattr(self, 'wind_dir_map') and \ + self.map[_field]['units'] == 'degree_compass': + # we have a csv import and we are mapping + # to a direction field, so try a cardinal + # conversion + + # first strip any whitespace and hyphens + # from the data + _stripped = re.sub(r'[\s-]+', '', + _row[self.map[_field]['field_name']]) + # try to use the data as the key in a dict + # mapping directions to degrees, if there + # is no match we will have None returned + try: + _value = self.wind_dir_map[_stripped.upper()] + except KeyError: + # we did not find a match so pass + pass + # we have exhausted all possibilities, so if we + # have a non-None result use it, otherwise we + # either ignore it or raise an error + if _value is None and not self.ignore_invalid_data: + _msg = "%s: cannot convert '%s' to float at " \ + "timestamp '%s'." % (_field, + _row[self.map[_field]['field_name']], + timestamp_to_string(_rec['dateTime'])) + raise ValueError(_msg) + + # some fields need some special processing + + # rain - if our imported 'rain' field is cumulative + # (self.rain == 'cumulative') then we need to calculate + # the discrete rainfall for this archive period + if _field == "rain": + if self.rain == "cumulative": + _rain = self.getRain(_last_rain, _value) + _last_rain = _value + _value = _rain + # wind - check any wind direction fields are within our + # bounds and convert to 0 to 360 range + elif _field == "windDir" or _field == "windGustDir": + if _value is not None and (self.wind_dir[0] <= _value <= self.wind_dir[1]): + # normalise to 0 to 360 + _value %= 360 + else: + # outside our bounds so set to None + _value = None + # UV - if there was no UV sensor used to create the + # imported data then we need to set the imported value + # to None + elif _field == 'UV': + if not self.UV_sensor: + _value = None + # solar radiation - if there was no solar radiation + # sensor used to create the imported data then we need + # to set the imported value to None + elif _field == 'radiation': + if not self.solar_sensor: + _value = None + + # check and ignore if required temperature and humidity + # values of 255.0 and greater + if self.ignore_extr_th \ + and self.map[_field]['units'] in ['degree_C', 'degree_F', 'percent'] \ + and _value >= 255.0: + _value = None + + # if there is no mapped field for a unit system we + # have to do field by field unit conversions + if _units is None: + _vt = ValueTuple(_value, + self.map[_field]['units'], + weewx.units.obs_group_dict[_field]) + _conv_vt = convertStd(_vt, unit_sys) + _rec[_field] = _conv_vt.value + else: + # we do have a mapped field for a unit system so + # save the field in our record and continue, any + # unit conversion will be done in bulk later + _rec[_field] = _value + else: + # no it's not in our record, so set the field in our + # output to None + _rec[_field] = None + # now warn the user about this field if we have not + # already done so + if self.map[_field]['field_name'] not in _warned: + _msg = "Warning: Import field '%s' is mapped to WeeWX " \ + "field '%s' but the" % (self.map[_field]['field_name'], + _field) + if not self.suppress: + print(_msg) + log.info(_msg) + _msg = " import field '%s' could not be found " \ + "in one or more records." % self.map[_field]['field_name'] + if not self.suppress: + print(_msg) + log.info(_msg) + _msg = " WeeWX field '%s' will be "\ + "set to 'None' in these records." % _field + if not self.suppress: + print(_msg) + log.info(_msg) + # make sure we do this warning once only + _warned.append(self.map[_field]['field_name']) + # if we have a mapped field for a unit system with a valid value, + # then all we need do is set 'usUnits', bulk conversion is taken + # care of by saveToArchive() + if _units is not None: + # we have a mapped field for a unit system with a valid value + _rec['usUnits'] = _units + else: + # no mapped field for unit system, but we have already + # converted any necessary fields on a field by field basis so + # all we need do is set 'usUnits', any bulk conversion will be + # taken care of by saveToArchive() + _rec['usUnits'] = unit_sys + # If interval is being derived from record timestamps our first + # record will have an interval of None. In this case we wait until + # we have the second record, and then we use the interval between + # records 1 and 2 as the interval for record 1. + if len(_records) == 1 and _records[0]['interval'] is None: + _records[0]['interval'] = _rec['interval'] + _last_ts = _rec['dateTime'] + # this record is done, add it to our list of records to return + _records.append(_rec) + # If we have more than 1 unique value for interval in our records it + # could be a sign of missing data and impact the integrity of our data, + # so do the check and see if the user wants to continue + if len(_records) > 0: + # if we have any records to return do the unique interval check + # before we return the records + _start_interval = _records[0]['interval'] + _diff_interval = False + for _rec in _records: + if _rec['interval'] != _start_interval: + _diff_interval = True + break + if _diff_interval and self.interval_ans != 'y': + # we had more than one unique value for interval, warn the user + _msg = "Warning: Records to be imported contain multiple " \ + "different 'interval' values." + print(_msg) + log.info(_msg) + print(" This may mean the imported data is missing " + "some records and it may lead") + print(" to data integrity issues. If the raw data has " + "a known, fixed interval") + print(" value setting the relevant 'interval' setting " + "in wee_import config to") + print(" this value may give a better result.") + while self.interval_ans not in ['y', 'n']: + if self.no_prompt: + self.interval_ans = 'y' + else: + self.interval_ans = input('Are you sure you want to proceed (y/n)? ') + if self.interval_ans == 'n': + # the user chose to abort, but we may have already + # processed some records. So log it then raise a SystemExit() + if self.dry_run: + print("Dry run import aborted by user. %d records were processed." % self.total_rec_proc) + else: + if self.total_rec_proc > 0: + print("Those records with a timestamp already in the " + "archive will not have been") + print("imported. As the import was aborted before completion " + "refer to the WeeWX log") + print("file to confirm which records were imported.") + raise SystemExit('Exiting.') + else: + print("Import aborted by user. No records saved to archive.") + _msg = "User chose to abort import. %d records were processed. " \ + "Exiting." % self.total_rec_proc + log.info(_msg) + raise SystemExit('Exiting. Nothing done.') + _msg = "Mapped %d records." % len(_records) + if self.verbose: + print(_msg) + log.info(_msg) + # the user wants to continue, or we have only one unique value for + # interval so return the records + return _records + else: + _msg = "Mapped 0 records." + if self.verbose: + print(_msg) + log.info(_msg) + # we have no records to return so return None + return None + + def getInterval(self, last_ts, current_ts): + """Determine an interval value for a record. + + The interval field can be determined in one of the following ways: + + - Derived from the raw data. The interval is calculated as the + difference between the timestamps of consecutive records rounded to + the nearest minute. In this case interval can change between + records if the records are not evenly spaced in time or if there + are missing records. This method is the default and is used when + the interval parameter in wee_import.conf is 'derive'. + + - Read from weewx.conf. The interval value is read from the + archive_interval parameter in [StdArchive] in weewx.conf. In this + case interval may or may not be the same as the difference in time + between consecutive records. This method may be of use when the + import source has a known interval but may be missing a number of + records which makes deriving the interval from the imported data + problematic. This method is used when the interval parameter in + wee_import.conf is 'conf'. + + Input parameters: + + last_ts. timestamp of the previous record. + current_rain. timestamp of the current record. + + Returns the interval (in minutes) for the current record. + """ + + # did we have a number specified in wee_import.conf, if so use that + try: + return float(self.interval) + except ValueError: + pass + # how are we getting interval + if self.interval.lower() == 'conf': + # get interval from weewx.conf + return to_int(float(self.config_dict['StdArchive'].get('archive_interval')) / 60.0) + elif self.interval.lower() == 'derive': + # get interval from the timestamps of consecutive records + try: + _interval = int((current_ts - last_ts) / 60.0) + # but if _interval < 0 our records are not in date-time order + if _interval < 0: + # so raise a WeeImportFieldError exception + _msg = "Cannot derive 'interval' for record "\ + "timestamp: %s. " % timestamp_to_string(current_ts) + raise WeeImportFieldError(_msg) + except TypeError: + _interval = None + return _interval + else: + # we don't know what to do so raise an error + _msg = "Cannot derive 'interval'. Unknown 'interval' "\ + "setting in %s." % self.import_config_path + raise ValueError(_msg) + + @staticmethod + def getRain(last_rain, current_rain): + """Determine period rainfall from two cumulative rainfall values. + + If the data source provides rainfall as a cumulative value then the + rainfall in a period is the simple difference between the two values. + But we need to take into account some special cases: + + No last_rain value. Will occur for very first record or maybe in an + error condition. Need to return 0.0. + last_rain > current_rain. Occurs when rain counter was reset (maybe + daily or some other period). Need to return + current_rain. + current_rain is None. Could occur if imported rainfall value could not + be converted to a numeric and config option + ignore_invalid_data is set. + + Input parameters: + + last_rain. Previous rainfall total. + current_rain. Current rainfall total. + + Returns the rainfall in the period. + """ + + if last_rain is not None: + # we have a value for the previous period + if current_rain is not None and current_rain >= last_rain: + # just return the difference + return current_rain - last_rain + else: + # we are at a cumulative reset point or we current_rain is None, + # either way we just want current_rain + return current_rain + else: + # we have no previous rain value so return zero + return 0.0 + + def qc(self, data_dict, data_type): + """ Apply weewx.conf QC to a record. + + If qc option is set in the import config file then apply any StdQC + min/max checks specified in weewx.conf. + + Input parameters: + + data_dict: A WeeWX compatible archive record. + + Returns nothing. data_dict is modified directly with obs outside of QC + limits set to None. + """ + + if self.apply_qc: + self.import_QC.apply_qc(data_dict, data_type=data_type) + + def saveToArchive(self, archive, records): + """ Save records to the WeeWX archive. + + Supports saving one or more records to archive. Each collection of + records is processed and saved to archive in transactions of + self.tranche records at a time. + + if the import config file qc option was set quality checks on the + imported record are performed using the WeeWX StdQC configuration from + weewx.conf. Any missing derived observations are then added to the + archive record using the WeeWX WXCalculate class if the import config + file calc_missing option was set. WeeWX API addRecord() method is used + to add archive records. + + If --dry-run was set then every aspect of the import is carried out but + nothing is saved to archive. If --dry-run was not set then the user is + requested to confirm the import before any records are saved to archive. + + Input parameters: + + archive: database manager object for the WeeWX archive. + + records: iterable that provides WeeWX compatible archive records + (in dict form) to be written to archive + """ + + # do we have any records? + if records and len(records) > 0: + # if this is the first period then give a little summary about what + # records we have + # TODO. Check that a single period shows correct and consistent console output + if self.first_period and self.last_period: + # there is only 1 period, so we can count them + print("%s records identified for import." % len(records)) + # we do, confirm the user actually wants to save them + while self.ans not in ['y', 'n'] and not self.dry_run: + if self.no_prompt: + self.ans = 'y' + else: + print("Proceeding will save all imported records in the WeeWX archive.") + self.ans = input("Are you sure you want to proceed (y/n)? ") + if self.ans == 'y' or self.dry_run: + # we are going to save them + # reset record counter + nrecs = 0 + # initialise our list of records for this tranche + _tranche = [] + # initialise a set for use in our dry run, this lets us + # give some better stats on records imported + unique_set = set() + # step through each record in this period + for _rec in records: + # convert our record + _conv_rec = to_std_system(_rec, self.archive_unit_sys) + # perform any required QC checks + self.qc(_conv_rec, 'Archive') + # add the record to our tranche and increment our count + _tranche.append(_conv_rec) + nrecs += 1 + # if we have a full tranche then save to archive and reset + # the tranche + if len(_tranche) >= self.tranche: + # add the record only if it is not a dry run + if not self.dry_run: + # add the record only if it is not a dry run + archive.addRecord(_tranche) + # add our the dateTime for each record in our tranche + # to the dry run set + for _trec in _tranche: + unique_set.add(_trec['dateTime']) + # tell the user what we have done + _msg = "Unique records processed: %d; "\ + "Last timestamp: %s\r" % (nrecs, + timestamp_to_string(_rec['dateTime'])) + print(_msg, end='', file=sys.stdout) + sys.stdout.flush() + _tranche = [] + # we have processed all records but do we have any records left + # in the tranche? + if len(_tranche) > 0: + # we do so process them + if not self.dry_run: + # add the record only if it is not a dry run + archive.addRecord(_tranche) + # add our the dateTime for each record in our tranche to + # the dry run set + for _trec in _tranche: + unique_set.add(_trec['dateTime']) + # tell the user what we have done + _msg = "Unique records processed: %d; "\ + "Last timestamp: %s\r" % (nrecs, + timestamp_to_string(_rec['dateTime'])) + print(_msg, end='', file=sys.stdout) + print() + sys.stdout.flush() + # update our counts + self.total_rec_proc += nrecs + self.total_unique_rec += len(unique_set) + # mention any duplicates we encountered + num_duplicates = len(self.period_duplicates) + self.total_duplicate_rec += num_duplicates + if num_duplicates > 0: + if num_duplicates == 1: + _msg = " 1 duplicate record was identified "\ + "in period %d:" % self.period_no + else: + _msg = " %d duplicate records were identified "\ + "in period %d:" % (num_duplicates, + self.period_no) + if not self.suppress: + print(_msg) + log.info(_msg) + for ts in sorted(self.period_duplicates): + _msg = " %s" % timestamp_to_string(ts) + if not self.suppress: + print(_msg) + log.info(_msg) + # add the period duplicates to the overall duplicates + self.duplicates |= self.period_duplicates + # reset the period duplicates + self.period_duplicates = set() + elif self.ans == 'n': + # user does not want to import so display a message and then + # ask to exit + _msg = "User chose not to import records. Exiting. Nothing done." + print(_msg) + log.info(_msg) + raise SystemExit('Exiting. Nothing done.') + else: + # we have no records to import, advise the user but what we say + # will depend on if there are any more periods to import + if self.first_period and self.last_period: + # there was only 1 period + _msg = 'No records identified for import.' + else: + # multiple periods + _msg = 'Period %d - no records identified for import.' % self.period_no + print(_msg) + # if we have finished record the time taken for our summary + if self.last_period: + self.tdiff = time.time() - self.t1 + + +# ============================================================================ +# Utility functions +# ============================================================================ + + +def get_binding(config_dict): + """Get the binding for the WeeWX database.""" + + # Extract our binding from the StdArchive section of the config file. If + # it's missing, return None. + if 'StdArchive' in config_dict: + db_binding_wx = config_dict['StdArchive'].get('data_binding', + 'wx_binding') + else: + db_binding_wx = None + return db_binding_wx diff --git a/dist/weewx-4.10.1/bin/weeimport/wuimport.py b/dist/weewx-4.10.1/bin/weeimport/wuimport.py new file mode 100644 index 0000000..92319bd --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeimport/wuimport.py @@ -0,0 +1,378 @@ +# +# Copyright (c) 2009-2019 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# + +"""Module to interact with the Weather Underground API and obtain raw +observational data for use with wee_import. +""" + +# Python imports +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +import datetime +import gzip +import json +import logging +import numbers +import socket +import sys + +from datetime import datetime as dt + +# python3 compatibility shims +import six +from six.moves import urllib + +# WeeWX imports +from . import weeimport +import weewx + +from weeutil.weeutil import timestamp_to_string, option_as_list, startOfDay +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + +# ============================================================================ +# class WUSource +# ============================================================================ + + +class WUSource(weeimport.Source): + """Class to interact with the Weather Underground API. + + Uses PWS history call via http to obtain historical daily weather + observations for a given PWS. Unlike the previous WU import module the use + of the API requires an API key. + """ + + # Dict to map all possible WU field names to WeeWX archive field names and + # units + _header_map = {'epoch': {'units': 'unix_epoch', 'map_to': 'dateTime'}, + 'tempAvg': {'units': 'degree_F', 'map_to': 'outTemp'}, + 'dewptAvg': {'units': 'degree_F', 'map_to': 'dewpoint'}, + 'heatindexAvg': {'units': 'degree_F', 'map_to': 'heatindex'}, + 'windchillAvg': {'units': 'degree_F', 'map_to': 'windchill'}, + 'pressureAvg': {'units': 'inHg', 'map_to': 'barometer'}, + 'winddirAvg': {'units': 'degree_compass', + 'map_to': 'windDir'}, + 'windspeedAvg': {'units': 'mile_per_hour', + 'map_to': 'windSpeed'}, + 'windgustHigh': {'units': 'mile_per_hour', + 'map_to': 'windGust'}, + 'humidityAvg': {'units': 'percent', 'map_to': 'outHumidity'}, + 'precipTotal': {'units': 'inch', 'map_to': 'rain'}, + 'precipRate': {'units': 'inch_per_hour', + 'map_to': 'rainRate'}, + 'solarRadiationHigh': {'units': 'watt_per_meter_squared', + 'map_to': 'radiation'}, + 'uvHigh': {'units': 'uv_index', 'map_to': 'UV'} + } + _extras = ['pressureMin', 'pressureMax'] + + def __init__(self, config_dict, config_path, wu_config_dict, import_config_path, options): + + # call our parents __init__ + super(WUSource, self).__init__(config_dict, + wu_config_dict, + options) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.wu_config_dict = wu_config_dict + + # get our WU station ID + try: + self.station_id = wu_config_dict['station_id'] + except KeyError: + _msg = "Weather Underground station ID not specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # get our WU API key + try: + self.api_key = wu_config_dict['api_key'] + except KeyError: + _msg = "Weather Underground API key not specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # wind dir bounds + _wind_direction = option_as_list(wu_config_dict.get('wind_direction', + '0,360')) + try: + if float(_wind_direction[0]) <= float(_wind_direction[1]): + self.wind_dir = [float(_wind_direction[0]), + float(_wind_direction[1])] + else: + self.wind_dir = [0, 360] + except (IndexError, TypeError): + self.wind_dir = [0, 360] + + # some properties we know because of the format of the returned WU data + # WU returns a fixed format date-time string + self.raw_datetime_format = '%Y-%m-%d %H:%M:%S' + # WU only provides hourly rainfall and a daily cumulative rainfall. + # We use the latter so force 'cumulative' for rain. + self.rain = 'cumulative' + + # initialise our import field-to-WeeWX archive field map + self.map = None + # For a WU import we might have to import multiple days but we can only + # get one day at a time from WU. So our start and end properties + # (counters) are datetime objects and our increment is a timedelta. + # Get datetime objects for any date or date range specified on the + # command line, if there wasn't one then default to today. + self.start = dt.fromtimestamp(startOfDay(self.first_ts)) + self.end = dt.fromtimestamp(startOfDay(self.last_ts)) + # set our increment + self.increment = datetime.timedelta(days=1) + + # property holding the current period being processed + self.period = None + + # tell the user/log what we intend to do + _msg = "Observation history for Weather Underground station '%s' will be imported." % self.station_id + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if options.date: + _msg = " station=%s, date=%s" % (self.station_id, options.date) + else: + # we must have --from and --to + _msg = " station=%s, from=%s, to=%s" % (self.station_id, + options.date_from, + options.date_to) + if self.verbose: + print(_msg) + log.debug(_msg) + _obf_api_key_msg = '='.join([' apiKey', + '*'*(len(self.api_key) - 4) + self.api_key[-4:]]) + if self.verbose: + print(_obf_api_key_msg) + log.debug(_obf_api_key_msg) + _msg = " dry-run=%s, calc_missing=%s, ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s, wind_direction=%s" % (self.tranche, + self.interval, + self.wind_dir) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound to database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + if self.calc_missing: + print("Missing derived observations will be calculated.") + if options.date or options.date_from: + print("Observations timestamped after %s and up to and" % timestamp_to_string(self.first_ts)) + print("including %s will be imported." % timestamp_to_string(self.last_ts)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def getRawData(self, period): + """Get raw observation data and construct a map from WU to WeeWX + archive fields. + + Obtain raw observational data from WU via the WU API. This raw data + needs some basic processing to place it in a format suitable for + wee_import to ingest. + + Input parameters: + + period: a datetime object representing the day of WU data from + which raw obs data will be read. + """ + + # the date for which we want the WU data is held in a datetime object, + # we need to convert it to a timetuple + day_tt = period.timetuple() + # and then format the date suitable for use in the WU API URL + day = "%4d%02d%02d" % (day_tt.tm_year, + day_tt.tm_mon, + day_tt.tm_mday) + + # construct the URL to be used + url = "https://api.weather.com/v2/pws/history/all?" \ + "stationId=%s&format=json&units=e&numericPrecision=decimal&date=%s&apiKey=%s" \ + % (self.station_id, day, self.api_key) + # create a Request object using the constructed URL + request_obj = urllib.request.Request(url) + # add necessary headers + request_obj.add_header('Cache-Control', 'no-cache') + request_obj.add_header('Accept-Encoding', 'gzip') + # hit the API wrapping in a try..except to catch any errors + try: + response = urllib.request.urlopen(request_obj) + except urllib.error.URLError as e: + print("Unable to open Weather Underground station " + self.station_id, " or ", e, file=sys.stderr) + log.error("Unable to open Weather Underground station %s or %s" % (self.station_id, e)) + raise + except socket.timeout as e: + print("Socket timeout for Weather Underground station " + self.station_id, file=sys.stderr) + log.error("Socket timeout for Weather Underground station %s" % self.station_id) + print(" **** %s" % e, file=sys.stderr) + log.error(" **** %s" % e) + raise + # check the response code and raise an exception if there was an error + if hasattr(response, 'code') and response.code != 200: + if response.code == 204: + _msg = "Possibly a bad station ID, an invalid date or data does not exist for this period." + else: + _msg = "Bad response code returned: %d." % response.code + raise weeimport.WeeImportIOError(_msg) + + # The WU API says that compression is required, but let's be prepared + # if compression is not used + if response.info().get('Content-Encoding') == 'gzip': + buf = six.BytesIO(response.read()) + f = gzip.GzipFile(fileobj=buf) + # but what charset is in use + try: + char_set = response.headers.get_content_charset() + except AttributeError: + # must be python2 + char_set = response.headers.getparam('charset') + # get the raw data making sure we decode the charset + _raw_data = f.read().decode(char_set) + # decode the json data + _raw_decoded_data = json.loads(_raw_data) + else: + _raw_data = response + # decode the json data + _raw_decoded_data = json.load(_raw_data) + + # The raw WU response is not suitable to return as is, we need to + # return an iterable that provides a dict of observational data for each + # available timestamp. In this case a list of dicts is appropriate. + + # initialise a list of dicts + wu_data = [] + # first check we have some observational data + if 'observations' in _raw_decoded_data: + # iterate over each record in the WU data + for record in _raw_decoded_data['observations']: + # initialise a dict to hold the resulting data for this record + _flat_record = {} + # iterate over each WU API response field that we can use + _fields = list(self._header_map) + self._extras + for obs in _fields: + # The field may appear as a top level field in the WU data + # or it may be embedded in the dict in the WU data that + # contains variable unit data. Look in the top level record + # first. If its there uses it, otherwise look in the + # variable units dict. If it can't be fond then skip it. + if obs in record: + # it's in the top level record + _flat_record[obs] = record[obs] + else: + # it's not in the top level so look in the variable + # units dict + try: + _flat_record[obs] = record['imperial'][obs] + except KeyError: + # it's not there so skip it + pass + if obs == 'epoch': + # An epoch timestamp could be in seconds or + # milliseconds, WeeWX uses seconds. We can check by + # trying to convert the epoch value into a datetime + # object, if the epoch value is in milliseconds it will + # fail. In that case divide the epoch value by 1000. + # Note we would normally expect to see a ValueError but + # on armhf platforms we might see an OverflowError. + try: + _date = datetime.date.fromtimestamp(_flat_record['epoch']) + except (ValueError, OverflowError): + _flat_record['epoch'] = _flat_record['epoch'] // 1000 + # WU in its wisdom provides min and max pressure but no average + # pressure (unlike other obs) so we need to calculate it. If + # both min and max are numeric use a simple average of the two + # (they will likely be the same anyway for non-RF stations). + # Otherwise use max if numeric, then use min if numeric + # otherwise skip. + self.calc_pressure(_flat_record) + # append the data dict for the current record to the list of + # dicts for this period + wu_data.append(_flat_record) + # finally, get our database-source mapping + self.map = self.parseMap('WU', wu_data, self.wu_config_dict) + # return our dict + return wu_data + + @staticmethod + def calc_pressure(record): + """Calculate pressureAvg field. + + The WU API provides min and max pressure but no average pressure. + Calculate an average pressure to be used in the import using one of the + following (in order): + + 1. simple average of min and max pressure + 2. max pressure + 3. min pressure + 4. None + """ + + if 'pressureMin' in record and 'pressureMax' in record and isinstance(record['pressureMin'], numbers.Number) and isinstance(record['pressureMax'], numbers.Number): + record['pressureAvg'] = (record['pressureMin'] + record['pressureMax'])/2.0 + elif 'pressureMax' in record and isinstance(record['pressureMax'], numbers.Number): + record['pressureAvg'] = record['pressureMax'] + elif 'pressureMin' in record and isinstance(record['pressureMin'], numbers.Number): + record['pressureAvg'] = record['pressureMin'] + elif 'pressureMin' in record or 'pressureMax' in record: + record['pressureAvg'] = None + + def period_generator(self): + """Generator function yielding a sequence of datetime objects. + + This generator controls the FOR statement in the parents run() method + that loops over the WU days to be imported. The generator yields a + datetime object from the range of dates to be imported.""" + + self.period = self.start + while self.period <= self.end: + yield self.period + self.period += self.increment + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list or it is None (the initialisation value). + """ + + return self.period == self.start if self.period is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current period being processed is >= the end of the + WU import period. Return False if the current period is None (the + initialisation value). + """ + + return self.period >= self.end if self.period is not None else False diff --git a/dist/weewx-4.10.1/bin/weeplot/__init__.py b/dist/weewx-4.10.1/bin/weeplot/__init__.py new file mode 100644 index 0000000..fb2c6ac --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeplot/__init__.py @@ -0,0 +1,14 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Package weeplot. A set of modules for doing simple plots + +""" +# Define possible exceptions that could get thrown. + +class ViolatedPrecondition(Exception): + """Exception thrown when a function is called with violated preconditions. + + """ diff --git a/dist/weewx-4.10.1/bin/weeplot/genplot.py b/dist/weewx-4.10.1/bin/weeplot/genplot.py new file mode 100644 index 0000000..83b2d34 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeplot/genplot.py @@ -0,0 +1,734 @@ +# +# Copyright (c) 2009-2023 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Routines for generating image plots.""" +from __future__ import absolute_import + +import colorsys +import locale +import os +import time + +try: + from PIL import Image, ImageDraw +except ImportError: + import Image, ImageDraw + +from six.moves import zip + +import weeplot.utilities +from weeplot.utilities import tobgr +import weeutil.weeutil +from weeutil.weeutil import max_with_none, min_with_none, to_bool, to_text + + +# NB: PIL (and most fonts) expect text strings to be in Unicode. Hence, any place where a label can +# be set should be protected by a call to weeutil.weeutil.to_text() to make sure the label is in +# Unicode. + +class GeneralPlot(object): + """Holds various parameters necessary for a plot. It should be specialized by the type of plot. + """ + def __init__(self, plot_dict): + """Initialize an instance of GeneralPlot. + + plot_dict: an instance of ConfigObj, or something that looks like it. + """ + + self.line_list = [] + + self.xscale = (None, None, None) + self.yscale = (None, None, None) + + self.anti_alias = int(plot_dict.get('anti_alias', 1)) + + self.image_width = int(plot_dict.get('image_width', 300)) * self.anti_alias + self.image_height = int(plot_dict.get('image_height', 180)) * self.anti_alias + self.image_background_color = tobgr(plot_dict.get('image_background_color', '0xf5f5f5')) + + self.chart_background_color = tobgr(plot_dict.get('chart_background_color', '0xd8d8d8')) + self.chart_gridline_color = tobgr(plot_dict.get('chart_gridline_color', '0xa0a0a0')) + color_list = plot_dict.get('chart_line_colors', ['0xff0000', '0x00ff00', '0x0000ff']) + fill_color_list = plot_dict.get('chart_fill_colors', color_list) + width_list = plot_dict.get('chart_line_width', [1, 1, 1]) + self.chart_line_colors = [tobgr(v) for v in color_list] + self.chart_fill_colors = [tobgr(v) for v in fill_color_list] + self.chart_line_widths = [int(v) for v in width_list] + + + self.top_label_font_path = plot_dict.get('top_label_font_path') + self.top_label_font_size = int(plot_dict.get('top_label_font_size', 10)) * self.anti_alias + + self.unit_label = None + self.unit_label_font_path = plot_dict.get('unit_label_font_path') + self.unit_label_font_color = tobgr(plot_dict.get('unit_label_font_color', '0x000000')) + self.unit_label_font_size = int(plot_dict.get('unit_label_font_size', 10)) * self.anti_alias + self.unit_label_position = (10 * self.anti_alias, 0) + + self.bottom_label = u"" + self.bottom_label_font_path = plot_dict.get('bottom_label_font_path') + self.bottom_label_font_color= tobgr(plot_dict.get('bottom_label_font_color', '0x000000')) + self.bottom_label_font_size = int(plot_dict.get('bottom_label_font_size', 10)) * self.anti_alias + self.bottom_label_offset = int(plot_dict.get('bottom_label_offset', 3)) + + self.axis_label_font_path = plot_dict.get('axis_label_font_path') + self.axis_label_font_color = tobgr(plot_dict.get('axis_label_font_color', '0x000000')) + self.axis_label_font_size = int(plot_dict.get('axis_label_font_size', 10)) * self.anti_alias + + # Make sure the formats used for the x- and y-axes are in unicode. + self.x_label_format = to_text(plot_dict.get('x_label_format')) + self.y_label_format = to_text(plot_dict.get('y_label_format')) + + self.x_nticks = int(plot_dict.get('x_nticks', 10)) + self.y_nticks = int(plot_dict.get('y_nticks', 10)) + + self.x_label_spacing = int(plot_dict.get('x_label_spacing', 2)) + self.y_label_spacing = int(plot_dict.get('y_label_spacing', 2)) + + # Calculate sensible margins for the given image and font sizes. + self.y_label_side = plot_dict.get('y_label_side', 'left') + if self.y_label_side == 'left' or self.y_label_side == 'both': + self.lmargin = int(4.0 * self.axis_label_font_size) + else: + self.lmargin = 20 * self.anti_alias + if self.y_label_side == 'right' or self.y_label_side == 'both': + self.rmargin = int(4.0 * self.axis_label_font_size) + else: + self.rmargin = 20 * self.anti_alias + self.bmargin = int(1.5 * (self.bottom_label_font_size + self.axis_label_font_size) + 0.5) + self.tmargin = int(1.5 * self.top_label_font_size + 0.5) + self.tbandht = int(1.2 * self.top_label_font_size + 0.5) + self.padding = 3 * self.anti_alias + + self.render_rose = False + self.rose_width = int(plot_dict.get('rose_width', 21)) + self.rose_height = int(plot_dict.get('rose_height', 21)) + self.rose_diameter = int(plot_dict.get('rose_diameter', 10)) + self.rose_position = (self.lmargin + self.padding + 5, self.image_height - self.bmargin - self.padding - self.rose_height) + self.rose_rotation = None + self.rose_label = to_text(plot_dict.get('rose_label', u'N')) + self.rose_label_font_path = plot_dict.get('rose_label_font_path', self.bottom_label_font_path) + self.rose_label_font_size = int(plot_dict.get('rose_label_font_size', 10)) + self.rose_label_font_color = tobgr(plot_dict.get('rose_label_font_color', '0x000000')) + self.rose_line_width = int(plot_dict.get('rose_line_width', 1)) + self.rose_color = plot_dict.get('rose_color') + if self.rose_color is not None: + self.rose_color = tobgr(self.rose_color) + + # Show day/night transitions + self.show_daynight = to_bool(plot_dict.get('show_daynight', False)) + self.daynight_day_color = tobgr(plot_dict.get('daynight_day_color', '0xffffff')) + self.daynight_night_color = tobgr(plot_dict.get('daynight_night_color', '0xf0f0f0')) + self.daynight_edge_color = tobgr(plot_dict.get('daynight_edge_color', '0xefefef')) + self.daynight_gradient = int(plot_dict.get('daynight_gradient', 20)) + + # initialize the location + self.latitude = None + self.longitude = None + + # normalize the font paths relative to the skin directory + skin_dir = plot_dict.get('skin_dir', '') + self.top_label_font_path = self.normalize_path(skin_dir, self.top_label_font_path) + self.bottom_label_font_path = self.normalize_path(skin_dir, self.bottom_label_font_path) + self.unit_label_font_path = self.normalize_path(skin_dir, self.unit_label_font_path) + self.axis_label_font_path = self.normalize_path(skin_dir, self.axis_label_font_path) + self.rose_label_font_path = self.normalize_path(skin_dir, self.rose_label_font_path) + + @staticmethod + def normalize_path(skin_dir, path): + if path is None: + return None + return os.path.join(skin_dir, path) + + def setBottomLabel(self, bottom_label): + """Set the label to be put at the bottom of the plot. """ + # Make sure the label is in unicode or is None + self.bottom_label = to_text(bottom_label) + + def setUnitLabel(self, unit_label): + """Set the label to be used to show the units of the plot. """ + # Make sure the label is in unicode + self.unit_label = to_text(unit_label) + + def setXScaling(self, xscale): + """Set the X scaling. + + xscale: A 3-way tuple (xmin, xmax, xinc) + """ + self.xscale = xscale + + def setYScaling(self, yscale): + """Set the Y scaling. + + yscale: A 3-way tuple (ymin, ymax, yinc) + """ + self.yscale = yscale + + def addLine(self, line): + """Add a line to be plotted. + + line: an instance of PlotLine + """ + if None in line.x: + raise weeplot.ViolatedPrecondition("X vector cannot have any values 'None' ") + self.line_list.append(line) + + def setLocation(self, lat, lon): + self.latitude = lat + self.longitude = lon + + def setDayNight(self, showdaynight, daycolor, nightcolor, edgecolor): + """Configure day/night bands. + + showdaynight: Boolean flag indicating whether to draw day/night bands + + daycolor: color for day bands + + nightcolor: color for night bands + + edgecolor: color for transition between day and night + """ + self.show_daynight = showdaynight + self.daynight_day_color = daycolor + self.daynight_night_color = nightcolor + self.daynight_edge_color = edgecolor + + def render(self): + """Traverses the universe of things that have to be plotted in this image, rendering + them and returning the results as a new Image object. + """ + + # NB: In what follows the variable 'draw' is an instance of an ImageDraw object and is in pixel units. + # The variable 'sdraw' is an instance of ScaledDraw and its units are in the "scaled" units of the plot + # (e.g., the horizontal scaling might be for seconds, the vertical for degrees Fahrenheit.) + image = Image.new("RGB", (self.image_width, self.image_height), self.image_background_color) + draw = self._getImageDraw(image) + draw.rectangle(((self.lmargin,self.tmargin), + (self.image_width - self.rmargin, self.image_height - self.bmargin)), + fill=self.chart_background_color) + + self._renderBottom(draw) + self._renderTopBand(draw) + + self._calcXScaling() + self._calcYScaling() + self._calcXLabelFormat() + self._calcYLabelFormat() + + sdraw = self._getScaledDraw(draw) + if self.show_daynight: + self._renderDayNight(sdraw) + self._renderXAxes(sdraw) + self._renderYAxes(sdraw) + self._renderPlotLines(sdraw) + if self.render_rose: + self._renderRose(image, draw) + + if self.anti_alias != 1: + image.thumbnail((self.image_width / self.anti_alias, self.image_height / self.anti_alias), Image.ANTIALIAS) + + return image + + # noinspection PyMethodMayBeStatic + def _getImageDraw(self, image): + """Returns an instance of ImageDraw with the proper dimensions and background color""" + draw = UniDraw(image) + return draw + + def _getScaledDraw(self, draw): + """Returns an instance of ScaledDraw, with the appropriate scaling. + + draw: An instance of ImageDraw + """ + sdraw = weeplot.utilities.ScaledDraw( + draw, + ( + (self.lmargin + self.padding, self.tmargin + self.padding), + (self.image_width - self.rmargin - self.padding, self.image_height - self.bmargin - self.padding) + ), + ( + (self.xscale[0], self.yscale[0]), + (self.xscale[1], self.yscale[1]) + ) + ) + return sdraw + + def _renderDayNight(self, sdraw): + """Draw vertical bands for day/night.""" + (first, transitions) = weeutil.weeutil.getDayNightTransitions( + self.xscale[0], self.xscale[1], self.latitude, self.longitude) + color = self.daynight_day_color \ + if first == 'day' else self.daynight_night_color + xleft = self.xscale[0] + for x in transitions: + sdraw.rectangle(((xleft,self.yscale[0]), + (x,self.yscale[1])), fill=color) + xleft = x + color = self.daynight_night_color \ + if color == self.daynight_day_color else self.daynight_day_color + sdraw.rectangle(((xleft,self.yscale[0]), + (self.xscale[1],self.yscale[1])), fill=color) + if self.daynight_gradient: + if first == 'day': + color1 = self.daynight_day_color + color2 = self.daynight_night_color + else: + color1 = self.daynight_night_color + color2 = self.daynight_day_color + nfade = self.daynight_gradient + # gradient is longer at the poles than the equator + d = 120 + 300 * (1 - (90.0 - abs(self.latitude)) / 90.0) + for i in range(len(transitions)): + last_ = self.xscale[0] if i == 0 else transitions[i-1] + next_ = transitions[i+1] if i < len(transitions)-1 else self.xscale[1] + for z in range(1,nfade): + c = blend_hls(color2, color1, float(z)/float(nfade)) + rgbc = int2rgbstr(c) + x1 = transitions[i]-d*(nfade+1)/2+d*z + if last_ < x1 < next_: + sdraw.rectangle(((x1, self.yscale[0]), + (x1+d, self.yscale[1])), + fill=rgbc) + if color1 == self.daynight_day_color: + color1 = self.daynight_night_color + color2 = self.daynight_day_color + else: + color1 = self.daynight_day_color + color2 = self.daynight_night_color + # draw a line at the actual sunrise/sunset + for x in transitions: + sdraw.line((x,x),(self.yscale[0],self.yscale[1]), + fill=self.daynight_edge_color) + + def _renderXAxes(self, sdraw): + """Draws the x axis and vertical constant-x lines, as well as the labels. """ + + axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path, + self.axis_label_font_size) + + drawlabelcount = 0 + for x in weeutil.weeutil.stampgen(self.xscale[0], self.xscale[1], self.xscale[2]) : + sdraw.line((x, x), + (self.yscale[0], self.yscale[1]), + fill=self.chart_gridline_color, + width=self.anti_alias) + if drawlabelcount % self.x_label_spacing == 0 : + xlabel = self._genXLabel(x) + axis_label_size = sdraw.draw.textsize(xlabel, font=axis_label_font) + xpos = sdraw.xtranslate(x) + sdraw.draw.text((xpos - axis_label_size[0]/2, self.image_height - self.bmargin + 2), + xlabel, fill=self.axis_label_font_color, font=axis_label_font) + drawlabelcount += 1 + + def _renderYAxes(self, sdraw): + """Draws the y axis and horizontal constant-y lines, as well as the labels. + Should be sufficient for most purposes. + """ + nygridlines = int((self.yscale[1] - self.yscale[0]) / self.yscale[2] + 1.5) + axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path, + self.axis_label_font_size) + + # Draw the (constant y) grid lines + for i in range(nygridlines) : + y = self.yscale[0] + i * self.yscale[2] + sdraw.line((self.xscale[0], self.xscale[1]), (y, y), fill=self.chart_gridline_color, + width=self.anti_alias) + # Draw a label on every other line: + if i % self.y_label_spacing == 0 : + ylabel = self._genYLabel(y) + axis_label_size = sdraw.draw.textsize(ylabel, font=axis_label_font) + ypos = sdraw.ytranslate(y) + if self.y_label_side == 'left' or self.y_label_side == 'both': + sdraw.draw.text( (self.lmargin - axis_label_size[0] - 2, ypos - axis_label_size[1]/2), + ylabel, fill=self.axis_label_font_color, font=axis_label_font) + if self.y_label_side == 'right' or self.y_label_side == 'both': + sdraw.draw.text( (self.image_width - self.rmargin + 4, ypos - axis_label_size[1]/2), + ylabel, fill=self.axis_label_font_color, font=axis_label_font) + + def _renderPlotLines(self, sdraw): + """Draw the collection of lines, using a different color for each one. Because there is + a limited set of colors, they need to be recycled if there are very many lines. + """ + nlines = len(self.line_list) + ncolors = len(self.chart_line_colors) + nfcolors = len(self.chart_fill_colors) + nwidths = len(self.chart_line_widths) + + # Draw them in reverse order, so the first line comes out on top of the image + for j, this_line in enumerate(self.line_list[::-1]): + + iline=nlines-j-1 + color = self.chart_line_colors[iline%ncolors] if this_line.color is None else this_line.color + fill_color = self.chart_fill_colors[iline%nfcolors] if this_line.fill_color is None else this_line.fill_color + width = (self.chart_line_widths[iline%nwidths] if this_line.width is None else this_line.width) * self.anti_alias + + # Calculate the size of a gap in data + maxdx = None + if this_line.line_gap_fraction is not None: + maxdx = this_line.line_gap_fraction * (self.xscale[1] - self.xscale[0]) + + if this_line.plot_type == 'line': + ms = this_line.marker_size + if ms is not None: + ms *= self.anti_alias + sdraw.line(this_line.x, + this_line.y, + line_type=this_line.line_type, + marker_type=this_line.marker_type, + marker_size=ms, + fill = color, + width = width, + maxdx = maxdx) + elif this_line.plot_type == 'bar' : + for x, y, bar_width in zip(this_line.x, this_line.y, this_line.bar_width): + if y is None: + continue + sdraw.rectangle(((x - bar_width, self.yscale[0]), (x, y)), fill=fill_color, outline=color) + elif this_line.plot_type == 'vector' : + for (x, vec) in zip(this_line.x, this_line.y): + sdraw.vector(x, vec, + vector_rotate = this_line.vector_rotate, + fill = color, + width = width) + self.render_rose = True + self.rose_rotation = this_line.vector_rotate + if self.rose_color is None: + self.rose_color = color + + def _renderBottom(self, draw): + """Draw anything at the bottom (just some text right now). """ + bottom_label_font = weeplot.utilities.get_font_handle(self.bottom_label_font_path, + self.bottom_label_font_size) + bottom_label_size = draw.textsize(self.bottom_label, font=bottom_label_font) + + draw.text(((self.image_width - bottom_label_size[0])/2, + self.image_height - bottom_label_size[1] - self.bottom_label_offset), + self.bottom_label, + fill=self.bottom_label_font_color, + font=bottom_label_font) + + def _renderTopBand(self, draw): + """Draw the top band and any text in it. """ + # Draw the top band rectangle + draw.rectangle(((0,0), + (self.image_width, self.tbandht)), + fill = self.chart_background_color) + + # Put the units in the upper left corner + unit_label_font = weeplot.utilities.get_font_handle(self.unit_label_font_path, + self.unit_label_font_size) + if self.unit_label: + if self.y_label_side == 'left' or self.y_label_side == 'both': + draw.text(self.unit_label_position, + self.unit_label, + fill=self.unit_label_font_color, + font=unit_label_font) + if self.y_label_side == 'right' or self.y_label_side == 'both': + unit_label_position_right = (self.image_width - self.rmargin + 4, 0) + draw.text(unit_label_position_right, + self.unit_label, + fill=self.unit_label_font_color, + font=unit_label_font) + + top_label_font = weeplot.utilities.get_font_handle(self.top_label_font_path, + self.top_label_font_size) + + # The top label is the appended label_list. However, it has to be drawn in segments + # because each label may be in a different color. For now, append them together to get + # the total width + top_label = u' '.join([line.label for line in self.line_list]) + top_label_size = draw.textsize(top_label, font=top_label_font) + + x = (self.image_width - top_label_size[0])/2 + y = 0 + + ncolors = len(self.chart_line_colors) + for i, this_line in enumerate(self.line_list): + color = self.chart_line_colors[i%ncolors] if this_line.color is None else this_line.color + # Draw a label + draw.text( (x,y), this_line.label, fill = color, font = top_label_font) + # Now advance the width of the label we just drew, plus a space: + label_size = draw.textsize(this_line.label + u' ', font= top_label_font) + x += label_size[0] + + def _renderRose(self, image, draw): + """Draw a compass rose.""" + + rose_center_x = self.rose_width/2 + 1 + rose_center_y = self.rose_height/2 + 1 + barb_width = 3 + barb_height = 3 + # The background is all white with a zero alpha (totally transparent) + rose_image = Image.new("RGBA", (self.rose_width, self.rose_height), (0x00, 0x00, 0x00, 0x00)) + rose_draw = ImageDraw.Draw(rose_image) + + fill_color = add_alpha(self.rose_color) + # Draw the arrow straight up (North). First the shaft: + rose_draw.line( ((rose_center_x, 0), (rose_center_x, self.rose_height)), + width = self.rose_line_width, + fill = fill_color) + # Now the left barb: + rose_draw.line( ((rose_center_x - barb_width, barb_height), (rose_center_x, 0)), + width = self.rose_line_width, + fill = fill_color) + # And the right barb: + rose_draw.line( ((rose_center_x, 0), (rose_center_x + barb_width, barb_height)), + width = self.rose_line_width, + fill = fill_color) + + rose_draw.ellipse(((rose_center_x - self.rose_diameter/2, + rose_center_y - self.rose_diameter/2), + (rose_center_x + self.rose_diameter/2, + rose_center_y + self.rose_diameter/2)), + outline = fill_color) + + # Rotate if necessary: + if self.rose_rotation: + rose_image = rose_image.rotate(self.rose_rotation) + rose_draw = ImageDraw.Draw(rose_image) + + # Calculate the position of the "N" label: + rose_label_font = weeplot.utilities.get_font_handle(self.rose_label_font_path, + self.rose_label_font_size) + rose_label_size = draw.textsize(self.rose_label, font=rose_label_font) + + # Draw the label in the middle of the (possibly) rotated arrow + rose_draw.text((rose_center_x - rose_label_size[0]/2 - 1, + rose_center_y - rose_label_size[1]/2 - 1), + self.rose_label, + fill = add_alpha(self.rose_label_font_color), + font = rose_label_font) + + # Paste the image of the arrow on to the main plot. The alpha + # channel of the image will be used as the mask. + # This will cause the arrow to overlay the background plot + image.paste(rose_image, self.rose_position, rose_image) + + def _calcXScaling(self): + """Calculates the x scaling. It will probably be specialized by + plots where the x-axis represents time. + """ + (xmin, xmax) = self._calcXMinMax() + + self.xscale = weeplot.utilities.scale(xmin, xmax, self.xscale, nsteps=self.x_nticks) + + def _calcYScaling(self): + """Calculates y scaling. Can be used 'as-is' for most purposes.""" + # The filter is necessary because unfortunately the value 'None' is not + # excluded from min and max (i.e., min(None, x) is not necessarily x). + # The try block is necessary because min of an empty list throws a + # ValueError exception. + ymin = ymax = None + for line in self.line_list: + if line.plot_type == 'vector': + try: + # For progressive vector plots, we want the magnitude of the complex vector + yline_max = max(abs(c) for c in [v for v in line.y if v is not None]) + except ValueError: + yline_max = None + yline_min = - yline_max if yline_max is not None else None + else: + yline_min = min_with_none(line.y) + yline_max = max_with_none(line.y) + ymin = min_with_none([ymin, yline_min]) + ymax = max_with_none([ymax, yline_max]) + + if ymin is None and ymax is None : + # No valid data. Pick an arbitrary scaling + self.yscale=(0.0, 1.0, 0.2) + else: + self.yscale = weeplot.utilities.scale(ymin, ymax, self.yscale, nsteps=self.y_nticks) + + def _calcXLabelFormat(self): + if self.x_label_format is None: + self.x_label_format = weeplot.utilities.pickLabelFormat(self.xscale[2]) + + def _calcYLabelFormat(self): + if self.y_label_format is None: + self.y_label_format = weeplot.utilities.pickLabelFormat(self.yscale[2]) + + def _genXLabel(self, x): + xlabel = locale.format_string(self.x_label_format, x) + return xlabel + + def _genYLabel(self, y): + ylabel = locale.format_string(self.y_label_format, y) + return ylabel + + def _calcXMinMax(self): + xmin = xmax = None + for line in self.line_list: + xline_min = min_with_none(line.x) + xline_max = max_with_none(line.x) + # If the line represents a bar chart, then the actual minimum has to + # be adjusted for the bar width of the first point + if line.plot_type == 'bar': + xline_min = xline_min - line.bar_width[0] + xmin = min_with_none([xmin, xline_min]) + xmax = max_with_none([xmax, xline_max]) + return xmin, xmax + + +class TimePlot(GeneralPlot) : + """Class that specializes GeneralPlot for plots where the x-axis is time.""" + + def _calcXScaling(self): + """Specialized version for time plots.""" + if None in self.xscale: + (xmin, xmax) = self._calcXMinMax() + self.xscale = weeplot.utilities.scaletime(xmin, xmax) + + def _calcXLabelFormat(self): + """Specialized version for time plots. Assumes that time is in unix epoch time.""" + if self.x_label_format is None: + (xmin, xmax) = self._calcXMinMax() + if xmin is not None and xmax is not None: + delta = xmax - xmin + if delta > 30*24*3600: + self.x_label_format = u"%x" + elif delta > 24*3600: + self.x_label_format = u"%x %X" + else: + self.x_label_format = u"%X" + + def _genXLabel(self, x): + """Specialized version for time plots. Assumes that time is in unix epoch time.""" + if self.x_label_format is None: + return u'' + time_tuple = time.localtime(x) + # There are still some strftimes out there that don't support Unicode. + try: + xlabel = time.strftime(self.x_label_format, time_tuple) + except UnicodeEncodeError: + # Convert it to UTF8, then back again: + xlabel = time.strftime(self.x_label_format.encode('utf-8'), time_tuple).decode('utf-8') + return xlabel + + +class PlotLine(object): + """Represents a single line (or bar) in a plot. """ + def __init__(self, x, y, label='', color=None, fill_color=None, width=None, plot_type='line', + line_type='solid', marker_type=None, marker_size=10, + bar_width=None, vector_rotate = None, line_gap_fraction=None): + self.x = x + self.y = y + self.label = to_text(label) # Make sure the label is in unicode + self.plot_type = plot_type + self.line_type = line_type + self.marker_type = marker_type + self.marker_size = marker_size + self.color = color + self.fill_color = fill_color + self.width = width + self.bar_width = bar_width + self.vector_rotate = vector_rotate + self.line_gap_fraction = line_gap_fraction + + +class UniDraw(ImageDraw.ImageDraw): + """Supports non-Unicode fonts + + Not all fonts support Unicode characters. These will raise a UnicodeEncodeError exception. + This class subclasses the regular ImageDraw.Draw class, adding overridden functions to + catch these exceptions. It then tries drawing the string again, this time as a UTF8 string + """ + + def text(self, position, string, **options): + try: + return ImageDraw.ImageDraw.text(self, position, string, **options) + except UnicodeEncodeError: + return ImageDraw.ImageDraw.text(self, position, string.encode('utf-8'), **options) + + def textsize(self, string, **options): + try: + return ImageDraw.ImageDraw.textsize(self, string, **options) + except UnicodeEncodeError: + return ImageDraw.ImageDraw.textsize(self, string.encode('utf-8'), **options) + + +def blend_hls(c, bg, alpha): + """Fade from c to bg using alpha channel where 1 is solid and 0 is + transparent. This fades across the hue, saturation, and lightness.""" + return blend(c, bg, alpha, alpha, alpha) + + +def blend_ls(c, bg, alpha): + """Fade from c to bg where 1 is solid and 0 is transparent. + Change only the lightness and saturation, not hue.""" + return blend(c, bg, 1.0, alpha, alpha) + + +def blend(c, bg, alpha_h, alpha_l, alpha_s): + """Fade from c to bg in the hue, lightness, saturation colorspace. + Added hue directionality to choose shortest circular hue path e.g. + https://stackoverflow.com/questions/1416560/hsl-interpolation + Also, grey detection to minimize colour wheel travel. Interesting resource: + http://davidjohnstone.net/pages/lch-lab-colour-gradient-picker + """ + + r1,g1,b1 = int2rgb(c) + h1,l1,s1 = colorsys.rgb_to_hls(r1/255.0, g1/255.0, b1/255.0) + + r2,g2,b2 = int2rgb(bg) + h2,l2,s2 = colorsys.rgb_to_hls(r2/255.0, g2/255.0, b2/255.0) + + # Check if either of the values is grey (saturation 0), + # in which case don't needlessly reset hue to '0', reducing travel around colour wheel + if s1 == 0: h1 = h2 + if s2 == 0: h2 = h1 + + h_delta = h2 - h1 + + if abs(h_delta) > 0.5: + # If interpolating over more than half-circle (0.5 radians) take shorter, opposite direction... + h_range = 1.0 - abs(h_delta) + h_dir = +1.0 if h_delta < 0.0 else -1.0 + + # Calculte h based on line back from h2 as proportion of h_range and alpha + h = h2 - ( h_dir * h_range * alpha_h ) + + # Clamp h within 0.0 to 1.0 range + h = h + 1.0 if h < 0.0 else h + h = h - 1.0 if h > 1.0 else h + else: + # Interpolating over less than a half-circle, so use normal interpolation as before + h = alpha_h * h1 + (1 - alpha_h) * h2 + + l = alpha_l * l1 + (1 - alpha_l) * l2 + s = alpha_s * s1 + (1 - alpha_s) * s2 + + r,g,b = colorsys.hls_to_rgb(h, l, s) + + r = round(r * 255.0) + g = round(g * 255.0) + b = round(b * 255.0) + + t = rgb2int(int(r),int(g),int(b)) + + return int(t) + + +def int2rgb(x): + b = (x >> 16) & 0xff + g = (x >> 8) & 0xff + r = x & 0xff + return r,g,b + + +def int2rgbstr(x): + return '#%02x%02x%02x' % int2rgb(x) + + +def rgb2int(r,g,b): + return r + g*256 + b*256*256 + + +def add_alpha(i): + """Add an opaque alpha channel to an integer RGB value""" + r = i & 0xff + g = (i >> 8) & 0xff + b = (i >> 16) & 0xff + a = 0xff # Opaque alpha + return r,g,b,a diff --git a/dist/weewx-4.10.1/bin/weeplot/utilities.py b/dist/weewx-4.10.1/bin/weeplot/utilities.py new file mode 100644 index 0000000..73e955b --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeplot/utilities.py @@ -0,0 +1,642 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Various utilities used by the plot package. + +""" +from __future__ import absolute_import +from __future__ import print_function +from six.moves import zip + +try: + from PIL import ImageFont, ImageColor +except ImportError: + import ImageFont, ImageColor +import datetime +import time +import math + +import six + +import weeplot + + +def scale(data_min, data_max, prescale=(None, None, None), nsteps=10): + """Calculates an appropriate min, max, and step size for scaling axes on a plot. + + The origin (zero) is guaranteed to be on an interval boundary. + + data_min: The minimum data value + + data_max: The maximum data value. Must be greater than or equal to data_min. + + prescale: A 3-way tuple. A non-None min or max value (positions 0 and 1, + respectively) will be fixed to that value. A non-None interval (position 2) + be at least as big as that value. Default = (None, None, None) + + nsteps: The nominal number of desired steps. Default = 10 + + Returns: a three-way tuple. First value is the lowest scale value, second the highest. + The third value is the step (increment) between them. + + Examples: + >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3, (0, 14, 2))) + (0.0, 14.0, 2.0) + >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3)) + (0.0, 14.0, 2.0) + >>> print("(%.1f, %.1f, %.1f)" % scale(-1.1, 12.3)) + (-2.0, 14.0, 2.0) + >>> print("(%.1f, %.1f, %.1f)" % scale(-12.1, -5.3)) + (-13.0, -5.0, 1.0) + >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0)) + (10.00, 10.10, 0.01) + >>> print("(%.2f, %.4f, %.4f)" % scale(10.0, 10.001)) + (10.00, 10.0010, 0.0001) + >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0+1e-8)) + (10.00, 10.10, 0.01) + >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.05, (None, None, .1), 10)) + (0.00, 1.00, 0.10) + >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 10)) + (16.00, 36.00, 2.00) + >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 4)) + (16.00, 22.00, 2.00) + >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.21, (None, None, .02))) + (0.00, 0.22, 0.02) + >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (None, 100, None))) + (99.00, 100.00, 0.20) + >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (100, None, None))) + (100.00, 101.00, 0.20) + >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (0, None, None))) + (0.00, 120.00, 20.00) + >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.2, (None, 100, None))) + (0.00, 100.00, 20.00) + + """ + + # If all the values are hard-wired in, then there's nothing to do: + if None not in prescale: + return prescale + + # Unpack + minscale, maxscale, min_interval = prescale + + # Make sure data_min and data_max are float values, in case a user passed + # in integers: + data_min = float(data_min) + data_max = float(data_max) + + if data_max < data_min: + raise weeplot.ViolatedPrecondition("scale() called with max value less than min value") + + # In case minscale and/or maxscale was specified, clip data_min and data_max to make sure they + # stay within bounds + if maxscale is not None: + data_max = min(data_max, maxscale) + if data_max < data_min: + data_min = data_max + if minscale is not None: + data_min = max(data_min, minscale) + if data_max < data_min: + data_max = data_min + + # Check the special case where the min and max values are equal. + if _rel_approx_equal(data_min, data_max): + # They are equal. We need to move one or the other to create a range, while + # being careful that the resultant min/max stay within the interval [minscale, maxscale] + # Pick a step out value based on min_interval if the user has supplied one. Otherwise, + # arbitrarily pick 0.1 + if min_interval is not None: + step_out = min_interval * nsteps + else: + step_out = 0.01 * round(abs(data_max), 2) if data_max else 0.1 + if maxscale is not None: + # maxscale if fixed. Move data_min. + data_min = data_max - step_out + elif minscale is not None: + # minscale if fixed. Move data_max. + data_max = data_min + step_out + else: + # Both can float. Check special case where data_min and data_max are zero + if data_min == 0.0: + data_max = 1.0 + else: + # Just arbitrarily move one. Say, data_max. + data_max = data_min + step_out + + if minscale is not None and maxscale is not None: + if maxscale < minscale: + raise weeplot.ViolatedPrecondition("scale() called with prescale max less than min") + frange = maxscale - minscale + elif minscale is not None: + frange = data_max - minscale + elif maxscale is not None: + frange = maxscale - data_min + else: + frange = data_max - data_min + steps = frange / float(nsteps) + + mag = math.floor(math.log10(steps)) + magPow = math.pow(10.0, mag) + magMsd = math.floor(steps / magPow + 0.5) + + if magMsd > 5.0: + magMsd = 10.0 + elif magMsd > 2.0: + magMsd = 5.0 + else: # magMsd > 1.0 + magMsd = 2 + + # This will be the nominal interval size + interval = magMsd * magPow + + # Test it against the desired minimum, if any + if min_interval is None or interval >= min_interval: + # Either no min interval was specified, or its safely + # less than the chosen interval. + if minscale is None: + minscale = interval * math.floor(data_min / interval) + + if maxscale is None: + maxscale = interval * math.ceil(data_max / interval) + + else: + + # The request for a minimum interval has kicked in. + # Sometimes this can make for a plot with just one or + # two intervals in it. Adjust the min and max values + # to get a nice plot + interval = float(min_interval) + + if minscale is None: + if maxscale is None: + # Both can float. Pick values so the range is near the bottom + # of the scale: + minscale = interval * math.floor(data_min / interval) + maxscale = minscale + interval * nsteps + else: + # Only minscale can float + minscale = maxscale - interval * nsteps + else: + if maxscale is None: + # Only maxscale can float + maxscale = minscale + interval * nsteps + else: + # Both are fixed --- nothing to be done + pass + + return minscale, maxscale, interval + + +def scaletime(tmin_ts, tmax_ts): + """Picks a time scaling suitable for a time plot. + + tmin_ts, tmax_ts: The time stamps in epoch time around which the times will be picked. + + Returns a scaling 3-tuple. First element is the start time, second the stop + time, third the increment. All are in seconds (epoch time in the case of the + first two). + + Example 1: 24 hours on an hour boundary + >>> from weeutil.weeutil import timestamp_to_string as to_string + >>> time_ts = time.mktime(time.strptime("2013-05-17 08:00", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800 + + Example 2: 24 hours on a 3-hour boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 09:00", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800 + + Example 3: 24 hours on a non-hour boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 09:01", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 12:00:00 PDT (1368730800) 2013-05-17 12:00:00 PDT (1368817200) 10800 + + Example 4: 27 hours + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 27*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 06:00:00 PDT (1368709200) 2013-05-17 09:00:00 PDT (1368806400) 10800 + + Example 5: 3 hours on a 15 minute boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900 + + Example 6: 3 hours on a non-15 minute boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900 + + Example 7: 12 hours + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 12*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 20:00:00 PDT (1368759600) 2013-05-17 08:00:00 PDT (1368802800) 3600 + + Example 8: 15 hours + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 15*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 17:00:00 PDT (1368748800) 2013-05-17 08:00:00 PDT (1368802800) 7200 + """ + if tmax_ts <= tmin_ts: + raise weeplot.ViolatedPrecondition("scaletime called with tmax <= tmin") + + tdelta = tmax_ts - tmin_ts + + tmin_dt = datetime.datetime.fromtimestamp(tmin_ts) + tmax_dt = datetime.datetime.fromtimestamp(tmax_ts) + + if tdelta <= 16 * 3600: + if tdelta <= 3 * 3600: + # For time intervals less than 3 hours, use an increment of 15 minutes + interval = 900 + elif tdelta <= 12 * 3600: + # For intervals from 3 hours up through 12 hours, use one hour + interval = 3600 + else: + # For intervals from 12 through 16 hours, use two hours. + interval = 7200 + # Get to the one hour boundary below tmax: + stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) + # if tmax happens to be on a one hour boundary we're done. Otherwise, round + # up to the next one hour boundary: + if tmax_dt > stop_dt: + stop_dt += datetime.timedelta(hours=1) + n_hours = int((tdelta + 3599) / 3600) + start_dt = stop_dt - datetime.timedelta(hours=n_hours) + + elif tdelta <= 27 * 3600: + # A day plot is wanted. A time increment of 3 hours is appropriate + interval = 3 * 3600 + # h is the hour of tmax_dt + h = tmax_dt.timetuple()[3] + # Subtract off enough to get to the lower 3-hour boundary from tmax: + stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) \ + - datetime.timedelta(hours=h % 3) + # If tmax happens to lie on a 3 hour boundary we don't need to do anything. If not, we need + # to round up to the next 3 hour boundary: + if tmax_dt > stop_dt: + stop_dt += datetime.timedelta(hours=3) + # The stop time is one day earlier + start_dt = stop_dt - datetime.timedelta(days=1) + + if tdelta == 27 * 3600: + # A "slightly more than a day plot" is wanted. Start 3 hours earlier: + start_dt -= datetime.timedelta(hours=3) + + elif 27 * 3600 < tdelta <= 31 * 24 * 3600: + # The time scale is between a day and a month. A time increment of one day is appropriate + start_dt = tmin_dt.replace(hour=0, minute=0, second=0, microsecond=0) + stop_dt = tmax_dt.replace(hour=0, minute=0, second=0, microsecond=0) + + tmax_tt = tmax_dt.timetuple() + if tmax_tt[3] != 0 or tmax_tt[4] != 0: + stop_dt += datetime.timedelta(days=1) + + interval = 24 * 3600 + elif tdelta <= 2 * 365.25 * 24 * 3600: + # The time scale is between a month and 2 years, inclusive. A time increment of a month + # is appropriate + start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + year, mon, day = tmax_dt.timetuple()[0:3] + if day != 1: + mon += 1 + if mon == 13: + mon = 1 + year += 1 + stop_dt = datetime.datetime(year, mon, 1) + # Average month length: + interval = 365.25 / 12 * 24 * 3600 + else: + # The time scale is over 2 years. A time increment of six months is appropriate + start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + year, mon, day = tmax_dt.timetuple()[0:3] + if day != 1 or mon != 1: + day = 1 + mon = 1 + year += 1 + stop_dt = datetime.datetime(year, mon, 1) + # Average length of six months + interval = 365.25 * 24 * 3600 / 2.0 + + # Convert to epoch time stamps + start_ts = int(time.mktime(start_dt.timetuple())) + stop_ts = int(time.mktime(stop_dt.timetuple())) + + return start_ts, stop_ts, interval + + +class ScaledDraw(object): + """Like an ImageDraw object, but lines are scaled. + + """ + + def __init__(self, draw, imagebox, scaledbox): + """Initialize a ScaledDraw object. + + Example: + scaledraw = ScaledDraw(draw, ((10, 10), (118, 246)), ((0.0, 0.0), (10.0, 1.0))) + + would create a scaled drawing where the upper-left image coordinate (10, 10) would + correspond to the scaled coordinate( 0.0, 1.0). The lower-right image coordinate + would correspond to the scaled coordinate (10.0, 0.0). + + draw: an instance of ImageDraw + + imagebox: a 2-tuple of the box coordinates on the image ((ulx, uly), (lrx, lry)) + + scaledbox: a 2-tuple of the box coordinates of the scaled plot ((llx, lly), (urx, ury)) + + """ + uli = imagebox[0] + lri = imagebox[1] + lls = scaledbox[0] + urs = scaledbox[1] + if urs[1] == lls[1]: + pass + self.xscale = float(lri[0] - uli[0]) / float(urs[0] - lls[0]) + self.yscale = -float(lri[1] - uli[1]) / float(urs[1] - lls[1]) + self.xoffset = int(lri[0] - urs[0] * self.xscale + 0.5) + self.yoffset = int(uli[1] - urs[1] * self.yscale + 0.5) + + self.draw = draw + + def line(self, x, y, line_type='solid', marker_type=None, marker_size=8, maxdx=None, + **options): + """Draw a scaled line on the instance's ImageDraw object. + + x: sequence of x coordinates + + y: sequence of y coordinates, some of which are possibly null (value of None) + + line_type: 'solid' for line that connect the coordinates + None for no line + + marker_type: None or 'none' for no marker. + 'cross' for a cross + 'circle' for a circle + 'box' for a box + 'x' for an X + + maxdx: defines what constitutes a gap in samples. if two data points + are more than maxdx apart they are treated as separate segments. + + For a scatter plot, set line_type to None and marker_type to something other than None. + """ + # Break the line up around any nulls or gaps between samples + for xy_seq in xy_seq_line(x, y, maxdx): + # Create a list with the scaled coordinates... + xy_seq_scaled = [(self.xtranslate(xc), self.ytranslate(yc)) for (xc, yc) in xy_seq] + if line_type == 'solid': + # Now pick the appropriate drawing function, depending on the length of the line: + if len(xy_seq) == 1: + self.draw.point(xy_seq_scaled, fill=options['fill']) + else: + self.draw.line(xy_seq_scaled, **options) + if marker_type and marker_type.lower().strip() not in ['none', '']: + self.marker(xy_seq_scaled, marker_type, marker_size=marker_size, **options) + + def marker(self, xy_seq, marker_type, marker_size=10, **options): + half_size = marker_size / 2 + marker = marker_type.lower() + for x, y in xy_seq: + if marker == 'cross': + self.draw.line([(x - half_size, y), (x + half_size, y)], **options) + self.draw.line([(x, y - half_size), (x, y + half_size)], **options) + elif marker == 'x': + self.draw.line([(x - half_size, y - half_size), (x + half_size, y + half_size)], + **options) + self.draw.line([(x - half_size, y + half_size), (x + half_size, y - half_size)], + **options) + elif marker == 'circle': + self.draw.ellipse([(x - half_size, y - half_size), + (x + half_size, y + half_size)], outline=options['fill']) + elif marker == 'box': + self.draw.line([(x - half_size, y - half_size), + (x + half_size, y - half_size), + (x + half_size, y + half_size), + (x - half_size, y + half_size), + (x - half_size, y - half_size)], **options) + + def rectangle(self, box, **options): + """Draw a scaled rectangle. + + box: A pair of 2-way tuples, containing coordinates of opposing corners + of the box. + + options: passed on to draw.rectangle. Usually contains 'fill' (the color) + """ + box_scaled = [(coord[0] * self.xscale + self.xoffset + 0.5, + coord[1] * self.yscale + self.yoffset + 0.5) for coord in box] + self.draw.rectangle(box_scaled, **options) + + def vector(self, x, vec, vector_rotate, **options): + + if vec is None: + return + xstart_scaled = self.xtranslate(x) + ystart_scaled = self.ytranslate(0) + + vecinc_scaled = vec * self.yscale + + if vector_rotate: + vecinc_scaled *= complex(math.cos(math.radians(vector_rotate)), + math.sin(math.radians(vector_rotate))) + + # Subtract off the x increment because the x-axis + # *increases* to the right, unlike y, which increases + # downwards + xend_scaled = xstart_scaled - vecinc_scaled.real + yend_scaled = ystart_scaled + vecinc_scaled.imag + + self.draw.line(((xstart_scaled, ystart_scaled), (xend_scaled, yend_scaled)), **options) + + def xtranslate(self, x): + return int(x * self.xscale + self.xoffset + 0.5) + + def ytranslate(self, y): + return int(y * self.yscale + self.yoffset + 0.5) + + +def xy_seq_line(x, y, maxdx=None): + """Generator function that breaks a line up into individual segments around + any nulls held in y or any gaps in x greater than maxdx. + + x: iterable sequence of x coordinates. All values must be non-null + + y: iterable sequence of y coordinates, possibly with some embedded + nulls (that is, their value==None) + + yields: Lists of (x,y) coordinates + + Example 1 + >>> x=[ 1, 2, 3] + >>> y=[10, 20, 30] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + [(1, 10), (2, 20), (3, 30)] + + Example 2 + >>> x=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + >>> y=[0, 10, None, 30, None, None, 60, 70, 80, None] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + [(0, 0), (1, 10)] + [(3, 30)] + [(6, 60), (7, 70), (8, 80)] + + Example 3 + >>> x=[ 0 ] + >>> y=[None] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + + Example 4 + >>> x=[ 0, 1, 2] + >>> y=[None, None, None] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + + Example 5 (using gap) + >>> x=[0, 1, 2, 3, 5.1, 6, 7, 8, 9] + >>> y=[0, 10, 20, 30, 50, 60, 70, 80, 90] + >>> for xy_seq in xy_seq_line(x,y,2): + ... print(xy_seq) + [(0, 0), (1, 10), (2, 20), (3, 30)] + [(5.1, 50), (6, 60), (7, 70), (8, 80), (9, 90)] + """ + + line = [] + last_x = None + for xy in zip(x, y): + dx = xy[0] - last_x if last_x is not None else 0 + last_x = xy[0] + # If the y coordinate is None or dx > maxdx, that marks a break + if xy[1] is None or (maxdx is not None and dx > maxdx): + # If the length of the line is non-zero, yield it + if len(line): + yield line + line = [] if xy[1] is None else [xy] + else: + line.append(xy) + if len(line): + yield line + + +def pickLabelFormat(increment): + """Pick an appropriate label format for the given increment. + + Examples: + >>> print(pickLabelFormat(1)) + %.0f + >>> print(pickLabelFormat(20)) + %.0f + >>> print(pickLabelFormat(.2)) + %.1f + >>> print(pickLabelFormat(.01)) + %.2f + """ + + i_log = math.log10(increment) + if i_log < 0: + i_log = abs(i_log) + decimal_places = int(i_log) + if i_log != decimal_places: + decimal_places += 1 + else: + decimal_places = 0 + + return u"%%.%df" % decimal_places + + +def get_font_handle(fontpath, *args): + """Get a handle for a font path, caching the results""" + + # For Python 2, we want to make sure fontpath is a string, not unicode + fontpath_str = six.ensure_str(fontpath) if fontpath is not None else None + + # Look for the font in the cache + font_key = (fontpath_str, args) + if font_key in get_font_handle.fontCache: + return get_font_handle.fontCache[font_key] + + font = None + if fontpath_str is not None: + try: + if fontpath_str.endswith('.ttf'): + font = ImageFont.truetype(fontpath_str, *args) + else: + font = ImageFont.load_path(fontpath_str) + except IOError: + pass + + if font is None: + font = ImageFont.load_default() + if font is not None: + get_font_handle.fontCache[font_key] = font + return font + + +get_font_handle.fontCache = {} + + +def _rel_approx_equal(x, y, rel=1e-7): + """Relative test for equality. + + Example + >>> _rel_approx_equal(1.23456, 1.23457) + False + >>> _rel_approx_equal(1.2345678, 1.2345679) + True + >>> _rel_approx_equal(0.0, 0.0) + True + >>> _rel_approx_equal(0.0, 0.1) + False + >>> _rel_approx_equal(0.0, 1e-9) + False + >>> _rel_approx_equal(1.0, 1.0+1e-9) + True + >>> _rel_approx_equal(1e8, 1e8+1e-3) + True + """ + return abs(x - y) <= rel * max(abs(x), abs(y)) + + +def tobgr(x): + """Convert a color to little-endian integer. The PIL wants either + a little-endian integer (0xBBGGRR) or a string (#RRGGBB). weewx expects + little-endian integer. Accept any standard color format that is known + by ImageColor for example #RGB, #RRGGBB, hslHSL as well as standard color + names from X11 and CSS3. See ImageColor for complete set of colors. + """ + if isinstance(x, six.string_types): + if x.startswith('0x'): + return int(x, 0) + try: + r, g, b = ImageColor.getrgb(x) + return r + g * 256 + b * 256 * 256 + except ValueError: + try: + return int(x) + except ValueError: + raise ValueError("Unknown color specifier: '%s'. " + "Colors must be specified as 0xBBGGRR, #RRGGBB, or standard color names." % x) + return x + + +if __name__ == "__main__": + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weeutil/Moon.py b/dist/weewx-4.10.1/bin/weeutil/Moon.py new file mode 100644 index 0000000..be81e71 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/Moon.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2009-2019 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Given a date, determine the phase of the moon.""" + +from __future__ import absolute_import +import time +import math + +moon_phases = ["new (totally dark)", + "waxing crescent (increasing to full)", + "in its first quarter (increasing to full)", + "waxing gibbous (increasing to full)", + "full (full light)", + "waning gibbous (decreasing from full)", + "in its last quarter (decreasing from full)", + "waning crescent (decreasing from full)"] + +# First new moon of 2018: 17-Jan-2018 at 02:17 UTC +new_moon_2018 = 1516155420 + + +def moon_phase(year, month, day, hour=12): + """Calculates the phase of the moon, given a year, month, day. + + returns: a tuple. First value is an index into an array + of moon phases, such as Moon.moon_phases above. Second + value is the percent fullness of the moon. + """ + + # Convert to UTC + time_ts = time.mktime((year, month, day, hour, 0, 0, 0, 0, -1)) + + return moon_phase_ts(time_ts) + + +def moon_phase_ts(time_ts): + # How many days since the first moon of 2018 + delta_days = (time_ts - new_moon_2018) / 86400.0 + # Number of lunations + lunations = delta_days / 29.530588 + + # The fraction of the lunar cycle + position = float(lunations) % 1.0 + # The percent illumination, rounded to the nearest integer + fullness = int(100.0 * (1.0 - math.cos(2.0 * math.pi * position)) / 2.0 + 0.5) + index = int((position * 8) + 0.5) & 7 + + return index, fullness diff --git a/dist/weewx-4.10.1/bin/weeutil/Sun.py b/dist/weewx-4.10.1/bin/weeutil/Sun.py new file mode 100644 index 0000000..9b3e234 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/Sun.py @@ -0,0 +1,544 @@ +# -*- coding: iso-8859-1 -*- +""" +SUNRISET.C - computes Sun rise/set times, start/end of twilight, and + the length of the day at any date and latitude + +Written as DAYLEN.C, 1989-08-16 + +Modified to SUNRISET.C, 1992-12-01 + +(c) Paul Schlyter, 1989, 1992 + +Released to the public domain by Paul Schlyter, December 1992 + +Direct conversion to Java +Sean Russell + +Conversion to Python Class, 2002-03-21 +Henrik Hrknen + +Solar Altitude added by Miguel Tremblay 2005-01-16 +Solar flux, equation of time and import of python library + added by Miguel Tremblay 2007-11-22 + + +2007-12-12 - v1.5 by Miguel Tremblay: bug fix to solar flux calculation + +2009-03-27 - v1.6 by Tom Keffer; Got rid of the unnecessary (and stateless) + class Sun. Cleaned up. + + +""" + +from __future__ import absolute_import +from __future__ import print_function +SUN_PY_VERSION = "1.6.0" + +import math +from math import pi + +import calendar + +# Some conversion factors between radians and degrees +RADEG= 180.0 / pi +DEGRAD = pi / 180.0 +INV360 = 1.0 / 360.0 + +#Convenience functions for working in degrees: +# The trigonometric functions in degrees +def sind(x): + """Returns the sin in degrees""" + return math.sin(x * DEGRAD) + +def cosd(x): + """Returns the cos in degrees""" + return math.cos(x * DEGRAD) + +def tand(x): + """Returns the tan in degrees""" + return math.tan(x * DEGRAD) + +def atand(x): + """Returns the arc tan in degrees""" + return math.atan(x) * RADEG + +def asind(x): + """Returns the arc sin in degrees""" + return math.asin(x) * RADEG + +def acosd(x): + """Returns the arc cos in degrees""" + return math.acos(x) * RADEG + +def atan2d(y, x): + """Returns the atan2 in degrees""" + return math.atan2(y, x) * RADEG + + +def daysSince2000Jan0(y, m, d): + """A macro to compute the number of days elapsed since 2000 Jan 0.0 + (which is equal to 1999 Dec 31, 0h UT)""" + return 367.0 * y - ((7.0 * (y + ((m + 9.0) / 12.0))) / 4.0) + (275.0 * m / 9.0) + d - 730530.0 + +# Following are some macros around the "workhorse" function __daylen__ +# They mainly fill in the desired values for the reference altitude +# below the horizon, and also selects whether this altitude should +# refer to the Sun's center or its upper limb. + +def dayLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, from sunrise to sunset. + Sunrise/set is considered to occur when the Sun's upper limb is + 35 arc minutes below the horizon (this accounts for the refraction + of the Earth's atmosphere). + """ + return __daylen__(year, month, day, lon, lat, -35.0/60.0, 1) + + +def dayCivilTwilightLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, including civil twilight. + Civil twilight starts/ends when the Sun's center is 6 degrees below + the horizon. + """ + return __daylen__(year, month, day, lon, lat, -6.0, 0) + + +def dayNauticalTwilightLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, incl. nautical twilight. + Nautical twilight starts/ends when the Sun's center is 12 degrees + below the horizon. + """ + return __daylen__(year, month, day, lon, lat, -12.0, 0) + + +def dayAstronomicalTwilightLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, incl. astronomical twilight. + Astronomical twilight starts/ends when the Sun's center is 18 degrees + below the horizon. + """ + return __daylen__(year, month, day, lon, lat, -18.0, 0) + + +def sunRiseSet(year, month, day, lon, lat): + """ + This macro computes times for sunrise/sunset. + Sunrise/set is considered to occur when the Sun's upper limb is + 35 arc minutes below the horizon (this accounts for the refraction + of the Earth's atmosphere). + """ + return __sunriset__(year, month, day, lon, lat, -35.0/60.0, 1) + + +def civilTwilight(year, month, day, lon, lat): + """ + This macro computes the start and end times of civil twilight. + Civil twilight starts/ends when the Sun's center is 6 degrees below + the horizon. + """ + return __sunriset__(year, month, day, lon, lat, -6.0, 0) + + +def nauticalTwilight(year, month, day, lon, lat): + """ + This macro computes the start and end times of nautical twilight. + Nautical twilight starts/ends when the Sun's center is 12 degrees + below the horizon. + """ + return __sunriset__(year, month, day, lon, lat, -12.0, 0) + + +def astronomicalTwilight(year, month, day, lon, lat): + """ + This macro computes the start and end times of astronomical twilight. + Astronomical twilight starts/ends when the Sun's center is 18 degrees + below the horizon. + """ + return __sunriset__(year, month, day, lon, lat, -18.0, 0) + + +# The "workhorse" function for sun rise/set times +def __sunriset__(year, month, day, lon, lat, altit, upper_limb): + """ + Note: year,month,date = calendar date, 1801-2099 only. + Eastern longitude positive, Western longitude negative + Northern latitude positive, Southern latitude negative + The longitude value IS critical in this function! + altit = the altitude which the Sun should cross + Set to -35/60 degrees for rise/set, -6 degrees + for civil, -12 degrees for nautical and -18 + degrees for astronomical twilight. + upper_limb: non-zero -> upper limb, zero -> center + Set to non-zero (e.g. 1) when computing rise/set + times, and to zero when computing start/end of + twilight. + *rise = where to store the rise time + *set = where to store the set time + Both times are relative to the specified altitude, + and thus this function can be used to compute + various twilight times, as well as rise/set times + Return value: 0 = sun rises/sets this day, times stored at + *trise and *tset. + +1 = sun above the specified 'horizon' 24 hours. + *trise set to time when the sun is at south, + minus 12 hours while *tset is set to the south + time plus 12 hours. 'Day' length = 24 hours + -1 = sun is below the specified 'horizon' 24 hours + 'Day' length = 0 hours, *trise and *tset are + both set to the time when the sun is at south. + """ + # Compute d of 12h local mean solar time + d = daysSince2000Jan0(year,month,day) + 0.5 - (lon/360.0) + + # Compute local sidereal time of this moment + sidtime = revolution(GMST0(d) + 180.0 + lon) + + # Compute Sun's RA + Decl at this moment + res = sunRADec(d) + sRA = res[0] + sdec = res[1] + sr = res[2] + + # Compute time when Sun is at south - in hours UT + tsouth = 12.0 - rev180(sidtime - sRA)/15.0; + + # Compute the Sun's apparent radius, degrees + sradius = 0.2666 / sr; + + # Do correction to upper limb, if necessary + if upper_limb: + altit = altit - sradius + + # Compute the diurnal arc that the Sun traverses to reach + # the specified altitude altit: + + cost = (sind(altit) - sind(lat) * sind(sdec))/\ + (cosd(lat) * cosd(sdec)) + + if cost >= 1.0: + t = 0.0 # Sun always below altit + + elif cost <= -1.0: + t = 12.0; # Sun always above altit + + else: + t = acosd(cost)/15.0 # The diurnal arc, hours + + + # Store rise and set times - in hours UT + return (tsouth-t, tsouth+t) + + +def __daylen__(year, month, day, lon, lat, altit, upper_limb): + """ + Note: year,month,date = calendar date, 1801-2099 only. + Eastern longitude positive, Western longitude negative + Northern latitude positive, Southern latitude negative + The longitude value is not critical. Set it to the correct + longitude if you're picky, otherwise set to, say, 0.0 + The latitude however IS critical - be sure to get it correct + altit = the altitude which the Sun should cross + Set to -35/60 degrees for rise/set, -6 degrees + for civil, -12 degrees for nautical and -18 + degrees for astronomical twilight. + upper_limb: non-zero -> upper limb, zero -> center + Set to non-zero (e.g. 1) when computing day length + and to zero when computing day+twilight length. + + """ + + # Compute d of 12h local mean solar time + d = daysSince2000Jan0(year,month,day) + 0.5 - (lon/360.0) + + # Compute obliquity of ecliptic (inclination of Earth's axis) + obl_ecl = 23.4393 - 3.563E-7 * d + + # Compute Sun's position + res = sunpos(d) + slon = res[0] + sr = res[1] + + # Compute sine and cosine of Sun's declination + sin_sdecl = sind(obl_ecl) * sind(slon) + cos_sdecl = math.sqrt(1.0 - sin_sdecl * sin_sdecl) + + # Compute the Sun's apparent radius, degrees + sradius = 0.2666 / sr + + # Do correction to upper limb, if necessary + if upper_limb: + altit = altit - sradius + + + cost = (sind(altit) - sind(lat) * sin_sdecl) / \ + (cosd(lat) * cos_sdecl) + if cost >= 1.0: + t = 0.0 # Sun always below altit + + elif cost <= -1.0: + t = 24.0 # Sun always above altit + + else: + t = (2.0/15.0) * acosd(cost); # The diurnal arc, hours + + return t + + +def sunpos(d): + """ + Computes the Sun's ecliptic longitude and distance + at an instant given in d, number of days since + 2000 Jan 0.0. The Sun's ecliptic latitude is not + computed, since it's always very near 0. + """ + + # Compute mean elements + M = revolution(356.0470 + 0.9856002585 * d) + w = 282.9404 + 4.70935E-5 * d + e = 0.016709 - 1.151E-9 * d + + # Compute true longitude and radius vector + E = M + e * RADEG * sind(M) * (1.0 + e * cosd(M)) + x = cosd(E) - e + y = math.sqrt(1.0 - e*e) * sind(E) + r = math.sqrt(x*x + y*y) #Solar distance + v = atan2d(y, x) # True anomaly + lon = v + w # True solar longitude + if lon >= 360.0: + lon = lon - 360.0 # Make it 0..360 degrees + + return (lon,r) + + +def sunRADec(d): + """ + Returns the angle of the Sun (RA) + the declination (dec) and the distance of the Sun (r) + for a given day d. + """ + + # Compute Sun's ecliptical coordinates + res = sunpos(d) + lon = res[0] # True solar longitude + r = res[1] # Solar distance + + # Compute ecliptic rectangular coordinates (z=0) + x = r * cosd(lon) + y = r * sind(lon) + + # Compute obliquity of ecliptic (inclination of Earth's axis) + obl_ecl = 23.4393 - 3.563E-7 * d + + # Convert to equatorial rectangular coordinates - x is unchanged + z = y * sind(obl_ecl) + y = y * cosd(obl_ecl) + + # Convert to spherical coordinates + RA = atan2d(y, x) + dec = atan2d(z, math.sqrt(x*x + y*y)) + + return (RA, dec, r) + + + +def GMST0(d): + """ + This function computes GMST0, the Greenwich Mean Sidereal Time + at 0h UT (i.e. the sidereal time at the Greenwhich meridian at + 0h UT). GMST is then the sidereal time at Greenwich at any + time of the day. I've generalized GMST0 as well, and define it + as: GMST0 = GMST - UT -- this allows GMST0 to be computed at + other times than 0h UT as well. While this sounds somewhat + contradictory, it is very practical: instead of computing + GMST like: + + GMST = (GMST0) + UT * (366.2422/365.2422) + + where (GMST0) is the GMST last time UT was 0 hours, one simply + computes: + + GMST = GMST0 + UT + + where GMST0 is the GMST "at 0h UT" but at the current moment! + Defined in this way, GMST0 will increase with about 4 min a + day. It also happens that GMST0 (in degrees, 1 hr = 15 degr) + is equal to the Sun's mean longitude plus/minus 180 degrees! + (if we neglect aberration, which amounts to 20 seconds of arc + or 1.33 seconds of time) + """ + # Sidtime at 0h UT = L (Sun's mean longitude) + 180.0 degr + # L = M + w, as defined in sunpos(). Since I'm too lazy to + # add these numbers, I'll let the C compiler do it for me. + # Any decent C compiler will add the constants at compile + # time, imposing no runtime or code overhead. + + sidtim0 = revolution((180.0 + 356.0470 + 282.9404) + + (0.9856002585 + 4.70935E-5) * d) + return sidtim0; + + +def solar_altitude(latitude, year, month, day): + """ + Compute the altitude of the sun. No atmospherical refraction taken + in account. + Altitude of the southern hemisphere are given relative to + true north. + Altitude of the northern hemisphere are given relative to + true south. + Declination is between 23.5 North and 23.5 South depending + on the period of the year. + Source of formula for altitude is PhysicalGeography.net + http://www.physicalgeography.net/fundamentals/6h.html + """ + # Compute declination + N = daysSince2000Jan0(year, month, day) + res = sunRADec(N) + declination = res[1] + + # Compute the altitude + altitude = 90.0 - latitude + declination + + # In the tropical and in extreme latitude, values over 90 may occurs. + if altitude > 90: + altitude = 90 - (altitude-90) + + if altitude < 0: + altitude = 0 + + return altitude + + +def get_max_solar_flux(latitude, year, month, day): + """ + Compute the maximal solar flux to reach the ground for this date and + latitude. + Originaly comes from Environment Canada weather forecast model. + Information was of the public domain before release by Environment Canada + Output is in W/M^2. + """ + + (unused_fEot, fR0r, tDeclsc) = equation_of_time(year, month, day, latitude) + fSF = (tDeclsc[0]+tDeclsc[1])*fR0r + + # In the case of a negative declinaison, solar flux is null + if fSF < 0: + fCoeff = 0 + else: + fCoeff = -1.56e-12*fSF**4 + 5.972e-9*fSF**3 -\ + 8.364e-6*fSF**2 + 5.183e-3*fSF - 0.435 + + fSFT = fSF * fCoeff + + if fSFT < 0: + fSFT=0 + + return fSFT + + +def equation_of_time(year, month, day, latitude): + """ + Description: Subroutine computing the part of the equation of time + needed in the computing of the theoritical solar flux + Correction originating of the CMC GEM model. + + Parameters: int nTime : cTime for the correction of the time. + + Returns: tuple (double fEot, double fR0r, tuple tDeclsc) + dEot: Correction for the equation of time + dR0r: Corrected solar constant for the equation of time + tDeclsc: Declinaison + """ + # Julian date + nJulianDate = Julian(year, month, day) + # Check if it is a leap year + if(calendar.isleap(year)): + fDivide = 366.0 + else: + fDivide = 365.0 + # Correction for "equation of time" + fA = nJulianDate/fDivide*2*pi + fR0r = __Solcons(fA)*0.1367e4 + fRdecl = 0.412*math.cos((nJulianDate+10.0)*2.0*pi/fDivide-pi) + fDeclsc1 = sind(latitude)*math.sin(fRdecl) + fDeclsc2 = cosd(latitude)*math.cos(fRdecl) + tDeclsc = (fDeclsc1, fDeclsc2) + # in minutes + fEot = 0.002733 -7.343*math.sin(fA)+ .5519*math.cos(fA) \ + - 9.47*math.sin(2.0*fA) - 3.02*math.cos(2.0*fA) \ + - 0.3289*math.sin(3.*fA) -0.07581*math.cos(3.0*fA) \ + -0.1935*math.sin(4.0*fA) -0.1245*math.cos(4.0*fA) + # Express in fraction of hour + fEot = fEot/60.0 + # Express in radians + fEot = fEot*15*pi/180.0 + + return (fEot, fR0r, tDeclsc) + + +def __Solcons(dAlf): + """ + Name: __Solcons + + Parameters: [I] double dAlf : Solar constant to correct the excentricity + + Returns: double dVar : Variation of the solar constant + + Functions Called: cos, sin + + Description: Statement function that calculates the variation of the + solar constant as a function of the julian day. (dAlf, in radians) + + Notes: Comes from the + + Revision History: + Author Date Reason + Miguel Tremblay June 30th 2004 + """ + + dVar = 1.0/(1.0-9.464e-4*math.sin(dAlf)-0.01671*math.cos(dAlf)- \ + + 1.489e-4*math.cos(2.0*dAlf)-2.917e-5*math.sin(3.0*dAlf)- \ + + 3.438e-4*math.cos(4.0*dAlf))**2 + return dVar + + +def Julian(year, month, day): + """ + Return julian day. + """ + if calendar.isleap(year): # Bissextil year, 366 days + lMonth = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366] + else: # Normal year, 365 days + lMonth = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365] + + nJulian = lMonth[month-1] + day + return nJulian + +def revolution(x): + """ + This function reduces any angle to within the first revolution + by subtracting or adding even multiples of 360.0 until the + result is >= 0.0 and < 360.0 + + Reduce angle to within 0..360 degrees + """ + return (x - 360.0 * math.floor(x * INV360)) + + +def rev180(x): + """ + Reduce angle to within +180..+180 degrees + """ + return (x - 360.0 * math.floor(x * INV360 + 0.5)) + + + +if __name__ == "__main__": + (sunrise_utc, sunset_utc) = sunRiseSet(2009, 3, 27, -122.65, 45.517) + print(sunrise_utc, sunset_utc) + + #Assert that the results are within 1 minute of NOAA's + # calculator (see http://www.srrb.noaa.gov/highlights/sunrise/sunrise.html) + assert((sunrise_utc - 14.00) < 1.0/60.0) + assert((sunset_utc - 26.55) < 1.0/60.0) diff --git a/dist/weewx-4.10.1/bin/weeutil/__init__.py b/dist/weewx-4.10.1/bin/weeutil/__init__.py new file mode 100644 index 0000000..3c5ab5c --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/__init__.py @@ -0,0 +1,6 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""General utilities""" diff --git a/dist/weewx-4.10.1/bin/weeutil/config.py b/dist/weewx-4.10.1/bin/weeutil/config.py new file mode 100644 index 0000000..933b3bc --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/config.py @@ -0,0 +1,278 @@ +# +# Copyright (c) 2018-2020 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Convenience functions for ConfigObj""" + +from __future__ import absolute_import + +import configobj +from configobj import Section + + +def search_up(d, k, *default): + """Search a ConfigObj dictionary for a key. If it's not found, try my parent, and so on + to the root. + + d: An instance of configobj.Section + + k: A key to be searched for. If not found in d, it's parent will be searched + + default: If the key is not found, then the default is returned. If no default is given, + then an AttributeError exception is raised. + + Example: + + >>> c = configobj.ConfigObj({"color":"blue", "size":10, "robin":{"color":"red", "sound": {"volume": "loud"}}}) + >>> print(search_up(c['robin'], 'size')) + 10 + >>> print(search_up(c, 'color')) + blue + >>> print(search_up(c['robin'], 'color')) + red + >>> print(search_up(c['robin'], 'flavor', 'salty')) + salty + >>> try: + ... print(search_up(c['robin'], 'flavor')) + ... except AttributeError: + ... print('not found') + not found + >>> print(search_up(c['robin'], 'sound')) + {'volume': 'loud'} + >>> print(search_up(c['robin'], 'smell', {})) + {} + """ + if k in d: + return d[k] + if d.parent is d: + if len(default): + return default[0] + else: + raise AttributeError(k) + else: + return search_up(d.parent, k, *default) + + +def accumulateLeaves(d, max_level=99): + """Merges leaf options above a ConfigObj section with itself, accumulating the results. + + This routine is useful for specifying defaults near the root node, + then having them overridden in the leaf nodes of a ConfigObj. + + d: instance of a configobj.Section (i.e., a section of a ConfigObj) + + Returns: a dictionary with all the accumulated scalars, up to max_level deep, + going upwards + + Example: Supply a default color=blue, size=10. The section "dayimage" overrides the former: + + >>> c = configobj.ConfigObj({"color":"blue", "size":10, "dayimage":{"color":"red", "position":{"x":20, "y":30}}}) + >>> accumulateLeaves(c["dayimage"]) == {"color":"red", "size": 10} + True + >>> accumulateLeaves(c["dayimage"], max_level=0) == {'color': 'red'} + True + >>> accumulateLeaves(c["dayimage"]["position"]) == {'color': 'red', 'size': 10, 'y': 30, 'x': 20} + True + >>> accumulateLeaves(c["dayimage"]["position"], max_level=1) == {'color': 'red', 'y': 30, 'x': 20} + True + """ + + # Use recursion. If I am the root object, then there is nothing above + # me to accumulate. Start with a virgin ConfigObj + if d.parent is d: + cum_dict = configobj.ConfigObj() + else: + if max_level: + # Otherwise, recursively accumulate scalars above me + cum_dict = accumulateLeaves(d.parent, max_level - 1) + else: + cum_dict = configobj.ConfigObj() + + # Now merge my scalars into the results: + merge_dict = {k: d[k] for k in d.scalars} + cum_dict.merge(merge_dict) + return cum_dict + + +def merge_config(self_config, indict): + """Merge and patch a config file""" + + self_config.merge(indict) + patch_config(self_config, indict) + + +def patch_config(self_config, indict): + """The ConfigObj merge does not transfer over parentage, nor comments. This function + fixes these limitations. + + Example: + >>> import sys + >>> from six.moves import StringIO + >>> c = configobj.ConfigObj(StringIO('''[Section1] + ... option1 = bar''')) + >>> d = configobj.ConfigObj(StringIO('''[Section1] + ... # This is a Section2 comment + ... [[Section2]] + ... option2 = foo + ... ''')) + >>> c.merge(d) + >>> # First do accumulateLeaves without a patch + >>> print(accumulateLeaves(c['Section1']['Section2'])) + {'option2': 'foo'} + >>> # Now patch and try again + >>> patch_config(c, d) + >>> print(accumulateLeaves(c['Section1']['Section2'])) + {'option1': 'bar', 'option2': 'foo'} + >>> c.write() + ['[Section1]', 'option1 = bar', '# This is a Section2 comment', '[[Section2]]', 'option2 = foo'] + """ + for key in self_config: + if isinstance(self_config[key], Section) \ + and key in indict and isinstance(indict[key], Section): + self_config[key].parent = self_config + self_config[key].main = self_config.main + self_config.comments[key] = indict.comments[key] + self_config.inline_comments[key] = indict.inline_comments[key] + patch_config(self_config[key], indict[key]) + + +def comment_scalar(a_dict, key): + """Comment out a scalar in a ConfigObj object. + + Convert an entry into a comment, sticking it at the beginning of the section. + + Returns: 0 if nothing was done. + 1 if the ConfigObj object was changed. + """ + + # If the key is not in the list of scalars there is no need to do anything. + if key not in a_dict.scalars: + return 0 + + # Save the old comments + comment = a_dict.comments[key] + inline_comment = a_dict.inline_comments[key] + if inline_comment is None: + inline_comment = '' + # Build a new inline comment holding the key and value, as well as the old inline comment + new_inline_comment = "%s = %s %s" % (key, a_dict[key], inline_comment) + + # Delete the old key + del a_dict[key] + + # If that was the only key, there's no place to put the comments. Do nothing. + if len(a_dict.scalars): + # Otherwise, put the comments before the first entry + first_key = a_dict.scalars[0] + a_dict.comments[first_key] += comment + a_dict.comments[first_key].append(new_inline_comment) + + return 1 + + +def delete_scalar(a_dict, key): + """Delete a scalar in a ConfigObj object. + + Returns: 0 if nothing was done. + 1 if the scalar was deleted + """ + + if key not in a_dict.scalars: + return 0 + + del a_dict[key] + return 1 + + +def conditional_merge(a_dict, b_dict): + """Merge fields from b_dict into a_dict, but only if they do not yet + exist in a_dict""" + # Go through each key in b_dict + for k in b_dict: + if isinstance(b_dict[k], dict): + if k not in a_dict: + # It's a new section. Initialize it... + a_dict[k] = {} + # ... and transfer over the section comments, if available + try: + a_dict.comments[k] = b_dict.comments[k] + except AttributeError: + pass + conditional_merge(a_dict[k], b_dict[k]) + elif k not in a_dict: + # It's a scalar. Transfer over the value... + a_dict[k] = b_dict[k] + # ... then its comments, if available: + try: + a_dict.comments[k] = b_dict.comments[k] + except AttributeError: + pass + + +def config_from_str(input_str): + """Return a ConfigObj from a string. Values will be in Unicode.""" + import six + from six import StringIO + # This is a bit of a hack. We want to return a ConfigObj with unicode values. Under Python 2, + # ConfigObj v5 requires a unicode input string, but earlier versions require a + # byte-string. + if configobj.__version__ >= '5.0.0': + # Convert to unicode + open_str = six.ensure_text(input_str) + else: + open_str = input_str + config = configobj.ConfigObj(StringIO(open_str), encoding='utf-8', default_encoding='utf-8') + return config + + +def deep_copy(old_dict, parent=None, depth=None, main=None): + """Return a deep copy of a ConfigObj""" + + # Is this a copy starting from the top level? + if isinstance(old_dict, configobj.ConfigObj): + new_dict = configobj.ConfigObj('', + encoding=old_dict.encoding, + default_encoding=old_dict.default_encoding, + interpolation=old_dict.interpolation, + indent_type=old_dict.indent_type) + new_dict.initial_comment = list(old_dict.initial_comment) + else: + # No. It's a copy of something deeper down. If no parent or main is given, then + # adopt the parent and main of the incoming dictionary. + new_dict = configobj.Section(parent if parent is not None else old_dict.parent, + depth if depth is not None else old_dict.depth, + main if main is not None else old_dict.main) + for entry in old_dict: + # Avoid interpolation by using the version of __getitem__ from dict + old_value = dict.__getitem__(old_dict, entry) + if isinstance(old_value, configobj.Section): + new_value = deep_copy(old_value, new_dict, new_dict.depth + 1, new_dict.main) + elif isinstance(old_value, list): + # Make a copy + new_value = list(old_value) + elif isinstance(old_value, tuple): + # Make a copy + new_value = tuple(old_value) + else: + # It's a scalar, possibly a string + new_value = old_value + new_dict[entry] = new_value + # A comment is a list of strings. We need to make a copy of the list, but the strings + # themselves are immutable, so we don't need to copy them. That means a simple shallow + # copy will do: + new_dict.comments[entry] = list(old_dict.comments[entry]) + # An inline comment is either None, or a string. Either way, they are immutable, so + # a simple assignment will work: + new_dict.inline_comments[entry] = old_dict.inline_comments[entry] + return new_dict + +if __name__ == "__main__": + import six + if not six.PY3: + exit("units.py doctest must be run under Python 3") + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weeutil/ftpupload.py b/dist/weewx-4.10.1/bin/weeutil/ftpupload.py new file mode 100644 index 0000000..d2d3c3e --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/ftpupload.py @@ -0,0 +1,337 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""For uploading files to a remove server via FTP""" + +from __future__ import absolute_import +from __future__ import print_function +from __future__ import with_statement + +import ftplib +import logging +import os +import sys +import time + +from six.moves import cPickle + +try: + import hashlib + has_hashlib=True +except ImportError: + has_hashlib=False + +log = logging.getLogger(__name__) + + +class FtpUpload(object): + """Uploads a directory and all its descendants to a remote server. + + Keeps track of when a file was last uploaded, so it is uploaded only + if its modification time is newer.""" + + def __init__(self, server, + user, password, + local_root, remote_root, + port=21, + name="FTP", + passive=True, + secure=False, + debug=0, + secure_data=True, + reuse_ssl=False, + encoding='utf-8', + ciphers=None): + """Initialize an instance of FtpUpload. + + After initializing, call method run() to perform the upload. + + server: The remote server to which the files are to be uploaded. + + user, + password : The user name and password that are to be used. + + name: A unique name to be given for this FTP session. This allows more + than one session to be uploading from the same local directory. [Optional. + Default is 'FTP'.] + + passive: True to use passive mode; False to use active mode. [Optional. + Default is True (passive mode)] + + secure: Set to True to attempt an FTP over TLS (FTPS) session. + + debug: Set to 1 for extra debug information, 0 otherwise. + + secure_data: If a secure session is requested (option secure=True), + should we attempt a secure data connection as well? This option is useful + due to a bug in the Python FTP client library. See Issue #284. + [Optional. Default is True] + + reuse_ssl: Work around a bug in the Python library that closes ssl sockets that should + be reused. See https://bit.ly/3dKq4JY [Optional. Default is False] + + encoding: The vast majority of FTP servers chat using UTF-8. However, there are a few + oddballs that use Latin-1. + + ciphers: Explicitly set the cipher(s) to be used by the ssl sockets. + """ + self.server = server + self.user = user + self.password = password + self.local_root = os.path.normpath(local_root) + self.remote_root = os.path.normpath(remote_root) + self.port = port + self.name = name + self.passive = passive + self.secure = secure + self.debug = debug + self.secure_data = secure_data + self.reuse_ssl = reuse_ssl + self.encoding = encoding + self.ciphers = ciphers + + if self.reuse_ssl and (sys.version_info.major < 3 or sys.version_info.minor < 6): + raise ValueError("Reusing an SSL connection requires Python version 3.6 or greater") + + def run(self): + """Perform the actual upload. + + returns: the number of files uploaded.""" + + # Get the timestamp and members of the last upload: + timestamp, fileset, hashdict = self.get_last_upload() + + n_uploaded = 0 + + try: + if self.secure: + log.debug("Attempting secure connection to %s", self.server) + if self.reuse_ssl: + # Activate the workaround for the Python ftplib library. + from ssl import SSLSocket + + class ReusedSslSocket(SSLSocket): + def unwrap(self): + pass + + class WeeFTPTLS(ftplib.FTP_TLS): + """Explicit FTPS, with shared TLS session""" + + def ntransfercmd(self, cmd, rest=None): + conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) + if self._prot_p: + conn = self.context.wrap_socket(conn, + server_hostname=self.host, + session=self.sock.session) + conn.__class__ = ReusedSslSocket + return conn, size + log.debug("Reusing SSL connections.") + # Python 3.8 and earlier do not support the encoding + # parameter. Be prepared to catch the TypeError that may + # occur with python 3.8 and earlier. + try: + ftp_server = WeeFTPTLS(encoding=self.encoding) + except TypeError: + # we likely have python 3.8 or earlier, so try again + # without encoding + ftp_server = WeeFTPTLS() + log.debug("FTP encoding not supported, ignoring.") + else: + # Python 3.8 and earlier do not support the encoding + # parameter. Be prepared to catch the TypeError that may + # occur with python 3.8 and earlier. + try: + ftp_server = ftplib.FTP_TLS(encoding=self.encoding) + except TypeError: + # we likely have python 3.8 or earlier, so try again + # without encoding + ftp_server = ftplib.FTP_TLS() + log.debug("FTP encoding not supported, ignoring.") + + # If the user has specified one, set a customized cipher: + if self.ciphers: + ftp_server.context.set_ciphers(self.ciphers) + log.debug("Set ciphers to %s", self.ciphers) + + else: + log.debug("Attempting connection to %s", self.server) + # Python 3.8 and earlier do not support the encoding parameter. + # Be prepared to catch the TypeError that may occur with + # python 3.8 and earlier. + try: + ftp_server = ftplib.FTP(encoding=self.encoding) + except TypeError: + # we likely have python 3.8 or earlier, so try again + # without encoding + ftp_server = ftplib.FTP() + log.debug("FTP encoding not supported, ignoring.") + + if self.debug >= 2: + ftp_server.set_debuglevel(self.debug) + + ftp_server.set_pasv(self.passive) + ftp_server.connect(self.server, self.port) + ftp_server.login(self.user, self.password) + if self.secure and self.secure_data: + ftp_server.prot_p() + log.debug("Secure data connection to %s", self.server) + else: + log.debug("Connected to %s", self.server) + + # Walk the local directory structure + for (dirpath, unused_dirnames, filenames) in os.walk(self.local_root): + + # Strip out the common local root directory. What is left + # will be the relative directory both locally and remotely. + local_rel_dir_path = dirpath.replace(self.local_root, '.') + if _skip_this_dir(local_rel_dir_path): + continue + # This is the absolute path to the remote directory: + remote_dir_path = os.path.normpath(os.path.join(self.remote_root, + local_rel_dir_path)) + + # Make the remote directory if necessary: + _make_remote_dir(ftp_server, remote_dir_path) + + # Now iterate over all members of the local directory: + for filename in filenames: + + full_local_path = os.path.join(dirpath, filename) + + # calculate hash + if has_hashlib: + filehash=sha256sum(full_local_path) + else: + filehash=None + + # See if this file can be skipped: + if _skip_this_file(timestamp, fileset, hashdict, full_local_path, filehash): + continue + + full_remote_path = os.path.join(remote_dir_path, filename) + stor_cmd = "STOR %s" % full_remote_path + + log.debug("%s %s/%s %s" % (n_uploaded,local_rel_dir_path,filename,filehash)) + + with open(full_local_path, 'rb') as fd: + try: + ftp_server.storbinary(stor_cmd, fd) + except ftplib.all_errors as e: + # Unsuccessful. Log it, then reraise the exception + log.error("Failed uploading %s to server %s. Reason: '%s'", + full_local_path, self.server, e) + raise + # Success. + n_uploaded += 1 + fileset.add(full_local_path) + hashdict[full_local_path]=filehash + log.debug("Uploaded file %s to %s", full_local_path, full_remote_path) + finally: + try: + ftp_server.quit() + except Exception: + pass + + timestamp = time.time() + self.save_last_upload(timestamp, fileset, hashdict) + return n_uploaded + + def get_last_upload(self): + """Reads the time and members of the last upload from the local root""" + + timestamp_file_path = os.path.join(self.local_root, "#%s.last" % self.name) + + # If the file does not exist, an IOError exception will be raised. + # If the file exists, but is truncated, an EOFError will be raised. + # Either way, be prepared to catch it. + try: + with open(timestamp_file_path, "rb") as f: + timestamp = cPickle.load(f) + fileset = cPickle.load(f) + hashdict = cPickle.load(f) + except (IOError, EOFError, cPickle.PickleError, AttributeError): + timestamp = 0 + fileset = set() + hashdict = {} + # Either the file does not exist, or it is garbled. + # Either way, it's safe to remove it. + try: + os.remove(timestamp_file_path) + except OSError: + pass + + return timestamp, fileset, hashdict + + def save_last_upload(self, timestamp, fileset, hashdict): + """Saves the time and members of the last upload in the local root.""" + timestamp_file_path = os.path.join(self.local_root, "#%s.last" % self.name) + with open(timestamp_file_path, "wb") as f: + cPickle.dump(timestamp, f) + cPickle.dump(fileset, f) + cPickle.dump(hashdict, f) + + +def _skip_this_file(timestamp, fileset, hashdict, full_local_path, filehash): + """Determine whether to skip a specific file.""" + + filename = os.path.basename(full_local_path) + if filename[-1] == '~' or filename[0] == '#': + return True + + if full_local_path not in fileset: + return False + + if has_hashlib and filehash is not None: + # use hash if available + if full_local_path not in hashdict: + return False + if hashdict[full_local_path]!=filehash: + return False + else: + # otherwise use file time + if os.stat(full_local_path).st_mtime > timestamp: + return False + + # Filename is in the set, and is up to date. + return True + + +def _skip_this_dir(local_dir): + """Determine whether to skip a directory.""" + + return os.path.basename(local_dir) in ('.svn', 'CVS') + + +def _make_remote_dir(ftp_server, remote_dir_path): + """Make a remote directory if necessary.""" + + try: + ftp_server.mkd(remote_dir_path) + except ftplib.all_errors as e: + # Got an exception. It might be because the remote directory already exists: + if sys.exc_info()[0] is ftplib.error_perm: + msg = str(e).strip() + # If a directory already exists, some servers respond with a '550' ("Requested + # action not taken") code, others with a '521' ("Access denied" or "Pathname + # already exists") code. + if msg.startswith('550') or msg.startswith('521'): + # Directory already exists + return + # It's a real error. Log it, then re-raise the exception. + log.error("Error creating directory %s", remote_dir_path) + raise + + log.debug("Made directory %s", remote_dir_path) + +# from https://stackoverflow.com/questions/22058048/hashing-a-file-in-python + +def sha256sum(filename): + h = hashlib.sha256() + b = bytearray(128*1024) + mv = memoryview(b) + with open(filename, 'rb', buffering=0) as f: + for n in iter(lambda : f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() diff --git a/dist/weewx-4.10.1/bin/weeutil/log.py b/dist/weewx-4.10.1/bin/weeutil/log.py new file mode 100644 index 0000000..7fed59d --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/log.py @@ -0,0 +1,119 @@ +# +# Copyright (c) 2019 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""WeeWX logging facility + +OBSOLETE: Use weeutil.logging instead +""" + +from __future__ import absolute_import + +import os +import syslog +import traceback + +import six +from six.moves import StringIO + +log_levels = { + 'debug': syslog.LOG_DEBUG, + 'info': syslog.LOG_INFO, + 'warning': syslog.LOG_WARNING, + 'critical': syslog.LOG_CRIT, + 'error': syslog.LOG_ERR +} + + +def log_open(log_label='weewx'): + syslog.openlog(log_label, syslog.LOG_PID | syslog.LOG_CONS) + + +def log_upto(log_level=None): + """Set what level of logging we want.""" + if log_level is None: + log_level = syslog.LOG_INFO + elif isinstance(log_level, six.string_types): + log_level = log_levels.get(log_level, syslog.LOG_INFO) + syslog.setlogmask(syslog.LOG_UPTO(log_level)) + + +def logdbg(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_DEBUG, "%s: %s" % (prefix, msg)) + + +def loginf(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_INFO, "%s: %s" % (prefix, msg)) + + +def lognote(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_NOTICE, "%s: %s" % (prefix, msg)) + + +# In case anyone is really wedded to the idea of 6 letter log functions: +lognot = lognote + + +def logwar(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_WARNING, "%s: %s" % (prefix, msg)) + + +def logerr(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_ERR, "%s: %s" % (prefix, msg)) + + +def logalt(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_ALERT, "%s: %s" % (prefix, msg)) + + +def logcrt(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_CRIT, "%s: %s" % (prefix, msg)) + + +def log_traceback(prefix='', log_level=None): + """Log the stack traceback into syslog. + + prefix: A string, which will be put in front of each log entry. Default is no string. + + log_level: Either a syslog level (e.g., syslog.LOG_INFO), or a string. Valid strings + are given by the keys of log_levels. + """ + if log_level is None: + log_level = syslog.LOG_INFO + elif isinstance(log_level, six.string_types): + log_level = log_levels.get(log_level, syslog.LOG_INFO) + sfd = StringIO() + traceback.print_exc(file=sfd) + sfd.seek(0) + for line in sfd: + syslog.syslog(log_level, "%s: %s" % (prefix, line)) + + +def _get_file_root(): + """Figure out who is the caller of the logging function""" + + # Get the stack: + tb = traceback.extract_stack() + # Go back 3 frames. First frame is get_file_root(), 2nd frame is the logging function, 3rd frame + # is what we want: what called the logging function + calling_frame = tb[-3] + # Get the file name of what called the logging function + calling_file = os.path.basename(calling_frame[0]) + # Get rid of any suffix (e.g., ".py"): + file_root = calling_file.split('.')[0] + return file_root diff --git a/dist/weewx-4.10.1/bin/weeutil/logger.py b/dist/weewx-4.10.1/bin/weeutil/logger.py new file mode 100644 index 0000000..33df7d6 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/logger.py @@ -0,0 +1,182 @@ +# +# Copyright (c) 2020-2023 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""WeeWX logging facility""" + +from __future__ import absolute_import + +import sys +import logging.config +import six +from six.moves import StringIO + +import configobj + +import weewx + +# The logging defaults. Note that two kinds of placeholders are used: +# +# {value}: these are plugged in by the function setup(). +# %(value)s: these are plugged in by the Python logging module. +# +LOGGING_STR = """[Logging] + version = 1 + disable_existing_loggers = False + + # Root logger + [[root]] + level = {log_level} + handlers = syslog, + + # Additional loggers would go in the following section. This is useful for tailoring logging + # for individual modules. + [[loggers]] + + # Definitions of possible logging destinations + [[handlers]] + + # System logger + [[[syslog]]] + level = DEBUG + formatter = standard + class = logging.handlers.SysLogHandler + address = {address} + facility = {facility} + + # Log to console + [[[console]]] + level = DEBUG + formatter = verbose + class = logging.StreamHandler + # Alternate choice is 'ext://sys.stderr' + stream = ext://sys.stdout + + # How to format log messages + [[formatters]] + [[[simple]]] + format = "%(levelname)s %(message)s" + [[[standard]]] + format = "{process_name}[%(process)d] %(levelname)s %(name)s: %(message)s" + [[[verbose]]] + format = "%(asctime)s {process_name}[%(process)d] %(levelname)s %(name)s: %(message)s" + # Format to use for dates and times: + datefmt = %Y-%m-%d %H:%M:%S +""" + +# These values are known only at runtime +if sys.platform == "darwin": + address = '/var/run/syslog' + facility = 'local1' +elif sys.platform.startswith('linux'): + address = '/dev/log' + facility = 'user' +elif sys.platform.startswith('freebsd'): + address = '/var/run/log' + facility = 'user' +elif sys.platform.startswith('netbsd'): + address = '/var/run/log' + facility = 'user' +elif sys.platform.startswith('openbsd'): + address = '/dev/log' + facility = 'user' +else: + address = ('localhost', 514) + facility = 'user' + + +def setup(process_name, user_log_dict): + """Set up the weewx logging facility""" + + global address, facility + + # Create a ConfigObj from the default string. No interpolation (it interferes with the + # interpolation directives embedded in the string). + log_config = configobj.ConfigObj(StringIO(LOGGING_STR), interpolation=False, encoding='utf-8') + + # Turn off interpolation in the incoming dictionary. First save the old + # value, then restore later. However, the incoming dictionary may be a simple + # Python dictionary and not have interpolation. Hence the try block. + try: + old_interpolation = user_log_dict.interpolation + user_log_dict.interpolation = False + except AttributeError: + old_interpolation = None + + # Merge in the user additions / changes: + log_config.merge(user_log_dict) + + # Adjust the logging level in accordance with whether or not the 'debug' flag is on + log_level = 'DEBUG' if weewx.debug else 'INFO' + + # Now we need to walk the structure, plugging in the values we know. + # First, we need a function to do this: + def _fix(section, key): + if isinstance(section[key], (list, tuple)): + # The value is a list or tuple + section[key] = [item.format(log_level=log_level, + address=address, + facility=facility, + process_name=process_name) for item in section[key]] + else: + # The value is a string + section[key] = section[key].format(log_level=log_level, + address=address, + facility=facility, + process_name=process_name) + + # Using the function, walk the 'Logging' part of the structure + log_config['Logging'].walk(_fix) + + # Now walk the structure again, this time converting any strings to an appropriate type: + log_config['Logging'].walk(_convert_from_string) + + # Extract just the part used by Python's logging facility + log_dict = log_config.dict().get('Logging', {}) + + # Finally! The dictionary is ready. Set the defaults. + logging.config.dictConfig(log_dict) + + # Restore the old interpolation value + if old_interpolation is not None: + user_log_dict.interpolation = old_interpolation + + +def log_traceback(log_fn, prefix=''): + """Log the stack traceback into a logger. + + log_fn: One of the logging.Logger logging functions, such as logging.Logger.warning. + + prefix: A string, which will be put in front of each log entry. Default is no string. + """ + import traceback + sfd = StringIO() + traceback.print_exc(file=sfd) + sfd.seek(0) + for line in sfd: + log_fn("%s%s", prefix, line) + + +def _convert_from_string(section, key): + """If possible, convert any strings to an appropriate type.""" + # Check to make sure it is a string + if isinstance(section[key], six.string_types): + if section[key].lower() == 'false': + # It's boolean False + section[key] = False + elif section[key].lower() == 'true': + # It's boolean True + section[key] = True + elif section[key].count('.') == 1: + # Contains a decimal point. Could be float + try: + section[key] = float(section[key]) + except ValueError: + pass + else: + # Try integer? + try: + section[key] = int(section[key]) + except ValueError: + pass diff --git a/dist/weewx-4.10.1/bin/weeutil/rsyncupload.py b/dist/weewx-4.10.1/bin/weeutil/rsyncupload.py new file mode 100644 index 0000000..6e7cce7 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/rsyncupload.py @@ -0,0 +1,177 @@ +# +# Copyright (c) 2012 Will Page +# Derivative of ftpupload.py, credit to Tom Keffer +# +# Refactored by tk 3-Jan-2021 +# +# See the file LICENSE.txt for your full rights. +# +"""For uploading files to a remove server via Rsync""" + +from __future__ import absolute_import +from __future__ import print_function + +import errno +import logging +import os +import subprocess +import sys +import time + +log = logging.getLogger(__name__) + + +class RsyncUpload(object): + """Uploads a directory and all its descendants to a remote server. + + Keeps track of what files have changed, and only updates changed files.""" + + def __init__(self, local_root, remote_root, + server, user=None, delete=False, port=None, + ssh_options=None, compress=False, + log_success=True, log_failure=True, + timeout=None): + """Initialize an instance of RsyncUpload. + + After initializing, call method run() to perform the upload. + + server: The remote server to which the files are to be uploaded. + + user: The user name that is to be used. [Optional, maybe] + + delete: delete remote files that don't match with local files. Use + with caution. [Optional. Default is False.] + """ + self.local_root = os.path.normpath(local_root) + self.remote_root = os.path.normpath(remote_root) + self.server = server + self.user = user + self.delete = delete + self.port = port + self.ssh_options = ssh_options + self.compress = compress + self.log_success = log_success + self.log_failure = log_failure + self.timeout = timeout + + def run(self): + """Perform the actual upload.""" + + t1 = time.time() + + # If the source path ends with a slash, rsync interprets + # that as a request to copy all the directory's *contents*, + # whereas if it doesn't, it copies the entire directory. + # We want the former, so make it end with a slash. + # Note: Don't add the slash if local_root isn't a directory + if self.local_root.endswith(os.sep) or not os.path.isdir(self.local_root): + rsynclocalspec = self.local_root + else: + rsynclocalspec = self.local_root + os.sep + + if self.user: + rsyncremotespec = "%s@%s:%s" % (self.user, self.server, self.remote_root) + else: + rsyncremotespec = "%s:%s" % (self.server, self.remote_root) + + if self.port: + rsyncsshstring = "ssh -p %d" % self.port + else: + rsyncsshstring = "ssh" + + if self.ssh_options: + rsyncsshstring = rsyncsshstring + " " + self.ssh_options + + cmd = ['rsync'] + # archive means: + # recursive, copy symlinks as symlinks, preserve permissions, + # preserve modification times, preserve group and owner, + # preserve device files and special files, but not ACLs, + # no hardlinks, and no extended attributes + cmd.extend(["--archive"]) + # provide some stats on the transfer + cmd.extend(["--stats"]) + # Remove files remotely when they're removed locally + if self.delete: + cmd.extend(["--delete"]) + if self.compress: + cmd.extend(["--compress"]) + if self.timeout is not None: + cmd.extend(["--timeout=%s" % self.timeout]) + cmd.extend(["-e"]) + cmd.extend([rsyncsshstring]) + cmd.extend([rsynclocalspec]) + cmd.extend([rsyncremotespec]) + + try: + log.debug("rsyncupload: cmd: [%s]" % cmd) + rsynccmd = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + stdout = rsynccmd.communicate()[0] + stroutput = stdout.decode("utf-8").strip() + except OSError as e: + if e.errno == errno.ENOENT: + log.error("rsync does not appear to be installed on " + "this system. (errno %d, '%s')" % (e.errno, e.strerror)) + raise + + t2 = time.time() + + # we have some output from rsync so generate an appropriate message + if 'rsync error' not in stroutput: + # No rsync error message. Parse the status message for useful information. + if self.log_success: + # Create a dictionary of message and their values. kv_list is a list of + # (key, value) tuples. + kv_list = [line.split(':', 1) for line in stroutput.splitlines() if ':' in line] + # Now convert to dictionary, while stripping the keys and values + rsyncinfo = {k.strip(): v.strip() for k, v in kv_list} + # Get number of files and bytes transferred, and produce an appropriate message + N = rsyncinfo.get('Number of regular files transferred', + rsyncinfo.get('Number of files transferred')) + Nbytes = rsyncinfo.get('Total transferred file size') + if N is not None and Nbytes is not None: + log.info("rsync'd %s files (%s) in %0.2f seconds", N.strip(), + Nbytes.strip(), t2 - t1) + else: + log.info("rsync executed in %0.2f seconds", t2 - t1) + else: + # rsync error message found. If requested, log it + if self.log_failure: + log.error("rsync reported errors. Original command: %s", cmd) + for line in stroutput.splitlines(): + log.error("**** %s", line) + + +if __name__ == '__main__': + import configobj + + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('rsyncupload', {}) + + if len(sys.argv) < 2: + print("""Usage: rsyncupload.py path-to-configuration-file [path-to-be-rsync'd]""") + sys.exit(weewx.CMD_ERROR) + + try: + config_dict = configobj.ConfigObj(sys.argv[1], file_error=True, encoding='utf-8') + except IOError: + print("Unable to open configuration file %s" % sys.argv[1]) + raise + + if len(sys.argv) == 2: + try: + rsync_dir = os.path.join(config_dict['WEEWX_ROOT'], + config_dict['StdReport']['HTML_ROOT']) + except KeyError: + print("No HTML_ROOT in configuration dictionary.") + sys.exit(1) + else: + rsync_dir = sys.argv[2] + + rsync_upload = RsyncUpload(rsync_dir, **config_dict['StdReport']['RSYNC']) + rsync_upload.run() diff --git a/dist/weewx-4.10.1/bin/weeutil/timediff.py b/dist/weewx-4.10.1/bin/weeutil/timediff.py new file mode 100644 index 0000000..bc88125 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/timediff.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2019-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Class for calculating time derivatives""" +import weewx + + +class TimeDerivative(object): + """Calculate time derivative for a specific observation type.""" + + def __init__(self, obs_type, stale_age): + """Initialize. + + obs_type: the observation type for which the derivative will be calculated. + + stale_age: Derivatives are calculated as a difference over time. This is how old the old value + can be and still be considered useful. + """ + self.obs_type = obs_type + self.stale_age = stale_age + self.old_timestamp = None + self.old_value = None + + def add_record(self, record): + """Add a new record, then return the difference in value divided by the difference in time.""" + + # If the type does not appear in the incoming record, then we can't calculate the derivative + if self.obs_type not in record: + raise weewx.CannotCalculate(self.obs_type) + + derivative = None + if record[self.obs_type] is not None: + # We can't calculate anything if we don't have an old record + if self.old_timestamp: + # Check to make sure the incoming record is later than the retained record + if record['dateTime'] < self.old_timestamp: + raise weewx.ViolatedPrecondition("Records presented out of order (%s vs %s)" + % (record['dateTime'], self.old_timestamp)) + # Calculate the time derivative only if there is a delta in time, + # and the old record is not too old. + if record['dateTime'] != self.old_timestamp \ + and (record['dateTime'] - self.old_timestamp) <= self.stale_age: + # All OK. + derivative = (record[self.obs_type] - self.old_value) \ + / (record['dateTime'] - self.old_timestamp) + # Save the current values + self.old_timestamp = record['dateTime'] + self.old_value = record[self.obs_type] + + return derivative diff --git a/dist/weewx-4.10.1/bin/weeutil/weeutil.py b/dist/weewx-4.10.1/bin/weeutil/weeutil.py new file mode 100644 index 0000000..cad9736 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weeutil/weeutil.py @@ -0,0 +1,1857 @@ +# This Python file uses the following encoding: utf-8 +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Various handy utilities that don't belong anywhere else. + Works under Python 2 and Python 3. + + NB: To run the doctests, this code must be run as a module. For example: + cd ~/git/weewx/bin + python -m weeutil.weeutil +""" + +from __future__ import absolute_import +from __future__ import print_function + +import calendar +import cmath +import datetime +import math +import os +import re +import shutil +import time + +# Compatibility shims +import six +from six.moves import input + +# For backwards compatibility: +from weeutil.config import accumulateLeaves, search_up + + +def convertToFloat(seq): + """Convert a sequence with strings to floats, honoring 'Nones'""" + + if seq is None: + return None + res = [None if s in ('None', 'none') else float(s) for s in seq] + return res + + +def option_as_list(option): + if option is None: + return None + return [option] if not isinstance(option, list) else option + +to_list = option_as_list + +def list_as_string(option): + """Returns the argument as a string. + + Useful for insuring that ConfigObj options are always returned + as a string, despite the presence of a comma in the middle. + + Example: + >>> print(list_as_string('a string')) + a string + >>> print(list_as_string(['a', 'string'])) + a, string + >>> print(list_as_string('Reno, NV')) + Reno, NV + """ + # Check if it's already a string. + if option is not None and not isinstance(option, six.string_types): + return ', '.join(option) + return option + + +def startOfInterval(time_ts, interval): + """Find the start time of an interval. + + This algorithm assumes unit epoch time is divided up into + intervals of 'interval' length. Given a timestamp, it + figures out which interval it lies in, returning the start + time. + + Args: + + time_ts (float): A timestamp. The start of the interval containing this + timestamp will be returned. + interval (int): An interval length in seconds. + + Returns: + int: A timestamp with the start of the interval. + + Examples: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 01:55:00 2013' + >>> time.ctime(startOfInterval(start_ts, 300.0)) + 'Thu Jul 4 01:55:00 2013' + >>> time.ctime(startOfInterval(start_ts, 600)) + 'Thu Jul 4 01:50:00 2013' + >>> time.ctime(startOfInterval(start_ts, 900)) + 'Thu Jul 4 01:45:00 2013' + >>> time.ctime(startOfInterval(start_ts, 3600)) + 'Thu Jul 4 01:00:00 2013' + >>> time.ctime(startOfInterval(start_ts, 7200)) + 'Thu Jul 4 01:00:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:00:00", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 00:55:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:00:01", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 01:00:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:04:59", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 01:00:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 00:00:00", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Wed Jul 3 23:55:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 07:51:00", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 60)) + 'Thu Jul 4 07:50:00 2013' + >>> start_ts += 0.1 + >>> time.ctime(startOfInterval(start_ts, 60)) + 'Thu Jul 4 07:51:00 2013' + """ + + start_interval_ts = int(time_ts / interval) * interval + + if time_ts == start_interval_ts: + start_interval_ts -= interval + return start_interval_ts + + +def _ord_to_ts(ord): + """Convert from ordinal date to unix epoch time. + + Args: + ord (int): A proleptic Gregorian ordinal. + + Returns: + int: Unix epoch time of the start of the corresponding day. + """ + d = datetime.date.fromordinal(ord) + t = int(time.mktime(d.timetuple())) + return t + + +# =============================================================================== +# What follows is a bunch of "time span" routines. Generally, time spans +# are used when start and stop times fall on calendar boundaries +# such as days, months, years. So, it makes sense to talk of "daySpans", +# "weekSpans", etc. They are generally not used between two random times. +# =============================================================================== + +class TimeSpan(tuple): + """Represents a time span, exclusive on the left, inclusive on the right.""" + + def __new__(cls, *args): + if args[0] > args[1]: + raise ValueError("start time (%d) is greater than stop time (%d)" % (args[0], args[1])) + return tuple.__new__(cls, args) + + @property + def start(self): + return self[0] + + @property + def stop(self): + return self[1] + + @property + def length(self): + return self[1] - self[0] + + def includesArchiveTime(self, timestamp): + """ + Returns True if the span includes the time timestamp, otherwise False. + + timestamp: The timestamp to be tested. + """ + return self.start < timestamp <= self.stop + + def includes(self, span): + + return self.start <= span.start <= self.stop and self.start <= span.stop <= self.stop + + def __eq__(self, other): + return self.start == other.start and self.stop == other.stop + + def __str__(self): + return "[%s -> %s]" % (timestamp_to_string(self.start), + timestamp_to_string(self.stop)) + + def __hash__(self): + return hash(self.start) ^ hash(self.stop) + + +nominal_intervals = { + 'hour': 3600, + 'day': 86400, + 'week': 7 * 86400, + 'month': int(365.25 / 12 * 86400), + 'year': int(365.25 * 86400), +} + + +def nominal_spans(label): + """Convert a (possible) string into an integer time.""" + if label is None: + return None + try: + # Is the label either an integer, or something that can be converted into an integer? + interval = int(label) + except ValueError: + # Is it in our list of nominal spans? If not, fail hard. + interval = nominal_intervals[label.lower()] + return interval + + +def isStartOfDay(time_ts): + """Is the indicated time at the start of the day, local time? + + This algorithm will work even in countries that switch to DST at midnight, such as Brazil. + + Args: + time_ts (float): A unix epoch timestamp. + + Returns: + bool: True if the timestamp is at midnight, False otherwise. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> print(isStartOfDay(time_ts)) + False + >>> time_ts = time.mktime(time.strptime("2013-07-04 00:00:00", "%Y-%m-%d %H:%M:%S")) + >>> print(isStartOfDay(time_ts)) + True + >>> os.environ['TZ'] = 'America/Sao_Paulo' + >>> time.tzset() + >>> time_ts = 1541300400 + >>> print(isStartOfDay(time_ts)) + True + >>> print(isStartOfDay(time_ts - 1)) + False + """ + + # Test the date of the time against the date a tenth of a second before. + # If they do not match, the time must have been the start of the day + dt1 = datetime.date.fromtimestamp(time_ts) + dt2 = datetime.date.fromtimestamp(time_ts - .1) + return not dt1 == dt2 + + +def isMidnight(time_ts): + """Is the indicated time on a midnight boundary, local time? + NB: This algorithm does not work in countries that switch to DST + at midnight, such as Brazil. + + Args: + time_ts (float): A unix epoch timestamp. + + Returns: + bool: True if the timestamp is at midnight, False otherwise. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> print(isMidnight(time_ts)) + False + >>> time_ts = time.mktime(time.strptime("2013-07-04 00:00:00", "%Y-%m-%d %H:%M:%S")) + >>> print(isMidnight(time_ts)) + True + """ + + time_tt = time.localtime(time_ts) + return time_tt.tm_hour == 0 and time_tt.tm_min == 0 and time_tt.tm_sec == 0 + + +def archiveSpanSpan(time_ts, time_delta=0, hour_delta=0, day_delta=0, week_delta=0, month_delta=0, + year_delta=0, boundary=None): + """ Returns a TimeSpan for the last xxx seconds where xxx equals + time_delta sec + hour_delta hours + day_delta days + week_delta weeks + month_delta months + year_delta years + + NOTE: Use of month_delta and year_delta is deprecated. + See issue #436 (https://github.com/weewx/weewx/issues/436) + + Example: + >>> os.environ['TZ'] = 'Australia/Brisbane' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2015-07-21 09:05:35", "%Y-%m-%d %H:%M:%S")) + >>> print(archiveSpanSpan(time_ts, time_delta=3600)) + [2015-07-21 08:05:35 AEST (1437429935) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, hour_delta=6)) + [2015-07-21 03:05:35 AEST (1437411935) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, day_delta=1)) + [2015-07-20 09:05:35 AEST (1437347135) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, time_delta=3600, day_delta=1)) + [2015-07-20 08:05:35 AEST (1437343535) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, week_delta=4)) + [2015-06-23 09:05:35 AEST (1435014335) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, month_delta=1)) + [2015-06-21 09:05:35 AEST (1434841535) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, year_delta=1)) + [2014-07-21 09:05:35 AEST (1405897535) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts)) + [2015-07-21 09:05:34 AEST (1437433534) -> 2015-07-21 09:05:35 AEST (1437433535)] + + Example over a DST boundary. Because Brisbane does not observe DST, we need to + switch timezones. + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1457888400 + >>> print(timestamp_to_string(time_ts)) + 2016-03-13 10:00:00 PDT (1457888400) + >>> span = archiveSpanSpan(time_ts, day_delta=1) + >>> print(span) + [2016-03-12 10:00:00 PST (1457805600) -> 2016-03-13 10:00:00 PDT (1457888400)] + + Note that there is not 24 hours of time over this span: + >>> print((span.stop - span.start) / 3600.0) + 23.0 + """ + + if time_ts is None: + return None + + # Use a datetime.timedelta so that it can take DST into account: + time_dt = datetime.datetime.fromtimestamp(time_ts) + time_dt -= datetime.timedelta(weeks=week_delta, days=day_delta, hours=hour_delta, + seconds=time_delta) + + # Now add the deltas for months and years. Because these can be variable in length, + # some special arithmetic is needed. Start by calculating the number of + # months since 0 AD: + total_months = 12 * time_dt.year + time_dt.month - 1 - 12 * year_delta - month_delta + # Convert back from total months since 0 AD to year and month: + year = total_months // 12 + month = total_months % 12 + 1 + # Apply the delta to our datetime object + start_dt = time_dt.replace(year=year, month=month) + + # Finally, convert to unix epoch time + if boundary is None: + start_ts = int(time.mktime(start_dt.timetuple())) + if start_ts == time_ts: + start_ts -= 1 + elif boundary.lower() == 'midnight': + start_ts = _ord_to_ts(start_dt.toordinal()) + else: + raise ValueError("Unknown boundary %s" % boundary) + + return TimeSpan(start_ts, time_ts) + + +def archiveHoursAgoSpan(time_ts, hours_ago=0): + """Returns a one-hour long TimeSpan for x hours ago that includes the given time. + + Args: + time_ts (float): A timestamp. An hour long time span will be returned that encompasses this + timestamp. + hours_ago (int, optional): Which hour we want. 0=this hour, 1=last hour, etc. Default is + zero (this hour). + + Returns: + TimeSpan: A TimeSpan object one hour long, that includes time_ts. + + NB: A timestamp that falls exactly on the hour boundary is considered to belong + to the *previous* hour. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=0)) + [2013-07-04 01:00:00 PDT (1372924800) -> 2013-07-04 02:00:00 PDT (1372928400)] + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=2)) + [2013-07-03 23:00:00 PDT (1372917600) -> 2013-07-04 00:00:00 PDT (1372921200)] + >>> time_ts = time.mktime(datetime.date(2013, 7, 4).timetuple()) + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=0)) + [2013-07-03 23:00:00 PDT (1372917600) -> 2013-07-04 00:00:00 PDT (1372921200)] + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=24)) + [2013-07-02 23:00:00 PDT (1372831200) -> 2013-07-03 00:00:00 PDT (1372834800)] + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at an hour boundary, the start of the archive hour is actually + # the *previous* hour. + if time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + hours_ago += 1 + + # Find the start of the hour + start_of_hour_dt = time_dt.replace(minute=0, second=0, microsecond=0) + + start_span_dt = start_of_hour_dt - datetime.timedelta(hours=hours_ago) + stop_span_dt = start_span_dt + datetime.timedelta(hours=1) + + return TimeSpan(int(time.mktime(start_span_dt.timetuple())), + int(time.mktime(stop_span_dt.timetuple()))) + + +def daySpan(time_ts, days_ago=0, archive=False): + """Returns a one-day long TimeSpan for x days ago that includes a given time. + + Args: + time_ts (float): The day will include this timestamp. + days_ago (int): Which day we want. 0=today, 1=yesterday, etc. + archive (bool, optional): True to calculate archive day; false otherwise. + + Returns: + TimeSpan: A TimeSpan object one day long. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2014-01-01 01:57:35", "%Y-%m-%d %H:%M:%S")) + + As for today: + >>> print(daySpan(time_ts)) + [2014-01-01 00:00:00 PST (1388563200) -> 2014-01-02 00:00:00 PST (1388649600)] + + Do it again, but on the midnight boundary + >>> time_ts = time.mktime(time.strptime("2014-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")) + + We should still get today (this differs from the function archiveDaySpan()) + >>> print(daySpan(time_ts)) + [2014-01-01 00:00:00 PST (1388563200) -> 2014-01-02 00:00:00 PST (1388649600)] +""" + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + if archive: + # If we are exactly at midnight, the start of the archive day is actually + # the *previous* day + if time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + days_ago += 1 + + # Find the start of the day + start_of_day_dt = time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + + start_span_dt = start_of_day_dt - datetime.timedelta(days=days_ago) + stop_span_dt = start_span_dt + datetime.timedelta(days=1) + + return TimeSpan(int(time.mktime(start_span_dt.timetuple())), + int(time.mktime(stop_span_dt.timetuple()))) + + +def archiveDaySpan(time_ts, days_ago=0): + """Returns a one-day long TimeSpan for x days ago that includes a given time. + + Args: + time_ts (float): The day will include this timestamp. + days_ago (int): Which day we want. 0=today, 1=yesterday, etc. + + Returns: + TimeSpan: A TimeSpan object one day long. + + NB: A timestamp that falls exactly on midnight is considered to belong + to the *previous* day. + + Example, which spans the end-of-year boundary + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2014-01-01 01:57:35", "%Y-%m-%d %H:%M:%S")) + + As for today: + >>> print(archiveDaySpan(time_ts)) + [2014-01-01 00:00:00 PST (1388563200) -> 2014-01-02 00:00:00 PST (1388649600)] + + Ask for yesterday: + >>> print(archiveDaySpan(time_ts, days_ago=1)) + [2013-12-31 00:00:00 PST (1388476800) -> 2014-01-01 00:00:00 PST (1388563200)] + + Day before yesterday + >>> print(archiveDaySpan(time_ts, days_ago=2)) + [2013-12-30 00:00:00 PST (1388390400) -> 2013-12-31 00:00:00 PST (1388476800)] + + Do it again, but on the midnight boundary + >>> time_ts = time.mktime(time.strptime("2014-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")) + + This time, we should get the previous day + >>> print(archiveDaySpan(time_ts)) + [2013-12-31 00:00:00 PST (1388476800) -> 2014-01-01 00:00:00 PST (1388563200)] + """ + return daySpan(time_ts, days_ago, True) + + +# For backwards compatibility. Not sure if anyone is actually using this +archiveDaysAgoSpan = archiveDaySpan + + +def archiveWeekSpan(time_ts, startOfWeek=6, weeks_ago=0): + """Returns a one-week long TimeSpan for x weeks ago that includes a given time. + + Args: + time_ts (float): The week will include this timestamp. + startOfWeek (int, optional): The start of the week (0=Monday, 1=Tues, ..., 6 = Sun). + weeks_ago (int, optional): Which week we want. 0=this week, 1=last week, etc. + + Returns: + TimeSpan: A TimeSpan object one week long that contains time_ts. It will + start at midnight of the day considered the start of the week. + + NB: The time at midnight at the end of the week is considered to + actually belong in the previous week. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1483429962 + >>> print(timestamp_to_string(time_ts)) + 2017-01-02 23:52:42 PST (1483429962) + >>> print(archiveWeekSpan(time_ts)) + [2017-01-01 00:00:00 PST (1483257600) -> 2017-01-08 00:00:00 PST (1483862400)] + >>> print(archiveWeekSpan(time_ts, weeks_ago=1)) + [2016-12-25 00:00:00 PST (1482652800) -> 2017-01-01 00:00:00 PST (1483257600)] + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # Find the start of the day: + start_of_day_dt = time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + + # Find the relative start of the week + day_of_week = start_of_day_dt.weekday() + delta = day_of_week - startOfWeek + if delta < 0: + delta += 7 + + # If we are exactly at midnight, the start of the archive week is actually + # the *previous* week + if day_of_week == startOfWeek \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + delta += 7 + + # Finally, find the start of the requested week. + delta += weeks_ago * 7 + + start_of_week = start_of_day_dt - datetime.timedelta(days=delta) + end_of_week = start_of_week + datetime.timedelta(days=7) + + return TimeSpan(int(time.mktime(start_of_week.timetuple())), + int(time.mktime(end_of_week.timetuple()))) + + +def archiveMonthSpan(time_ts, months_ago=0): + """Returns a one-month long TimeSpan for x months ago that includes a given time. + + Args: + time_ts (float): The month will include this timestamp. + months_ago (int, optional): Which month we want. 0=this month, 1=last month, etc. + + Returns: + TimeSpan: A TimeSpan object one month long that contains time_ts. + + NB: The time at midnight at the end of the month is considered to + actually belong in the previous week. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1483429962 + >>> print(timestamp_to_string(time_ts)) + 2017-01-02 23:52:42 PST (1483429962) + >>> print(archiveMonthSpan(time_ts)) + [2017-01-01 00:00:00 PST (1483257600) -> 2017-02-01 00:00:00 PST (1485936000)] + >>> print(archiveMonthSpan(time_ts, months_ago=1)) + [2016-12-01 00:00:00 PST (1480579200) -> 2017-01-01 00:00:00 PST (1483257600)] + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at midnight of the first day of the month, + # the start of the archive month is actually the *previous* month + if time_dt.day == 1 \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + months_ago += 1 + + # Find the start of the month + start_of_month_dt = time_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # Total number of months since 0AD + total_months = 12 * start_of_month_dt.year + start_of_month_dt.month - 1 + + # Adjust for the requested delta: + total_months -= months_ago + + # Now rebuild the date + start_year = total_months // 12 + start_month = total_months % 12 + 1 + start_date = datetime.date(year=start_year, month=start_month, day=1) + + # Advance to the start of the next month. This will be the end of the time span. + total_months += 1 + stop_year = total_months // 12 + stop_month = total_months % 12 + 1 + stop_date = datetime.date(year=stop_year, month=stop_month, day=1) + + return TimeSpan(int(time.mktime(start_date.timetuple())), + int(time.mktime(stop_date.timetuple()))) + + +def archiveYearSpan(time_ts, years_ago=0): + """Returns a TimeSpan representing a year that includes a given time. + + Args: + time_ts (float): The year will include this timestamp. + years_ago (int, optional): Which year we want. 0=this year, 1=last year, etc. + + Returns: + TimeSpan: A TimeSpan object one year long that contains time_ts. It will + start at midnight of 1-Jan + + NB: Midnight of the 1st of the January is considered to actually belong + in the previous year. + """ + + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at midnight 1-Jan, then the start of the archive year is actually + # the *previous* year + if time_dt.month == 1 \ + and time_dt.day == 1 \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + years_ago += 1 + + return TimeSpan(int(time.mktime((time_dt.year - years_ago, 1, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((time_dt.year - years_ago + 1, 1, 1, 0, 0, 0, 0, 0, -1)))) + + +def archiveRainYearSpan(time_ts, sory_mon, years_ago=0): + """Returns a TimeSpan representing a rain year that includes a given time. + + Args: + time_ts (float): The rain year will include this timestamp. + sory_mon (int): The start of the rain year (1=Jan, 2=Feb, etc.) + years_ago (int, optional): Which rain year we want. 0=this year, 1=last year, etc. + + Returns: + TimeSpan: A one-year long TimeSpan object containing the timestamp. + + NB: Midnight of the 1st of the month starting the rain year is considered to + actually belong in the previous rain year. + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at midnight of the start of the rain year, then the start is actually + # the *previous* year + if time_dt.month == sory_mon \ + and time_dt.day == 1 \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + years_ago += 1 + + if time_dt.month < sory_mon: + years_ago += 1 + + year = time_dt.year - years_ago + + return TimeSpan(int(time.mktime((year, sory_mon, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((year + 1, sory_mon, 1, 0, 0, 0, 0, 0, -1)))) + + +def timespan_by_name(label, time_ts, **kwargs): + """Calculate an an appropriate TimeSpan""" + return { + 'hour': archiveHoursAgoSpan, + 'day': archiveDaySpan, + 'week': archiveWeekSpan, + 'month': archiveMonthSpan, + 'year': archiveYearSpan, + 'rainyear': archiveRainYearSpan + }[label](time_ts, **kwargs) + + +def stampgen(startstamp, stopstamp, interval): + """Generator function yielding a sequence of timestamps, spaced interval apart. + + The sequence will fall on the same local time boundary as startstamp. + + Args: + startstamp (float): The start of the sequence in unix epoch time. + stopstamp (float): The end of the sequence in unix epoch time. + interval (int): The time length of an interval in seconds. + + Yields: + float: yields a sequence of timestamps between startstamp and endstamp, inclusive. + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> startstamp = 1236560400 + >>> print(timestamp_to_string(startstamp)) + 2009-03-08 18:00:00 PDT (1236560400) + >>> stopstamp = 1236607200 + >>> print(timestamp_to_string(stopstamp)) + 2009-03-09 07:00:00 PDT (1236607200) + + >>> for stamp in stampgen(startstamp, stopstamp, 10800): + ... print(timestamp_to_string(stamp)) + 2009-03-08 18:00:00 PDT (1236560400) + 2009-03-08 21:00:00 PDT (1236571200) + 2009-03-09 00:00:00 PDT (1236582000) + 2009-03-09 03:00:00 PDT (1236592800) + 2009-03-09 06:00:00 PDT (1236603600) + + Note that DST started in the middle of the sequence and that therefore the + actual time deltas between stamps is not necessarily 3 hours. + """ + dt = datetime.datetime.fromtimestamp(startstamp) + stop_dt = datetime.datetime.fromtimestamp(stopstamp) + if interval == 365.25 / 12 * 24 * 3600: + # Interval is a nominal month. This algorithm is + # necessary because not all months have the same length. + while dt <= stop_dt: + t_tuple = dt.timetuple() + yield time.mktime(t_tuple) + year = t_tuple[0] + month = t_tuple[1] + month += 1 + if month > 12: + month -= 12 + year += 1 + dt = dt.replace(year=year, month=month) + else: + # This rather complicated algorithm is necessary (rather than just + # doing some time stamp arithmetic) because of the possibility that DST + # changes in the middle of an interval. + delta = datetime.timedelta(seconds=interval) + ts_last = 0 + while dt <= stop_dt: + ts = int(time.mktime(dt.timetuple())) + # This check is necessary because time.mktime() cannot + # disambiguate between 2am ST and 3am DST. For example, + # time.mktime((2013, 3, 10, 2, 0, 0, 0, 0, -1)) and + # time.mktime((2013, 3, 10, 3, 0, 0, 0, 0, -1)) + # both give the same value (1362909600) + if ts > ts_last: + yield ts + ts_last = ts + dt += delta + + +def intervalgen(start_ts, stop_ts, interval): + """Generator function yielding a sequence of time spans whose boundaries + are on constant local time. + + Args: + start_ts (float): The start of the first interval in unix epoch time. In unix epoch time. + stop_ts (float): The end of the last interval will be equal to or less than this. + In unix epoch time. + interval (int): The time length of an interval in seconds. + + Yields: + TimeSpan: A sequence of TimeSpans. Both the start and end of the timespan + will be on the same time boundary as start_ts. See the example below. + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> startstamp = 1236477600 + >>> print(timestamp_to_string(startstamp)) + 2009-03-07 18:00:00 PST (1236477600) + >>> stopstamp = 1236538800 + >>> print(timestamp_to_string(stopstamp)) + 2009-03-08 12:00:00 PDT (1236538800) + + >>> for span in intervalgen(startstamp, stopstamp, 10800): + ... print(span) + [2009-03-07 18:00:00 PST (1236477600) -> 2009-03-07 21:00:00 PST (1236488400)] + [2009-03-07 21:00:00 PST (1236488400) -> 2009-03-08 00:00:00 PST (1236499200)] + [2009-03-08 00:00:00 PST (1236499200) -> 2009-03-08 03:00:00 PDT (1236506400)] + [2009-03-08 03:00:00 PDT (1236506400) -> 2009-03-08 06:00:00 PDT (1236517200)] + [2009-03-08 06:00:00 PDT (1236517200) -> 2009-03-08 09:00:00 PDT (1236528000)] + [2009-03-08 09:00:00 PDT (1236528000) -> 2009-03-08 12:00:00 PDT (1236538800)] + + (Note how in this example the local time boundaries are constant, despite + DST kicking in. The interval length is not constant.) + + Another example, this one over the Fall DST boundary, and using 1 hour intervals: + + >>> startstamp = 1257051600 + >>> print(timestamp_to_string(startstamp)) + 2009-10-31 22:00:00 PDT (1257051600) + >>> stopstamp = 1257080400 + >>> print(timestamp_to_string(stopstamp)) + 2009-11-01 05:00:00 PST (1257080400) + >>> for span in intervalgen(startstamp, stopstamp, 3600): + ... print(span) + [2009-10-31 22:00:00 PDT (1257051600) -> 2009-10-31 23:00:00 PDT (1257055200)] + [2009-10-31 23:00:00 PDT (1257055200) -> 2009-11-01 00:00:00 PDT (1257058800)] + [2009-11-01 00:00:00 PDT (1257058800) -> 2009-11-01 01:00:00 PDT (1257062400)] + [2009-11-01 01:00:00 PDT (1257062400) -> 2009-11-01 02:00:00 PST (1257069600)] + [2009-11-01 02:00:00 PST (1257069600) -> 2009-11-01 03:00:00 PST (1257073200)] + [2009-11-01 03:00:00 PST (1257073200) -> 2009-11-01 04:00:00 PST (1257076800)] + [2009-11-01 04:00:00 PST (1257076800) -> 2009-11-01 05:00:00 PST (1257080400)] +""" + + dt1 = datetime.datetime.fromtimestamp(start_ts) + stop_dt = datetime.datetime.fromtimestamp(stop_ts) + + # If a string was passed in, convert to seconds using nominal time intervals. + interval = nominal_spans(interval) + + if interval == 365.25 / 12 * 24 * 3600: + # Interval is a nominal month. This algorithm is + # necessary because not all months have the same length. + while dt1 < stop_dt: + t_tuple = dt1.timetuple() + year = t_tuple[0] + month = t_tuple[1] + month += 1 + if month > 12: + month -= 12 + year += 1 + dt2 = min(dt1.replace(year=year, month=month), stop_dt) + stamp1 = time.mktime(t_tuple) + stamp2 = time.mktime(dt2.timetuple()) + yield TimeSpan(stamp1, stamp2) + dt1 = dt2 + else: + # This rather complicated algorithm is necessary (rather than just + # doing some time stamp arithmetic) because of the possibility that DST + # changes in the middle of an interval + delta = datetime.timedelta(seconds=interval) + last_stamp1 = 0 + while dt1 < stop_dt: + dt2 = min(dt1 + delta, stop_dt) + stamp1 = int(time.mktime(dt1.timetuple())) + stamp2 = int(time.mktime(dt2.timetuple())) + if stamp2 > stamp1 > last_stamp1: + yield TimeSpan(stamp1, stamp2) + last_stamp1 = stamp1 + dt1 = dt2 + + +def genHourSpans(start_ts, stop_ts): + """Generator function that generates start/stop of hours in an inclusive range. + + Args: + start_ts (float): A time stamp somewhere in the first day. + stop_ts (float): A time stamp somewhere in the last day. + + Yields: + TimeSpan: Instance of TimeSpan, where the start is the time stamp + of the start of the day, the stop is the time stamp of the start + of the next day. + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = 1204796460 + >>> stop_ts = 1204818360 + + >>> print(timestamp_to_string(start_ts)) + 2008-03-06 01:41:00 PST (1204796460) + >>> print(timestamp_to_string(stop_ts)) + 2008-03-06 07:46:00 PST (1204818360) + + >>> for span in genHourSpans(start_ts, stop_ts): + ... print(span) + [2008-03-06 01:00:00 PST (1204794000) -> 2008-03-06 02:00:00 PST (1204797600)] + [2008-03-06 02:00:00 PST (1204797600) -> 2008-03-06 03:00:00 PST (1204801200)] + [2008-03-06 03:00:00 PST (1204801200) -> 2008-03-06 04:00:00 PST (1204804800)] + [2008-03-06 04:00:00 PST (1204804800) -> 2008-03-06 05:00:00 PST (1204808400)] + [2008-03-06 05:00:00 PST (1204808400) -> 2008-03-06 06:00:00 PST (1204812000)] + [2008-03-06 06:00:00 PST (1204812000) -> 2008-03-06 07:00:00 PST (1204815600)] + [2008-03-06 07:00:00 PST (1204815600) -> 2008-03-06 08:00:00 PST (1204819200)] + """ + _stop_dt = datetime.datetime.fromtimestamp(stop_ts) + _start_hour = int(start_ts / 3600) + _stop_hour = int(stop_ts / 3600) + if (_stop_dt.minute, _stop_dt.second) == (0, 0): + _stop_hour -= 1 + + for _hour in range(_start_hour, _stop_hour + 1): + yield TimeSpan(_hour * 3600, (_hour + 1) * 3600) + + +def genDaySpans(start_ts, stop_ts): + """Generator function that generates start/stop of days in an inclusive range. + + Args: + + start_ts (float): A time stamp somewhere in the first day. + stop_ts (float): A time stamp somewhere in the last day. + + Yields: + TimeSpan: A sequence of TimeSpans, where the start is the time stamp + of the start of the day, the stop is the time stamp of the start + of the next day. + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = 1204796460 + >>> stop_ts = 1205265720 + + >>> print(timestamp_to_string(start_ts)) + 2008-03-06 01:41:00 PST (1204796460) + >>> print(timestamp_to_string(stop_ts)) + 2008-03-11 13:02:00 PDT (1205265720) + + >>> for span in genDaySpans(start_ts, stop_ts): + ... print(span) + [2008-03-06 00:00:00 PST (1204790400) -> 2008-03-07 00:00:00 PST (1204876800)] + [2008-03-07 00:00:00 PST (1204876800) -> 2008-03-08 00:00:00 PST (1204963200)] + [2008-03-08 00:00:00 PST (1204963200) -> 2008-03-09 00:00:00 PST (1205049600)] + [2008-03-09 00:00:00 PST (1205049600) -> 2008-03-10 00:00:00 PDT (1205132400)] + [2008-03-10 00:00:00 PDT (1205132400) -> 2008-03-11 00:00:00 PDT (1205218800)] + [2008-03-11 00:00:00 PDT (1205218800) -> 2008-03-12 00:00:00 PDT (1205305200)] + + Note that a daylight savings time change happened 8 March 2009. + """ + _start_dt = datetime.datetime.fromtimestamp(start_ts) + _stop_dt = datetime.datetime.fromtimestamp(stop_ts) + + _start_ord = _start_dt.toordinal() + _stop_ord = _stop_dt.toordinal() + if (_stop_dt.hour, _stop_dt.minute, _stop_dt.second) == (0, 0, 0): + _stop_ord -= 1 + + for _ord in range(_start_ord, _stop_ord + 1): + yield TimeSpan(_ord_to_ts(_ord), _ord_to_ts(_ord + 1)) + + +def genMonthSpans(start_ts, stop_ts): + """Generator function that generates start/stop of months in an + inclusive range. + + Args: + start_ts: A time stamp somewhere in the first month. + stop_ts: A time stamp somewhere in the last month. + + Yields: + TimeSpan: A sequence of TimeSpans, where the start is the time stamp + of the start of the month, the stop is the time stamp of the start + of the next month. + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = 1196705700 + >>> stop_ts = 1206101100 + >>> print("start time is %s" % timestamp_to_string(start_ts)) + start time is 2007-12-03 10:15:00 PST (1196705700) + >>> print("stop time is %s" % timestamp_to_string(stop_ts)) + stop time is 2008-03-21 05:05:00 PDT (1206101100) + + >>> for span in genMonthSpans(start_ts, stop_ts): + ... print(span) + [2007-12-01 00:00:00 PST (1196496000) -> 2008-01-01 00:00:00 PST (1199174400)] + [2008-01-01 00:00:00 PST (1199174400) -> 2008-02-01 00:00:00 PST (1201852800)] + [2008-02-01 00:00:00 PST (1201852800) -> 2008-03-01 00:00:00 PST (1204358400)] + [2008-03-01 00:00:00 PST (1204358400) -> 2008-04-01 00:00:00 PDT (1207033200)] + + Note that a daylight savings time change happened 8 March 2009. + """ + if None in (start_ts, stop_ts): + return + _start_dt = datetime.date.fromtimestamp(start_ts) + _stop_date = datetime.datetime.fromtimestamp(stop_ts) + + _start_month = 12 * _start_dt.year + _start_dt.month + _stop_month = 12 * _stop_date.year + _stop_date.month + + if (_stop_date.day, _stop_date.hour, _stop_date.minute, _stop_date.second) == (1, 0, 0, 0): + _stop_month -= 1 + + for month in range(_start_month, _stop_month + 1): + _this_yr, _this_mo = divmod(month, 12) + _next_yr, _next_mo = divmod(month + 1, 12) + yield TimeSpan(int(time.mktime((_this_yr, _this_mo, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((_next_yr, _next_mo, 1, 0, 0, 0, 0, 0, -1)))) + + +def genYearSpans(start_ts, stop_ts): + if None in (start_ts, stop_ts): + return + _start_date = datetime.date.fromtimestamp(start_ts) + _stop_dt = datetime.datetime.fromtimestamp(stop_ts) + + _start_year = _start_date.year + _stop_year = _stop_dt.year + + if (_stop_dt.month, _stop_dt.day, _stop_dt.hour, + _stop_dt.minute, _stop_dt.second) == (1, 1, 0, 0, 0): + _stop_year -= 1 + + for year in range(_start_year, _stop_year + 1): + yield TimeSpan(int(time.mktime((year, 1, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((year + 1, 1, 1, 0, 0, 0, 0, 0, -1)))) + + +def startOfDay(time_ts): + """Calculate the unix epoch time for the start of a (local time) day. + + Args: + + time_ts (float): A timestamp somewhere in the day for which the start-of-day + is desired. + + Returns: + float: The timestamp for the start-of-day (00:00) in unix epoch time. + + """ + _time_tt = time.localtime(time_ts) + _bod_ts = time.mktime((_time_tt.tm_year, + _time_tt.tm_mon, + _time_tt.tm_mday, + 0, 0, 0, 0, 0, -1)) + return int(_bod_ts) + + +def startOfGregorianDay(date_greg): + """Given a Gregorian day, returns the start of the day in unix epoch time. + + Args: + date_greg (int): A date as an ordinal Gregorian day. + + Returns: + int: The local start of the day as a unix epoch time. + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> date_greg = 735973 # 10-Jan-2016 + >>> print(startOfGregorianDay(date_greg)) + 1452412800 + """ + date_dt = datetime.datetime.fromordinal(date_greg) + date_tt = date_dt.timetuple() + sod_ts = int(time.mktime(date_tt)) + return sod_ts + + +def toGregorianDay(time_ts): + """Return the Gregorian day a timestamp belongs to. + + Args: + time_ts (float): A time in unix epoch time. + + Returns: + int: The ordinal Gregorian day that contains that time + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1452412800 # Midnight, 10-Jan-2016 + >>> print(toGregorianDay(time_ts)) + 735972 + >>> time_ts = 1452412801 # Just after midnight, 10-Jan-2016 + >>> print(toGregorianDay(time_ts)) + 735973 + """ + + date_dt = datetime.datetime.fromtimestamp(time_ts) + date_greg = date_dt.toordinal() + if date_dt.hour == date_dt.minute == date_dt.second == date_dt.microsecond == 0: + # Midnight actually belongs to the previous day + date_greg -= 1 + return date_greg + + +def startOfDayUTC(time_ts): + """Calculate the unix epoch time for the start of a UTC day. + + Args: + time_ts (float): A timestamp somewhere in the day for which the start-of-day + is desired. + + Returns: + int: The timestamp for the start-of-day (00:00) in unix epoch time. + + """ + _time_tt = time.gmtime(time_ts) + _bod_ts = calendar.timegm((_time_tt.tm_year, + _time_tt.tm_mon, + _time_tt.tm_mday, + 0, 0, 0, 0, 0, -1)) + return int(_bod_ts) + + +def startOfArchiveDay(time_ts): + """Given an archive time stamp, calculate its start of day. + + Similar to startOfDay(), except that an archive stamped at midnight + actually belongs to the *previous* day. + + Args: + time_ts (float): A timestamp somewhere in the day for which the start-of-day + is desired. + + Returns: + float: The timestamp for the start-of-day (00:00) in unix epoch time.""" + + time_dt = datetime.datetime.fromtimestamp(time_ts) + start_of_day_dt = time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + # If we are exactly on the midnight boundary, the start of the archive day is actually + # the *previous* day. + if time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + start_of_day_dt -= datetime.timedelta(days=1) + start_of_day_tt = start_of_day_dt.timetuple() + start_of_day_ts = int(time.mktime(start_of_day_tt)) + return start_of_day_ts + + +def getDayNightTransitions(start_ts, end_ts, lat, lon): + """Return the day-night transitions between the start and end times. + + Args: + + start_ts (float or int): A timestamp (UTC) indicating the beginning of the period + end_ts (float or int): A timestamp (UTC) indicating the end of the period + lat (float): The latitude in degrees + lon (float): The longitude in degrees + + Returns: + tuple: A two-way tuple, The first element is either the string 'day' or 'night'. + If 'day', the first transition is from day to night. + If 'night', the first transition is from night to day. + The second element is a sequence of transition times in unix epoch times. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> startstamp = 1658428400 + >>> # Stop stamp is three days later: + >>> stopstamp = startstamp + 3 * 24 * 3600 + >>> print(timestamp_to_string(startstamp)) + 2022-07-21 11:33:20 PDT (1658428400) + >>> print(timestamp_to_string(stopstamp)) + 2022-07-24 11:33:20 PDT (1658687600) + >>> whichway, transitions = getDayNightTransitions(startstamp, stopstamp, 45, -122) + >>> print(whichway) + day + >>> for x in transitions: + ... print(timestamp_to_string(x)) + 2022-07-21 20:47:00 PDT (1658461620) + 2022-07-22 05:42:58 PDT (1658493778) + 2022-07-22 20:46:02 PDT (1658547962) + 2022-07-23 05:44:00 PDT (1658580240) + 2022-07-23 20:45:03 PDT (1658634303) + 2022-07-24 05:45:04 PDT (1658666704) + """ + from weeutil import Sun + + start_ts = int(start_ts) + end_ts = int(end_ts) + + first = None + values = [] + for t in range(start_ts - 3600 * 24, end_ts + 3600 * 24 + 1, 3600 * 24): + x = startOfDayUTC(t) + x_tt = time.gmtime(x) + y, m, d = x_tt[:3] + (sunrise_utc, sunset_utc) = Sun.sunRiseSet(y, m, d, lon, lat) + daystart_ts = calendar.timegm((y, m, d, 0, 0, 0, 0, 0, -1)) + sunrise_ts = int(daystart_ts + sunrise_utc * 3600.0 + 0.5) + sunset_ts = int(daystart_ts + sunset_utc * 3600.0 + 0.5) + + if start_ts < sunrise_ts < end_ts: + values.append(sunrise_ts) + if first is None: + first = 'night' + if start_ts < sunset_ts < end_ts: + values.append(sunset_ts) + if first is None: + first = 'day' + return first, values + + +# The following has been replaced by the I18N-aware function delta_secs_to_string in units.py: + +# def secs_to_string(secs): +# """Convert seconds to a string with days, hours, and minutes""" +# str_list = [] +# for (label, interval) in (('day', 86400), ('hour', 3600), ('minute', 60)): +# amt = int(secs / interval) +# plural = u'' if amt == 1 else u's' +# str_list.append(u"%d %s%s" % (amt, label, plural)) +# secs %= interval +# ans = ', '.join(str_list) +# return ans + + +def timestamp_to_string(ts, format_str="%Y-%m-%d %H:%M:%S %Z"): + """Return a string formatted from the timestamp + + Example: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> print(timestamp_to_string(1196705700)) + 2007-12-03 10:15:00 PST (1196705700) + >>> print(timestamp_to_string(None)) + ******* N/A ******* ( N/A ) + """ + if ts is not None: + return "%s (%d)" % (time.strftime(format_str, time.localtime(ts)), ts) + else: + return "******* N/A ******* ( N/A )" + + +def timestamp_to_gmtime(ts): + """Return a string formatted for GMT + + >>> print(timestamp_to_gmtime(1196705700)) + 2007-12-03 18:15:00 UTC (1196705700) + >>> print(timestamp_to_gmtime(None)) + ******* N/A ******* ( N/A ) + """ + if ts: + return "%s (%d)" % (time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(ts)), ts) + else: + return "******* N/A ******* ( N/A )" + + +def utc_to_ts(y, m, d, hrs_utc): + """Converts from a tuple-time in UTC to unix epoch time. + + Args: + + y (int): The year for which the conversion is desired. + m (int): The month. + d (int): The day. + hrs_utc (float): Floating point number with the number of hours since midnight in UTC. + + Returns: + int: The corresponding unix epoch time. + + >>> print(utc_to_ts(2009, 3, 27, 14.5)) + 1238164200 + """ + # Construct a time tuple with the time at midnight, UTC: + daystart_utc_tt = (y, m, d, 0, 0, 0, 0, 0, -1) + # Convert the time tuple to a time stamp and add on the number of seconds since midnight: + time_ts = int(calendar.timegm(daystart_utc_tt) + hrs_utc * 3600.0 + 0.5) + return time_ts + + +def utc_to_local_tt(y, m, d, hrs_utc): + """Converts from a UTC time to a local time. + + y,m,d: The year, month, day for which the conversion is desired. + + hrs_tc: Floating point number with the number of hours since midnight in UTC. + + Returns: A timetuple with the local time. + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> tt=utc_to_local_tt(2009, 3, 27, 14.5) + >>> print(tt.tm_year, tt.tm_mon, tt.tm_mday, tt.tm_hour, tt.tm_min) + 2009 3 27 7 30 + """ + # Get the UTC time: + time_ts = utc_to_ts(y, m, d, hrs_utc) + # Convert to local time: + time_local_tt = time.localtime(time_ts) + return time_local_tt + + +def latlon_string(ll, hemi, which, format_list=None): + """Decimal degrees into a string for degrees, and one for minutes. + + Args: + ll (float): The decimal latitude or longitude + hemi (list or tuple): A tuple holding strings representing positive or negative values. + E.g.: ('N', 'S') + which (str): 'lat' for latitude, 'long' for longitude + format_list (list or tuple): A list or tuple holding the format strings to be used. + These are [whole degrees latitude, whole degrees longitude, minutes] + + Returns: + tuple: A 3-way tuple holding (latlon whole degrees, latlon minutes, + hemisphere designator). Example: ('022', '08.3', 'N') + """ + labs = abs(ll) + frac, deg = math.modf(labs) + minutes = frac * 60.0 + format_list = format_list or ["%02d", "%03d", "%05.2f"] + return ((format_list[0] if which == 'lat' else format_list[1]) % deg, + format_list[2] % minutes, + hemi[0] if ll >= 0 else hemi[1]) + + +def get_object(module_class): + """Given a string with a module class name, it imports and returns the class.""" + # Split the path into its parts + parts = module_class.split('.') + # Strip off the classname: + module = '.'.join(parts[:-1]) + # Import the top level module + mod = __import__(module) + # Recursively work down from the top level module to the class name. + # Be prepared to catch an exception if something cannot be found. + try: + for part in parts[1:]: + mod = getattr(mod, part) + except AttributeError: + # Can't find something. Give a more informative error message: + raise AttributeError( + "Module '%s' has no attribute '%s' when searching for '%s'" + % (mod.__name__, part, module_class)) + return mod + + +# For backwards compatibility: +_get_object = get_object + + +class GenWithPeek(object): + """Generator object which allows a peek at the next object to be returned. + + Sometimes Python solves a complicated problem with such elegance! This is + one of them. + + Example of usage: + >>> # Define a generator function: + >>> def genfunc(N): + ... for j in range(N): + ... yield j + >>> + >>> # Now wrap it with the GenWithPeek object: + >>> g_with_peek = GenWithPeek(genfunc(5)) + >>> # We can iterate through the object as normal: + >>> for i in g_with_peek: + ... print(i) + ... # Every second object, let's take a peek ahead + ... if i%2: + ... # We can get a peek at the next object without disturbing the wrapped generator: + ... print("peeking ahead, the next object will be: %s" % g_with_peek.peek()) + 0 + 1 + peeking ahead, the next object will be: 2 + 2 + 3 + peeking ahead, the next object will be: 4 + 4 + """ + + def __init__(self, generator): + """Initialize the generator object. + + generator: A generator object to be wrapped + """ + self.generator = generator + self.have_peek = False + self.peek_obj = None + + def __iter__(self): + return self + + def __next__(self): + """Advance to the next object""" + if self.have_peek: + self.have_peek = False + return self.peek_obj + else: + return next(self.generator) + + # For Python 2: + next = __next__ + + def peek(self): + """Take a peek at the next object""" + if not self.have_peek: + self.peek_obj = next(self.generator) + self.have_peek = True + return self.peek_obj + + # For Python 3 compatiblity + __next__ = next + + +class GenByBatch(object): + """Generator wrapper. Calls the wrapped generator in batches of a specified size.""" + + def __init__(self, generator, batch_size=0): + """Initialize an instance of GenWithConvert + + generator: An iterator which will be wrapped. + + batch_size: The number of items to fetch in a batch. + """ + self.generator = generator + self.batch_size = batch_size + self.batch_buffer = [] + + def __iter__(self): + return self + + def __next__(self): + # If there isn't anything in the buffer, fetch new items + if not self.batch_buffer: + # Fetch in batches of 'batch_size'. + count = 0 + for item in self.generator: + self.batch_buffer.append(item) + count += 1 + # If batch_size is zero, that means fetch everything in one big batch, so keep + # going. Otherwise, break when we have fetched 'batch_size" items. + if self.batch_size and count >= self.batch_size: + break + # If there's still nothing in the buffer, we're done. Stop the iteration. Otherwise, + # return the first item in the buffer. + if self.batch_buffer: + return self.batch_buffer.pop(0) + else: + raise StopIteration + + # For Python 2: + next = __next__ + + +def tobool(x): + """Convert an object to boolean. + + Examples: + >>> print(tobool('TRUE')) + True + >>> print(tobool(True)) + True + >>> print(tobool(1)) + True + >>> print(tobool('FALSE')) + False + >>> print(tobool(False)) + False + >>> print(tobool(0)) + False + >>> print(tobool('Foo')) + Traceback (most recent call last): + ValueError: Unknown boolean specifier: 'Foo'. + >>> print(tobool(None)) + Traceback (most recent call last): + ValueError: Unknown boolean specifier: 'None'. + """ + + try: + if x.lower() in ('true', 'yes', 'y'): + return True + elif x.lower() in ('false', 'no', 'n'): + return False + except AttributeError: + pass + try: + return bool(int(x)) + except (ValueError, TypeError): + pass + raise ValueError("Unknown boolean specifier: '%s'." % x) + + +to_bool = tobool + + +def to_int(x): + """Convert an object to an integer, unless it is None + + Examples: + >>> print(to_int(123)) + 123 + >>> print(to_int('123')) + 123 + >>> print(to_int(-5.2)) + -5 + >>> print(to_int(None)) + None + """ + if isinstance(x, six.string_types) and x.lower() == 'none': + x = None + try: + return int(x) if x is not None else None + except ValueError: + # Perhaps it's a string, holding a floating point number? + return int(float(x)) + + +def to_float(x): + """Convert an object to a float, unless it is None + + Examples: + >>> print(to_float(12.3)) + 12.3 + >>> print(to_float('12.3')) + 12.3 + >>> print(to_float(None)) + None + """ + if isinstance(x, six.string_types) and x.lower() == 'none': + x = None + return float(x) if x is not None else None + + +def to_complex(magnitude, direction): + """Convert from magnitude and direction to a complex number.""" + if magnitude is None: + value = None + elif magnitude == 0: + # If magnitude is zero, it doesn't matter what direction is. Can even be None. + value = complex(0.0, 0.0) + elif direction is None: + # Magnitude must be non-zero, but we don't know the direction. + value = None + else: + # Magnitude is non-zero, and we have a good direction. + x = magnitude * math.cos(math.radians(90.0 - direction)) + y = magnitude * math.sin(math.radians(90.0 - direction)) + value = complex(x, y) + return value + + +def to_text(x): + """Ensure the results are in unicode, while honoring 'None'.""" + return six.ensure_text(x) if x is not None else None + + +def dirN(c): + """Given a complex number, return its phase as a compass heading""" + if c is None: + value = None + else: + value = (450 - math.degrees(cmath.phase(c))) % 360.0 + return value + + +class Polar(object): + """Polar notation, except the direction is a compass heading.""" + + def __init__(self, mag, dir): + self.mag = mag + self.dir = dir + + @classmethod + def from_complex(cls, c): + return cls(abs(c), dirN(c)) + + def __str__(self): + return "(%s, %s)" % (self.mag, self.dir) + + def __eq__(self, other): + return self.mag == other.mag and self.dir == other.dir + + +def rounder(x, ndigits): + """Round a number, or sequence of numbers, to a specified number of decimal digits + + Args: + x (None, float, complex, list): The number or sequence of numbers to be rounded. If the + argument is None, then None will be returned. + ndigits (int): The number of decimal digits to retain. + + Returns: + None, float, complex, list: Returns the number, or sequence of numbers, with the requested + number of decimal digits. If 'None', no rounding is done, and the function returns + the original value. + """ + if ndigits is None: + return x + elif x is None: + return None + elif isinstance(x, complex): + return complex(round(x.real, ndigits), round(x.imag, ndigits)) + elif isinstance(x, Polar): + return Polar(round(x.mag, ndigits), round(x.dir, ndigits)) + elif isinstance(x, float): + return round(x, ndigits) if ndigits else int(x) + elif is_iterable(x): + return [rounder(v, ndigits) for v in x] + return x + + +def min_with_none(x_seq): + """Find the minimum in a (possibly empty) sequence, ignoring Nones""" + xmin = None + for x in x_seq: + if xmin is None: + xmin = x + elif x is not None: + xmin = min(x, xmin) + return xmin + + +def max_with_none(x_seq): + """Find the maximum in a (possibly empty) sequence, ignoring Nones. + + While this function is not necessary under Python 2, under Python 3 it is. + """ + xmax = None + for x in x_seq: + if xmax is None: + xmax = x + elif x is not None: + xmax = max(x, xmax) + return xmax + + +def move_with_timestamp(filepath): + """Save a file to a path with a timestamp.""" + import shutil + # Sometimes the target has a trailing '/'. This will take care of it: + filepath = os.path.normpath(filepath) + newpath = filepath + time.strftime(".%Y%m%d%H%M%S") + # Check to see if this name already exists + if os.path.exists(newpath): + # It already exists. Stick a version number on it: + version = 1 + while os.path.exists(newpath + '-' + str(version)): + version += 1 + newpath = newpath + '-' + str(version) + shutil.move(filepath, newpath) + return newpath + + +try: + # Python 3 + from collections import ChainMap + + + class ListOfDicts(ChainMap): + def extend(self, m): + self.maps.append(m) + + def prepend(self, m): + self.maps.insert(0, m) + +except ImportError: + + # Python 2. We'll have to supply our own + class ListOfDicts(object): + """A near clone of ChainMap""" + + def __init__(self, *maps): + self.maps = list(maps) or [{}] + + def __missing__(self, key): + raise KeyError(key) + + def __getitem__(self, key): + for mapping in self.maps: + try: + return mapping[key] + except KeyError: + pass + return self.__missing__(key) + + def get(self, key, default=None): + return self[key] if key in self else default + + def __len__(self): + return len(set().union(*self.maps)) + + def __iter__(self): + return iter(set().union(*self.maps)) + + def __contains__(self, key): + return any(key in m for m in self.maps) + + def __bool__(self): + return any(self.maps) + + def __setitem__(self, key, value): + """Set a key, value on the first map. """ + self.maps[0][key] = value + + def __delitem__(self, key): + try: + del self.maps[0][key] + except KeyError: + raise KeyError('Key not found in the first mapping: {!r}'.format(key)) + + def popitem(self): + 'Remove and return an item pair from maps[0]. Raise KeyError is maps[0] is empty.' + try: + return self.maps[0].popitem() + except KeyError: + raise KeyError('No keys found in the first mapping.') + + def pop(self, key, *args): + 'Remove *key* from maps[0] and return its value. Raise KeyError if *key* not in maps[0].' + try: + return self.maps[0].pop(key, *args) + except KeyError: + raise KeyError('Key not found in the first mapping: {!r}'.format(key)) + + def extend(self, m): + self.maps.append(m) + + def prepend(self, m): + self.maps.insert(0, m) + + def copy(self): + return self.__class__(self.maps[0].copy(), *self.maps[1:]) + + __copy__ = copy + + def keys(self): + """Return an iterator of all keys in the maps.""" + return iter(i for s in self.maps for i in s) + + def values(self): + """Return an iterator of all values in the maps.""" + return iter(i for s in self.maps for i in s.values()) + + +class KeyDict(dict): + """A dictionary that returns the key for an unsuccessful lookup.""" + + def __missing__(self, key): + return key + + +def atoi(text): + return int(text) if text.isdigit() else text + + +def natural_keys(text): + """Natural key sort. + + Allows use of key=natural_keys to sort a list in human order, eg: + alist.sort(key=natural_keys) + + http://nedbatchelder.com/blog/200712/human_sorting.html + """ + + return [atoi(c) for c in re.split(natural_keys.compiled_re, text.lower())] + + +natural_keys.compiled_re = re.compile(r'(\d+)') + + +def natural_sort_keys(source_dict): + """Return a naturally sorted list of keys for a dict.""" + + # create a list of keys in the dict + keys_list = list(source_dict.keys()) + # naturally sort the list of keys such that, for example, xxxxx16 appears + # after xxxxx1 + keys_list.sort(key=natural_keys) + # return the sorted list + return keys_list + + +def to_sorted_string(rec, simple_sort=False): + """Return a string representation of a dict sorted by key. + + Default action is to perform a 'natural' sort by key, ie 'xxx1' appears + before 'xxx16'. If called with simple_sort=True a simple alphanumeric sort + is performed instead which will result in 'xxx16' appearing before 'xxx1'. + """ + + if simple_sort: + import locale + return ", ".join(["%s: %s" % (k, rec.get(k)) for k in sorted(rec, key=locale.strxfrm)]) + else: + # first obtain a list of key:value pairs sorted naturally by key + sorted_dict = ["'%s': '%s'" % (k, rec[k]) for k in natural_sort_keys(rec)] + # return as a string of comma separated key:value pairs in braces + return ", ".join(sorted_dict) + + +def y_or_n(msg, noprompt=False): + """Prompt and look for a 'y' or 'n' response""" + + # If noprompt is truthy, always return 'y' + if noprompt: + return 'y' + + ans = None + while ans not in ['y', 'n']: + ans = input(msg) + return ans + + +def deep_copy_path(path, dest_dir): + """Copy a path to a destination, making any subdirectories along the way. + The source path is relative to the current directory. + + Returns the number of files copied + """ + + ncopy = 0 + # Are we copying a directory? + if os.path.isdir(path): + # Yes. Walk it + for dirpath, _, filenames in os.walk(path): + for f in filenames: + # For each source file found, call myself recursively: + ncopy += deep_copy_path(os.path.join(dirpath, f), dest_dir) + else: + # path is a file. Get the directory it's in. + d = os.path.dirname(os.path.join(dest_dir, path)) + # Make the destination directory, wrapping it in a try block in + # case it already exists: + try: + os.makedirs(d) + except OSError: + pass + # This version of copy does not copy over modification time, + # so it will look like a new file, causing it to be (for + # example) ftp'd to the server: + shutil.copy(path, d) + ncopy += 1 + return ncopy + + +def is_iterable(x): + """Test if something is iterable, but not a string""" + return hasattr(x, '__iter__') and not isinstance(x, (bytes, six.string_types)) + + +if __name__ == '__main__': + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weewx/__init__.py b/dist/weewx-4.10.1/bin/weewx/__init__.py new file mode 100644 index 0000000..e97c4d8 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/__init__.py @@ -0,0 +1,147 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Package weewx, containing modules specific to the weewx runtime engine.""" +from __future__ import absolute_import +import time + +__version__="4.10.1" + +# Holds the program launch time in unix epoch seconds: +# Useful for calculating 'uptime.' +launchtime_ts = time.time() + +# Set to true for extra debug information: +debug = False + +# Exit return codes +CMD_ERROR = 2 +CONFIG_ERROR = 3 +IO_ERROR = 4 +DB_ERROR = 5 + +# Constants used to indicate a unit system: +METRIC = 0x10 +METRICWX = 0x11 +US = 0x01 + +# ============================================================================= +# Define possible exceptions that could get thrown. +# ============================================================================= + +class WeeWxIOError(IOError): + """Base class of exceptions thrown when encountering an input/output error + with the hardware.""" + +class WakeupError(WeeWxIOError): + """Exception thrown when unable to wake up or initially connect with the + hardware.""" + +class CRCError(WeeWxIOError): + """Exception thrown when unable to pass a CRC check.""" + +class RetriesExceeded(WeeWxIOError): + """Exception thrown when max retries exceeded.""" + +class HardwareError(Exception): + """Exception thrown when an error is detected in the hardware.""" + +class UnknownArchiveType(HardwareError): + """Exception thrown after reading an unrecognized archive type.""" + +class UnsupportedFeature(Exception): + """Exception thrown when attempting to access a feature that is not + supported (yet).""" + +class ViolatedPrecondition(Exception): + """Exception thrown when a function is called with violated + preconditions.""" + +class StopNow(Exception): + """Exception thrown to stop the engine.""" + +class UnknownDatabase(Exception): + """Exception thrown when attempting to use an unknown database.""" + +class UnknownDatabaseType(Exception): + """Exception thrown when attempting to use an unknown database type.""" + +class UnknownBinding(Exception): + """Exception thrown when attempting to use an unknown data binding.""" + +class UnitError(ValueError): + """Exception thrown when there is a mismatch in unit systems.""" + +class UnknownType(ValueError): + """Exception thrown for an unknown observation type""" + +class UnknownAggregation(ValueError): + """Exception thrown for an unknown aggregation type""" + +class CannotCalculate(ValueError): + """Exception raised when a type cannot be calculated.""" + +class NoCalculate(Exception): + """Exception raised when a type does not need to be calculated.""" + + +# ============================================================================= +# Possible event types. +# ============================================================================= + +class STARTUP(object): + """Event issued when the engine first starts up. Services have been + loaded.""" +class PRE_LOOP(object): + """Event issued just before the main packet loop is entered. Services + have been loaded.""" +class NEW_LOOP_PACKET(object): + """Event issued when a new LOOP packet is available. The event contains + attribute 'packet', which is the new LOOP packet.""" +class CHECK_LOOP(object): + """Event issued in the main loop, right after a new LOOP packet has been + processed. Generally, it is used to throw an exception, breaking the main + loop, so the console can be used for other things.""" +class END_ARCHIVE_PERIOD(object): + """Event issued at the end of an archive period.""" +class NEW_ARCHIVE_RECORD(object): + """Event issued when a new archive record is available. The event contains + attribute 'record', which is the new archive record.""" +class POST_LOOP(object): + """Event issued right after the main loop has been broken. Services hook + into this to access the console for things other than generating LOOP + packet.""" + +# ============================================================================= +# Service groups. +# ============================================================================= + +# All existent service groups and the order in which they should be run: +all_service_groups = ['prep_services', 'data_services', 'process_services', 'xtype_services', + 'archive_services', 'restful_services', 'report_services'] + +# ============================================================================= +# Class Event +# ============================================================================= +class Event(object): + """Represents an event.""" + def __init__(self, event_type, **argv): + self.event_type = event_type + + for key in argv: + setattr(self, key, argv[key]) + + def __str__(self): + """Return a string with a reasonable representation of the event.""" + et = "Event type: %s | " % self.event_type + s = "; ".join("%s: %s" %(k, self.__dict__[k]) for k in self.__dict__ if k!="event_type") + return et + s + +def require_weewx_version(module, required_version): + """utility to check for version compatibility""" + from distutils.version import StrictVersion + if StrictVersion(__version__) < StrictVersion(required_version): + raise UnsupportedFeature("%s requires weewx %s or greater, found %s" + % (module, required_version, __version__)) diff --git a/dist/weewx-4.10.1/bin/weewx/accum.py b/dist/weewx-4.10.1/bin/weewx/accum.py new file mode 100644 index 0000000..5c8397d --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/accum.py @@ -0,0 +1,728 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Statistical accumulators. They accumulate the highs, lows, averages, etc., +of a sequence of records.""" +# +# General strategy. +# +# Most observation types are scalars, so they can be treated simply. Values are added to a scalar +# accumulator, which keeps track of highs, lows, and a sum. When it comes time for extraction, the +# average over the archive period is typically produced. +# +# However, wind is a special case. It is a vector, which has been flatted over at least two +# scalars, windSpeed and windDir. Some stations, notably the Davis Vantage, add windGust and +# windGustDir. The accumulators cannot simply treat them individually as if they were just another +# scalar. Instead they must be grouped together. This is done by treating windSpeed as a 'special' +# scalar. When it appears, it is coupled with windDir and, if available, windGust and windGustDir, +# and added to a vector accumulator. When the other types ( windDir, windGust, and windGustDir) +# appear, they are ignored, having already been handled during the processing of type windSpeed. +# +# When it comes time to extract wind, vector averages are calculated, then the results are +# flattened again. +# +from __future__ import absolute_import + +import logging +import math + +import six + +import weewx +from weeutil.weeutil import ListOfDicts, to_float, timestamp_to_string +import weeutil.config + +log = logging.getLogger(__name__) + +# +# Default mappings from observation types to accumulator classes and functions +# + +DEFAULTS_INI = """ +[Accumulator] + [[consBatteryVoltage]] + extractor = last + [[dateTime]] + adder = noop + [[dayET]] + extractor = last + [[dayRain]] + extractor = last + [[ET]] + extractor = sum + [[hourRain]] + extractor = last + [[rain]] + extractor = sum + [[rain24]] + extractor = last + [[monthET]] + extractor = last + [[monthRain]] + extractor = last + [[stormRain]] + extractor = last + [[totalRain]] + extractor = last + [[txBatteryStatus]] + extractor = last + [[usUnits]] + adder = check_units + [[wind]] + accumulator = vector + extractor = wind + [[windDir]] + extractor = noop + [[windGust]] + extractor = noop + [[windGustDir]] + extractor = noop + [[windGust10]] + extractor = last + [[windGustDir10]] + extractor = last + [[windrun]] + extractor = sum + [[windSpeed]] + adder = add_wind + merger = avg + extractor = noop + [[windSpeed2]] + extractor = last + [[windSpeed10]] + extractor = last + [[yearET]] + extractor = last + [[yearRain]] + extractor = last + [[lightning_strike_count]] + extractor = sum +""" +defaults_dict = weeutil.config.config_from_str(DEFAULTS_INI) + +accum_dict = ListOfDicts(defaults_dict['Accumulator'].dict()) + + +class OutOfSpan(ValueError): + """Raised when attempting to add a record outside of the timespan held by an accumulator""" + + +# =============================================================================== +# ScalarStats +# =============================================================================== + +class ScalarStats(object): + """Accumulates statistics (min, max, average, etc.) for a scalar value. + + Property 'last' is the last non-None value seen. Property 'lasttime' is + the time it was seen. """ + + default_init = (None, None, None, None, 0.0, 0, 0.0, 0) + + def __init__(self, stats_tuple=None): + self.setStats(stats_tuple) + self.last = None + self.lasttime = None + + def setStats(self, stats_tuple=None): + (self.min, self.mintime, + self.max, self.maxtime, + self.sum, self.count, + self.wsum, self.sumtime) = stats_tuple if stats_tuple else ScalarStats.default_init + + def getStatsTuple(self): + """Return a stats-tuple. That is, a tuple containing the gathered statistics. + This tuple can be used to update the stats database""" + return (self.min, self.mintime, self.max, self.maxtime, + self.sum, self.count, self.wsum, self.sumtime) + + def mergeHiLo(self, x_stats): + """Merge the highs and lows of another accumulator into myself.""" + if x_stats.min is not None: + if self.min is None or x_stats.min < self.min: + self.min = x_stats.min + self.mintime = x_stats.mintime + if x_stats.max is not None: + if self.max is None or x_stats.max > self.max: + self.max = x_stats.max + self.maxtime = x_stats.maxtime + if x_stats.lasttime is not None: + if self.lasttime is None or x_stats.lasttime >= self.lasttime: + self.lasttime = x_stats.lasttime + self.last = x_stats.last + + def mergeSum(self, x_stats): + """Merge the sum and count of another accumulator into myself.""" + self.sum += x_stats.sum + self.count += x_stats.count + self.wsum += x_stats.wsum + self.sumtime += x_stats.sumtime + + def addHiLo(self, val, ts): + """Include a scalar value in my highs and lows. + val: A scalar value + ts: The timestamp. """ + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + val = to_float(val) + except ValueError: + val = None + + # Check for None and NaN: + if val is not None and val == val: + if self.min is None or val < self.min: + self.min = val + self.mintime = ts + if self.max is None or val > self.max: + self.max = val + self.maxtime = ts + if self.lasttime is None or ts >= self.lasttime: + self.last = val + self.lasttime = ts + + def addSum(self, val, weight=1): + """Add a scalar value to my running sum and count.""" + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + val = to_float(val) + except ValueError: + val = None + + # Check for None and NaN: + if val is not None and val == val: + self.sum += val + self.count += 1 + self.wsum += val * weight + self.sumtime += weight + + @property + def avg(self): + return self.wsum / self.sumtime if self.count else None + + +class VecStats(object): + """Accumulates statistics for a vector value. + + Property 'last' is the last non-None value seen. It is a two-way tuple (mag, dir). + Property 'lasttime' is the time it was seen. """ + + default_init = (None, None, None, None, + 0.0, 0, 0.0, 0, None, 0.0, 0.0, 0, 0.0, 0.0) + + def __init__(self, stats_tuple=None): + self.setStats(stats_tuple) + self.last = (None, None) + self.lasttime = None + + def setStats(self, stats_tuple=None): + (self.min, self.mintime, + self.max, self.maxtime, + self.sum, self.count, + self.wsum, self.sumtime, + self.max_dir, self.xsum, self.ysum, + self.dirsumtime, self.squaresum, + self.wsquaresum) = stats_tuple if stats_tuple else VecStats.default_init + + def getStatsTuple(self): + """Return a stats-tuple. That is, a tuple containing the gathered statistics.""" + return (self.min, self.mintime, + self.max, self.maxtime, + self.sum, self.count, + self.wsum, self.sumtime, + self.max_dir, self.xsum, self.ysum, + self.dirsumtime, self.squaresum, self.wsquaresum) + + def mergeHiLo(self, x_stats): + """Merge the highs and lows of another accumulator into myself.""" + if x_stats.min is not None: + if self.min is None or x_stats.min < self.min: + self.min = x_stats.min + self.mintime = x_stats.mintime + if x_stats.max is not None: + if self.max is None or x_stats.max > self.max: + self.max = x_stats.max + self.maxtime = x_stats.maxtime + self.max_dir = x_stats.max_dir + if x_stats.lasttime is not None: + if self.lasttime is None or x_stats.lasttime >= self.lasttime: + self.lasttime = x_stats.lasttime + self.last = x_stats.last + + def mergeSum(self, x_stats): + """Merge the sum and count of another accumulator into myself.""" + self.sum += x_stats.sum + self.count += x_stats.count + self.wsum += x_stats.wsum + self.sumtime += x_stats.sumtime + self.xsum += x_stats.xsum + self.ysum += x_stats.ysum + self.dirsumtime += x_stats.dirsumtime + self.squaresum += x_stats.squaresum + self.wsquaresum += x_stats.wsquaresum + + def addHiLo(self, val, ts): + """Include a vector value in my highs and lows. + val: A vector value. It is a 2-way tuple (mag, dir). + ts: The timestamp. + """ + speed, dirN = val + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + speed = to_float(speed) + except ValueError: + speed = None + try: + dirN = to_float(dirN) + except ValueError: + dirN = None + + # Check for None and NaN: + if speed is not None and speed == speed: + if self.min is None or speed < self.min: + self.min = speed + self.mintime = ts + if self.max is None or speed > self.max: + self.max = speed + self.maxtime = ts + self.max_dir = dirN + if self.lasttime is None or ts >= self.lasttime: + self.last = (speed, dirN) + self.lasttime = ts + + def addSum(self, val, weight=1): + """Add a vector value to my sum and squaresum. + val: A vector value. It is a 2-way tuple (mag, dir) + """ + speed, dirN = val + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + speed = to_float(speed) + except ValueError: + speed = None + try: + dirN = to_float(dirN) + except ValueError: + dirN = None + + # Check for None and NaN: + if speed is not None and speed == speed: + self.sum += speed + self.count += 1 + self.wsum += weight * speed + self.sumtime += weight + self.squaresum += speed ** 2 + self.wsquaresum += weight * speed ** 2 + if dirN is not None: + self.xsum += weight * speed * math.cos(math.radians(90.0 - dirN)) + self.ysum += weight * speed * math.sin(math.radians(90.0 - dirN)) + # It's OK for direction to be None, provided speed is zero: + if dirN is not None or speed == 0: + self.dirsumtime += weight + + @property + def avg(self): + return self.wsum / self.sumtime if self.count else None + + @property + def rms(self): + return math.sqrt(self.wsquaresum / self.sumtime) if self.count else None + + @property + def vec_avg(self): + if self.count: + return math.sqrt((self.xsum ** 2 + self.ysum ** 2) / self.sumtime ** 2) + + @property + def vec_dir(self): + if self.dirsumtime and (self.ysum or self.xsum): + _result = 90.0 - math.degrees(math.atan2(self.ysum, self.xsum)) + if _result < 0.0: + _result += 360.0 + return _result + # Return the last known direction when our vector sum is 0 + return self.last[1] + + +# =============================================================================== +# FirstLastAccum +# =============================================================================== + +class FirstLastAccum(object): + """Minimal accumulator, suitable for strings. + It can only return the first and last strings it has seen, along with their timestamps. + """ + + default_init = (None, None, None, None) + + def __init__(self, stats_tuple=None): + self.first = None + self.firsttime = None + self.last = None + self.lasttime = None + + def setStats(self, stats_tuple=None): + self.first, self.firsttime, self.last, self.lasttime = stats_tuple \ + if stats_tuple else FirstLastAccum.default_init + + def getStatsTuple(self): + """Return a stats-tuple. That is, a tuple containing the gathered statistics. + This tuple can be used to update the stats database""" + return self.first, self.firsttime, self.last, self.lasttime + + def mergeHiLo(self, x_stats): + """Merge the highs and lows of another accumulator into myself.""" + if x_stats.firsttime is not None: + if self.firsttime is None or x_stats.firsttime < self.firsttime: + self.firsttime = x_stats.firsttime + self.first = x_stats.first + if x_stats.lasttime is not None: + if self.lasttime is None or x_stats.lasttime >= self.lasttime: + self.lasttime = x_stats.lasttime + self.last = x_stats.last + + def mergeSum(self, x_stats): + """Merge the count of another accumulator into myself.""" + pass + + def addHiLo(self, val, ts): + """Include a value in my stats. + val: A value of almost any type. It will be converted to a string before being accumulated. + ts: The timestamp. + """ + if val is not None: + string_val = str(val) + if self.firsttime is None or ts < self.firsttime: + self.first = string_val + self.firsttime = ts + if self.lasttime is None or ts >= self.lasttime: + self.last = string_val + self.lasttime = ts + + def addSum(self, val, weight=1): + """Add a scalar value to my running count.""" + pass + + +# =============================================================================== +# Class Accum +# =============================================================================== + +class Accum(dict): + """Accumulates statistics for a set of observation types.""" + + def __init__(self, timespan, unit_system=None): + """Initialize a Accum. + + timespan: The time period over which stats will be accumulated. + unit_system: The unit system used by the accumulator""" + + self.timespan = timespan + # Set the accumulator's unit system. Usually left unspecified until the + # first observation comes in for normal operation or pre-set if + # obtaining a historical accumulator. + self.unit_system = unit_system + + def addRecord(self, record, add_hilo=True, weight=1): + """Add a record to my running statistics. + + The record must have keys 'dateTime' and 'usUnits'.""" + + # Check to see if the record is within my observation timespan + if not self.timespan.includesArchiveTime(record['dateTime']): + raise OutOfSpan("Attempt to add out-of-interval record (%s) to timespan (%s)" + % (timestamp_to_string(record['dateTime']), self.timespan)) + + for obs_type in record: + # Get the proper function ... + func = get_add_function(obs_type) + # ... then call it. + func(self, record, obs_type, add_hilo, weight) + + def updateHiLo(self, accumulator): + """Merge the high/low stats of another accumulator into me.""" + if accumulator.timespan.start < self.timespan.start \ + or accumulator.timespan.stop > self.timespan.stop: + raise OutOfSpan("Attempt to merge an accumulator whose timespan is not a subset") + + self._check_units(accumulator.unit_system) + + for obs_type in accumulator: + # Initialize the type if we have not seen it before + self._init_type(obs_type) + + # Get the proper function ... + func = get_merge_function(obs_type) + # ... then call it + func(self, accumulator, obs_type) + + def getRecord(self): + """Extract a record out of the results in the accumulator.""" + + # All records have a timestamp and unit type + record = {'dateTime': self.timespan.stop, + 'usUnits': self.unit_system} + + return self.augmentRecord(record) + + def augmentRecord(self, record): + + # Go through all observation types. + for obs_type in self: + # If the type does not appear in the record, then add it: + if obs_type not in record: + # Get the proper extraction function... + func = get_extract_function(obs_type) + # ... then call it + func(self, record, obs_type) + + return record + + def set_stats(self, obs_type, stats_tuple): + + self._init_type(obs_type) + self[obs_type].setStats(stats_tuple) + + # + # Begin add functions. These add a record to the accumulator. + # + + def add_value(self, record, obs_type, add_hilo, weight): + """Add a single observation to myself.""" + + val = record[obs_type] + + # If the type has not been seen before, initialize it + self._init_type(obs_type) + # Then add to highs/lows, and to the running sum: + if add_hilo: + self[obs_type].addHiLo(val, record['dateTime']) + self[obs_type].addSum(val, weight=weight) + + def add_wind_value(self, record, obs_type, add_hilo, weight): + """Add a single observation of type wind to myself.""" + + if obs_type in ['windDir', 'windGust', 'windGustDir']: + return + if weewx.debug: + assert (obs_type == 'windSpeed') + + # First add it to regular old 'windSpeed', then + # treat it like a vector. + self.add_value(record, obs_type, add_hilo, weight) + + # If the type has not been seen before, initialize it. + self._init_type('wind') + # Then add to highs/lows. + if add_hilo: + # If the station does not provide windGustDir, then substitute windDir. + # See issue #320, https://bit.ly/2HSo0ju + wind_gust_dir = record['windGustDir'] \ + if 'windGustDir' in record else record.get('windDir') + # Do windGust first, so that the last value entered is windSpeed, not windGust + # See Slack discussion https://bit.ly/3qV1nBV + self['wind'].addHiLo((record.get('windGust'), wind_gust_dir), + record['dateTime']) + self['wind'].addHiLo((record.get('windSpeed'), record.get('windDir')), + record['dateTime']) + # Add to the running sum. + self['wind'].addSum((record['windSpeed'], record.get('windDir')), weight=weight) + + def check_units(self, record, obs_type, add_hilo, weight): + if weewx.debug: + assert (obs_type == 'usUnits') + self._check_units(record['usUnits']) + + def noop(self, record, obs_type, add_hilo=True, weight=1): + pass + + # + # Begin hi/lo merge functions. These are called when merging two accumulators + # + + def merge_minmax(self, x_accumulator, obs_type): + """Merge value in another accumulator, using min/max""" + + self[obs_type].mergeHiLo(x_accumulator[obs_type]) + + def merge_avg(self, x_accumulator, obs_type): + """Merge value in another accumulator, using avg for max""" + x_stats = x_accumulator[obs_type] + if x_stats.min is not None: + if self[obs_type].min is None or x_stats.min < self[obs_type].min: + self[obs_type].min = x_stats.min + self[obs_type].mintime = x_stats.mintime + if x_stats.avg is not None: + if self[obs_type].max is None or x_stats.avg > self[obs_type].max: + self[obs_type].max = x_stats.avg + self[obs_type].maxtime = x_accumulator.timespan.stop + if x_stats.lasttime is not None: + if self[obs_type].lasttime is None or x_stats.lasttime >= self[obs_type].lasttime: + self[obs_type].lasttime = x_stats.lasttime + self[obs_type].last = x_stats.last + + # + # Begin extraction functions. These extract a record out of the accumulator. + # + + def extract_wind(self, record, obs_type): + """Extract wind values from myself, and put in a record.""" + # Wind records must be flattened into the separate categories: + if 'windSpeed' not in record: + record['windSpeed'] = self[obs_type].avg + if 'windDir' not in record: + record['windDir'] = self[obs_type].vec_dir + if 'windGust' not in record: + record['windGust'] = self[obs_type].max + if 'windGustDir' not in record: + record['windGustDir'] = self[obs_type].max_dir + + def extract_sum(self, record, obs_type): + record[obs_type] = self[obs_type].sum if self[obs_type].count else None + + def extract_last(self, record, obs_type): + record[obs_type] = self[obs_type].last + + def extract_avg(self, record, obs_type): + record[obs_type] = self[obs_type].avg + + def extract_min(self, record, obs_type): + record[obs_type] = self[obs_type].min + + def extract_max(self, record, obs_type): + record[obs_type] = self[obs_type].max + + def extract_count(self, record, obs_type): + record[obs_type] = self[obs_type].count + + # + # Miscellaneous, utility functions + # + + def _init_type(self, obs_type): + """Add a given observation type to my dictionary.""" + # Do nothing if this type has already been initialized: + if obs_type in self: + return + + # Get a new accumulator of the proper type + self[obs_type] = new_accumulator(obs_type) + + def _check_units(self, new_unit_system): + # If no unit system has been specified for me yet, adopt the incoming + # system + if self.unit_system is None: + self.unit_system = new_unit_system + else: + # Otherwise, make sure they match + if self.unit_system != new_unit_system: + raise ValueError("Unit system mismatch %d v. %d" % (self.unit_system, + new_unit_system)) + + @property + def isEmpty(self): + return self.unit_system is None + + +# =============================================================================== +# Configuration dictionaries +# =============================================================================== + +# +# Mappings from convenient string nicknames, which can be used in a config file, +# to actual functions and classes +# + +ACCUM_TYPES = { + 'scalar': ScalarStats, + 'vector': VecStats, + 'firstlast': FirstLastAccum +} + +ADD_FUNCTIONS = { + 'add': Accum.add_value, + 'add_wind': Accum.add_wind_value, + 'check_units': Accum.check_units, + 'noop': Accum.noop +} + +MERGE_FUNCTIONS = { + 'minmax': Accum.merge_minmax, + 'avg': Accum.merge_avg +} + +EXTRACT_FUNCTIONS = { + 'avg': Accum.extract_avg, + 'count': Accum.extract_count, + 'last': Accum.extract_last, + 'max': Accum.extract_max, + 'min': Accum.extract_min, + 'noop': Accum.noop, + 'sum': Accum.extract_sum, + 'wind': Accum.extract_wind, +} + +# The default actions for an individual observation type +OBS_DEFAULTS = { + 'accumulator': 'scalar', + 'adder': 'add', + 'merger': 'minmax', + 'extractor': 'avg' +} + + +def initialize(config_dict): + # Add the configuration dictionary to the beginning of the list of maps. + # This will cause it to override the defaults + global accum_dict + accum_dict.maps.insert(0, config_dict.get('Accumulator', {})) + + +def new_accumulator(obs_type): + """Instantiate an accumulator, appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the accumulator. Default is 'scalar' + accum_nickname = obs_options.get('accumulator', 'scalar') + # Instantiate and return the accumulator. + # If we don't know this nickname, then fail hard with a KeyError + return ACCUM_TYPES[accum_nickname]() + + +def get_add_function(obs_type): + """Get an adder function appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the adder. Default is 'add' + add_nickname = obs_options.get('adder', 'add') + # If we don't know this nickname, then fail hard with a KeyError + return ADD_FUNCTIONS[add_nickname] + + +def get_merge_function(obs_type): + """Get a merge function appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the merger. Default is 'minmax' + add_nickname = obs_options.get('merger', 'minmax') + # If we don't know this nickname, then fail hard with a KeyError + return MERGE_FUNCTIONS[add_nickname] + + +def get_extract_function(obs_type): + """Get an extraction function appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the extractor. Default is 'avg' + add_nickname = obs_options.get('extractor', 'avg') + # If we don't know this nickname, then fail hard with a KeyError + return EXTRACT_FUNCTIONS[add_nickname] diff --git a/dist/weewx-4.10.1/bin/weewx/almanac.py b/dist/weewx-4.10.1/bin/weewx/almanac.py new file mode 100644 index 0000000..95b2007 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/almanac.py @@ -0,0 +1,537 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Almanac data + +This module can optionally use PyEphem, which offers high quality +astronomical calculations. See http://rhodesmill.org/pyephem. """ + +from __future__ import absolute_import +from __future__ import print_function +import time +import sys +import math +import copy + +import weeutil.Moon +import weewx.units +from weewx.units import ValueTuple + +# If the user has installed ephem, use it. Otherwise, fall back to the weeutil algorithms: +try: + import ephem +except ImportError: + import weeutil.Sun + + +# NB: Have Almanac inherit from 'object'. However, this will cause +# an 'autocall' bug in Cheetah versions before 2.1. +class Almanac(object): + """Almanac data. + + ATTRIBUTES. + + As a minimum, the following attributes are available: + + sunrise: Time (local) upper limb of the sun rises above the horizon, formatted using the format 'timeformat'. + sunset: Time (local) upper limb of the sun sinks below the horizon, formatted using the format 'timeformat'. + moon_phase: A description of the moon phase(eg. "new moon", Waxing crescent", etc.) + moon_fullness: Percent fullness of the moon (0=new moon, 100=full moon) + + If the module 'ephem' is used, them many other attributes are available. + Here are a few examples: + + sun.rise: Time upper limb of sun will rise above the horizon today in unix epoch time + sun.transit: Time of transit today (sun over meridian) in unix epoch time + sun.previous_sunrise: Time of last sunrise in unix epoch time + sun.az: Azimuth (in degrees) of sun + sun.alt: Altitude (in degrees) of sun + mars.rise: Time when upper limb of mars will rise above horizon today in unix epoch time + mars.ra: Right ascension of mars + etc. + + EXAMPLES: + + These examples require pyephem to be installed. + >>> if "ephem" not in sys.modules: + ... raise KeyboardInterrupt("Almanac examples require 'pyephem'") + + These examples are designed to work in the Pacific timezone + >>> import os + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> from weeutil.weeutil import timestamp_to_string, timestamp_to_gmtime + >>> t = 1238180400 + >>> print(timestamp_to_string(t)) + 2009-03-27 12:00:00 PDT (1238180400) + + Test conversions to Dublin Julian Days + >>> t_djd = timestamp_to_djd(t) + >>> print("%.5f" % t_djd) + 39898.29167 + + Test the conversion back + >>> print("%.0f" % djd_to_timestamp(t_djd)) + 1238180400 + + >>> almanac = Almanac(t, 46.0, -122.0, formatter=weewx.units.get_default_formatter()) + + Test backwards compatibility with attribute 'moon_fullness': + >>> print("Fullness of the moon (rounded) is %.2f%% [%s]" % (almanac._moon_fullness, almanac.moon_phase)) + Fullness of the moon (rounded) is 3.00% [new (totally dark)] + + Now get a more precise result for fullness of the moon: + >>> print("Fullness of the moon (more precise) is %.2f%%" % almanac.moon.moon_fullness) + Fullness of the moon (more precise) is 1.70% + + Test backwards compatibility with attributes 'sunrise' and 'sunset' + >>> print("Sunrise, sunset: %s, %s" % (almanac.sunrise, almanac.sunset)) + Sunrise, sunset: 06:56:36, 19:30:41 + + Get sunrise, sun transit, and sunset using the new 'ephem' syntax: + >>> print("Sunrise, sun transit, sunset: %s, %s, %s" % (almanac.sun.rise, almanac.sun.transit, almanac.sun.set)) + Sunrise, sun transit, sunset: 06:56:36, 13:13:13, 19:30:41 + + Do the same with the moon: + >>> print("Moon rise, transit, set: %s, %s, %s" % (almanac.moon.rise, almanac.moon.transit, almanac.moon.set)) + Moon rise, transit, set: 06:59:14, 14:01:57, 21:20:06 + + And Mars + >>> print("Mars rise, transit, set: %s, %s, %s" % (almanac.mars.rise, almanac.mars.transit, almanac.mars.set)) + Mars rise, transit, set: 06:08:57, 11:34:13, 17:00:04 + + Finally, try a star + >>> print("Rigel rise, transit, set: %s, %s, %s" % (almanac.rigel.rise, almanac.rigel.transit, almanac.rigel.set)) + Rigel rise, transit, set: 12:32:33, 18:00:38, 23:28:43 + + Exercise sidereal time + >>> print("%.4f" % almanac.sidereal_time) + 348.3400 + + Exercise equinox, solstice routines + >>> print(almanac.next_vernal_equinox) + 03/20/10 10:32:11 + >>> print(almanac.next_autumnal_equinox) + 09/22/09 14:18:39 + >>> print(almanac.next_summer_solstice) + 06/20/09 22:45:40 + >>> print(almanac.previous_winter_solstice) + 12/21/08 04:03:36 + >>> print(almanac.next_winter_solstice) + 12/21/09 09:46:38 + + Exercise moon state routines + >>> print(almanac.next_full_moon) + 04/09/09 07:55:49 + >>> print(almanac.next_new_moon) + 04/24/09 20:22:33 + >>> print(almanac.next_first_quarter_moon) + 04/02/09 07:33:42 + + Now location of the sun and moon + >>> print("Solar azimuth, altitude = (%.2f, %.2f)" % (almanac.sun.az, almanac.sun.alt)) + Solar azimuth, altitude = (154.14, 44.02) + >>> print("Moon azimuth, altitude = (%.2f, %.2f)" % (almanac.moon.az, almanac.moon.alt)) + Moon azimuth, altitude = (133.55, 47.89) + + Try a time and location where the sun is always up + >>> t = 1371044003 + >>> print(timestamp_to_string(t)) + 2013-06-12 06:33:23 PDT (1371044003) + >>> almanac = Almanac(t, 64.0, 0.0) + >>> print(almanac(horizon=-6).sun(use_center=1).rise) + N/A + + Try the pyephem "Naval Observatory" example. + >>> t = 1252256400 + >>> print(timestamp_to_gmtime(t)) + 2009-09-06 17:00:00 UTC (1252256400) + >>> atlanta = Almanac(t, 33.8, -84.4, pressure=0, horizon=-34.0/60.0) + >>> # Print it in GMT, so it can easily be compared to the example: + >>> print(timestamp_to_gmtime(atlanta.sun.previous_rising.raw)) + 2009-09-06 11:14:56 UTC (1252235696) + >>> print(timestamp_to_gmtime(atlanta.moon.next_setting.raw)) + 2009-09-07 14:05:29 UTC (1252332329) + + Now try the civil twilight examples: + >>> print(timestamp_to_gmtime(atlanta(horizon=-6).sun(use_center=1).previous_rising.raw)) + 2009-09-06 10:49:40 UTC (1252234180) + >>> print(timestamp_to_gmtime(atlanta(horizon=-6).sun(use_center=1).next_setting.raw)) + 2009-09-07 00:21:22 UTC (1252282882) + + Try sun rise again, to make sure the horizon value cleared: + >>> print(timestamp_to_gmtime(atlanta.sun.previous_rising.raw)) + 2009-09-06 11:14:56 UTC (1252235696) + + Try an attribute that does not explicitly appear in the class Almanac + >>> print("%.3f" % almanac.mars.sun_distance) + 1.494 + + Try a specialized attribute for Jupiter + >>> print(almanac.jupiter.cmlI) + 191:16:58.0 + + Should fail if applied to a different body + >>> print(almanac.venus.cmlI) + Traceback (most recent call last): + ... + AttributeError: 'Venus' object has no attribute 'cmlI' + + Try a nonsense body: + >>> x = almanac.bar.rise + Traceback (most recent call last): + ... + KeyError: 'Bar' + + Try a nonsense tag: + >>> x = almanac.sun.foo + Traceback (most recent call last): + ... + AttributeError: 'Sun' object has no attribute 'foo' + """ + + def __init__(self, time_ts, lat, lon, + altitude=None, + temperature=None, + pressure=None, + horizon=None, + moon_phases=weeutil.Moon.moon_phases, + formatter=None, + converter=None): + """Initialize an instance of Almanac + + Args: + + time_ts (int): A unix epoch timestamp with the time of the almanac. If None, the + present time will be used. + + lat (float): Observer's latitude in degrees. + + lon (float): Observer's longitude in degrees. + + altitude: (float) Observer's elevation in **meters**. [Optional. Default is 0 (sea level)] + + temperature (float): Observer's temperature in **degrees Celsius**. [Optional. Default is 15.0] + + pressure (float): Observer's atmospheric pressure in **mBars**. [Optional. Default is 1010] + + horizon (float): Angle of the horizon in degrees [Optional. Default is zero] + + moon_phases (list): An array of 8 strings with descriptions of the moon + phase. [optional. If not given, then weeutil.Moon.moon_phases will be used] + + formatter (weewx.units.Formatter): An instance of weewx.units.Formatter() with the formatting information + to be used. + + converter (weewx.units.Converter): An instance of weewx.units.Converter with the conversion information to be used. + """ + self.time_ts = time_ts if time_ts else time.time() + self.lat = lat + self.lon = lon + self.altitude = altitude if altitude is not None else 0.0 + self.temperature = temperature if temperature is not None else 15.0 + self.pressure = pressure if pressure is not None else 1010.0 + self.horizon = horizon if horizon is not None else 0.0 + self.moon_phases = moon_phases + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self._precalc() + + def _precalc(self): + """Precalculate local variables.""" + self.moon_index, self._moon_fullness = weeutil.Moon.moon_phase_ts(self.time_ts) + self.moon_phase = self.moon_phases[self.moon_index] + self.time_djd = timestamp_to_djd(self.time_ts) + + # Check to see whether the user has module 'ephem'. + if 'ephem' in sys.modules: + + self.hasExtras = True + + else: + + # No ephem package. Use the weeutil algorithms, which supply a minimum of functionality + (y, m, d) = time.localtime(self.time_ts)[0:3] + (sunrise_utc_h, sunset_utc_h) = weeutil.Sun.sunRiseSet(y, m, d, self.lon, self.lat) + sunrise_ts = weeutil.weeutil.utc_to_ts(y, m, d, sunrise_utc_h) + sunset_ts = weeutil.weeutil.utc_to_ts(y, m, d, sunset_utc_h) + self._sunrise = weewx.units.ValueHelper( + ValueTuple(sunrise_ts, "unix_epoch", "group_time"), + context="ephem_day", + formatter=self.formatter, + converter=self.converter) + self._sunset = weewx.units.ValueHelper( + ValueTuple(sunset_ts, "unix_epoch", "group_time"), + context="ephem_day", + formatter=self.formatter, + converter=self.converter) + self.hasExtras = False + + # Shortcuts, used for backwards compatibility + @property + def sunrise(self): + return self.sun.rise if self.hasExtras else self._sunrise + + @property + def sunset(self): + return self.sun.set if self.hasExtras else self._sunset + + @property + def moon_fullness(self): + return int(self.moon.moon_fullness + 0.5) if self.hasExtras else self._moon_fullness + + def __call__(self, **kwargs): + """Call an almanac object as a functor. This allows overriding the values + used when the Almanac instance was initialized. + + Named arguments: + + almanac_time: The observer's time in unix epoch time. + lat: The observer's latitude in degrees + lon: The observer's longitude in degrees + altitude: The observer's altitude in meters + horizon: The horizon angle in degrees + temperature: The observer's temperature (used to calculate refraction) + pressure: The observer's pressure (used to calculate refraction) + """ + # Make a copy of myself. + almanac = copy.copy(self) + # Now set a new value for any named arguments. + for key in kwargs: + if key == 'almanac_time': + almanac.time_ts = kwargs['almanac_time'] + else: + setattr(almanac, key, kwargs[key]) + almanac._precalc() + + return almanac + + def separation(self, body1, body2): + return ephem.separation(body1, body2) + + def __getattr__(self, attr): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if attr.startswith('__') or attr == 'has_key': + raise AttributeError(attr) + + if not self.hasExtras: + # If the Almanac does not have extended capabilities, we can't + # do any of the following. Raise an exception. + raise AttributeError("Unknown attribute %s" % attr) + + # We do have extended capability. Check to see if the attribute is a calendar event: + elif attr in {'previous_equinox', 'next_equinox', + 'previous_solstice', 'next_solstice', + 'previous_autumnal_equinox', 'next_autumnal_equinox', + 'previous_vernal_equinox', 'next_vernal_equinox', + 'previous_winter_solstice', 'next_winter_solstice', + 'previous_summer_solstice', 'next_summer_solstice', + 'previous_new_moon', 'next_new_moon', + 'previous_first_quarter_moon', 'next_first_quarter_moon', + 'previous_full_moon', 'next_full_moon', + 'previous_last_quarter_moon', 'next_last_quarter_moon'}: + # This is how you call a function on an instance when all you have + # is the function's name as a string + djd = getattr(ephem, attr)(self.time_djd) + return weewx.units.ValueHelper(ValueTuple(djd, "dublin_jd", "group_time"), + context="ephem_year", + formatter=self.formatter, + converter=self.converter) + # Check to see if the attribute is sidereal time + elif attr == 'sidereal_time': + # sidereal time is obtained from an ephem Observer method, first get + # an Observer object + observer = _get_observer(self, self.time_djd) + # Then call the method returning the result in degrees + return math.degrees(getattr(observer, attr)()) + else: + # The attribute must be a heavenly body (such as 'sun', or 'jupiter'). + # Bind the almanac and the heavenly body together and return as an + # AlmanacBinder + return AlmanacBinder(self, attr) + + +fn_map = {'rise': 'next_rising', + 'set': 'next_setting', + 'transit': 'next_transit'} + + +class AlmanacBinder(object): + """This class binds the observer properties held in Almanac, with the heavenly + body to be observed.""" + + def __init__(self, almanac, heavenly_body): + self.almanac = almanac + + # Calculate and store the start-of-day in Dublin Julian Days. + y, m, d = time.localtime(self.almanac.time_ts)[0:3] + self.sod_djd = timestamp_to_djd(time.mktime((y, m, d, 0, 0, 0, 0, 0, -1))) + + self.heavenly_body = heavenly_body + self.use_center = False + + def __call__(self, use_center=False): + self.use_center = use_center + return self + + @property + def visible(self): + """Calculate how long the body has been visible today""" + ephem_body = _get_ephem_body(self.heavenly_body) + observer = _get_observer(self.almanac, self.sod_djd) + try: + time_rising_djd = observer.next_rising(ephem_body, use_center=self.use_center) + time_setting_djd = observer.next_setting(ephem_body, use_center=self.use_center) + except ephem.AlwaysUpError: + visible = 86400 + except ephem.NeverUpError: + visible = 0 + else: + visible = (time_setting_djd - time_rising_djd) * weewx.units.SECS_PER_DAY + + return weewx.units.ValueHelper(ValueTuple(visible, "second", "group_deltatime"), + context="day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + def visible_change(self, days_ago=1): + """Change in visibility of the heavenly body compared to 'days_ago'.""" + # Visibility for today: + today_visible = self.visible + # The time to compare to + then_time = self.almanac.time_ts - days_ago * 86400 + # Get a new almanac, set up for the time back then + then_almanac = self.almanac(almanac_time=then_time) + # Find the visibility back then + then_visible = getattr(then_almanac, self.heavenly_body).visible + # Take the difference + diff = today_visible.raw - then_visible.raw + return weewx.units.ValueHelper(ValueTuple(diff, "second", "group_deltatime"), + context="hour", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + def __getattr__(self, attr): + """Get the requested observation, such as when the body will rise.""" + + # Don't try any attributes that start with a double underscore, or any of these + # special names: they are used by the Python language: + if attr.startswith('__') or attr in ['mro', 'im_func', 'func_code']: + raise AttributeError(attr) + + # Many of these functions have the unfortunate side effect of changing the state of the body + # being examined. So, create a temporary body and then throw it away + ephem_body = _get_ephem_body(self.heavenly_body) + + if attr in ['rise', 'set', 'transit']: + # These verbs refer to the time the event occurs anytime in the day, which + # is not necessarily the *next* sunrise. + attr = fn_map[attr] + # These functions require the time at the start of day + observer = _get_observer(self.almanac, self.sod_djd) + # Call the function. Be prepared to catch an exception if the body is always up. + try: + if attr in ['next_rising', 'next_setting']: + time_djd = getattr(observer, attr)(ephem_body, use_center=self.use_center) + else: + time_djd = getattr(observer, attr)(ephem_body) + except (ephem.AlwaysUpError, ephem.NeverUpError): + time_djd = None + return weewx.units.ValueHelper(ValueTuple(time_djd, "dublin_jd", "group_time"), + context="ephem_day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + elif attr in {'next_rising', 'next_setting', 'next_transit', 'next_antitransit', + 'previous_rising', 'previous_setting', 'previous_transit', + 'previous_antitransit'}: + # These functions require the time of the observation + observer = _get_observer(self.almanac, self.almanac.time_djd) + # Call the function. Be prepared to catch an exception if the body is always up. + try: + if attr in ['next_rising', 'next_setting', 'previous_rising', 'previous_setting']: + time_djd = getattr(observer, attr)(ephem_body, use_center=self.use_center) + else: + time_djd = getattr(observer, attr)(ephem_body) + except (ephem.AlwaysUpError, ephem.NeverUpError): + time_djd = None + return weewx.units.ValueHelper(ValueTuple(time_djd, "dublin_jd", "group_time"), + context="ephem_day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + else: + # These functions need the current time in Dublin Julian Days + observer = _get_observer(self.almanac, self.almanac.time_djd) + ephem_body.compute(observer) + if attr in {'az', 'alt', 'a_ra', 'a_dec', 'g_ra', 'ra', 'g_dec', 'dec', + 'elong', 'radius', 'hlong', 'hlat', 'sublat', 'sublong'}: + # Return the results in degrees rather than radians + return math.degrees(getattr(ephem_body, attr)) + elif attr == 'moon_fullness': + # The attribute "moon_fullness" is the percentage of the moon surface that is illuminated. + # Unfortunately, phephem calls it "moon_phase", so call ephem with that name. + # Return the result in percent. + return 100.0 * ephem_body.moon_phase + else: + # Just return the result unchanged. This will raise an AttributeError exception + # if the attribute does not exist. + return getattr(ephem_body, attr) + + +def _get_observer(almanac_obj, time_ts): + # Build an ephem Observer object + observer = ephem.Observer() + observer.lat = math.radians(almanac_obj.lat) + observer.long = math.radians(almanac_obj.lon) + observer.elevation = almanac_obj.altitude + observer.horizon = math.radians(almanac_obj.horizon) + observer.temp = almanac_obj.temperature + observer.pressure = almanac_obj.pressure + observer.date = time_ts + return observer + + +def _get_ephem_body(heavenly_body): + # The library 'ephem' refers to heavenly bodies using a capitalized + # name. For example, the module used for 'mars' is 'ephem.Mars'. + cap_name = heavenly_body.title() + + # If the heavenly body is a star, or if the body does not exist, then an + # exception will be raised. Be prepared to catch it. + try: + ephem_body = getattr(ephem, cap_name)() + except AttributeError: + # That didn't work. Try a star. If this doesn't work either, + # then a KeyError exception will be raised. + ephem_body = ephem.star(cap_name) + except TypeError: + # Heavenly bodies added by a ephem.readdb() statement are not functions. + # So, just return the attribute, without calling it: + ephem_body = getattr(ephem, cap_name) + + return ephem_body + + +def timestamp_to_djd(time_ts): + """Convert from a unix time stamp to the number of days since 12/31/1899 12:00 UTC + (aka "Dublin Julian Days")""" + # The number 25567.5 is the start of the Unix epoch (1/1/1970). Just add on the + # number of days since then + return 25567.5 + time_ts / 86400.0 + + +def djd_to_timestamp(djd): + """Convert from number of days since 12/31/1899 12:00 UTC ("Dublin Julian Days") to + unix time stamp""" + return (djd - 25567.5) * 86400.0 + + +if __name__ == '__main__': + + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weewx/cheetahgenerator.py b/dist/weewx-4.10.1/bin/weewx/cheetahgenerator.py new file mode 100644 index 0000000..8204faf --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/cheetahgenerator.py @@ -0,0 +1,831 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# Class Gettext is Copyright (C) 2021 Johanna Karen Roedenbeck +# +# See the file LICENSE.txt for your full rights. +# +"""Generate files from templates using the Cheetah template engine. + +For more information about Cheetah, see http://www.cheetahtemplate.org + +Configuration Options + + encoding = (html_entities|utf8|strict_ascii|normalized_ascii) + template = filename.tmpl # must end with .tmpl + stale_age = s # age in seconds + search_list = a, b, c + search_list_extensions = d, e, f + +The strings YYYY, MM, DD and WW will be replaced if they appear in the filename. + +search_list will override the default search_list + +search_list_extensions will be appended to search_list + +Both search_list and search_list_extensions must be lists of classes. Each +class in the list must be derived from SearchList. + +Generally it is better to extend by using search_list_extensions rather than +search_list, just in case the default search list changes. + +Example: + +[CheetahGenerator] + # How to specify search list extensions: + search_list_extensions = user.forecast.ForecastVariables, user.extstats.ExtStatsVariables + encoding = html_entities + [[SummaryByMonth]] # period + [[[NOAA_month]]] # report + encoding = normalized_ascii + template = NOAA-YYYY-MM.txt.tmpl + [[SummaryByYear]] + [[[NOAA_year]]]] + encoding = normalized_ascii + template = NOAA-YYYY.txt.tmpl + [[ToDate]] + [[[day]]] + template = index.html.tmpl + [[[week]]] + template = week.html.tmpl + [[wuforecast_details]] # period/report + stale_age = 3600 # how old before regenerating + template = wuforecast.html.tmpl + [[nwsforecast_details]] # period/report + stale_age = 10800 # how old before generating + template = nwsforecast.html.tmpl + +""" + +from __future__ import absolute_import + +import datetime +import json +import logging +import os.path +import time +import unicodedata + +import Cheetah.Filters +import Cheetah.Template +import six + +import weedb +import weeutil.logger +import weeutil.weeutil +import weewx.almanac +import weewx.reportengine +import weewx.station +import weewx.tags +import weewx.units +from weeutil.config import search_up, accumulateLeaves, deep_copy +from weeutil.weeutil import to_bool, to_int, timestamp_to_string + +log = logging.getLogger(__name__) + +# The default search list includes standard information sources that should be +# useful in most templates. +default_search_list = [ + "weewx.cheetahgenerator.Almanac", + "weewx.cheetahgenerator.Current", + "weewx.cheetahgenerator.DisplayOptions", + "weewx.cheetahgenerator.Extras", + "weewx.cheetahgenerator.Gettext", + "weewx.cheetahgenerator.JSONHelpers", + "weewx.cheetahgenerator.PlotInfo", + "weewx.cheetahgenerator.SkinInfo", + "weewx.cheetahgenerator.Station", + "weewx.cheetahgenerator.Stats", + "weewx.cheetahgenerator.UnitInfo", +] + + +# ============================================================================= +# CheetahGenerator +# ============================================================================= + +class CheetahGenerator(weewx.reportengine.ReportGenerator): + """Class for generating files from cheetah templates. + + Useful attributes (some inherited from ReportGenerator): + + config_dict: The weewx configuration dictionary + skin_dict: The dictionary for this skin + gen_ts: The generation time + first_run: Is this the first time the generator has been run? + stn_info: An instance of weewx.station.StationInfo + record: A copy of the "current" record. May be None. + formatter: An instance of weewx.units.Formatter + converter: An instance of weewx.units.Converter + search_list_objs: A list holding search list extensions + db_binder: An instance of weewx.manager.DBBinder from which the + data should be extracted + """ + + generator_dict = {'SummaryByDay' : weeutil.weeutil.genDaySpans, + 'SummaryByMonth': weeutil.weeutil.genMonthSpans, + 'SummaryByYear' : weeutil.weeutil.genYearSpans} + + format_dict = {'SummaryByDay' : "%Y-%m-%d", + 'SummaryByMonth': "%Y-%m", + 'SummaryByYear' : "%Y"} + + def __init__(self, config_dict, skin_dict, *args, **kwargs): + """Initialize an instance of CheetahGenerator""" + # Initialize my superclass + weewx.reportengine.ReportGenerator.__init__(self, config_dict, skin_dict, *args, **kwargs) + + self.search_list_objs = [] + self.formatter = weewx.units.Formatter.fromSkinDict(skin_dict) + self.converter = weewx.units.Converter.fromSkinDict(skin_dict) + + # This dictionary will hold the formatted dates of all generated files + self.outputted_dict = {k: [] for k in CheetahGenerator.generator_dict} + + def run(self): + """Main entry point for file generation using Cheetah Templates.""" + + t1 = time.time() + + # Make a deep copy of the skin dictionary (we will be modifying it): + gen_dict = deep_copy(self.skin_dict) + + # Look for options in [CheetahGenerator], + section_name = "CheetahGenerator" + # but accept options from [FileGenerator] for backward compatibility. + if "FileGenerator" in gen_dict and "CheetahGenerator" not in gen_dict: + section_name = "FileGenerator" + + # The default summary time span is 'None'. + gen_dict[section_name]['summarize_by'] = 'None' + + # determine how much logging is desired + log_success = to_bool(search_up(gen_dict[section_name], 'log_success', True)) + + # configure the search list extensions + self.init_extensions(gen_dict[section_name]) + + # Generate any templates in the given dictionary: + ngen = self.generate(gen_dict[section_name], section_name, self.gen_ts) + + self.teardown() + + elapsed_time = time.time() - t1 + if log_success: + log.info("Generated %d files for report %s in %.2f seconds", + ngen, self.skin_dict['REPORT_NAME'], elapsed_time) + + def init_extensions(self, gen_dict): + """Load the search list""" + + # Build the search list. Start with user extensions: + search_list = weeutil.weeutil.option_as_list(gen_dict.get('search_list_extensions', [])) + + # Add on the default search list: + search_list.extend(weeutil.weeutil.option_as_list(gen_dict.get('search_list', + default_search_list))) + + # Provide feedback about the final list + log.debug("Using search list %s", search_list) + + # Now go through search_list (which is a list of strings holding the + # names of the extensions), and instantiate each one + for c in search_list: + x = c.strip() + if x: + # Get the class + klass = weeutil.weeutil.get_object(x) + # Then instantiate the class, passing self as the sole argument + self.search_list_objs.append(klass(self)) + + def teardown(self): + """Delete any extension objects we created to prevent back references + from slowing garbage collection""" + while self.search_list_objs: + self.search_list_objs[-1].finalize() + del self.search_list_objs[-1] + + def generate(self, section, section_name, gen_ts): + """Generate one or more reports for the indicated section. Each + section in a period is a report. A report has one or more templates. + + section: A ConfigObj dictionary, holding the templates to be + generated. Any subsections in the dictionary will be recursively + processed as well. + + gen_ts: The report will be current to this time. + """ + + ngen = 0 + # Go through each subsection (if any) of this section, + # generating from any templates they may contain + for subsection in section.sections: + # Sections 'SummaryByMonth' and 'SummaryByYear' imply summarize_by + # certain time spans + if 'summarize_by' not in section[subsection]: + if subsection in CheetahGenerator.generator_dict: + section[subsection]['summarize_by'] = subsection + # Call recursively, to generate any templates in this subsection + ngen += self.generate(section[subsection], subsection, gen_ts) + + # We have finished recursively processing any subsections in this + # section. Time to do the section itself. If there is no option + # 'template', then there isn't anything to do. Return. + if 'template' not in section: + return ngen + + report_dict = accumulateLeaves(section) + + generate_once = to_bool(report_dict.get('generate_once', False)) + if generate_once and not self.first_run: + return ngen + + # Change directory to the skin subdirectory. We use absolute paths + # for cheetah, so the directory change is not necessary for generating + # files. However, changing to the skin directory provides a known + # location so that calls to os.getcwd() in any templates will return + # a predictable result. + os.chdir(os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict['SKIN_ROOT'], + self.skin_dict.get('skin', ''))) + + (template, dest_dir, encoding, default_binding) = self._prepGen(report_dict) + + # Get start and stop times + default_archive = self.db_binder.get_manager(default_binding) + start_ts = default_archive.firstGoodStamp() + if not start_ts: + log.info('Skipping template %s: cannot find start time', section['template']) + return ngen + + if gen_ts: + record = default_archive.getRecord(gen_ts, + max_delta=to_int(report_dict.get('max_delta'))) + if record: + stop_ts = record['dateTime'] + else: + log.info('Skipping template %s: generate time %s not in database', + section['template'], timestamp_to_string(gen_ts)) + return ngen + else: + stop_ts = default_archive.lastGoodStamp() + + # Get an appropriate generator function + summarize_by = report_dict['summarize_by'] + if summarize_by in CheetahGenerator.generator_dict: + _spangen = CheetahGenerator.generator_dict[summarize_by] + else: + # Just a single timespan to generate. Use a lambda expression. + _spangen = lambda start_ts, stop_ts: [weeutil.weeutil.TimeSpan(start_ts, stop_ts)] + + # Use the generator function + for timespan in _spangen(start_ts, stop_ts): + start_tt = time.localtime(timespan.start) + stop_tt = time.localtime(timespan.stop) + + if summarize_by in CheetahGenerator.format_dict: + # This is a "SummaryBy" type generation. If it hasn't been done already, save the + # date as a string, to be used inside the document + date_str = time.strftime(CheetahGenerator.format_dict[summarize_by], start_tt) + if date_str not in self.outputted_dict[summarize_by]: + self.outputted_dict[summarize_by].append(date_str) + # For these "SummaryBy" generations, the file name comes from the start of the timespan: + _filename = self._getFileName(template, start_tt) + else: + # This is a "ToDate" generation. File name comes + # from the stop (i.e., present) time: + _filename = self._getFileName(template, stop_tt) + + # Get the absolute path for the target of this template + _fullname = os.path.join(dest_dir, _filename) + + # Skip summary files outside the timespan + if report_dict['summarize_by'] in CheetahGenerator.generator_dict \ + and os.path.exists(_fullname) \ + and not timespan.includesArchiveTime(stop_ts): + continue + + # skip files that are fresh, but only if staleness is defined + stale = to_int(report_dict.get('stale_age')) + if stale is not None: + t_now = time.time() + try: + last_mod = os.path.getmtime(_fullname) + if t_now - last_mod < stale: + log.debug("Skip '%s': last_mod=%s age=%s stale=%s", + _filename, last_mod, t_now - last_mod, stale) + continue + except os.error: + pass + + searchList = self._getSearchList(encoding, timespan, + default_binding, section_name, + os.path.join( + os.path.dirname(report_dict['template']), + _filename)) + + # First, compile the template + try: + # TODO: Look into caching the compiled template. + # Under Python 2, Cheetah V2 will crash if given a template file name in Unicode, + # so make sure it's a string first, using six.ensure_str(). + compiled_template = Cheetah.Template.Template( + file=six.ensure_str(template), + searchList=searchList, + filter='AssureUnicode', + filtersLib=weewx.cheetahgenerator) + except Exception as e: + log.error("Compilation of template %s failed with exception '%s'", template, type(e)) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + weeutil.logger.log_traceback(log.error, "**** ") + continue + + # Second, evaluate the compiled template + try: + # We have a compiled template in hand. Evaluate it. The result will be a long + # Unicode string. + unicode_string = compiled_template.respond() + except Cheetah.Parser.ParseError as e: + log.error("Parse error while evaluating file %s", template) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + continue + except Cheetah.NameMapper.NotFound as e: + log.error("Evaluation of template %s failed.", template) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + log.error("**** To debug, try inserting '#errorCatcher Echo' at top of template") + continue + except Exception as e: + log.error("Evaluation of template %s failed with exception '%s'", template, type(e)) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + weeutil.logger.log_traceback(log.error, "**** ") + continue + + # Third, convert the results to a byte string, using the strategy chosen by the user. + if encoding == 'html_entities': + byte_string = unicode_string.encode('ascii', 'xmlcharrefreplace') + elif encoding == 'strict_ascii': + byte_string = unicode_string.encode('ascii', 'ignore') + elif encoding == 'normalized_ascii': + # Normalize the string, replacing accented characters with non-accented + # equivalents + normalized = unicodedata.normalize('NFD', unicode_string) + byte_string = normalized.encode('ascii', 'ignore') + else: + byte_string = unicode_string.encode(encoding) + + # Finally, write the byte string to the target file + try: + # Write to a temporary file first + tmpname = _fullname + '.tmp' + # Open it in binary mode. We are writing a byte-string, not a string + with open(tmpname, mode='wb') as fd: + fd.write(byte_string) + # Now move the temporary file into place + os.rename(tmpname, _fullname) + ngen += 1 + finally: + try: + os.unlink(tmpname) + except OSError: + pass + + return ngen + + def _getSearchList(self, encoding, timespan, default_binding, section_name, file_name): + """Get the complete search list to be used by Cheetah.""" + + # Get the basic search list + timespan_start_tt = time.localtime(timespan.start) + search_list = [{'month_name' : time.strftime("%b", timespan_start_tt), + 'year_name' : timespan_start_tt[0], + 'encoding' : encoding, + 'page' : section_name, + 'filename' : file_name}, + self.outputted_dict] + + # Bind to the default_binding: + db_lookup = self.db_binder.bind_default(default_binding) + + # Then add the V3.X style search list extensions + for obj in self.search_list_objs: + search_list += obj.get_extension_list(timespan, db_lookup) + + return search_list + + def _getFileName(self, template, ref_tt): + """Calculate a destination filename given a template filename. + + For backwards compatibility replace 'YYYY' with the year, 'MM' with the + month, 'DD' with the day. Also observe any strftime format strings in + the filename. Finally, strip off any trailing .tmpl.""" + + _filename = os.path.basename(template).replace('.tmpl', '') + + # If the filename contains YYYY, MM, DD or WW, then do the replacement + if 'YYYY' in _filename or 'MM' in _filename or 'DD' in _filename or 'WW' in _filename: + # Get strings representing year, month, and day + _yr_str = "%4d" % ref_tt[0] + _mo_str = "%02d" % ref_tt[1] + _day_str = "%02d" % ref_tt[2] + _week_str = "%02d" % datetime.date(ref_tt[0], ref_tt[1], ref_tt[2]).isocalendar()[1]; + # Replace any instances of 'YYYY' with the year string + _filename = _filename.replace('YYYY', _yr_str) + # Do the same thing with the month... + _filename = _filename.replace('MM', _mo_str) + # ... the week ... + _filename = _filename.replace('WW', _week_str) + # ... and the day + _filename = _filename.replace('DD', _day_str) + # observe any strftime format strings in the base file name + # first obtain a datetime object from our timetuple + ref_dt = datetime.datetime.fromtimestamp(time.mktime(ref_tt)) + # then apply any strftime formatting + _filename = ref_dt.strftime(_filename) + + return _filename + + def _prepGen(self, report_dict): + """Get the template, destination directory, encoding, and default + binding.""" + + # -------- Template --------- + template = os.path.join(self.config_dict['WEEWX_ROOT'], + self.config_dict['StdReport']['SKIN_ROOT'], + report_dict['skin'], + report_dict['template']) + + # ------ Destination directory -------- + destination_dir = os.path.join(self.config_dict['WEEWX_ROOT'], + report_dict['HTML_ROOT'], + os.path.dirname(report_dict['template'])) + try: + # Create the directory that is to receive the generated files. If + # it already exists an exception will be thrown, so be prepared to + # catch it. + os.makedirs(destination_dir) + except OSError: + pass + + # ------ Encoding ------ + encoding = report_dict.get('encoding', 'html_entities').strip().lower() + # Convert to 'utf8'. This is because 'utf-8' cannot be a class name + if encoding == 'utf-8': + encoding = 'utf8' + + # ------ Default binding --------- + default_binding = report_dict['data_binding'] + + return (template, destination_dir, encoding, default_binding) + + +# ============================================================================= +# Classes used to implement the Search list +# ============================================================================= + +class SearchList(object): + """Abstract base class used for search list extensions.""" + + def __init__(self, generator): + """Create an instance of SearchList. + + generator: The generator that is using this search list + """ + self.generator = generator + + def get_extension_list(self, timespan, db_lookup): # @UnusedVariable + """For weewx V3.x extensions. Should return a list + of objects whose attributes or keys define the extension. + + timespan: An instance of weeutil.weeutil.TimeSpan. This will hold the + start and stop times of the domain of valid times. + + db_lookup: A function with call signature db_lookup(data_binding), + which returns a database manager and where data_binding is + an optional binding name. If not given, then a default + binding will be used. + """ + return [self] + + def finalize(self): + """Called when the extension is no longer needed""" + + +class Almanac(SearchList): + """Class that implements the '$almanac' tag.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + + celestial_ts = generator.gen_ts + + # For better accuracy, the almanac requires the current temperature + # and barometric pressure, so retrieve them from the default archive, + # using celestial_ts as the time + + # The default values of temperature and pressure + temperature_C = 15.0 + pressure_mbar = 1010.0 + + # See if we can get more accurate values by looking them up in the + # weather database. The database might not exist, so be prepared for + # a KeyError exception. + try: + binding = self.generator.skin_dict.get('data_binding', 'wx_binding') + archive = self.generator.db_binder.get_manager(binding) + except (KeyError, weewx.UnknownBinding, weedb.NoDatabaseError): + pass + else: + # If a specific time has not been specified, then use the timestamp + # of the last record in the database. + if not celestial_ts: + celestial_ts = archive.lastGoodStamp() + + # Check to see whether we have a good time. If so, retrieve the + # record from the database + if celestial_ts: + # Look for the record closest in time. Up to one hour off is + # acceptable: + rec = archive.getRecord(celestial_ts, max_delta=3600) + if rec is not None: + if 'outTemp' in rec: + temperature_C = weewx.units.convert(weewx.units.as_value_tuple(rec, 'outTemp'), "degree_C")[0] + if 'barometer' in rec: + pressure_mbar = weewx.units.convert(weewx.units.as_value_tuple(rec, 'barometer'), "mbar")[0] + + self.moonphases = generator.skin_dict.get('Almanac', {}).get('moon_phases', weeutil.Moon.moon_phases) + + altitude_vt = weewx.units.convert(generator.stn_info.altitude_vt, "meter") + + self.almanac = weewx.almanac.Almanac(celestial_ts, + generator.stn_info.latitude_f, + generator.stn_info.longitude_f, + altitude=altitude_vt[0], + temperature=temperature_C, + pressure=pressure_mbar, + moon_phases=self.moonphases, + formatter=generator.formatter, + converter=generator.converter) + + +class Station(SearchList): + """Class that implements the $station tag.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + self.station = weewx.station.Station(generator.stn_info, + generator.formatter, + generator.converter, + generator.skin_dict) + + +class Current(SearchList): + """Class that implements the $current tag""" + + def get_extension_list(self, timespan, db_lookup): + record_binder = weewx.tags.RecordBinder(db_lookup, timespan.stop, + self.generator.formatter, self.generator.converter, + record=self.generator.record) + return [record_binder] + + +class Stats(SearchList): + """Class that implements the time-based statistical tags, such + as $day.outTemp.max""" + + def get_extension_list(self, timespan, db_lookup): + try: + trend_dict = self.generator.skin_dict['Units']['Trend'] + except KeyError: + trend_dict = {'time_delta': 10800, + 'time_grace': 300} + + stats = weewx.tags.TimeBinder( + db_lookup, + timespan.stop, + formatter=self.generator.formatter, + converter=self.generator.converter, + week_start=self.generator.stn_info.week_start, + rain_year_start=self.generator.stn_info.rain_year_start, + trend=trend_dict, + skin_dict=self.generator.skin_dict) + + return [stats] + + +class UnitInfo(SearchList): + """Class that implements the $unit and $obs tags.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + # This implements the $unit tag: + self.unit = weewx.units.UnitInfoHelper(generator.formatter, + generator.converter) + # This implements the $obs tag: + self.obs = weewx.units.ObsInfoHelper(generator.skin_dict) + + +if six.PY3: + # Dictionaries in Python 3 no longer have the "has_key()" function. + # This will break a lot of skins. Use a wrapper to provide it + class ExtraDict(dict): + + def has_key(self, key): + return key in self +else: + # Not necessary in Python 2 + ExtraDict = dict + + +class Extras(SearchList): + """Class for exposing the [Extras] section in the skin config dictionary + as tag $Extras.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + # If the user has supplied an '[Extras]' section in the skin + # dictionary, include it in the search list. Otherwise, just include + # an empty dictionary. + self.Extras = ExtraDict(generator.skin_dict.get('Extras', {})) + + +class JSONHelpers(SearchList): + """Helper functions for formatting JSON""" + + @staticmethod + def jsonize(arg): + """ + Format my argument as JSON + + Args: + arg (iterable): An iterable, such as a list, or zip structure + + Returns: + str: The argument formatted as JSON. + """ + val = list(arg) + return json.dumps(val, cls=weewx.units.ComplexEncoder) + + @staticmethod + def rnd(arg, ndigits): + """Round a number, or sequence of numbers, to a specified number of decimal digits + + Args: + arg (None, float, complex, list): The number or sequence of numbers to be rounded. + If the argument is None, then None will be returned. + ndigits (int): The number of decimal digits to retain. + + Returns: + None, float, complex, list: Returns the number, or sequence of numbers, with the + requested number of decimal digits + """ + return weeutil.weeutil.rounder(arg, ndigits) + + @staticmethod + def to_int(arg): + """Convert the argument into an integer, honoring 'None' + + Args: + arg (None, float, str): + + Returns: + int: The argument converted to an integer. + """ + return weeutil.weeutil.to_int(arg) + + @staticmethod + def to_bool(arg): + """Convert the argument to boolean True or False, if possible.""" + return weeutil.weeutil.to_bool(arg) + + @staticmethod + def to_list(arg): + """Convert the argment into a list""" + return weeutil.weeutil.to_list(arg) + +class Gettext(SearchList): + """Values provided by $gettext() are found in the [Texts] section of the localization file.""" + + def gettext(self, key): + try: + v = self.generator.skin_dict['Texts'].get(key, key) + except KeyError: + v = key + return v + + def pgettext(self, context, key): + try: + v = self.generator.skin_dict['Texts'][context].get(key, key) + except KeyError: + v = key + return v + + # An underscore is a common alias for gettext: + _ = gettext + + +class PlotInfo(SearchList): + """Return information about plots, based on what's in the [ImageGenerator] section.""" + + def getobs(self, plot_name): + """ + Given a plot name, return the set of observations in that plot. + If there is no plot by the indicated name, return an empty set. + """ + obs = set() + # If there is no [ImageGenerator] section, return the empty set. + try: + timespan_names = self.generator.skin_dict['ImageGenerator'].sections + except (KeyError, AttributeError): + return obs + + # Scan all the timespans, looking for plot_name + for timespan_name in timespan_names: + if plot_name in self.generator.skin_dict['ImageGenerator'][timespan_name]: + # Found it. To make things manageable, get just the plot dictionary: + plot_dict = self.generator.skin_dict['ImageGenerator'][timespan_name][plot_name] + # Now extract all observation names from it + for obs_name in plot_dict.sections: + # The observation name might be specified directly, + # or it might be specified by the data_type field. + if 'data_type' in plot_dict[obs_name]: + data_name = plot_dict[obs_name]['data_type'] + else: + data_name = obs_name + # A data type of 'windvec' or 'windgustvec' requires special treatment + if data_name == 'windvec': + data_name = 'windSpeed' + elif data_name == 'windgustvec': + data_name = 'windGust' + + obs.add(data_name) + break + return obs + + +class DisplayOptions(SearchList): + """Class for exposing the [DisplayOptions] section in the skin config + dictionary as tag $DisplayOptions.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + self.DisplayOptions = dict(generator.skin_dict.get('DisplayOptions', {})) + + +class SkinInfo(SearchList): + """Class for exposing information about the skin.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + for k in ['HTML_ROOT', 'lang', 'REPORT_NAME', 'skin', + 'SKIN_NAME', 'SKIN_ROOT', 'SKIN_VERSION', 'unit_system' + ]: + setattr(self, k, generator.skin_dict.get(k, 'unknown')) + + +# ============================================================================= +# Filter +# ============================================================================= + +class AssureUnicode(Cheetah.Filters.Filter): + """Assures that whatever a search list extension might return, it will be converted into + Unicode. """ + + def filter(self, val, **kwargs): + """Convert the expression 'val' to unicode.""" + # There is a 2x4 matrix of possibilities: + # input PY2 PY3 + # _____ ________ _______ + # bytes decode() decode() + # str decode() -done- + # unicode -done- N/A + # object unicode() str() + if val is None: + return u'' + + # Is it already unicode? This takes care of cells 4 and 5. + if isinstance(val, six.text_type): + filtered = val + # This conditional covers cells 1,2, and 3. That is, val is a byte string + elif isinstance(val, six.binary_type): + filtered = val.decode('utf-8') + # That leaves cells 7 and 8, that is val is an object, such as a ValueHelper + else: + # Must be an object. Convert to unicode string + try: + # For late tag bindings under Python 2, the following forces the invocation of + # __unicode__(). Under Python 3, it invokes __str__(). Either way, it can force + # an XTypes query. For a tag such as $day.foobar.min, where 'foobar' is an unknown + # type, this will cause an attribute error. Be prepared to catch it. + filtered = six.text_type(val) + except AttributeError as e: + # Offer a debug message. + log.debug("Unrecognized: %s", kwargs.get('rawExpr', e)) + # Return the raw expression, if available. Otherwise, the exception message + # concatenated with a question mark. + filtered = kwargs.get('rawExpr', str(e) + '?') + + return filtered diff --git a/dist/weewx-4.10.1/bin/weewx/crc16.py b/dist/weewx-4.10.1/bin/weewx/crc16.py new file mode 100644 index 0000000..fdddc1f --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/crc16.py @@ -0,0 +1,68 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Routines for calculating a 16 bit CRC check. """ + +from __future__ import absolute_import +from functools import reduce + +_table=[ +0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, # 0x00 +0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, # 0x08 +0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, # 0x10 +0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, # 0x18 +0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, # 0x20 +0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, # 0x28 +0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, # 0x30 +0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, # 0x38 +0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, # 0x40 +0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, # 0x48 +0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, # 0x50 +0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, # 0x58 +0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, # 0x60 +0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, # 0x68 +0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, # 0x70 +0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, # 0x78 +0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, # 0x80 +0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, # 0x88 +0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, # 0x90 +0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, # 0x98 +0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, # 0xA0 +0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, # 0xA8 +0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, # 0xB0 +0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, # 0xB8 +0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, # 0xC0 +0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, # 0xC8 +0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, # 0xD0 +0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, # 0xD8 +0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, # 0xE0 +0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, # 0xE8 +0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, # 0xF0 +0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 # 0xF8 +] + + +def crc16(bytes, crc_start=0): + """ Calculate CRC16 sum""" + + # We need something that returns integers when iterated over. + try: + # Python 2 + byte_iter = [ord(x) for x in bytes] + except TypeError: + # Python 3 + byte_iter = bytes + + crc_sum = reduce(lambda crc, ch : (_table[(crc >> 8) ^ ch] ^ (crc << 8)) & 0xffff, byte_iter, crc_start) + + return crc_sum + + +if __name__ == '__main__' : + import struct + # This is the example given in the Davis documentation: + test_bytes = struct.pack(" +# +# See the file LICENSE.txt for your rights. +# + +"""Backstop defaults used in the absence of any other values.""" + +from __future__ import absolute_import +import weeutil.config + +DEFAULT_STR = """# Copyright (c) 2009-2021 Tom Keffer +# See the file LICENSE.txt for your rights. + +# Where the skins reside, relative to WEEWX_ROOT +SKIN_ROOT = skins + +# Where the generated reports should go, relative to WEEWX_ROOT +HTML_ROOT = public_html + +# The database binding indicates which data should be used in reports. +data_binding = wx_binding + +# Whether to log a successful operation +log_success = True + +# Whether to log an unsuccessful operation +log_failure = False + +# The following section determines the selection and formatting of units. +[Units] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[Groups]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_amp = amp + group_concentration= microgram_per_meter_cubed + group_data = byte + group_db = dB + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_deltatime = second + group_direction = degree_compass + group_distance = mile # Options are 'mile' or 'km' + group_energy = watt_hour + group_energy2 = watt_second + group_fraction = ppm + group_frequency = hertz + group_illuminance = lux + group_length = inch + group_moisture = centibar + group_percent = percent + group_power = watt + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + group_pressure_rate= inHq_per_hour + group_radiation = watt_per_meter_squared + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + group_uv = uv_index + group_volt = volt + group_volume = gallon + + # The following are used internally and should not be changed: + group_boolean = boolean + group_count = count + group_elapsed = second + group_interval = minute + group_time = unix_epoch + + # The following section sets the formatting for each type of unit. + [[StringFormats]] + + amp = %.1f + bit = %.0f + boolean = %d + byte = %.0f + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + count = %d + cubic_foot = %.1f + day = %.1f + dB = %.0f + degree_C = %.1f + degree_C_day = %.1f + degree_compass = %.0f + degree_E = %.1f + degree_F = %.1f + degree_F_day = %.1f + degree_K = %.1f + foot = %.0f + gallon = %.1f + hertz = %.1f + hour = %.1f + hPa = %.1f + hPa_per_hour = %.3f + inch = %.2f + inch_per_hour = %.2f + inHg = %.3f + inHg_per_hour = %.5f + kilowatt = %.1f + kilowatt_hour = %.1f + km = %.1f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + kPa = %.2f + kPa_per_hour = %.4f + liter = %.1f + litre = %.1f + lux = %.0f + mbar = %.1f + mbar_per_hour = %.4f + mega_joule = %.0f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + microgram_per_meter_cubed = %.0f + mile = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + minute = %.1f + mm = %.1f + mm_per_hour = %.1f + mmHg = %.1f + mmHg_per_hour = %.4f + percent = %.0f + ppm = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt = %.1f + watt_hour = %.1f + watt_per_meter_squared = %.0f + watt_second = %.0f + NONE = " N/A" + + # The following section sets the label to be used for each type of unit + [[Labels]] + + amp = " A" + bit = " b" + boolean = "" + byte = " B" + centibar = " cb" + cm = " cm" + cm_per_hour = " cm/h" + count = "" + cubic_foot = " ft³" + day = " day", " days" + dB = " dB" + degree_C = "°C" + degree_C_day = "°C-day" + degree_compass = "°" + degree_E = "°E" + degree_F = "°F" + degree_F_day = "°F-day" + degree_K = "°K" + foot = " feet" + gallon = " gal" + hertz = " Hz" + hour = " hour", " hours" + hPa = " hPa" + hPa_per_hour = " hPa/h" + inch = " in" + inch_per_hour = " in/h" + inHg = " inHg" + inHg_per_hour = " inHg/h" + kilowatt_hour = " kWh" + km = " km" + km_per_hour = " km/h" + km_per_hour2 = " km/h" + knot = " knots" + knot2 = " knots" + kPa = " kPa", + kPa_per_hour = " kPa/h", + liter = " l", + litre = " l", + lux = " lx", + mbar = " mbar" + mbar_per_hour = " mbar/h" + mega_joule = " MJ" + meter = " meter", " meters" + meter_per_second = " m/s" + meter_per_second2 = " m/s" + microgram_per_meter_cubed = " µg/m³", + mile = " mile", " miles" + mile_per_hour = " mph" + mile_per_hour2 = " mph" + minute = " minute", " minutes" + mm = " mm" + mm_per_hour = " mm/h" + mmHg = " mmHg" + mmHg_per_hour = " mmHg/h" + percent = % + ppm = " ppm" + second = " second", " seconds" + uv_index = "" + volt = " V" + watt = " W" + watt_hour = " Wh" + watt_per_meter_squared = " W/m²" + watt_second = " Ws" + NONE = "" + + # The following section sets the format to be used for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. See the Customization Guide for alternatives. + [[TimeFormats]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[DeltaTimeFormats]] + current = "%(minute)d%(minute_label)s, %(second)d%(second_label)s" + hour = "%(minute)d%(minute_label)s, %(second)d%(second_label)s" + day = "%(hour)d%(hour_label)s, %(minute)d%(minute_label)s, %(second)d%(second_label)s" + week = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s" + month = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s" + year = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s" + + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating and cooling degree-days. + [[DegreeDays]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[Trend]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + +# The labels are applied to observations or any other strings. +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = "%02d", "%03d", "%05.2f" + + # Generic labels, keyed by an observation type. + [[Generic]] + barometer = Barometer + barometerRate = Barometer Change Rate + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Outside Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent +""" + +defaults = weeutil.config.config_from_str(DEFAULT_STR) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/__init__.py b/dist/weewx-4.10.1/bin/weewx/drivers/__init__.py new file mode 100644 index 0000000..0177a6d --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/__init__.py @@ -0,0 +1,151 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Device drivers for the weewx weather system.""" + +from __future__ import absolute_import +import weewx + + +class AbstractDevice(object): + """Device drivers should inherit from this class.""" + + @property + def hardware_name(self): + raise NotImplementedError("Property 'hardware_name' not implemented") + + @property + def archive_interval(self): + raise NotImplementedError("Property 'archive_interval' not implemented") + + def genStartupRecords(self, last_ts): + return self.genArchiveRecords(last_ts) + + def genLoopPackets(self): + raise NotImplementedError("Method 'genLoopPackets' not implemented") + + def genArchiveRecords(self, lastgood_ts): + raise NotImplementedError("Method 'genArchiveRecords' not implemented") + + def getTime(self): + raise NotImplementedError("Method 'getTime' not implemented") + + def setTime(self): + raise NotImplementedError("Method 'setTime' not implemented") + + def closePort(self): + pass + + +class AbstractConfigurator(object): + """The configurator class defines an interface for configuring devices. + Inherit from this class to provide a comman-line interface for setting + up a device, querying device status, and other setup/maintenance + operations.""" + + @property + def description(self): + return "Configuration utility for weewx devices." + + @property + def usage(self): + return "%prog [config_file] [options] [-y] [--debug] [--help]" + + @property + def epilog(self): + return "Be sure to stop weewx first before using. Mutating actions will"\ + " request confirmation before proceeding.\n" + + def configure(self, config_dict): + parser = self.get_parser() + self.add_options(parser) + options, _ = parser.parse_args() + if options.debug: + weewx.debug = options.debug + self.do_options(options, parser, config_dict, not options.noprompt) + + def get_parser(self): + import optparse + return optparse.OptionParser(description=self.description, + usage=self.usage, epilog=self.epilog) + + def add_options(self, parser): + """Add command line options. Derived classes should override this + method to add more options.""" + + parser.add_option("--debug", dest="debug", + action="store_true", + help="display diagnostic information while running") + parser.add_option("-y", dest="noprompt", + action="store_true", + help="answer yes to every prompt") + + def do_options(self, options, parser, config_dict, prompt): + """Derived classes must implement this to actually do something.""" + raise NotImplementedError("Method 'do_options' not implemented") + + +class AbstractConfEditor(object): + """The conf editor class provides methods for producing and updating + configuration stanzas for use in configuration file. + """ + + @property + def default_stanza(self): + """Return a plain text stanza. This will look something like: + +[Acme] + # This section is for the Acme weather station + + # The station model + model = acme100 + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The driver to use: + driver = weewx.drivers.acme + """ + raise NotImplementedError("property 'default_stanza' is not defined") + + def get_conf(self, orig_stanza=None): + """Given a configuration stanza, return a possibly modified copy + that will work with the current version of the device driver. + + The default behavior is to return the original stanza, unmodified. + + Derived classes should override this if they need to modify previous + configuration options or warn about deprecated or harmful options. + + The return value should be a long string. See default_stanza above + for an example string stanza.""" + return self.default_stanza if orig_stanza is None else orig_stanza + + def prompt_for_settings(self): + """Prompt for settings required for proper operation of this driver. + """ + return dict() + + def _prompt(self, label, dflt=None, opts=None): + if label in self.existing_options: + dflt = self.existing_options[label] + import weecfg + val = weecfg.prompt_with_options(label, dflt, opts) + del weecfg + return val + + def modify_config(self, config_dict): + """Given a configuration dictionary, make any modifications required + by the driver. + + The default behavior is to make no changes. + + This method gives a driver the opportunity to modify configuration + settings that affect its performance. For example, if a driver can + support hardware archive record generation, but software archive record + generation is preferred, the driver can change that parameter using + this method. + """ + pass diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/acurite.py b/dist/weewx-4.10.1/bin/weewx/drivers/acurite.py new file mode 100644 index 0000000..25f4b7f --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/acurite.py @@ -0,0 +1,1005 @@ +#!/usr/bin/env python +# Copyright 2014 Matthew Wall +# See the file LICENSE.txt for your rights. +# +# Credits: +# Thanks to Rich of Modern Toil (2012) +# http://moderntoil.com/?p=794 +# +# Thanks to George Nincehelser +# http://nincehelser.com/ipwx/ +# +# Thanks to Dave of 'desert home' (2014) +# http://www.desert-home.com/2014/11/acurite-weather-station-raspberry-pi.html +# +# Thanks to Brett Warden +# figured out a linear function for the pressure sensor in the 02032 +# +# Thanks to Weather Guy and Andrew Daviel (2015) +# decoding of the R3 messages and R3 reports +# decoding of the windspeed +# +# golf clap to Michael Walsh +# http://forum1.valleyinfosys.com/index.php +# +# No thanks to AcuRite or Chaney instruments. They refused to provide any +# technical details for the development of this driver. + +"""Driver for AcuRite weather stations. + +There are many variants of the AcuRite weather stations and sensors. This +driver is known to work with the consoles that have a USB interface such as +models 01025, 01035, 02032C, and 02064C. + +The AcuRite stations were introduced in 2011. The 02032 model was introduced +in 2013 or 2014. The 02032 appears to be a low-end model - it has fewer +buttons, and a different pressure sensor. The 02064 model was introduced in +2015 and appears to be an attempt to fix problems in the 02032. + +AcuRite publishes the following specifications: + + temperature outdoor: -40F to 158F; -40C to 70C + temperature indoor: 32F to 122F; 0C to 50C + humidity outdoor: 1% to 99% + humidity indoor: 16% to 98% + wind speed: 0 to 99 mph; 0 to 159 kph + wind direction: 16 points + rainfall: 0 to 99.99 in; 0 to 99.99 mm + wireless range: 330 ft; 100 m + operating frequency: 433 MHz + display power: 4.5V AC adapter (6 AA bateries, optional) + sensor power: 4 AA batteries + +The memory size is 512 KB and is not expandable. The firmware cannot be +modified or upgraded. + +According to AcuRite specs, the update frequencies are as follows: + + wind speed: 18 second updates + wind direction: 30 second updates + outdoor temperature and humidity: 60 second updates + pc connect csv data logging: 12 minute intervals + pc connect to acurite software: 18 second updates + +In fact, because of the message structure and the data logging design, these +are the actual update frequencies: + + wind speed: 18 seconds + outdoor temperature, outdoor humidity: 36 seconds + wind direction, rain total: 36 seconds + indoor temperature, pressure: 60 seconds + indoor humidity: 12 minutes (only when in USB mode 3) + +These are the frequencies possible when reading data via USB. + +There is no known way to change the archive interval of 12 minutes. + +There is no known way to clear the console memory via software. + +The AcuRite stations have no notion of wind gust. + +The pressure sensor in the console reports a station pressure, but the +firmware does some kind of averaging to it so the console displays a pressure +that is usually nothing close to the station pressure. + +According to AcuRite they use a 'patented, self-adjusting altitude pressure +compensation' algorithm. Not helpful, and in practice not accurate. + +Apparently the AcuRite bridge uses the HP03S integrated pressure sensor: + + http://www.hoperf.com/upload/sensor/HP03S.pdf + +The calculation in that specification happens to work for some of the AcuRite +consoles (01035, 01036, others?). However, some AcuRite consoles (only the +02032?) use the MS5607-02BA03 sensor: + + http://www.meas-spec.com/downloads/MS5607-02BA03.pdf + +Communication + +The AcuRite station has 4 modes: + + show data store data stream data + 1 x x + 2 x + 3 x x x + 4 x x + +The console does not respond to USB requests when in mode 1 or mode 2. + +There is no known way to change the mode via software. + +The acurite stations are probably a poor choice for remote operation. If +the power cycles on the console, communication might not be possible. Some +consoles (but not all?) default to mode 2, which means no USB communication. + +The console shows up as a USB device even if it is turned off. If the console +is powered on and communication has been established, then power is removed, +the communication will continue. So the console appears to draw some power +from the bus. + +Apparently some stations have issues when the history buffer fills up. Some +reports say that the station stops recording data. Some reports say that +the 02032 (and possibly other stations) should not use history mode at all +because the data are written to flash memory, which wears out, sometimes +quickly. Some reports say this 'bricks' the station, however those reports +mis-use the term 'brick', because the station still works and communication +can be re-established by power cycling and/or resetting the USB. + +There may be firmware timing issues that affect USB communication. Reading +R3 messages too frequently can cause the station to stop responding via USB. +Putting the station in mode 3 sometimes interferes with the collection of +data from the sensors; it can cause the station to report bad values for R1 +messages (this was observed on a 01036 console, but not consistantly). + +Testing with a 01036 showed no difference between opening the USB port once +during driver initialization and opening the USB port for each read. However, +tests with a 02032 showed that opening for each read was much more robust. + +Message Types + +The AcuRite stations show up as USB Human Interface Device (HID). This driver +uses the lower-level, raw USB API. However, the communication is standard +requests for data from a HID. + +The AcuRite station emits three different data strings, R1, R2 and R3. The R1 +string is 10 bytes long, contains readings from the remote sensors, and comes +in different flavors. One contains wind speed, wind direction, and rain +counter. Another contains wind speed, temperature, and humidity. The R2 +string is 25 bytes long and contains the temperature and pressure readings +from the console, plus a whole bunch of calibration constants required to +figure out the actual pressure and temperature. The R3 string is 33 bytes +and contains historical data and (apparently) the humidity readings from the +console sensors. + +The contents of the R2 message depends on the pressure sensor. For stations +that use the HP03S sensor (e.g., 01035, 01036) the R2 message contains +factory-set constants for calculating temperature and pressure. For stations +that use the MS5607-02BA03 sensor (e.g., 02032) the R2 message contents are +unknown. In both cases, the last 4 bytes appear to contain raw temperature +and pressure readings, while the rest of the message bytes are constant. + +Message Maps + +R1 - 10 bytes + 0 1 2 3 4 5 6 7 8 9 +01 CS SS ?1 ?W WD 00 RR ?r ?? +01 CS SS ?8 ?W WT TT HH ?r ?? + +01 CF FF FF FF FF FF FF 00 00 no sensor unit found +01 FF FF FF FF FF FF FF FF 00 no sensor unit found +01 8b fa 71 00 06 00 0c 00 00 connection to sensor unit lost +01 8b fa 78 00 08 75 24 01 00 connection to sensor unit weak/lost +01 8b fa 78 00 08 48 25 03 ff flavor 8 +01 8b fa 71 00 06 00 02 03 ff flavor 1 +01 C0 5C 78 00 08 1F 53 03 FF flavor 8 +01 C0 5C 71 00 05 00 0C 03 FF flavor 1 +01 cd ff 71 00 6c 39 71 03 ff +01 cd ff 78 00 67 3e 59 03 ff +01 cd ff 71 01 39 39 71 03 ff +01 cd ff 78 01 58 1b 4c 03 ff + +0: identifier 01 indicates R1 messages +1: channel x & 0xf0 observed values: 0xC=A, 0x8=B, 0x0=C +1: sensor_id hi x & 0x0f +2: sensor_id lo +3: ?status x & 0xf0 7 is 5-in-1? 7 is battery ok? +3: message flavor x & 0x0f type 1 is windSpeed, windDir, rain +4: wind speed (x & 0x1f) << 3 +5: wind speed (x & 0x70) >> 4 +5: wind dir (x & 0x0f) +6: ? always seems to be 0 +7: rain (x & 0x7f) +8: ? +8: rssi (x & 0x0f) observed values: 0,1,2,3 +9: ? observed values: 0x00, 0xff + +0: identifier 01 indicates R1 messages +1: channel x & 0xf0 observed values: 0xC=A, 0x8=B, 0x0=C +1: sensor_id hi x & 0x0f +2: sensor_id lo +3: ?status x & 0xf0 7 is 5-in-1? 7 is battery ok? +3: message flavor x & 0x0f type 8 is windSpeed, outTemp, outHumidity +4: wind speed (x & 0x1f) << 3 +5: wind speed (x & 0x70) >> 4 +5: temp (x & 0x0f) << 7 +6: temp (x & 0x7f) +7: humidity (x & 0x7f) +8: ? +8: rssi (x & 0x0f) observed values: 0,1,2,3 +9: ? observed values: 0x00, 0xff + + +R2 - 25 bytes + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 +02 00 00 C1 C1 C2 C2 C3 C3 C4 C4 C5 C5 C6 C6 C7 C7 AA BB CC DD TR TR PR PR + +02 00 00 4C BE 0D EC 01 52 03 62 7E 38 18 EE 09 C4 08 22 06 07 7B A4 8A 46 +02 00 00 80 00 00 00 00 00 04 00 10 00 00 00 09 60 01 01 01 01 8F C7 4C D3 + +for HP03S sensor: + + 0: identifier 02 indicates R2 messages + 1: ? always seems to be 0 + 2: ? always seems to be 0 + 3-4: C1 sensitivity coefficient 0x100 - 0xffff + 5-6: C2 offset coefficient 0x00 - 0x1fff + 7-8: C3 temperature coefficient of sensitivity 0x00 - 0x400 + 9-10: C4 temperature coefficient of offset 0x00 - 0x1000 + 11-12: C5 reference temperature 0x1000 - 0xffff + 13-14: C6 temperature coefficient of temperature 0x00 - 0x4000 + 15-16: C7 offset fine tuning 0x960 - 0xa28 + 17: A sensor-specific parameter 0x01 - 0x3f + 18: B sensor-specific parameter 0x01 - 0x3f + 19: C sensor-specific parameter 0x01 - 0x0f + 20: D sensor-specific parameter 0x01 - 0x0f + 21-22: TR measured temperature 0x00 - 0xffff + 23-24: PR measured pressure 0x00 - 0xffff + +for MS5607-02BA03 sensor: + + 0: identifier 02 indicates R2 messages + 1: ? always seems to be 0 + 2: ? always seems to be 0 + 3-4: C1 sensitivity coefficient 0x800 + 5-6: C2 offset coefficient 0x00 + 7-8: C3 temperature coefficient of sensitivity 0x00 + 9-10: C4 temperature coefficient of offset 0x0400 + 11-12: C5 reference temperature 0x1000 + 13-14: C6 temperature coefficient of temperature 0x00 + 15-16: C7 offset fine tuning 0x0960 + 17: A sensor-specific parameter 0x01 + 18: B sensor-specific parameter 0x01 + 19: C sensor-specific parameter 0x01 + 20: D sensor-specific parameter 0x01 + 21-22: TR measured temperature 0x00 - 0xffff + 23-24: PR measured pressure 0x00 - 0xffff + + +R3 - 33 bytes + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ... +03 aa 55 01 00 00 00 20 20 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ... + +An R3 report consists of multiple R3 messages. Each R3 report contains records +that are delimited by the sequence 0xaa 0x55. There is a separator sequence +prior to the first record, but not after the last record. + +There are 6 types of records, each type identified by number: + + 1,2 8-byte chunks of historical min/max data. Each 8-byte chunk + appears to contain two data bytes plus a 5-byte timestamp + indicating when the event occurred. + 3 Timestamp indicating when the most recent history record was + stored, based on the console clock. + 4 History data + 5 Timestamp indicating when the request for history data was + received, based on the console clock. + 6 End marker indicating that no more data follows in the report. + +Each record has the following header: + + 0: record id possible values are 1-6 + 1,2: unknown always seems to be 0 + 3,4: size size of record, in 'chunks' + 5: checksum total of bytes 0..4 minus one + + where the size of a 'chunk' depends on the record id: + + id chunk size + + 1,2,3,5 8 bytes + 4 32 bytes + 6 n/a + +For all but ID6, the total record size should be equal to 6 + chunk_size * size +ID6 never contains data, but a size of 4 is always declared. + +Timestamp records (ID3 and ID5): + + 0-1: for ID3, the number of history records when request was received + 0-1: for ID5, unknown + 2: year + 3: month + 4: day + 5: hour + 6: minute + 7: for ID3, checksum - sum of bytes 0..6 (do not subtract 1) + 7: for ID5, unknown (always 0xff) + +History Records (ID4): + +Bytes 3,4 contain the number of history records that follow, say N. After +stripping off the 6-byte record header there should be N*32 bytes of history +data. If not, then the data are corrupt or there was an incomplete transfer. + +The most recent history record is first, so the timestamp on record ID applies +to the first 32-byte chunk, and each record is 12 minutes into the past from +the previous. Each 32-byte chunk has the following decoding: + + 0-1: indoor temperature (r[0]*256 + r[1])/18 - 100 C + 2-3: outdoor temperature (r[2]*256 + r[3])/18 - 100 C + 4: unknown + 5: indoor humidity r[5] percent + 6: unknown + 7: outdoor humidity r[7] percent + 8-9: windchill (r[8]*256 + r[9])/18 - 100 C + 10-11: heat index (r[10]*256 + r[11])/18 - 100 C + 12-13: dewpoint (r[12]*256 + r[13])/18 - 100 C + 14-15: barometer ((r[14]*256 + r[15]) & 0x07ff)/10 kPa + 16: unknown + 17: unknown 0xf0 + 17: wind direction dirmap(r[17] & 0x0f) + 18-19: wind speed (r[18]*256 + r[19])/16 kph + 20-21: wind max (r[20]*256 + r[21])/16 kph + 22-23: wind average (r[22]*256 + r[23])/16 kph + 24-25: rain (r[24]*256 + r[25]) * 0.254 mm + 26-30: rain timestamp 0xff if no rain event + 31: unknown + +bytes 4 and 6 always seem to be 0 +byte 16 is always zero on 02032 console, but is a copy of byte 21 on 01035. +byte 31 is always zero on 02032 console, but is a copy of byte 30 on 01035. + + +X1 - 2 bytes + 0 2 +7c e2 +84 e2 + +0: ? +1: ? +""" + +# FIXME: how to detect mode via software? +# FIXME: what happens when memory fills up? overwrite oldest? +# FIXME: how to detect console type? +# FIXME: how to set station time? +# FIXME: how to get station time? +# FIXME: decode console battery level +# FIXME: decode sensor type - hi byte of byte 3 in R1 message? + +# FIXME: decode inside humidity +# FIXME: decode historical records +# FIXME: perhaps retry read when dodgey data or short read? + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +import logging +import time +import usb + +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import to_bool + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'AcuRite' +DRIVER_VERSION = '0.4' +DEBUG_RAW = 0 + +# USB constants for HID +USB_HID_GET_REPORT = 0x01 +USB_HID_SET_REPORT = 0x09 +USB_HID_INPUT_REPORT = 0x0100 +USB_HID_OUTPUT_REPORT = 0x0200 + +def loader(config_dict, engine): + return AcuRiteDriver(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return AcuRiteConfEditor() + +def _fmt_bytes(data): + return ' '.join(['%02x' % x for x in data]) + + +class AcuRiteDriver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with an AcuRite weather station. + + model: Which station model is this? + [Optional. Default is 'AcuRite'] + + max_tries - How often to retry communication before giving up. + [Optional. Default is 10] + + use_constants - Indicates whether to use calibration constants when + decoding pressure and temperature. For consoles that use the HP03 sensor, + use the constants reported by the sensor. Otherwise, use a linear + approximation to derive pressure and temperature values from the sensor + readings. + [Optional. Default is True] + + ignore_bounds - Indicates how to treat calibration constants from the + pressure/temperature sensor. Some consoles report constants that are + outside the limits specified by the sensor manufacturer. Typically this + would indicate bogus data - perhaps a bad transmission or noisy USB. + But in some cases, the apparently bogus constants actually work, and + no amount of power cycling or resetting of the console changes the values + that the console emits. Use this flag to indicate that this is one of + those quirky consoles. + [Optional. Default is False] + """ + _R1_INTERVAL = 18 # 5-in-1 sensor updates every 18 seconds + _R2_INTERVAL = 60 # console sensor updates every 60 seconds + _R3_INTERVAL = 12*60 # historical records updated every 12 minutes + + def __init__(self, **stn_dict): + log.info('driver version is %s' % DRIVER_VERSION) + self.model = stn_dict.get('model', 'AcuRite') + self.max_tries = int(stn_dict.get('max_tries', 10)) + self.retry_wait = int(stn_dict.get('retry_wait', 30)) + self.polling_interval = int(stn_dict.get('polling_interval', 6)) + self.use_constants = to_bool(stn_dict.get('use_constants', True)) + self.ignore_bounds = to_bool(stn_dict.get('ignore_bounds', False)) + if self.use_constants: + log.info('R2 will be decoded using sensor constants') + if self.ignore_bounds: + log.info('R2 bounds on constants will be ignored') + self.enable_r3 = int(stn_dict.get('enable_r3', 0)) + if self.enable_r3: + log.info('R3 data will be attempted') + self.last_rain = None + self.last_r3 = None + self.r3_fail_count = 0 + self.r3_max_fail = 3 + self.r1_next_read = 0 + self.r2_next_read = 0 + global DEBUG_RAW + DEBUG_RAW = int(stn_dict.get('debug_raw', 0)) + + @property + def hardware_name(self): + return self.model + + def genLoopPackets(self): + last_raw2 = None + ntries = 0 + while ntries < self.max_tries: + ntries += 1 + try: + packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + raw1 = raw2 = None + with Station() as station: + if time.time() >= self.r1_next_read: + raw1 = station.read_R1() + self.r1_next_read = time.time() + self._R1_INTERVAL + if DEBUG_RAW > 0 and raw1: + log.debug("R1: %s" % _fmt_bytes(raw1)) + if time.time() >= self.r2_next_read: + raw2 = station.read_R2() + self.r2_next_read = time.time() + self._R2_INTERVAL + if DEBUG_RAW > 0 and raw2: + log.debug("R2: %s" % _fmt_bytes(raw2)) + if self.enable_r3: + raw3 = self.read_R3_block(station) + if DEBUG_RAW > 0 and raw3: + for row in raw3: + log.debug("R3: %s" % _fmt_bytes(row)) + if raw1: + packet.update(Station.decode_R1(raw1)) + if raw2: + Station.check_pt_constants(last_raw2, raw2) + last_raw2 = raw2 + packet.update(Station.decode_R2( + raw2, self.use_constants, self.ignore_bounds)) + self._augment_packet(packet) + ntries = 0 + yield packet + next_read = min(self.r1_next_read, self.r2_next_read) + delay = max(int(next_read - time.time() + 1), + self.polling_interval) + log.debug("next read in %s seconds" % delay) + time.sleep(delay) + except (usb.USBError, weewx.WeeWxIOError) as e: + log.error("Failed attempt %d of %d to get LOOP data: %s" % + (ntries, self.max_tries, e)) + time.sleep(self.retry_wait) + else: + msg = "Max retries (%d) exceeded for LOOP data" % self.max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def _augment_packet(self, packet): + # calculate the rain delta from the total + if 'rain_total' in packet: + total = packet['rain_total'] + if (total is not None and self.last_rain is not None and + total < self.last_rain): + log.info("rain counter decrement ignored:" + " new: %s old: %s" % (total, self.last_rain)) + packet['rain'] = weewx.wxformulas.calculate_rain(total, self.last_rain) + self.last_rain = total + + # if there is no connection to sensors, clear the readings + if 'rssi' in packet and packet['rssi'] == 0: + packet['outTemp'] = None + packet['outHumidity'] = None + packet['windSpeed'] = None + packet['windDir'] = None + packet['rain'] = None + + # map raw data to observations in the default database schema + if 'sensor_battery' in packet: + if packet['sensor_battery'] is not None: + packet['outTempBatteryStatus'] = 1 if packet['sensor_battery'] else 0 + else: + packet['outTempBatteryStatus'] = None + if 'rssi' in packet and packet['rssi'] is not None: + packet['rxCheckPercent'] = 100 * packet['rssi'] / Station.MAX_RSSI + + def read_R3_block(self, station): + # attempt to read R3 every 12 minutes. if the read fails multiple + # times, make a single log message about enabling usb mode 3 then do + # not try it again. + # + # when the station is not in mode 3, attempts to read R3 leave + # it in an uncommunicative state. doing a reset, close, then open + # will sometimes, but not always, get communication started again on + # 01036 stations. + r3 = [] + if self.r3_fail_count >= self.r3_max_fail: + return r3 + if (self.last_r3 is None or + time.time() - self.last_r3 > self._R3_INTERVAL): + try: + x = station.read_x() + for i in range(17): + r3.append(station.read_R3()) + self.last_r3 = time.time() + except usb.USBError as e: + self.r3_fail_count += 1 + log.debug("R3: read failed %d of %d: %s" % + (self.r3_fail_count, self.r3_max_fail, e)) + if self.r3_fail_count >= self.r3_max_fail: + log.info("R3: put station in USB mode 3 to enable R3 data") + return r3 + + +class Station(object): + # these identify the weather station on the USB + VENDOR_ID = 0x24c0 + PRODUCT_ID = 0x0003 + + # map the raw wind direction index to degrees on the compass + IDX_TO_DEG = {6: 0.0, 14: 22.5, 12: 45.0, 8: 67.5, 10: 90.0, 11: 112.5, + 9: 135.0, 13: 157.5, 15: 180.0, 7: 202.5, 5: 225.0, 1: 247.5, + 3: 270.0, 2: 292.5, 0: 315.0, 4: 337.5} + + # map the raw channel value to something we prefer + # A is 1, B is 2, C is 3 + CHANNELS = {12: 1, 8: 2, 0: 3} + + # maximum value for the rssi + MAX_RSSI = 3.0 + + def __init__(self, vend_id=VENDOR_ID, prod_id=PRODUCT_ID, dev_id=None): + self.vendor_id = vend_id + self.product_id = prod_id + self.device_id = dev_id + self.handle = None + self.timeout = 1000 + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self): + dev = self._find_dev(self.vendor_id, self.product_id, self.device_id) + if not dev: + log.critical("Cannot find USB device with " + "VendorID=0x%04x ProductID=0x%04x DeviceID=%s" % + (self.vendor_id, self.product_id, self.device_id)) + raise weewx.WeeWxIOError('Unable to find station on USB') + + self.handle = dev.open() + if not self.handle: + raise weewx.WeeWxIOError('Open USB device failed') + +# self.handle.reset() + + # the station shows up as a HID with only one interface + interface = 0 + + # for linux systems, be sure kernel does not claim the interface + try: + self.handle.detachKernelDriver(interface) + except (AttributeError, usb.USBError): + pass + + # FIXME: is it necessary to set the configuration? + try: + self.handle.setConfiguration(dev.configurations[0]) + except (AttributeError, usb.USBError) as e: + pass + + # attempt to claim the interface + try: + self.handle.claimInterface(interface) + except usb.USBError as e: + self.close() + log.critical("Unable to claim USB interface %s: %s" % (interface, e)) + raise weewx.WeeWxIOError(e) + + # FIXME: is it necessary to set the alt interface? + try: + self.handle.setAltInterface(interface) + except (AttributeError, usb.USBError) as e: + pass + + def close(self): + if self.handle is not None: + try: + self.handle.releaseInterface() + except (ValueError, usb.USBError) as e: + log.error("release interface failed: %s" % e) + self.handle = None + + def reset(self): + self.handle.reset() + + def read(self, report_number, nbytes): + return self.handle.controlMsg( + requestType=usb.RECIP_INTERFACE + usb.TYPE_CLASS + usb.ENDPOINT_IN, + request=USB_HID_GET_REPORT, + buffer=nbytes, + value=USB_HID_INPUT_REPORT + report_number, + index=0x0, + timeout=self.timeout) + + def read_R1(self): + return self.read(1, 10) + + def read_R2(self): + return self.read(2, 25) + + def read_R3(self): + return self.read(3, 33) + + def read_x(self): + # FIXME: what do the two bytes mean? + return self.handle.controlMsg( + requestType=usb.RECIP_INTERFACE + usb.TYPE_CLASS, + request=USB_HID_SET_REPORT, + buffer=2, + value=USB_HID_OUTPUT_REPORT + 0x01, + index=0x0, + timeout=self.timeout) + + @staticmethod + def decode_R1(raw): + data = dict() + if len(raw) == 10 and raw[0] == 0x01: + if Station.check_R1(raw): + data['channel'] = Station.decode_channel(raw) + data['sensor_id'] = Station.decode_sensor_id(raw) + data['rssi'] = Station.decode_rssi(raw) + if data['rssi'] == 0: + data['sensor_battery'] = None + log.info("R1: ignoring stale data (rssi indicates no communication from sensors): %s" % _fmt_bytes(raw)) + else: + data['sensor_battery'] = Station.decode_sensor_battery(raw) + data['windSpeed'] = Station.decode_windspeed(raw) + if raw[3] & 0x0f == 1: + data['windDir'] = Station.decode_winddir(raw) + data['rain_total'] = Station.decode_rain(raw) + else: + data['outTemp'] = Station.decode_outtemp(raw) + data['outHumidity'] = Station.decode_outhumid(raw) + else: + data['channel'] = None + data['sensor_id'] = None + data['rssi'] = None + data['sensor_battery'] = None + elif len(raw) != 10: + log.error("R1: bad length: %s" % _fmt_bytes(raw)) + else: + log.error("R1: bad format: %s" % _fmt_bytes(raw)) + return data + + @staticmethod + def check_R1(raw): + ok = True + if raw[1] & 0x0f == 0x0f and raw[3] == 0xff: + log.info("R1: no sensors found: %s" % _fmt_bytes(raw)) + ok = False + else: + if raw[3] & 0x0f != 1 and raw[3] & 0x0f != 8: + log.info("R1: bogus message flavor (%02x): %s" % (raw[3], _fmt_bytes(raw))) + ok = False + if raw[9] != 0xff and raw[9] != 0x00: + log.info("R1: bogus final byte (%02x): %s" % (raw[9], _fmt_bytes(raw))) + ok = False + if raw[8] & 0x0f < 0 or raw[8] & 0x0f > 3: + log.info("R1: bogus signal strength (%02x): %s" % (raw[8], _fmt_bytes(raw))) + ok = False + return ok + + @staticmethod + def decode_R2(raw, use_constants=True, ignore_bounds=False): + data = dict() + if len(raw) == 25 and raw[0] == 0x02: + data['pressure'], data['inTemp'] = Station.decode_pt( + raw, use_constants, ignore_bounds) + elif len(raw) != 25: + log.error("R2: bad length: %s" % _fmt_bytes(raw)) + else: + log.error("R2: bad format: %s" % _fmt_bytes(raw)) + return data + + @staticmethod + def decode_R3(raw): + data = dict() + buf = [] + fail = False + for i, r in enumerate(raw): + if len(r) == 33 and r[0] == 0x03: + try: + for b in r: + buf.append(int(b, 16)) + except ValueError as e: + log.error("R3: bad value in row %d: %s" % (i, _fmt_bytes(r))) + fail = True + elif len(r) != 33: + log.error("R3: bad length in row %d: %s" % (i, _fmt_bytes(r))) + fail = True + else: + log.error("R3: bad format in row %d: %s" % (i, _fmt_bytes(r))) + fail = True + if fail: + return data + for i in range(2, len(buf)-2): + if buf[i-2] == 0xff and buf[i-1] == 0xaa and buf[i] == 0x55: + data['numrec'] = buf[i+1] + buf[i+2] * 0x100 + break + data['raw'] = raw + return data + + @staticmethod + def decode_channel(data): + return Station.CHANNELS.get(data[1] & 0xf0) + + @staticmethod + def decode_sensor_id(data): + return ((data[1] & 0x0f) << 8) | data[2] + + @staticmethod + def decode_rssi(data): + # signal strength goes from 0 to 3, inclusive + # according to nincehelser, this is a measure of the number of failed + # sensor queries, not the actual RF signal strength + return data[8] & 0x0f + + @staticmethod + def decode_sensor_battery(data): + # 0x7 indicates battery ok, 0xb indicates low battery? + a = (data[3] & 0xf0) >> 4 + return 0 if a == 0x7 else 1 + + @staticmethod + def decode_windspeed(data): + # extract the wind speed from an R1 message + # return value is kph + # for details see http://www.wxforum.net/index.php?topic=27244.0 + # minimum measurable speed is 1.83 kph + n = ((data[4] & 0x1f) << 3) | ((data[5] & 0x70) >> 4) + if n == 0: + return 0.0 + return 0.8278 * n + 1.0 + + @staticmethod + def decode_winddir(data): + # extract the wind direction from an R1 message + # decoded value is one of 16 points, convert to degrees + v = data[5] & 0x0f + return Station.IDX_TO_DEG.get(v) + + @staticmethod + def decode_outtemp(data): + # extract the temperature from an R1 message + # return value is degree C + a = (data[5] & 0x0f) << 7 + b = (data[6] & 0x7f) + return (a | b) / 18.0 - 40.0 + + @staticmethod + def decode_outhumid(data): + # extract the humidity from an R1 message + # decoded value is percentage + return data[7] & 0x7f + + @staticmethod + def decode_rain(data): + # decoded value is a count of bucket tips + # each tip is 0.01 inch, return value is cm + return (((data[6] & 0x3f) << 7) | (data[7] & 0x7f)) * 0.0254 + + @staticmethod + def decode_pt(data, use_constants=True, ignore_bounds=False): + # decode pressure and temperature from the R2 message + # decoded pressure is mbar, decoded temperature is degree C + c1,c2,c3,c4,c5,c6,c7,a,b,c,d = Station.get_pt_constants(data) + + if not use_constants: + # use a linear approximation for pressure and temperature + d2 = ((data[21] & 0x0f) << 8) + data[22] + if d2 >= 0x0800: + d2 -= 0x1000 + d1 = (data[23] << 8) + data[24] + return Station.decode_pt_acurite(d1, d2) + elif (c1 == 0x8000 and c2 == c3 == 0x0 and c4 == 0x0400 + and c5 == 0x1000 and c6 == 0x0 and c7 == 0x0960 + and a == b == c == d == 0x1): + # this is a MS5607 sensor, typical in 02032 consoles + d2 = ((data[21] & 0x0f) << 8) + data[22] + if d2 >= 0x0800: + d2 -= 0x1000 + d1 = (data[23] << 8) + data[24] + return Station.decode_pt_MS5607(d1, d2) + elif (0x100 <= c1 <= 0xffff and + 0x0 <= c2 <= 0x1fff and + 0x0 <= c3 <= 0x400 and + 0x0 <= c4 <= 0x1000 and + 0x1000 <= c5 <= 0xffff and + 0x0 <= c6 <= 0x4000 and + 0x960 <= c7 <= 0xa28 and + (0x01 <= a <= 0x3f and 0x01 <= b <= 0x3f and + 0x01 <= c <= 0x0f and 0x01 <= d <= 0x0f) or ignore_bounds): + # this is a HP038 sensor. some consoles return values outside the + # specified limits, but their data still seem to be ok. if the + # ignore_bounds flag is set, then permit values for A, B, C, or D + # that are out of bounds, but enforce constraints on the other + # constants C1-C7. + d2 = (data[21] << 8) + data[22] + d1 = (data[23] << 8) + data[24] + return Station.decode_pt_HP03S(c1,c2,c3,c4,c5,c6,c7,a,b,c,d,d1,d2) + log.error("R2: unknown calibration constants: %s" % _fmt_bytes(data)) + return None, None + + @staticmethod + def decode_pt_HP03S(c1,c2,c3,c4,c5,c6,c7,a,b,c,d,d1,d2): + # for devices with the HP03S pressure sensor + if d2 >= c5: + dut = d2 - c5 - ((d2-c5)/128) * ((d2-c5)/128) * a / (2<<(c-1)) + else: + dut = d2 - c5 - ((d2-c5)/128) * ((d2-c5)/128) * b / (2<<(c-1)) + off = 4 * (c2 + (c4 - 1024) * dut / 16384) + sens = c1 + c3 * dut / 1024 + x = sens * (d1 - 7168) / 16384 - off + p = 0.1 * (x * 10 / 32 + c7) + t = 0.1 * (250 + dut * c6 / 65536 - dut / (2<<(d-1))) + return p, t + + @staticmethod + def decode_pt_MS5607(d1, d2): + # for devices with the MS5607 sensor, do a linear scaling + return Station.decode_pt_acurite(d1, d2) + + @staticmethod + def decode_pt_acurite(d1, d2): + # apparently the new (2015) acurite software uses this function, which + # is quite close to andrew daviel's reverse engineered function of: + # p = 0.062585727 * d1 - 209.6211 + # t = 25.0 + 0.05 * d2 + p = d1 / 16.0 - 208 + t = 25.0 + 0.05 * d2 + return p, t + + @staticmethod + def decode_inhumid(data): + # FIXME: decode inside humidity + return None + + @staticmethod + def get_pt_constants(data): + c1 = (data[3] << 8) + data[4] + c2 = (data[5] << 8) + data[6] + c3 = (data[7] << 8) + data[8] + c4 = (data[9] << 8) + data[10] + c5 = (data[11] << 8) + data[12] + c6 = (data[13] << 8) + data[14] + c7 = (data[15] << 8) + data[16] + a = data[17] + b = data[18] + c = data[19] + d = data[20] + return (c1,c2,c3,c4,c5,c6,c7,a,b,c,d) + + @staticmethod + def check_pt_constants(a, b): + if a is None or len(a) != 25 or len(b) != 25: + return + c1 = Station.get_pt_constants(a) + c2 = Station.get_pt_constants(b) + if c1 != c2: + log.error("R2: constants changed: old: [%s] new: [%s]" % ( + _fmt_bytes(a), _fmt_bytes(b))) + + @staticmethod + def _find_dev(vendor_id, product_id, device_id=None): + """Find the vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vendor_id and dev.idProduct == product_id: + if device_id is None or dev.filename == device_id: + log.debug('Found station at bus=%s device=%s' % + (bus.dirname, dev.filename)) + return dev + return None + + +class AcuRiteConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[AcuRite] + # This section is for AcuRite weather stations. + + # The station model, e.g., 'AcuRite 01025' or 'AcuRite 02032C' + model = 'AcuRite 01035' + + # The driver to use: + driver = weewx.drivers.acurite +""" + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/acurite.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('acurite', {}) + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + (options, args) = parser.parse_args() + + if options.version: + print("acurite driver version %s" % DRIVER_VERSION) + exit(0) + + test_r1 = True + test_r2 = True + test_r3 = False + delay = 12*60 + with Station() as s: + while True: + ts = int(time.time()) + tstr = "%s (%d)" % (time.strftime("%Y-%m-%d %H:%M:%S %Z", + time.localtime(ts)), ts) + if test_r1: + r1 = s.read_R1() + print(tstr, _fmt_bytes(r1), Station.decode_R1(r1)) + delay = min(delay, 18) + if test_r2: + r2 = s.read_R2() + print(tstr, _fmt_bytes(r2), Station.decode_R2(r2)) + delay = min(delay, 60) + if test_r3: + try: + x = s.read_x() + print(tstr, _fmt_bytes(x)) + for i in range(17): + r3 = s.read_R3() + print(tstr, _fmt_bytes(r3)) + except usb.USBError as e: + print(tstr, e) + delay = min(delay, 12*60) + time.sleep(delay) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/cc3000.py b/dist/weewx-4.10.1/bin/weewx/drivers/cc3000.py new file mode 100644 index 0000000..22f01d1 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/cc3000.py @@ -0,0 +1,1520 @@ +#!/usr/bin/env python +# +# Copyright 2014 Matthew Wall +# See the file LICENSE.txt for your rights. + +"""Driver for CC3000 data logger + +http://www.rainwise.com/products/attachments/6832/20110518125531.pdf + +There are a few variants: + +CC-3000_ - __ + | | + | 41 = 418 MHz + | 42 = 433 MHz + | __ = 2.4 GHz (LR compatible) + R = serial (RS232, RS485) + _ = USB 2.0 + +The CC3000 communicates using FTDI USB serial bridge. The CC3000R has both +RS-232 and RS-485 serial ports, only one of which may be used at a time. +A long range (LR) version transmits up to 2 km using 2.4GHz. + +The RS232 communicates using 115200 N-8-1 + +The instrument cluster contains a DIP switch controls with value 0-3 and a +default of 0. This setting prevents interference when there are multiple +weather stations within radio range. + +The CC3000 includes a temperature sensor - that is the source of inTemp. The +manual indicates that the CC3000 should run for 3 or 4 hours before applying +any calibration to offset the heat generated by CC3000 electronics. + +The CC3000 uses 4 AA batteries to maintain its clock. Use only rechargeable +NiMH batteries. + +The logger contains 2MB of memory, with a capacity of 49834 records (over 11 +months of data at a 10 minute logging interval). The exact capacity depends +on the sensors; the basic sensor record is 42 bytes. + +The logger does not delete old records when it fills up; once the logger is +full, new data are lost. So the driver must periodically clear the logger +memory. + +This driver does not support hardware record_generation. It does support +catchup on startup. + +If you request many history records then interrupt the receive, the logger will +continue to send history records until it sends all that were requested. As a +result, any queries made while the logger is still sending will fail. + +The rainwise rain bucket measures 0.01 inches per tip. The logger firmware +automatically converts the bucket tip count to the measure of rain in ENGLISH +or METRIC units. + +The historical records (DOWNLOAD), as well as current readings (NOW) track +the amount of rain since midnight; i.e., DOWNLOAD records rain value resets to 0 +at midnight and NOW records do the same. + +The RAIN=? returns a rain counter that only resets with the RAIN=RESET command. +This counter isn't used by weewx. Also, RAIN=RESET doesn't just reset this +counter, it also resets the daily rain count. + +Logger uses the following units: + ENGLISH METRIC + wind mph m/s + rain inch mm + pressure inHg mbar + temperature F C + +The CC3000 has the habit of failing to execute about 1 in 6000 +commands. That the bad news. The good news is that the +condition is easily detected and the driver can recover in about 1s. +The telltale sing of failure is the first read after sending +the command (to read the echo of the command) times out. As such, +the timeout is set to 1s. If the timeout is hit, the buffers +are flushed and the command is retried. Oh, and there is one +more pecurliar part to this. On the retry, the command is echoed +as an empty string. That empty string is expected on the retry +and execution continues. + +weewx includes a logwatch script that makes it easy to see the above +behavior in action. In the snippet below, 3 NOW commands and one +IME=? were retried successfully. The Retry Info section shows +that all succeeded on the second try. + --------------------- weewx Begin ------------------------ + + average station clock skew: 0.0666250000000001 + min: -0.53 max: 0.65 samples: 160 + + counts: + archive: records added 988 + cc3000: NOW cmd echo timed out 3 + cc3000: NOW echoed as empty string 3 + cc3000: NOW successful retries 3 + cc3000: TIME=? cmd echo timed out 1 + cc3000: TIME=? echoed as empty string 1 + cc3000: TIME=? successful retries 1 + .... + cc3000 Retry Info: + Dec 29 00:50:04 ella weewx[24145] INFO weewx.drivers.cc3000: TIME=?: Retry worked. Total tries: 2 + Dec 29 04:46:21 ella weewx[24145] INFO weewx.drivers.cc3000: NOW: Retry worked. Total tries: 2 + Dec 29 08:31:11 ella weewx[22295] INFO weewx.drivers.cc3000: NOW: Retry worked. Total tries: 2 + Dec 29 08:50:51 ella weewx[22295] INFO weewx.drivers.cc3000: NOW: Retry worked. Total tries: 2 + .... + ---------------------- weewx End ------------------------- + + +Clearing memory on the CC3000 takes about 12s. As such, the 1s +timeout mentioned above won't work for this command. Consequently, +when executing MEM=CLEAR, the timeout is set to 20s. Should this +command fail, rather than losing 1 second retrying, 20 sexconds +will be lost. + + +The CC3000 very rarely stops returning observation values. +[Observed once in 28 months of operation over two devices.] +Operation returns to normal after the CC3000 is rebooted. +This driver now reboots when this situation is detected. +If this happens, the log will show: + INFO weewx.drivers.cc3000: No data from sensors, rebooting. + INFO weewx.drivers.cc3000: Back from a reboot: + INFO weewx.drivers.cc3000: .................... + INFO weewx.drivers.cc3000: + INFO weewx.drivers.cc3000: Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 + INFO weewx.drivers.cc3000: Flash ID 202015 + INFO weewx.drivers.cc3000: Initializing memory...OK. + +This driver was tested with: + Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 + +Earlier versions of this driver were tested with: + Rainwise CC-3000 Version: 1.3 Build 006 Sep 04 2013 + Rainwise CC-3000 Version: 1.3 Build 016 Aug 21 2014 +""" + +# FIXME: Come up with a way to deal with firmware inconsistencies. if we do +# a strict protocol where we wait for an OK response, but one version of +# the firmware responds whereas another version does not, this leads to +# comm problems. specializing the code to handle quirks of each +# firmware version is not desirable. +# UPDATE: As of 0.30, the driver does a flush of the serial buffer before +# doing any command. The problem detailed above (OK not being returned) +# was probably because the timeout was too short for the MEM=CLEAR +# command. That command gets a longer timeout in version 0.30. + +# FIXME: Figure out why system log messages are lost. When reading from the logger +# there are many messages to the log that just do not show up, or msgs +# that appear in one run but not in a second, identical run. I suspect +# that system log cannot handle the load? or its buffer is not big enough? +# Update: +# With debug=0, this has never been observed in v1.3 Build 22 Dec 02 2016. +# With debug=1, tailing the log looks like everything is running, but no +# attempt was made to compuare log data between runs. Observations on +# NUC7i5 running Debian Buster. + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function +import datetime +import logging +import math +import serial +import string +import sys +import time + +from six import byte2int +from six import PY2 +from six.moves import input + +import weeutil.weeutil +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import to_int +from weewx.crc16 import crc16 + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'CC3000' +DRIVER_VERSION = '0.40' + +def loader(config_dict, engine): + return CC3000Driver(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): + return CC3000Configurator() + +def confeditor_loader(): + return CC3000ConfEditor() + +DEBUG_SERIAL = 0 +DEBUG_CHECKSUM = 0 +DEBUG_OPENCLOSE = 0 + + +class ChecksumError(weewx.WeeWxIOError): + def __init__(self, msg): + weewx.WeeWxIOError.__init__(self, msg) + +class ChecksumMismatch(ChecksumError): + def __init__(self, a, b, buf=None): + msg = "Checksum mismatch: 0x%04x != 0x%04x" % (a, b) + if buf is not None: + msg = "%s (%s)" % (msg, buf) + ChecksumError.__init__(self, msg) + +class BadCRC(ChecksumError): + def __init__(self, a, b, buf=None): + msg = "Bad CRC: 0x%04x != '%s'" % (a, b) + if buf is not None: + msg = "%s (%s)" % (msg, buf) + ChecksumError.__init__(self, msg) + + +class CC3000Configurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super(CC3000Configurator, self).add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="display current weather readings") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N records (0 for all records)") + parser.add_option("--history-since", dest="nminutes", metavar="N", + type=int, help="display records since N minutes ago") + parser.add_option("--clear-memory", dest="clear", action="store_true", + help="clear station memory") + parser.add_option("--get-header", dest="gethead", action="store_true", + help="display data header") + parser.add_option("--get-rain", dest="getrain", action="store_true", + help="get the rain counter") + parser.add_option("--reset-rain", dest="resetrain", action="store_true", + help="reset the rain counter") + parser.add_option("--get-max", dest="getmax", action="store_true", + help="get the max values observed") + parser.add_option("--reset-max", dest="resetmax", action="store_true", + help="reset the max counters") + parser.add_option("--get-min", dest="getmin", action="store_true", + help="get the min values observed") + parser.add_option("--reset-min", dest="resetmin", action="store_true", + help="reset the min counters") + parser.add_option("--get-clock", dest="getclock", action="store_true", + help="display station clock") + parser.add_option("--set-clock", dest="setclock", action="store_true", + help="set station clock to computer time") + parser.add_option("--get-interval", dest="getint", action="store_true", + help="display logger archive interval, in seconds") + parser.add_option("--set-interval", dest="interval", metavar="N", + type=int, + help="set logging interval to N seconds") + parser.add_option("--get-units", dest="getunits", action="store_true", + help="show units of logger") + parser.add_option("--set-units", dest="units", metavar="UNITS", + help="set units to METRIC or ENGLISH") + parser.add_option('--get-dst', dest='getdst', action='store_true', + help='display daylight savings settings') + parser.add_option('--set-dst', dest='setdst', + metavar='mm/dd HH:MM,mm/dd HH:MM,[MM]M', + help='set daylight savings start, end, and amount') + parser.add_option("--get-channel", dest="getch", action="store_true", + help="display the station channel") + parser.add_option("--set-channel", dest="ch", metavar="CHANNEL", + type=int, + help="set the station channel") + + def do_options(self, options, parser, config_dict, prompt): # @UnusedVariable + self.driver = CC3000Driver(**config_dict[DRIVER_NAME]) + if options.current: + print(self.driver.get_current()) + elif options.nrecords is not None: + for r in self.driver.station.gen_records(options.nrecords): + print(r) + elif options.nminutes is not None: + since_ts = time.mktime((datetime.datetime.now()-datetime.timedelta( + minutes=options.nminutes)).timetuple()) + for r in self.driver.gen_records_since_ts(since_ts): + print(r) + elif options.clear: + self.clear_memory(options.noprompt) + elif options.gethead: + print(self.driver.station.get_header()) + elif options.getrain: + print(self.driver.station.get_rain()) + elif options.resetrain: + self.reset_rain(options.noprompt) + elif options.getmax: + print(self.driver.station.get_max()) + elif options.resetmax: + self.reset_max(options.noprompt) + elif options.getmin: + print(self.driver.station.get_min()) + elif options.resetmin: + self.reset_min(options.noprompt) + elif options.getclock: + print(self.driver.station.get_time()) + elif options.setclock: + self.set_clock(options.noprompt) + elif options.getdst: + print(self.driver.station.get_dst()) + elif options.setdst: + self.set_dst(options.setdst, options.noprompt) + elif options.getint: + print(self.driver.station.get_interval() * 60) + elif options.interval is not None: + self.set_interval(options.interval / 60, options.noprompt) + elif options.getunits: + print(self.driver.station.get_units()) + elif options.units is not None: + self.set_units(options.units, options.noprompt) + elif options.getch: + print(self.driver.station.get_channel()) + elif options.ch is not None: + self.set_channel(options.ch, options.noprompt) + else: + print("Firmware:", self.driver.station.get_version()) + print("Time:", self.driver.station.get_time()) + print("DST:", self.driver.station.get_dst()) + print("Units:", self.driver.station.get_units()) + print("Memory:", self.driver.station.get_memory_status()) + print("Interval:", self.driver.station.get_interval() * 60) + print("Channel:", self.driver.station.get_channel()) + print("Charger:", self.driver.station.get_charger()) + print("Baro:", self.driver.station.get_baro()) + print("Rain:", self.driver.station.get_rain()) + print("HEADER:", self.driver.station.get_header()) + print("MAX:", self.driver.station.get_max()) + print("MIN:", self.driver.station.get_min()) + self.driver.closePort() + + def clear_memory(self, noprompt): + print(self.driver.station.get_memory_status()) + ans = weeutil.weeutil.y_or_n("Clear console memory (y/n)? ", + noprompt) + if ans == 'y': + print('Clearing memory (takes approx. 12s)') + self.driver.station.clear_memory() + print(self.driver.station.get_memory_status()) + else: + print("Clear memory cancelled.") + + def reset_rain(self, noprompt): + print(self.driver.station.get_rain()) + ans = weeutil.weeutil.y_or_n("Reset rain counter (y/n)? ", + noprompt) + if ans == 'y': + print('Resetting rain counter') + self.driver.station.reset_rain() + print(self.driver.station.get_rain()) + else: + print("Reset rain cancelled.") + + def reset_max(self, noprompt): + print(self.driver.station.get_max()) + ans = weeutil.weeutil.y_or_n("Reset max counters (y/n)? ", + noprompt) + if ans == 'y': + print('Resetting max counters') + self.driver.station.reset_max() + print(self.driver.station.get_max()) + else: + print("Reset max cancelled.") + + def reset_min(self, noprompt): + print(self.driver.station.get_min()) + ans = weeutil.weeutil.y_or_n("Reset min counters (y/n)? ", + noprompt) + if ans == 'y': + print('Resetting min counters') + self.driver.station.reset_min() + print(self.driver.station.get_min()) + else: + print("Reset min cancelled.") + + def set_interval(self, interval, noprompt): + if interval < 0 or 60 < interval: + raise ValueError("Logger interval must be 0-60 minutes") + print("Interval is", self.driver.station.get_interval(), " minutes.") + ans = weeutil.weeutil.y_or_n("Set interval to %d minutes (y/n)? " % interval, + noprompt) + if ans == 'y': + print("Setting interval to %d minutes" % interval) + self.driver.station.set_interval(interval) + print("Interval is now", self.driver.station.get_interval()) + else: + print("Set interval cancelled.") + + def set_clock(self, noprompt): + print("Station clock is", self.driver.station.get_time()) + print("Current time is", datetime.datetime.now()) + ans = weeutil.weeutil.y_or_n("Set station time to current time (y/n)? ", + noprompt) + if ans == 'y': + print("Setting station clock to %s" % datetime.datetime.now()) + self.driver.station.set_time() + print("Station clock is now", self.driver.station.get_time()) + else: + print("Set clock cancelled.") + + def set_units(self, units, noprompt): + if units.lower() not in ['metric', 'english']: + raise ValueError("Units must be METRIC or ENGLISH") + print("Station units is", self.driver.station.get_units()) + ans = weeutil.weeutil.y_or_n("Set station units to %s (y/n)? " % units, + noprompt) + if ans == 'y': + print("Setting station units to %s" % units) + self.driver.station.set_units(units) + print("Station units is now", self.driver.station.get_units()) + else: + print("Set units cancelled.") + + def set_dst(self, dst, noprompt): + if dst != '0' and len(dst.split(',')) != 3: + raise ValueError("DST must be 0 (disabled) or start, stop, amount " + "with the format mm/dd HH:MM, mm/dd HH:MM, [MM]M") + print("Station DST is", self.driver.station.get_dst()) + ans = weeutil.weeutil.y_or_n("Set station DST to %s (y/n)? " % dst, + noprompt) + if ans == 'y': + print("Setting station DST to %s" % dst) + self.driver.station.set_dst(dst) + print("Station DST is now", self.driver.station.get_dst()) + else: + print("Set DST cancelled.") + + def set_channel(self, ch, noprompt): + if ch not in [0, 1, 2, 3]: + raise ValueError("Channel must be one of 0, 1, 2, or 3") + print("Station channel is", self.driver.station.get_channel()) + ans = weeutil.weeutil.y_or_n("Set station channel to %s (y/n)? " % ch, + noprompt) + if ans == 'y': + print("Setting station channel to %s" % ch) + self.driver.station.set_channel(ch) + print("Station channel is now", self.driver.station.get_channel()) + else: + print("Set channel cancelled.") + + +class CC3000Driver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with a RainWise CC3000 data logger.""" + + # map rainwise names to database schema names + DEFAULT_SENSOR_MAP = { + 'dateTime': 'TIMESTAMP', + 'outTemp': 'TEMP OUT', + 'outHumidity': 'HUMIDITY', + 'windDir': 'WIND DIRECTION', + 'windSpeed': 'WIND SPEED', + 'windGust': 'WIND GUST', + 'pressure': 'PRESSURE', + 'inTemp': 'TEMP IN', + 'extraTemp1': 'TEMP 1', + 'extraTemp2': 'TEMP 2', + 'day_rain_total': 'RAIN', + 'supplyVoltage': 'STATION BATTERY', + 'consBatteryVoltage': 'BATTERY BACKUP', + 'radiation': 'SOLAR RADIATION', + 'UV': 'UV INDEX', + } + + def __init__(self, **stn_dict): + log.info('Driver version is %s' % DRIVER_VERSION) + + global DEBUG_SERIAL + DEBUG_SERIAL = int(stn_dict.get('debug_serial', 0)) + global DEBUG_CHECKSUM + DEBUG_CHECKSUM = int(stn_dict.get('debug_checksum', 0)) + global DEBUG_OPENCLOSE + DEBUG_OPENCLOSE = int(stn_dict.get('debug_openclose', 0)) + + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.model = stn_dict.get('model', 'CC3000') + port = stn_dict.get('port', CC3000.DEFAULT_PORT) + log.info('Using serial port %s' % port) + self.polling_interval = float(stn_dict.get('polling_interval', 2)) + log.info('Polling interval is %s seconds' % self.polling_interval) + self.use_station_time = weeutil.weeutil.to_bool( + stn_dict.get('use_station_time', True)) + log.info('Using %s time for loop packets' % + ('station' if self.use_station_time else 'computer')) + # start with the default sensormap, then augment with user-specified + self.sensor_map = dict(self.DEFAULT_SENSOR_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('Sensor map is %s' % self.sensor_map) + + # periodically check the logger memory, then clear it if necessary. + # these track the last time a check was made, and how often to make + # the checks. threshold of None indicates do not clear logger. + self.logger_threshold = to_int( + stn_dict.get('logger_threshold', 0)) + self.last_mem_check = 0 + self.mem_interval = 7 * 24 * 3600 + if self.logger_threshold != 0: + log.info('Clear logger at %s records' % self.logger_threshold) + + # track the last rain counter value so we can determine deltas + self.last_rain = None + + self.station = CC3000(port) + self.station.open() + + # report the station configuration + settings = self._init_station_with_retries(self.station, self.max_tries) + log.info('Firmware: %s' % settings['firmware']) + self.arcint = settings['arcint'] + log.info('Archive interval: %s' % self.arcint) + self.header = settings['header'] + log.info('Header: %s' % self.header) + self.units = weewx.METRICWX if settings['units'] == 'METRIC' else weewx.US + log.info('Units: %s' % settings['units']) + log.info('Channel: %s' % settings['channel']) + log.info('Charger status: %s' % settings['charger']) + log.info('Memory: %s' % self.station.get_memory_status()) + + def time_to_next_poll(self): + now = time.time() + next_poll_event = int(now / self.polling_interval) * self.polling_interval + self.polling_interval + log.debug('now: %f, polling_interval: %d, next_poll_event: %f' % (now, self.polling_interval, next_poll_event)) + secs_to_poll = next_poll_event - now + log.debug('Next polling event in %f seconds' % secs_to_poll) + return secs_to_poll + + def genLoopPackets(self): + cmd_mode = True + if self.polling_interval == 0: + self.station.set_auto() + cmd_mode = False + + reboot_attempted = False + ntries = 0 + while ntries < self.max_tries: + ntries += 1 + try: + # Poll on polling_interval boundaries. + if self.polling_interval != 0: + time.sleep(self.time_to_next_poll()) + values = self.station.get_current_data(cmd_mode) + now = int(time.time()) + ntries = 0 + log.debug("Values: %s" % values) + if values: + packet = self._parse_current( + values, self.header, self.sensor_map) + log.debug("Parsed: %s" % packet) + if packet and 'dateTime' in packet: + if not self.use_station_time: + packet['dateTime'] = int(time.time() + 0.5) + packet['usUnits'] = self.units + if 'day_rain_total' in packet: + packet['rain'] = self._rain_total_to_delta( + packet['day_rain_total'], self.last_rain) + self.last_rain = packet['day_rain_total'] + else: + log.debug("No rain in packet: %s" % packet) + log.debug("Packet: %s" % packet) + yield packet + else: + if not reboot_attempted: + # To be on the safe side, max of one reboot per execution. + reboot_attempted = True + log.info("No data from sensors, rebooting.") + startup_msgs = self.station.reboot() + log.info("Back from a reboot:") + for line in startup_msgs: + log.info(line) + + # periodically check memory, clear if necessary + if time.time() - self.last_mem_check > self.mem_interval: + nrec = self.station.get_history_usage() + self.last_mem_check = time.time() + if nrec is None: + log.info("Memory check: Cannot determine memory usage") + else: + log.info("Logger is at %d records, " + "logger clearing threshold is %d" % + (nrec, self.logger_threshold)) + if self.logger_threshold != 0 and nrec >= self.logger_threshold: + log.info("Clearing all records from logger") + self.station.clear_memory() + except (serial.serialutil.SerialException, weewx.WeeWxIOError) as e: + log.error("Failed attempt %d of %d to get data: %s" % + (ntries, self.max_tries, e)) + else: + msg = "Max retries (%d) exceeded" % self.max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def genStartupRecords(self, since_ts): + """Return archive records from the data logger. Download all records + then return the subset since the indicated timestamp. + + Assumptions: + - the units are consistent for the entire history. + - the archive interval is constant for entire history. + - the HDR for archive records is the same as current HDR + """ + log.debug("GenStartupRecords: since_ts=%s" % since_ts) + log.info('Downloading new records (if any).') + last_rain = None + new_records = 0 + for pkt in self.gen_records_since_ts(since_ts): + log.debug("Packet: %s" % pkt) + pkt['usUnits'] = self.units + pkt['interval'] = self.arcint + if 'day_rain_total' in pkt: + pkt['rain'] = self._rain_total_to_delta( + pkt['day_rain_total'], last_rain) + last_rain = pkt['day_rain_total'] + else: + log.debug("No rain in record: %s" % r) + log.debug("Packet: %s" % pkt) + new_records += 1 + yield pkt + log.info('Downloaded %d new records.' % new_records) + + def gen_records_since_ts(self, since_ts): + return self.station.gen_records_since_ts(self.header, self.sensor_map, since_ts) + + @property + def hardware_name(self): + return self.model + + @property + def archive_interval(self): + return self.arcint + + def getTime(self): + try: + v = self.station.get_time() + return _to_ts(v) + except ValueError as e: + log.error("getTime failed: %s" % e) + return 0 + + def setTime(self): + self.station.set_time() + + @staticmethod + def _init_station_with_retries(station, max_tries): + for cnt in range(max_tries): + try: + return CC3000Driver._init_station(station) + except (serial.serialutil.SerialException, weewx.WeeWxIOError) as e: + log.error("Failed attempt %d of %d to initialize station: %s" % + (cnt + 1, max_tries, e)) + else: + raise weewx.RetriesExceeded("Max retries (%d) exceeded while initializing station" % max_tries) + + @staticmethod + def _init_station(station): + station.flush() + station.wakeup() + station.set_echo() + settings = dict() + settings['firmware'] = station.get_version() + settings['arcint'] = station.get_interval() * 60 # arcint is in seconds + settings['header'] = CC3000Driver._parse_header(station.get_header()) + settings['units'] = station.get_units() + settings['channel'] = station.get_channel() + settings['charger'] = station.get_charger() + return settings + + @staticmethod + def _rain_total_to_delta(rain_total, last_rain): + # calculate the rain delta between the current and previous rain totals. + return weewx.wxformulas.calculate_rain(rain_total, last_rain) + + @staticmethod + def _parse_current(values, header, sensor_map): + return CC3000Driver._parse_values(values, header, sensor_map, + "%Y/%m/%d %H:%M:%S") + + @staticmethod + def _parse_values(values, header, sensor_map, fmt): + """parse the values and map them into the schema names. if there is + a failure for any one value, then the entire record fails.""" + pkt = dict() + if len(values) != len(header) + 1: + log.info("Values/header mismatch: %s %s" % (values, header)) + return pkt + for i, v in enumerate(values): + if i >= len(header): + continue + label = None + for m in sensor_map: + if sensor_map[m] == header[i]: + label = m + if label is None: + continue + try: + if header[i] == 'TIMESTAMP': + pkt[label] = _to_ts(v, fmt) + else: + pkt[label] = float(v) + except ValueError as e: + log.error("Parse failed for '%s' '%s': %s (idx=%s values=%s)" % + (header[i], v, e, i, values)) + return dict() + return pkt + + @staticmethod + def _parse_header(header): + h = [] + for v in header: + if v == 'HDR' or v[0:1] == '!': + continue + h.append(v.replace('"', '')) + return h + + def get_current(self): + data = self.station.get_current_data() + return self._parse_current(data, self.header, self.sensor_map) + +def _to_ts(tstr, fmt="%Y/%m/%d %H:%M:%S"): + return time.mktime(time.strptime(tstr, fmt)) + +def _format_bytes(buf): + # byte2int not necessary in PY3 and will raise an exception + # if used ("int object is not subscriptable") + if PY2: + return ' '.join(['%0.2X' % byte2int(c) for c in buf]) + return ' '.join(['%0.2X' % c for c in buf]) + +def _check_crc(buf): + idx = buf.find(b'!') + if idx < 0: + return + a = 0 + b = 0 + cs = b'' + try: + cs = buf[idx+1:idx+5] + if DEBUG_CHECKSUM: + log.debug("Found checksum at %d: %s" % (idx, cs)) + a = crc16(buf[0:idx]) # calculate checksum + if DEBUG_CHECKSUM: + log.debug("Calculated checksum %x" % a) + b = int(cs, 16) # checksum provided in data + if a != b: + raise ChecksumMismatch(a, b, buf) + except ValueError as e: + raise BadCRC(a, cs, buf) + +class CC3000(object): + DEFAULT_PORT = '/dev/ttyUSB0' + + def __init__(self, port): + self.port = port + self.baudrate = 115200 + self.timeout = 1 # seconds for everyting except MEM=CLEAR + # MEM=CLEAR of even two records needs a timeout of 13 or more. 20 is probably safe. + # flush cmd echo value + # 0.000022 0.000037 12.819934 0.000084 + # 0.000018 0.000036 12.852024 0.000088 + self.mem_clear_timeout = 20 # reopen w/ bigger timeout for MEM=CLEAR + self.serial_port = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self, timeoutOverride=None): + if DEBUG_OPENCLOSE: + log.debug("Open serial port %s" % self.port) + to = timeoutOverride if timeoutOverride is not None else self.timeout + self.serial_port = serial.Serial(self.port, self.baudrate, + timeout=to) + + def close(self): + if self.serial_port is not None: + if DEBUG_OPENCLOSE: + log.debug("Close serial port %s" % self.port) + self.serial_port.close() + self.serial_port = None + + def write(self, data): + if not PY2: + # Encode could perhaps fail on bad user input (DST?). + # If so, this will be handled later when it is observed that the + # command does not do what is expected. + data = data.encode('ascii', 'ignore') + if DEBUG_SERIAL: + log.debug("Write: '%s'" % data) + n = self.serial_port.write(data) + if n is not None and n != len(data): + raise weewx.WeeWxIOError("Write expected %d chars, sent %d" % + (len(data), n)) + + def read(self): + """The station sends CR NL before and after any response. Some + responses have a 4-byte CRC checksum at the end, indicated with an + exclamation. Not every response has a checksum. + """ + data = self.serial_port.readline() + if DEBUG_SERIAL: + log.debug("Read: '%s' (%s)" % (data, _format_bytes(data))) + data = data.strip() + _check_crc(data) + if not PY2: + # CRC passed, so this is unlikely. + # Ignore as irregular data will be handled later. + data = data.decode('ascii', 'ignore') + return data + + def flush(self): + self.flush_input() + self.flush_output() + + def flush_input(self): + log.debug("Flush input buffer") + self.serial_port.flushInput() + + def flush_output(self): + log.debug("Flush output buffer") + self.serial_port.flushOutput() + + def queued_bytes(self): + return self.serial_port.inWaiting() + + def send_cmd(self, cmd): + """Any command must be terminated with a CR""" + self.write("%s\r" % cmd) + + def command(self, cmd): + # Sample timings for first fifteen NOW commands after startup. + # Flush CMD ECHO VALUE + # -------- -------- -------- -------- + # 0.000021 0.000054 0.041557 0.001364 + # 0.000063 0.000109 0.040432 0.001666 + # 0.000120 0.000123 0.024272 0.016871 + # 0.000120 0.000127 0.025148 0.016657 + # 0.000119 0.000126 0.024966 0.016665 + # 0.000130 0.000142 0.041037 0.001791 + # 0.000120 0.000126 0.023533 0.017023 + # 0.000120 0.000137 0.024336 0.016747 + # 0.000117 0.000133 0.026254 0.016684 + # 0.000120 0.000140 0.025014 0.016739 + # 0.000121 0.000134 0.024801 0.016779 + # 0.000120 0.000141 0.024635 0.016906 + # 0.000118 0.000129 0.024354 0.016894 + # 0.000120 0.000133 0.024214 0.016861 + # 0.000118 0.000122 0.024599 0.016865 + + # MEM=CLEAR needs a longer timeout. >12s to clear a small number of records has been observed. + # It also appears to be highly variable. The two examples below are from two different CC3000s. + # + # In this example, clearing at 11,595 records took > 6s. + # Aug 18 06:46:21 charlemagne weewx[684]: cc3000: logger is at 11595 records, logger clearing threshold is 10000 + # Aug 18 06:46:21 charlemagne weewx[684]: cc3000: clearing all records from logger + # Aug 18 06:46:21 charlemagne weewx[684]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.000779 seconds. + # Aug 18 06:46:28 charlemagne weewx[684]: cc3000: MEM=CLEAR: times: 0.000016 0.000118 6.281638 0.000076 + # Aug 18 06:46:28 charlemagne weewx[684]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001444 seconds. + # + # In this example, clearing at 11,475 records took > 12s. + # Aug 18 07:17:14 ella weewx[615]: cc3000: logger is at 11475 records, logger clearing threshold is 10000 + # Aug 18 07:17:14 ella weewx[615]: cc3000: clearing all records from logger + # Aug 18 07:17:14 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.001586 seconds. + # Aug 18 07:17:27 ella weewx[615]: cc3000: MEM=CLEAR: times: 0.000020 0.000058 12.459346 0.000092 + # Aug 18 07:17:27 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001755 seconds. + # + # Here, clearing 90 records took very close to 13 seconds. + # Aug 18 14:46:00 ella weewx[24602]: cc3000: logger is at 91 records, logger clearing threshold is 90 + # Aug 18 14:46:00 ella weewx[24602]: cc3000: clearing all records from logger + # Aug 18 14:46:00 ella weewx[24602]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.000821 seconds. + # Aug 18 14:46:13 ella weewx[24602]: cc3000: MEM=CLEAR: times: 0.000037 0.000061 12.970494 0.000084 + # Aug 18 14:46:13 ella weewx[24602]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001416 seconds. + + reset_timeout = False + + # MEM=CLEAR needs a much larger timeout value. Reopen with that larger timeout and reset below. + # + # Closing and reopening with a different timeout is quick: + # Aug 18 07:17:14 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.001586 seconds. + # Aug 18 07:17:27 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001755 seconds. + if cmd == 'MEM=CLEAR': + reset_timeout = True # Reopen with default timeout in finally. + t1 = time.time() + self.close() + self.open(self.mem_clear_timeout) + t2 = time.time() + close_open_time = t2 - t1 + log.info("%s: The resetting of timeout to %d took %f seconds." % (cmd, self.mem_clear_timeout, close_open_time)) + + try: + return self.exec_cmd_with_retries(cmd) + finally: + if reset_timeout: + t1 = time.time() + self.close() + self.open() + reset_timeout = True + t2 = time.time() + close_open_time = t2 - t1 + log.info("%s: The resetting of timeout to %d took %f seconds." % (cmd, self.timeout, close_open_time)) + + def exec_cmd_with_retries(self, cmd): + """Send cmd. Time the reading of the echoed command. If the measured + time is >= timeout, the cc3000 is borked. The input and output buffers + will be flushed and the command retried. Try up to 10 times. + It practice, one retry does the trick. + cc3000s. + """ + attempts = 0 + while attempts < 10: + attempts += 1 + t1 = time.time() + self.flush() # flush + t2 = time.time() + flush_time = t2 - t1 + self.send_cmd(cmd) # send cmd + t3 = time.time() + cmd_time = t3 - t2 + data = self.read() # read the cmd echo + t4 = time.time() + echo_time = t4 - t3 + + if ((cmd != 'MEM=CLEAR' and echo_time >= self.timeout) + or (cmd == 'MEM=CLEAR' and echo_time >= self.mem_clear_timeout)): + # The command timed out reading back the echo of the command. + # No need to read the values as it will also time out. + # Log it and retry. In practice, the retry always works. + log.info("%s: times: %f %f %f -retrying-" % + (cmd, flush_time, cmd_time, echo_time)) + log.info('%s: Reading cmd echo timed out (%f seconds), retrying.' % + (cmd, echo_time)) + # Retrying setting the time must be special cased as now a little + # more than one second has passed. As such, redo the command + # with the current time. + if cmd.startswith("TIME=") and cmd != "TIME=?": + cmd = self._compose_set_time_command() + # Retry + else: + # Success, the reading of the echoed command did not time out. + break + + if data != cmd and attempts > 1: + # After retrying, the cmd always echoes back as an empty string. + if data == '': + log.info("%s: Accepting empty string as cmd echo." % cmd) + else: + raise weewx.WeeWxIOError( + "command: Command failed: cmd='%s' reply='%s'" % (cmd, data)) + + t5 = time.time() + retval = self.read() + t6 = time.time() + value_time = t6 - t5 + if cmd == 'MEM=CLEAR': + log.info("%s: times: %f %f %f %f" % + (cmd, flush_time, cmd_time, echo_time, value_time)) + + if attempts > 1: + if retval != '': + log.info("%s: Retry worked. Total tries: %d" % (cmd, attempts)) + else: + log.info("%s: Retry failed." % cmd) + log.info("%s: times: %f %f %f %f" % + (cmd, flush_time, cmd_time, echo_time, value_time)) + + return retval + + def get_version(self): + log.debug("Get firmware version") + return self.command("VERSION") + + def reboot(self): + # Reboot outputs the following (after the reboot): + # .................... + # + # Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 + # Flash ID 202015 + # Initializing memory...OK. + log.debug("Rebooting CC3000.") + self.send_cmd("REBOOT") + time.sleep(5) + dots = self.read() + blank = self.read() + ver = self.read() + flash_id = self.read() + init_msg = self.read() + return [dots, blank, ver, flash_id, init_msg] + + # give the station some time to wake up. when we first hit it with a + # command, it often responds with an empty string. then subsequent + # commands get the proper response. so for a first command, send something + # innocuous and wait a bit. hopefully subsequent commands will then work. + # NOTE: This happens periodically and does not appear to be related to + # "waking up". Getter commands now retry, so removing the sleep. + def wakeup(self): + self.command('ECHO=?') + + def set_echo(self, cmd='ON'): + log.debug("Set echo to %s" % cmd) + data = self.command('ECHO=%s' % cmd) + if data != 'OK': + raise weewx.WeeWxIOError("Set ECHO failed: %s" % data) + + def get_header(self): + log.debug("Get header") + data = self.command("HEADER") + cols = data.split(',') + if cols[0] != 'HDR': + raise weewx.WeeWxIOError("Expected HDR, got %s" % cols[0]) + return cols + + def set_auto(self): + # auto does not echo the command + self.send_cmd("AUTO") + + def get_current_data(self, send_now=True): + data = '' + if send_now: + data = self.command("NOW") + else: + data = self.read() + if data == 'NO DATA' or data == 'NO DATA RECEIVED': + log.debug("No data from sensors") + return [] + return data.split(',') + + def get_time(self): + # unlike all of the other accessor methods, the TIME command returns + # OK after it returns the requested parameter. so we have to pop the + # OK off the serial so it does not trip up other commands. + log.debug("Get time") + tstr = self.command("TIME=?") + if tstr not in ['ERROR', 'OK']: + data = self.read() + if data != 'OK': + raise weewx.WeeWxIOError("Failed to get time: %s, %s" % (tstr, data)) + return tstr + + @staticmethod + def _compose_set_time_command(): + ts = time.time() + tstr = time.strftime("%Y/%m/%d %H:%M:%S", time.localtime(ts)) + log.info("Set time to %s (%s)" % (tstr, ts)) + return "TIME=%s" % tstr + + def set_time(self): + s = self._compose_set_time_command() + data = self.command(s) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set time to %s: %s" % + (s, data)) + + def get_dst(self): + log.debug("Get daylight saving") + return self.command("DST=?") + + def set_dst(self, dst): + log.debug("Set DST to %s" % dst) + # Firmware 1.3 Build 022 Dec 02 2016 returns 3 lines (,'',OK) + data = self.command("DST=%s" % dst) # echoed input dst + if data != dst: + raise weewx.WeeWxIOError("Failed to set DST to %s: %s" % + (dst, data)) + data = self.read() # read '' + if data not in ['ERROR', 'OK']: + data = self.read() # read OK + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set DST to %s: %s" % + (dst, data)) + + def get_units(self): + log.debug("Get units") + return self.command("UNITS=?") + + def set_units(self, units): + log.debug("Set units to %s" % units) + data = self.command("UNITS=%s" % units) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set units to %s: %s" % + (units, data)) + + def get_interval(self): + log.debug("Get logging interval") + return int(self.command("LOGINT=?")) + + def set_interval(self, interval=5): + log.debug("Set logging interval to %d minutes" % interval) + data = self.command("LOGINT=%d" % interval) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set logging interval: %s" % + data) + + def get_channel(self): + log.debug("Get channel") + return self.command("STATION") + + def set_channel(self, channel): + log.debug("Set channel to %d" % channel) + if channel < 0 or 3 < channel: + raise ValueError("Channel must be 0-3") + data = self.command("STATION=%d" % channel) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set channel: %s" % data) + + def get_charger(self): + log.debug("Get charger") + return self.command("CHARGER") + + def get_baro(self): + log.debug("Get baro") + return self.command("BARO") + + def set_baro(self, offset): + log.debug("Set barometer offset to %d" % offset) + if offset != '0': + parts = offset.split('.') + if (len(parts) != 2 or + (not (len(parts[0]) == 2 and len(parts[1]) == 2) and + not (len(parts[0]) == 3 and len(parts[1]) == 1))): + raise ValueError("Offset must be 0, XX.XX (inHg), or XXXX.X (mbar)") + data = self.command("BARO=%d" % offset) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set baro: %s" % data) + + def get_memory_status(self): + # query for logger memory use. output is something like this: + # 6438 bytes, 111 records, 0% + log.debug("Get memory status") + return self.command("MEM=?") + + def get_max(self): + log.debug("Get max values") + # Return outside temperature, humidity, pressure, wind direction, + # wind speed, rainfall (daily total), station voltage, inside + # temperature. + return self.command("MAX=?").split(',') + + def reset_max(self): + log.debug("Reset max values") + data = self.command("MAX=RESET") + if data != 'OK': + raise weewx.WeeWxIOError("Failed to reset max values: %s" % data) + + def get_min(self): + log.debug("Get min values") + # Return outside temperature, humidity, pressure, wind direction, + # wind speed, rainfall (ignore), station voltage, inside temperature. + return self.command("MIN=?").split(',') + + def reset_min(self): + log.debug("Reset min values") + data = self.command("MIN=RESET") + if data != 'OK': + raise weewx.WeeWxIOError("Failed to reset min values: %s" % data) + + def get_history_usage(self): + # return the number of records in the logger + s = self.get_memory_status() + if 'records' in s: + return int(s.split(',')[1].split()[0]) + return None + + def clear_memory(self): + log.debug("Clear memory") + data = self.command("MEM=CLEAR") + # It's a long wait for the OK. With a greatly increased timeout + # just for MEM=CLEAR, we should be able to read the OK. + if data == 'OK': + log.info("MEM=CLEAR succeeded.") + else: + raise weewx.WeeWxIOError("Failed to clear memory: %s" % data) + + def get_rain(self): + log.debug("Get rain total") + # Firmware 1.3 Build 022 Dec 02 2017 returns OK after the rain count + # This is like TIME=? + rstr = self.command("RAIN") + if rstr not in ['ERROR', 'OK']: + data = self.read() + if data != 'OK': + raise weewx.WeeWxIOError("Failed to get rain: %s" % data) + return rstr + + def reset_rain(self): + log.debug("Reset rain counter") + data = self.command("RAIN=RESET") + if data != 'OK': + raise weewx.WeeWxIOError("Failed to reset rain: %s" % data) + + def gen_records_since_ts(self, header, sensor_map, since_ts): + if since_ts is None: + since_ts = 0.0 + num_records = 0 + else: + now_ts = time.mktime(datetime.datetime.now().timetuple()) + nseconds = now_ts - since_ts + nminutes = math.ceil(nseconds / 60.0) + num_records = math.ceil(nminutes / float(self.get_interval())) + if num_records == 0: + log.debug('gen_records_since_ts: Asking for all records.') + else: + log.debug('gen_records_since_ts: Asking for %d records.' % num_records) + for r in self.gen_records(nrec=num_records): + pkt = CC3000Driver._parse_values(r[1:], header, sensor_map, "%Y/%m/%d %H:%M") + if 'dateTime' in pkt and pkt['dateTime'] > since_ts: + yield pkt + + def gen_records(self, nrec=0): + """ + Generator function for getting nrec records from the device. A value + of 0 indicates all records. + + The CC3000 returns a header ('HDR,'), the archive records + we are interested in ('REC,'), daily max and min records + ('MAX,', 'MIN,') as well as messages for various events such as a + reboot ('MSG,'). + + Things get interesting when nrec is non-zero. + + DOWNLOAD=n returns the latest n records in memory. The CC3000 does + not distinguish between REC, MAX, MIN and MSG records in memory. + As such, DOWNLOAD=5 does NOT mean fetch the latest 5 REC records. + For example, if the latest 5 records include a MIN and a MAX record, + only 3 REC records will be returned (along with the MIN and MAX + records). + + Given that one can't ask pecisely ask for a given number of archive + records, a heuristic is used and errs on the side of asking for + too many records. + + The heurisitic for number of records to ask for is: + the sum of: + nrec + 7 * the number of days convered in the request (rounded up) + Note: One can determine the number of days from the number of + records requested because the archive interval is known. + + Asking for an extra seven records per day allows for the one MIN and + one MAX records generated per day, plus a buffer for up to five MSG + records each day. Unless one is rebooting the CC3000 all day, this + will be plenty. Typically, there will be zero MSG records. Clearing + memory and rebooting actions generate MSG records. Both are uncommon. + As a result, gen_records will overshoot the records asked for, but this + is not a problem in practice. Also, if a new archive record is written + while this operation is taking place, it will be returned. As such, + the number wouldn't be precise anyway. One could work around this by + accumulating records before returning, and then returning an exact + amount, but it simply isn't worth it. + + Examining the records in the CC3000 (808 records at the time of the + examination) shows the following records found: + HDR: 1 (the header record, per the spec) + REC: 800 (the archive records -- ~2.8 days worth) + MSG: 1 (A clear command that executed ~2.8 days ago: + MSG 2019/12/20 15:48 CLEAR ON COMMAND!749D) + MIN: 3 (As expected for 3 days.) + MAX: 3 (As expected for 3 days.) + + Interrogating the CC3000 for a large number of records fails miserably + if, while reading the responses, the responses are parsed and added + to the datbase. (Check sum mismatches, partical records, etc.). If + these last two steps are skipped, reading from the CC3000 is very + reliable. This can be observed by asing for history with wee_config. + Observed with > 11K of records. + + To address the above problem, all records are read into memory. Reading + all records into memory before parsing and inserting into the database + is very reliable. For smaller amounts of recoreds, the reading into + memory could be skipped, but what would be the point? + """ + + log.debug('gen_records(%d)' % nrec) + totrec = self.get_history_usage() + log.debug('gen_records: Requested %d latest of %d records.' % (nrec, totrec)) + + if nrec == 0: + num_to_ask = 0 + else: + # Determine the number of records to ask for. + # See heuristic above. + num_mins_asked = nrec * self.get_interval() + num_days_asked = math.ceil(num_mins_asked / (24.0*60)) + num_to_ask = nrec + 7 * num_days_asked + + if num_to_ask == 0: + cmd = 'DOWNLOAD' + else: + cmd = 'DOWNLOAD=%d' % num_to_ask + log.debug('%s' % cmd) + + # Note: It takes about 14s to read 1000 records into memory. + if num_to_ask == 0: + log.info('Reading all records into memory. This could take some time.') + elif num_to_ask < 1000: + log.info('Reading %d records into memory.' % num_to_ask) + else: + log.info('Reading %d records into memory. This could take some time.' % num_to_ask) + yielded = 0 + recs = [] + data = self.command(cmd) + while data != 'OK': + recs.append(data) + data = self.read() + log.info('Finished reading %d records.' % len(recs)) + yielded = 0 + for data in recs: + values = data.split(',') + if values[0] == 'REC': + yielded += 1 + yield values + elif (values[0] == 'HDR' or values[0] == 'MSG' or + values[0] == 'MIN' or values[0] == 'MAX' or + values[0].startswith('DOWNLOAD')): + pass + else: + log.error("Unexpected record '%s' (%s)" % (values[0], data)) + log.debug('Downloaded %d records' % yielded) + +class CC3000ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[CC3000] + # This section is for RainWise MarkIII weather stations and CC3000 logger. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = %s + + # The station model, e.g., CC3000 or CC3000R + model = CC3000 + + # The driver to use: + driver = weewx.drivers.cc3000 +""" % (CC3000.DEFAULT_PORT,) + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + port = self._prompt('port', CC3000.DEFAULT_PORT) + return {'port': port} + + +# define a main entry point for basic testing. invoke from the weewx root dir: +# +# PYTHONPATH=bin python -m weewx.drivers.cc3000 --help +# +# FIXME: This duplicates all of the functionality in CC3000Conigurator. +# Perhaps pare this down to a version option and, by default, +# polling and printing records (a la, the vantage driver).. + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', action='store_true', + help='display driver version') + parser.add_option('--test-crc', dest='testcrc', action='store_true', + help='test crc') + parser.add_option('--port', metavar='PORT', + help='port to which the station is connected', + default=CC3000.DEFAULT_PORT) + parser.add_option('--get-version', dest='getver', action='store_true', + help='display firmware version') + parser.add_option('--debug', action='store_true', default=False, + help='emit additional diagnostic information') + parser.add_option('--get-status', dest='status', action='store_true', + help='display memory status') + parser.add_option('--get-channel', dest='getch', action='store_true', + help='display station channel') + parser.add_option('--set-channel', dest='setch', metavar='CHANNEL', + help='set station channel') + parser.add_option('--get-battery', dest='getbat', action='store_true', + help='display battery status') + parser.add_option('--get-current', dest='getcur', action='store_true', + help='display current data') + parser.add_option('--get-memory', dest='getmem', action='store_true', + help='display memory status') + parser.add_option('--get-records', dest='getrec', metavar='NUM_RECORDS', + help='display records from station memory') + parser.add_option('--get-header', dest='gethead', action='store_true', + help='display data header') + parser.add_option('--get-units', dest='getunits', action='store_true', + help='display units') + parser.add_option('--set-units', dest='setunits', metavar='UNITS', + help='set units to ENGLISH or METRIC') + parser.add_option('--get-time', dest='gettime', action='store_true', + help='display station time') + parser.add_option('--set-time', dest='settime', action='store_true', + help='set station time to computer time') + parser.add_option('--get-dst', dest='getdst', action='store_true', + help='display daylight savings settings') + parser.add_option('--set-dst', dest='setdst', + metavar='mm/dd HH:MM,mm/dd HH:MM,[MM]M', + help='set daylight savings start, end, and amount') + parser.add_option('--get-interval', dest='getint', action='store_true', + help='display logging interval, in seconds') + parser.add_option('--set-interval', dest='setint', metavar='INTERVAL', + type=int, help='set logging interval, in seconds') + parser.add_option('--clear-memory', dest='clear', action='store_true', + help='clear logger memory') + parser.add_option('--get-rain', dest='getrain', action='store_true', + help='get rain counter') + parser.add_option('--reset-rain', dest='resetrain', action='store_true', + help='reset rain counter') + parser.add_option('--get-max', dest='getmax', action='store_true', + help='get max counter') + parser.add_option('--reset-max', dest='resetmax', action='store_true', + help='reset max counters') + parser.add_option('--get-min', dest='getmin', action='store_true', + help='get min counter') + parser.add_option('--reset-min', dest='resetmin', action='store_true', + help='reset min counters') + parser.add_option('--poll', metavar='POLL_INTERVAL', type=int, + help='poll interval in seconds') + parser.add_option('--reboot', dest='reboot', action='store_true', + help='reboot the station') + (options, args) = parser.parse_args() + + if options.version: + print("%s driver version %s" % (DRIVER_NAME, DRIVER_VERSION)) + exit(0) + + if options.debug: + DEBUG_SERIAL = 1 + DEBUG_CHECKSUM = 1 + DEBUG_OPENCLOSE = 1 + weewx.debug = 1 + + weeutil.logger.setup('cc3000', {}) + + if options.testcrc: + _check_crc(b'OK') + _check_crc(b'REC,2010/01/01 14:12, 64.5, 85,29.04,349, 2.4, 4.2, 0.00, 6.21, 0.25, 73.2,!B82C') + _check_crc(b'MSG,2010/01/01 20:22,CHARGER ON,!4CED') + exit(0) + + with CC3000(options.port) as s: + s.flush() + s.wakeup() + s.set_echo() + if options.getver: + print(s.get_version()) + if options.reboot: + print('rebooting...') + startup_msgs = s.reboot() + for line in startup_msgs: + print(line) + if options.status: + print("Firmware:", s.get_version()) + print("Time:", s.get_time()) + print("DST:", s.get_dst()) + print("Units:", s.get_units()) + print("Memory:", s.get_memory_status()) + print("Interval:", s.get_interval() * 60) + print("Channel:", s.get_channel()) + print("Charger:", s.get_charger()) + print("Baro:", s.get_baro()) + print("Rain:", s.get_rain()) + print("Max values:", s.get_max()) + print("Min values:", s.get_min()) + if options.getch: + print(s.get_channel()) + if options.setch is not None: + s.set_channel(int(options.setch)) + if options.getbat: + print(s.get_charger()) + if options.getcur: + print(s.get_current_data()) + if options.getmem: + print(s.get_memory_status()) + if options.getrec is not None: + i = 0 + for r in s.gen_records(int(options.getrec)): + print(i, r) + i += 1 + if options.gethead: + print(s.get_header()) + if options.getunits: + print(s.get_units()) + if options.setunits: + s.set_units(options.setunits) + if options.gettime: + print(s.get_time()) + if options.settime: + s.set_time() + if options.getdst: + print(s.get_dst()) + if options.setdst: + s.set_dst(options.setdst) + if options.getint: + print(s.get_interval() * 60) + if options.setint: + s.set_interval(int(options.setint) / 60) + if options.clear: + s.clear_memory() + if options.getrain: + print(s.get_rain()) + if options.resetrain: + print(s.reset_rain()) + if options.getmax: + print(s.get_max()) + if options.resetmax: + print(s.reset_max()) + if options.getmin: + print(s.get_min()) + if options.resetmin: + print(s.reset_min()) + if options.poll is not None: + cmd_mode = True + if options.poll == 0: + cmd_mode = False + s.set_auto() + while True: + print(s.get_current_data(cmd_mode)) + time.sleep(options.poll) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/fousb.py b/dist/weewx-4.10.1/bin/weewx/drivers/fousb.py new file mode 100644 index 0000000..a7f713f --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/fousb.py @@ -0,0 +1,1872 @@ +# Copyright 2012 Matthew Wall +# See the file LICENSE.txt for your full rights. +# +# Thanks to Jim Easterbrook for pywws. This implementation includes +# significant portions that were copied directly from pywws. +# +# pywws was derived from wwsr.c by Michael Pendec (michael.pendec@gmail.com), +# wwsrdump.c by Svend Skafte (svend@skafte.net), modified by Dave Wells, +# and other sources. +# +# Thanks also to Mark Teel for the C implementation in wview. +# +# FineOffset support in wview was inspired by the fowsr project by Arne-Jorgen +# Auberg (arne.jorgen.auberg@gmail.com) with hidapi mods by Bill Northcott. + +# USB Lockups +# +# the ws2080 will frequently lock up and require a power cycle to regain +# communications. my sample size is small (3 ws2080 consoles running +# for 1.5 years), but fairly repeatable. one of the consoles has never +# locked up. the other two lock up after a month or so. the monitoring +# software will detect bad magic numbers, then the only way to clear the +# bad magic is to power cycle the console. this was with wview and pywws. +# i am collecting a table of bad magic numbers to see if there is a pattern. +# hopefully the device is simply trying to tell us something. on the other +# hand it could just be bad firmware. it seems to happen when the data +# logging buffer on the console is full, but not always when the buffer +# is full. +# --mwall 30dec2012 +# +# the magic numbers do not seem to be correlated with lockups. in some cases, +# a lockup happens immediately following an unknown magic number. in other +# cases, data collection continues with no problem. for example, a brand new +# WS2080A console reports 44 bf as its magic number, but performs just fine. +# --mwall 02oct2013 +# +# fine offset documentation indicates that set_clock should work, but so far +# it has not worked on any ambient weather WS2080 or WS1090 station i have +# tried. it looks like the station clock is set, but at some point the fixed +# block reverts to the previous clock value. also unclear is the behavior +# when the station attempts to sync with radio clock signal from sensor. +# -- mwall 14feb2013 + +"""Classes and functions for interfacing with FineOffset weather stations. + +FineOffset stations are branded by many vendors, including + * Ambient Weather + * Watson + * National Geographic + * Elecsa + * Tycon + +There are many variants, for example WS1080, WS1090, WH2080, WH2081, WA2080, +WA2081, WH2081, WH1080, WH1081. The variations include uv/luminance, solar +charging, touch screen, single instrument cluster versus separate cluster. + +This implementation supports the 1080, 2080, and 3080 series devices via USB. +The 1080 and 2080 use the same data format, referred to as the 1080 data format +in this code. The 3080 has an expanded data format, referred to as the 3080 +data format, that includes ultraviolet and luminance. + +It should not be necessary to specify the station type. The default behavior +is to expect the 1080 data format, then change to 3080 format if additional +data are available. + +The FineOffset station console updates every 48 seconds. UV data update every +60 seconds. This implementation defaults to sampling the station console via +USB for live data every 60 seconds. Use the parameter 'polling_interval' to +adjust this. An adaptive polling mode is also available. This mode attempts +to read only when the console is not writing to memory or reading data from +the sensors. + +This implementation maps the values decoded by pywws to the names needed +by weewx. The pywws code is mostly untouched - it has been modified to +conform to weewx error handling and reporting, and some additional error +checks have been added. + +Rainfall and Spurious Sensor Readings + +The rain counter occasionally reports incorrect rainfall. On some stations, +the counter decrements then increments. Or the counter may increase by more +than the number of bucket tips that actually occurred. The max_rain_rate +helps filter these bogus readings. This filter is applied to any sample +period. If the volume of the samples in the period divided by the sample +period interval are greater than the maximum rain rate, the samples are +ignored. + +Spurious rain counter decrements often accompany what appear to be noisy +sensor readings. So if we detect a spurious rain counter decrement, we ignore +the rest of the sensor data as well. The suspect sensor readings appear +despite the double reading (to ensure the read is not happening mid-write) +and do not seem to correlate to unstable reads. + +A single bucket tip is equivalent to 0.3 mm of rain. The default maximum +rate is 24 cm/hr (9.44 in/hr). For a sample period of 5 minutes this would +be 2 cm (0.78 in) or about 66 bucket tips, or one tip every 4 seconds. For +a sample period of 30 minutes this would be 12 cm (4.72 in) + +The rain counter is two bytes, so the maximum value is 0xffff or 65535. This +translates to 19660.5 mm of rainfall (19.66 m or 64.9 ft). The console would +have to run for two years with 2 inches of rainfall a day before the counter +wraps around. + +Pressure Calculations + +Pressures are calculated and reported differently by pywws and wview. These +are the variables: + + - abs_pressure - the raw sensor reading + - fixed_block_rel_pressure - value entered in console, then follows + abs_pressure as it changes + - fixed_block_abs_pressure - seems to follow abs_pressure, sometimes + with a lag of a minute or two + - pressure - station pressure (SP) - adjusted raw sensor reading + - barometer - sea level pressure derived from SP using temperaure and altitude + - altimeter - sea level pressure derived from SP using altitude + +wview reports the following: + + pressure = abs_pressure * calMPressure + calCPressure + barometer = sp2bp(pressure, altitude, temperature) + altimeter = sp2ap(pressure, altitude) + +pywws reports the following: + + pressure = abs_pressure + pressure_offset + +where pressure_offset is + + pressure_offset = fixed_block_relative_pressure - fixed_block_abs_pressure + +so that + + pressure = fixed_block_relative_pressure + +pywws does not do barometer or altimeter calculations. + +this implementation reports the abs_pressure from the hardware as 'pressure'. +altimeter and barometer are calculated by weewx. + +Illuminance and Radiation + +The 30xx stations include a sensor that reports illuminance (lux). The +conversion from lux to radiation is a function of the angle of the sun and +altitude, but this driver uses a single multiplier as an approximation. + +Apparently the display on fine offset stations is incorrect. The display +reports radiation with a lux-to-W/m^2 multiplier of 0.001464. Apparently +Cumulus and WeatherDisplay use a multiplier of 0.0079. The multiplier for +sea level with sun directly overhead is 0.01075. + +This driver uses the sea level multiplier of 0.01075. Use an entry in +StdCalibrate to adjust this for your location and altitude. + +From Jim Easterbrook: + +The weather station memory has two parts: a "fixed block" of 256 bytes +and a circular buffer of 65280 bytes. As each weather reading takes 16 +bytes the station can store 4080 readings, or 14 days of 5-minute +interval readings. (The 3080 type stations store 20 bytes per reading, +so store a maximum of 3264.) As data is read in 32-byte chunks, but +each weather reading is 16 or 20 bytes, a small cache is used to +reduce USB traffic. The caching behaviour can be over-ridden with the +``unbuffered`` parameter to ``get_data`` and ``get_raw_data``. + +Decoding the data is controlled by the static dictionaries +``reading_format``, ``lo_fix_format`` and ``fixed_format``. The keys +are names of data items and the values can be an ``(offset, type, +multiplier)`` tuple or another dictionary. So, for example, the +reading_format dictionary entry ``'rain' : (13, 'us', 0.3)`` means +that the rain value is an unsigned short (two bytes), 13 bytes from +the start of the block, and should be multiplied by 0.3 to get a +useful value. + +The use of nested dictionaries in the ``fixed_format`` dictionary +allows useful subsets of data to be decoded. For example, to decode +the entire block ``get_fixed_block`` is called with no parameters:: + + print get_fixed_block() + +To get the stored minimum external temperature, ``get_fixed_block`` is +called with a sequence of keys:: + + print get_fixed_block(['min', 'temp_out', 'val']) + +Often there is no requirement to read and decode the entire fixed +block, as its first 64 bytes contain the most useful data: the +interval between stored readings, the buffer address where the current +reading is stored, and the current date & time. The +``get_lo_fix_block`` method provides easy access to these. + +From Mark Teel: + +The WH1080 protocol is undocumented. The following was observed +by sniffing the USB interface: + +A1 is a read command: +It is sent as A1XX XX20 A1XX XX20 where XXXX is the offset in the +memory map. The WH1080 responds with 4 8 byte blocks to make up a +32 byte read of address XXXX. + +A0 is a write command: +It is sent as A0XX XX20 A0XX XX20 where XXXX is the offset in the +memory map. It is followed by 4 8 byte chunks of data to be written +at the offset. The WH1080 acknowledges the write with an 8 byte +chunk: A5A5 A5A5. + +A2 is a one byte write command. +It is used as: A200 1A20 A2AA 0020 to indicate a data refresh. +The WH1080 acknowledges the write with an 8 byte chunk: A5A5 A5A5. +""" + +from __future__ import absolute_import +from __future__ import print_function +import datetime +import logging +import sys +import time +import usb + +from six.moves import zip +from six.moves import input + +import weewx.drivers +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'FineOffsetUSB' +DRIVER_VERSION = '1.20' + +def loader(config_dict, engine): + return FineOffsetUSB(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): + return FOUSBConfigurator() + +def confeditor_loader(): + return FOUSBConfEditor() + + +# flags for enabling/disabling debug verbosity +DEBUG_SYNC = 0 +DEBUG_RAIN = 0 + + +def stash(slist, s): + if s.find('settings') != -1: + slist['settings'].append(s) + elif s.find('display') != -1: + slist['display_settings'].append(s) + elif s.find('alarm') != -1: + slist['alarm_settings'].append(s) + elif s.find('min.') != -1 or s.find('max.') != -1: + slist['minmax_values'].append(s) + else: + slist['values'].append(s) + return slist + +def fmtparam(label, value): + fmt = '%s' + if label in list(datum_display_formats.keys()): + fmt = datum_display_formats[label] + fmt = '%s: ' + fmt + return fmt % (label.rjust(30), value) + +def getvalues(station, name, value): + values = {} + if type(value) is tuple: + values[name] = station.get_fixed_block(name.split('.')) + elif type(value) is dict: + for x in value.keys(): + n = x + if len(name) > 0: + n = name + '.' + x + values.update(getvalues(station, n, value[x])) + return values + +def raw_dump(date, pos, data): + print(date, end=' ') + print("%04x" % pos, end=' ') + for item in data: + print("%02x" % item, end=' ') + print() + +def table_dump(date, data, showlabels=False): + if showlabels: + print('# date time', end=' ') + for key in data.keys(): + print(key, end=' ') + print() + print(date, end=' ') + for key in data.keys(): + print(data[key], end=' ') + print() + + +class FOUSBConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[FineOffsetUSB] + # This section is for the Fine Offset series of weather stations. + + # The station model, e.g., WH1080, WS1090, WS2080, WH3081 + model = WS2080 + + # How often to poll the station for data, in seconds + polling_interval = 60 + + # The driver to use: + driver = weewx.drivers.fousb +""" + + def get_conf(self, orig_stanza=None): + if orig_stanza is None: + return self.default_stanza + import configobj + stanza = configobj.ConfigObj(orig_stanza.splitlines()) + if 'pressure_offset' in stanza[DRIVER_NAME]: + print(""" +The pressure_offset is no longer supported by the FineOffsetUSB driver. Move +the pressure calibration constant to [StdCalibrate] instead.""") + if ('polling_mode' in stanza[DRIVER_NAME] and + stanza[DRIVER_NAME]['polling_mode'] == 'ADAPTIVE'): + print(""" +Using ADAPTIVE as the polling_mode can lead to USB lockups.""") + if ('polling_interval' in stanza[DRIVER_NAME] and + int(stanza[DRIVER_NAME]['polling_interval']) < 48): + print(""" +A polling_interval of anything less than 48 seconds is not recommened.""") + return orig_stanza + + def modify_config(self, config_dict): + print(""" +Setting record_generation to software.""") + config_dict['StdArchive']['record_generation'] = 'software' + + +class FOUSBConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super(FOUSBConfigurator, self).add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display records since N minutes ago") + parser.add_option("--clear-memory", dest="clear", action="store_true", + help="clear station memory") + parser.add_option("--set-time", dest="clock", action="store_true", + help="set station clock to computer time") + parser.add_option("--set-interval", dest="interval", + type=int, metavar="N", + help="set logging interval to N minutes") + parser.add_option("--live", dest="live", action="store_true", + help="display live readings from the station") + parser.add_option("--logged", dest="logged", action="store_true", + help="display logged readings from the station") + parser.add_option("--fixed-block", dest="showfb", action="store_true", + help="display the contents of the fixed block") + parser.add_option("--check-usb", dest="chkusb", action="store_true", + help="test the quality of the USB connection") + parser.add_option("--check-fixed-block", dest="chkfb", + action="store_true", + help="monitor the contents of the fixed block") + parser.add_option("--format", dest="format", + type=str, metavar="FORMAT", + help="format for output, one of raw, table, or dict") + + def do_options(self, options, parser, config_dict, prompt): + if options.format is None: + options.format = 'table' + elif (options.format.lower() != 'raw' and + options.format.lower() != 'table' and + options.format.lower() != 'dict'): + parser.error("Unknown format '%s'. Known formats include 'raw', 'table', and 'dict'." % options.format) + + self.station = FineOffsetUSB(**config_dict[DRIVER_NAME]) + if options.current: + self.show_current() + elif options.nrecords is not None: + self.show_history(0, options.nrecords, options.format) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(ts, 0, options.format) + elif options.live: + self.show_readings(False) + elif options.logged: + self.show_readings(True) + elif options.showfb: + self.show_fixedblock() + elif options.chkfb: + self.check_fixedblock() + elif options.chkusb: + self.check_usb() + elif options.clock: + self.set_clock(prompt) + elif options.interval is not None: + self.set_interval(options.interval, prompt) + elif options.clear: + self.clear_history(prompt) + else: + self.show_info() + self.station.closePort() + + def show_info(self): + """Query the station then display the settings.""" + + print("Querying the station...") + val = getvalues(self.station, '', fixed_format) + + print('Fine Offset station settings:') + print('%s: %s' % ('local time'.rjust(30), + time.strftime('%Y.%m.%d %H:%M:%S %Z', + time.localtime()))) + print('%s: %s' % ('polling mode'.rjust(30), self.station.polling_mode)) + + slist = {'values':[], 'minmax_values':[], 'settings':[], + 'display_settings':[], 'alarm_settings':[]} + for x in sorted(val.keys()): + if type(val[x]) is dict: + for y in val[x].keys(): + label = x + '.' + y + s = fmtparam(label, val[x][y]) + slist = stash(slist, s) + else: + s = fmtparam(x, val[x]) + slist = stash(slist, s) + for k in ('values', 'minmax_values', 'settings', + 'display_settings', 'alarm_settings'): + print('') + for s in slist[k]: + print(s) + + def check_usb(self): + """Run diagnostics on the USB connection.""" + print("This will read from the station console repeatedly to see if") + print("there are errors in the USB communications. Leave this running") + print("for an hour or two to see if any bad reads are encountered.") + print("Bad reads will be reported in the system log. A few bad reads") + print("per hour is usually acceptable.") + ptr = data_start + total_count = 0 + bad_count = 0 + while True: + if total_count % 1000 == 0: + active = self.station.current_pos() + while True: + ptr += 0x20 + if ptr >= 0x10000: + ptr = data_start + if active < ptr - 0x10 or active >= ptr + 0x20: + break + result_1 = self.station._read_block(ptr, retry=False) + result_2 = self.station._read_block(ptr, retry=False) + if result_1 != result_2: + log.info('read_block change %06x' % ptr) + log.info(' %s' % str(result_1)) + log.info(' %s' % str(result_2)) + bad_count += 1 + total_count += 1 + print("\rbad/total: %d/%d " % (bad_count, total_count), end=' ') + sys.stdout.flush() + + def check_fixedblock(self): + """Display changes to fixed block as they occur.""" + print('This will read the fixed block then display changes as they') + print('occur. Typically the most common change is the incrementing') + print('of the data pointer, which happens whenever readings are saved') + print('to the station memory. For example, if the logging interval') + print('is set to 5 minutes, the fixed block should change at least') + print('every 5 minutes.') + raw_fixed = self.station.get_raw_fixed_block() + while True: + new_fixed = self.station.get_raw_fixed_block(unbuffered=True) + for ptr in range(len(new_fixed)): + if new_fixed[ptr] != raw_fixed[ptr]: + print(datetime.datetime.now().strftime('%H:%M:%S'), end=' ') + print(' %04x (%d) %02x -> %02x' % ( + ptr, ptr, raw_fixed[ptr], new_fixed[ptr])) + raw_fixed = new_fixed + time.sleep(0.5) + + def show_fixedblock(self): + """Display the raw fixed block contents.""" + fb = self.station.get_raw_fixed_block(unbuffered=True) + for i, ptr in enumerate(range(len(fb))): + print('%02x' % fb[ptr], end=' ') + if (i+1) % 16 == 0: + print() + + def show_readings(self, logged_only): + """Display live readings from the station.""" + for data,ptr,_ in self.station.live_data(logged_only): + print('%04x' % ptr, end=' ') + print(data['idx'].strftime('%H:%M:%S'), end=' ') + del data['idx'] + print(data) + + def show_current(self): + """Display latest readings from the station.""" + for packet in self.station.genLoopPackets(): + print(packet) + break + + def show_history(self, ts=0, count=0, fmt='raw'): + """Display the indicated number of records or the records since the + specified timestamp (local time, in seconds)""" + records = self.station.get_records(since_ts=ts, num_rec=count) + for i,r in enumerate(records): + if fmt.lower() == 'raw': + raw_dump(r['datetime'], r['ptr'], r['raw_data']) + elif fmt.lower() == 'table': + table_dump(r['datetime'], r['data'], i==0) + else: + print(r['datetime'], r['data']) + + def clear_history(self, prompt): + ans = None + while ans not in ['y', 'n']: + v = self.station.get_fixed_block(['data_count'], True) + print("Records in memory:", v) + if prompt: + ans = input("Clear console memory (y/n)? ") + else: + print('Clearing console memory') + ans = 'y' + if ans == 'y' : + self.station.clear_history() + v = self.station.get_fixed_block(['data_count'], True) + print("Records in memory:", v) + elif ans == 'n': + print("Clear memory cancelled.") + + def set_interval(self, interval, prompt): + v = self.station.get_fixed_block(['read_period'], True) + ans = None + while ans not in ['y', 'n']: + print("Interval is", v) + if prompt: + ans = input("Set interval to %d minutes (y/n)? " % interval) + else: + print("Setting interval to %d minutes" % interval) + ans = 'y' + if ans == 'y' : + self.station.set_read_period(interval) + v = self.station.get_fixed_block(['read_period'], True) + print("Interval is now", v) + elif ans == 'n': + print("Set interval cancelled.") + + def set_clock(self, prompt): + ans = None + while ans not in ['y', 'n']: + v = self.station.get_fixed_block(['date_time'], True) + print("Station clock is", v) + now = datetime.datetime.now() + if prompt: + ans = input("Set station clock to %s (y/n)? " % now) + else: + print("Setting station clock to %s" % now) + ans = 'y' + if ans == 'y' : + self.station.set_clock() + v = self.station.get_fixed_block(['date_time'], True) + print("Station clock is now", v) + elif ans == 'n': + print("Set clock cancelled.") + + +# these are the raw data we get from the station: +# param values invalid description +# +# delay [1,240] the number of minutes since last stored reading +# hum_in [1,99] 0xff indoor relative humidity; % +# temp_in [-40,60] 0xffff indoor temp; multiply by 0.1 to get C +# hum_out [1,99] 0xff outdoor relative humidity; % +# temp_out [-40,60] 0xffff outdoor temp; multiply by 0.1 to get C +# abs_pres [920,1080] 0xffff pressure; multiply by 0.1 to get hPa (mbar) +# wind_ave [0,50] 0xff average wind speed; multiply by 0.1 to get m/s +# wind_gust [0,50] 0xff average wind speed; multiply by 0.1 to get m/s +# wind_dir [0,15] bit 7 wind direction; multiply by 22.5 to get degrees +# rain rain; multiply by 0.33 to get mm +# status +# illuminance +# uv + +# map between the pywws keys and the weewx keys +# 'weewx-key' : ( 'pywws-key', multiplier ) +# rain is total measure so must split into per-period and calculate rate +keymap = { + 'inHumidity' : ('hum_in', 1.0), + 'inTemp' : ('temp_in', 1.0), # station is C + 'outHumidity' : ('hum_out', 1.0), + 'outTemp' : ('temp_out', 1.0), # station is C + 'pressure' : ('abs_pressure', 1.0), # station is mbar + 'windSpeed' : ('wind_ave', 3.6), # station is m/s, weewx wants km/h + 'windGust' : ('wind_gust', 3.6), # station is m/s, weewx wants km/h + 'windDir' : ('wind_dir', 22.5), # station is 0-15, weewx wants deg + 'rain' : ('rain', 0.1), # station is mm, weewx wants cm + 'radiation' : ('illuminance', 0.01075), # lux, weewx wants W/m^2 + 'UV' : ('uv', 1.0), + 'status' : ('status', 1.0), +} + +# formats for displaying fixed_format fields +datum_display_formats = { + 'magic_1' : '0x%2x', + 'magic_2' : '0x%2x', + } + +# wrap value for rain counter +rain_max = 0x10000 + +# values for status: +rain_overflow = 0x80 +lost_connection = 0x40 +# unknown = 0x20 +# unknown = 0x10 +# unknown = 0x08 +# unknown = 0x04 +# unknown = 0x02 +# unknown = 0x01 + +def decode_status(status): + result = {} + if status is None: + return result + for key, mask in (('rain_overflow', 0x80), + ('lost_connection', 0x40), + ('unknown', 0x3f), + ): + result[key] = status & mask + return result + +def get_status(code, status): + return 1 if status & code == code else 0 + +def pywws2weewx(p, ts, last_rain, last_rain_ts, max_rain_rate): + """Map the pywws dictionary to something weewx understands. + + p: dictionary of pywws readings + + ts: timestamp in UTC + + last_rain: last rain total in cm + + last_rain_ts: timestamp of last rain total + + max_rain_rate: maximum value for rain rate in cm/hr. rainfall readings + resulting in a rain rate greater than this value will be ignored. + """ + + packet = {} + # required elements + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = ts + + # everything else... + for k in keymap.keys(): + if keymap[k][0] in p and p[keymap[k][0]] is not None: + packet[k] = p[keymap[k][0]] * keymap[k][1] + else: + packet[k] = None + + # track the pointer used to obtain the data + packet['ptr'] = int(p['ptr']) if 'ptr' in p else None + packet['delay'] = int(p['delay']) if 'delay' in p else None + + # station status is an integer + if packet['status'] is not None: + packet['status'] = int(packet['status']) + packet['rxCheckPercent'] = 0 if get_status(lost_connection, packet['status']) else 100 + packet['outTempBatteryStatus'] = get_status(rain_overflow, packet['status']) + + # calculate the rain increment from the rain total + # watch for spurious rain counter decrement. if decrement is significant + # then it is a counter wraparound. a small decrement is either a sensor + # glitch or a read from a previous record. if the small decrement persists + # across multiple samples, it was probably a firmware glitch rather than + # a sensor glitch or old read. a spurious increment will be filtered by + # the bogus rain rate check. + total = packet['rain'] + packet['rainTotal'] = packet['rain'] + if packet['rain'] is not None and last_rain is not None: + if packet['rain'] < last_rain: + pstr = '0x%04x' % packet['ptr'] if packet['ptr'] is not None else 'None' + if last_rain - packet['rain'] < rain_max * 0.3 * 0.5: + log.info('ignoring spurious rain counter decrement (%s): ' + 'new: %s old: %s' % (pstr, packet['rain'], last_rain)) + else: + log.info('rain counter wraparound detected (%s): ' + 'new: %s old: %s' % (pstr, packet['rain'], last_rain)) + total += rain_max * 0.3 + packet['rain'] = weewx.wxformulas.calculate_rain(total, last_rain) + + # report rainfall in log to diagnose rain counter issues + if DEBUG_RAIN and packet['rain'] is not None and packet['rain'] > 0: + log.debug('got rainfall of %.2f cm (new: %.2f old: %.2f)' % + (packet['rain'], packet['rainTotal'], last_rain)) + + return packet + +USB_RT_PORT = (usb.TYPE_CLASS | usb.RECIP_OTHER) +USB_PORT_FEAT_POWER = 8 + +def power_cycle_station(hub, port): + '''Power cycle the port on the specified hub. This works only with USB + hubs that support per-port power switching such as the linksys USB2HUB4.''' + log.info("Attempting to power cycle") + busses = usb.busses() + if not busses: + raise weewx.WeeWxIOError("Power cycle failed: cannot find USB busses") + device = None + for bus in busses: + for dev in bus.devices: + if dev.deviceClass == usb.CLASS_HUB: + devid = "%s:%03d" % (bus.dirname, dev.devnum) + if devid == hub: + device = dev + if device is None: + raise weewx.WeeWxIOError("Power cycle failed: cannot find hub %s" % hub) + handle = device.open() + try: + log.info("Power off port %d on hub %s" % (port, hub)) + handle.controlMsg(requestType=USB_RT_PORT, + request=usb.REQ_CLEAR_FEATURE, + value=USB_PORT_FEAT_POWER, + index=port, buffer=None, timeout=1000) + log.info("Waiting 30 seconds for station to power down") + time.sleep(30) + log.info("Power on port %d on hub %s" % (port, hub)) + handle.controlMsg(requestType=USB_RT_PORT, + request=usb.REQ_SET_FEATURE, + value=USB_PORT_FEAT_POWER, + index=port, buffer=None, timeout=1000) + log.info("Waiting 60 seconds for station to power up") + time.sleep(60) + finally: + del handle + log.info("Power cycle complete") + +# decode weather station raw data formats +def _signed_byte(raw, offset): + res = raw[offset] + if res == 0xFF: + return None + sign = 1 + if res >= 128: + sign = -1 + res = res - 128 + return sign * res +def _signed_short(raw, offset): + lo = raw[offset] + hi = raw[offset+1] + if lo == 0xFF and hi == 0xFF: + return None + sign = 1 + if hi >= 128: + sign = -1 + hi = hi - 128 + return sign * ((hi * 256) + lo) +def _unsigned_short(raw, offset): + lo = raw[offset] + hi = raw[offset+1] + if lo == 0xFF and hi == 0xFF: + return None + return (hi * 256) + lo +def _unsigned_int3(raw, offset): + lo = raw[offset] + md = raw[offset+1] + hi = raw[offset+2] + if lo == 0xFF and md == 0xFF and hi == 0xFF: + return None + return (hi * 256 * 256) + (md * 256) + lo +def _bcd_decode(byte): + hi = (byte // 16) & 0x0F + lo = byte & 0x0F + return (hi * 10) + lo +def _date_time(raw, offset): + year = _bcd_decode(raw[offset]) + month = _bcd_decode(raw[offset+1]) + day = _bcd_decode(raw[offset+2]) + hour = _bcd_decode(raw[offset+3]) + minute = _bcd_decode(raw[offset+4]) + return '%4d-%02d-%02d %02d:%02d' % (year + 2000, month, day, hour, minute) +def _bit_field(raw, offset): + mask = 1 + result = [] + for i in range(8): # @UnusedVariable + result.append(raw[offset] & mask != 0) + mask = mask << 1 + return result +def _decode(raw, fmt): + if not raw: + return None + if isinstance(fmt, dict): + result = {} + for key, value in fmt.items(): + result[key] = _decode(raw, value) + else: + pos, typ, scale = fmt + if typ == 'ub': + result = raw[pos] + if result == 0xFF: + result = None + elif typ == 'sb': + result = _signed_byte(raw, pos) + elif typ == 'us': + result = _unsigned_short(raw, pos) + elif typ == 'u3': + result = _unsigned_int3(raw, pos) + elif typ == 'ss': + result = _signed_short(raw, pos) + elif typ == 'dt': + result = _date_time(raw, pos) + elif typ == 'tt': + result = '%02d:%02d' % (_bcd_decode(raw[pos]), + _bcd_decode(raw[pos+1])) + elif typ == 'pb': + result = raw[pos] + elif typ == 'wa': + # wind average - 12 bits split across a byte and a nibble + result = raw[pos] + ((raw[pos+2] & 0x0F) << 8) + if result == 0xFFF: + result = None + elif typ == 'wg': + # wind gust - 12 bits split across a byte and a nibble + result = raw[pos] + ((raw[pos+1] & 0xF0) << 4) + if result == 0xFFF: + result = None + elif typ == 'wd': + # wind direction - check bit 7 for invalid + result = raw[pos] + if result & 0x80: + result = None + elif typ == 'bf': + # bit field - 'scale' is a list of bit names + result = {} + for k, v in zip(scale, _bit_field(raw, pos)): + result[k] = v + return result + else: + raise weewx.WeeWxIOError('decode failure: unknown type %s' % typ) + if scale and result: + result = float(result) * scale + return result +def _bcd_encode(value): + hi = value // 10 + lo = value % 10 + return (hi * 16) + lo + + +class ObservationError(Exception): + pass + +# mechanisms for polling the station +PERIODIC_POLLING = 'PERIODIC' +ADAPTIVE_POLLING = 'ADAPTIVE' + +class FineOffsetUSB(weewx.drivers.AbstractDevice): + """Driver for FineOffset USB stations.""" + + def __init__(self, **stn_dict) : + """Initialize the station object. + + model: Which station model is this? + [Optional. Default is 'WH1080 (USB)'] + + polling_mode: The mechanism to use when polling the station. PERIODIC + polling queries the station console at regular intervals. ADAPTIVE + polling adjusts the query interval in an attempt to avoid times when + the console is writing to memory or communicating with the sensors. + The polling mode applies only when the weewx StdArchive is set to + 'software', otherwise weewx reads archived records from the console. + [Optional. Default is 'PERIODIC'] + + polling_interval: How often to sample the USB interface for data. + [Optional. Default is 60 seconds] + + max_rain_rate: Maximum sane value for rain rate for a single polling + interval or archive interval, measured in cm/hr. If the rain sample + for a single period is greater than this rate, the sample will be + logged but not added to the loop or archive data. + [Optional. Default is 24] + + timeout: How long to wait, in seconds, before giving up on a response + from the USB port. + [Optional. Default is 15 seconds] + + wait_before_retry: How long to wait after a failure before retrying. + [Optional. Default is 30 seconds] + + max_tries: How many times to try before giving up. + [Optional. Default is 3] + + device_id: The USB device ID for the station. Specify this if there + are multiple devices of the same type on the bus. + [Optional. No default] + """ + + self.model = stn_dict.get('model', 'WH1080 (USB)') + self.polling_mode = stn_dict.get('polling_mode', PERIODIC_POLLING) + self.polling_interval = int(stn_dict.get('polling_interval', 60)) + self.max_rain_rate = int(stn_dict.get('max_rain_rate', 24)) + self.timeout = float(stn_dict.get('timeout', 15.0)) + self.wait_before_retry = float(stn_dict.get('wait_before_retry', 30.0)) + self.max_tries = int(stn_dict.get('max_tries', 3)) + self.device_id = stn_dict.get('device_id', None) + + # FIXME: prefer 'power_cycle_on_fail = (True|False)' + self.pc_hub = stn_dict.get('power_cycle_hub', None) + self.pc_port = stn_dict.get('power_cycle_port', None) + if self.pc_port is not None: + self.pc_port = int(self.pc_port) + + self.data_format = stn_dict.get('data_format', '1080') + self.vendor_id = 0x1941 + self.product_id = 0x8021 + self.usb_interface = 0 + self.usb_endpoint = 0x81 + self.usb_read_size = 0x20 + + # avoid USB activity this many seconds each side of the time when + # console is believed to be writing to memory. + self.avoid = 3.0 + # minimum interval between polling for data change + self.min_pause = 0.5 + + self.devh = None + self._arcint = None + self._last_rain_loop = None + self._last_rain_ts_loop = None + self._last_rain_arc = None + self._last_rain_ts_arc = None + self._last_status = None + self._fixed_block = None + self._data_block = None + self._data_pos = None + self._current_ptr = None + self._station_clock = None + self._sensor_clock = None + # start with known magic numbers. report any additional we encounter. + # these are from wview: 55??, ff??, 01??, 001e, 0001 + # these are from pywws: 55aa, ffff, 5555, c400 + self._magic_numbers = ['55aa'] + self._last_magic = None + + # FIXME: get last_rain_arc and last_rain_ts_arc from database + + global DEBUG_SYNC + DEBUG_SYNC = int(stn_dict.get('debug_sync', 0)) + global DEBUG_RAIN + DEBUG_RAIN = int(stn_dict.get('debug_rain', 0)) + + log.info('driver version is %s' % DRIVER_VERSION) + if self.pc_hub is not None: + log.info('power cycling enabled for port %s on hub %s' % + (self.pc_port, self.pc_hub)) + log.info('polling mode is %s' % self.polling_mode) + if self.polling_mode.lower() == PERIODIC_POLLING.lower(): + log.info('polling interval is %s' % self.polling_interval) + + self.openPort() + + # Unfortunately there is no provision to obtain the model from the station + # itself, so use what is specified from the configuration file. + @property + def hardware_name(self): + return self.model + + # weewx wants the archive interval in seconds, but the database record + # follows the wview convention of minutes and the console uses minutes. + @property + def archive_interval(self): + return self._archive_interval_minutes() * 60 + + # if power cycling is enabled, loop forever until we get a response from + # the weather station. + def _archive_interval_minutes(self): + if self._arcint is not None: + return self._arcint + if self.pc_hub is not None: + while True: + try: + self.openPort() + self._arcint = self._get_arcint() + break + except weewx.WeeWxIOError: + self.closePort() + power_cycle_station(self.pc_hub, self.pc_port) + else: + self._arcint = self._get_arcint() + return self._arcint + + def _get_arcint(self): + ival = None + for i in range(self.max_tries): + try: + ival = self.get_fixed_block(['read_period']) + break + except usb.USBError as e: + log.critical("Get archive interval failed attempt %d of %d: %s" + % (i+1, self.max_tries, e)) + else: + raise weewx.WeeWxIOError("Unable to read archive interval after %d tries" % self.max_tries) + if ival is None: + raise weewx.WeeWxIOError("Cannot determine archive interval") + return ival + + def openPort(self): + if self.devh is not None: + return + + dev = self._find_device() + if not dev: + log.critical("Cannot find USB device with Vendor=0x%04x ProdID=0x%04x Device=%s" + % (self.vendor_id, self.product_id, self.device_id)) + raise weewx.WeeWxIOError("Unable to find USB device") + + self.devh = dev.open() + if not self.devh: + raise weewx.WeeWxIOError("Open USB device failed") + + # be sure kernel does not claim the interface + try: + self.devh.detachKernelDriver(self.usb_interface) + except: + pass + + # attempt to claim the interface + try: + self.devh.claimInterface(self.usb_interface) + except usb.USBError as e: + self.closePort() + log.critical("Unable to claim USB interface %s: %s" % (self.usb_interface, e)) + raise weewx.WeeWxIOError(e) + + def closePort(self): + try: + self.devh.releaseInterface() + except: + pass + self.devh = None + + def _find_device(self): + """Find the vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == self.vendor_id and dev.idProduct == self.product_id: + if self.device_id is None or dev.filename == self.device_id: + log.info('found station on USB bus=%s device=%s' % (bus.dirname, dev.filename)) + return dev + return None + +# There is no point in using the station clock since it cannot be trusted and +# since we cannot synchronize it with the computer clock. + +# def getTime(self): +# return self.get_clock() + +# def setTime(self): +# self.set_clock() + + def genLoopPackets(self): + """Generator function that continuously returns decoded packets.""" + + for p in self.get_observations(): + ts = int(time.time() + 0.5) + packet = pywws2weewx(p, ts, + self._last_rain_loop, self._last_rain_ts_loop, + self.max_rain_rate) + self._last_rain_loop = packet['rainTotal'] + self._last_rain_ts_loop = ts + if packet['status'] != self._last_status: + log.info('station status %s (%s)' % + (decode_status(packet['status']), packet['status'])) + self._last_status = packet['status'] + yield packet + + def genArchiveRecords(self, since_ts): + """Generator function that returns records from the console. + + since_ts: local timestamp in seconds. All data since (but not + including) this time will be returned. A value of None + results in all data. + + yields: a sequence of dictionaries containing the data, each with + local timestamp in seconds. + """ + records = self.get_records(since_ts) + log.debug('found %d archive records' % len(records)) + epoch = datetime.datetime.utcfromtimestamp(0) + for r in records: + delta = r['datetime'] - epoch + # FIXME: deal with daylight saving corner case + ts = delta.days * 86400 + delta.seconds + data = pywws2weewx(r['data'], ts, + self._last_rain_arc, self._last_rain_ts_arc, + self.max_rain_rate) + data['interval'] = r['interval'] + data['ptr'] = r['ptr'] + self._last_rain_arc = data['rainTotal'] + self._last_rain_ts_arc = ts + log.debug('returning archive record %s' % ts) + yield data + + def get_observations(self): + """Get data from the station. + + There are a few types of non-fatal failures we might encounter while + reading. When we encounter one, log the failure then retry. + + Sometimes current_pos returns None for the pointer. This is useless to + us, so keep querying until we get a valid pointer. + + In live_data, sometimes the delay is None. This prevents calculation + of the timing intervals, so bail out and retry. + + If we get USB read failures, retry until we get something valid. + """ + nerr = 0 + old_ptr = None + interval = self._archive_interval_minutes() + while True: + try: + if self.polling_mode.lower() == ADAPTIVE_POLLING.lower(): + for data,ptr,logged in self.live_data(): # @UnusedVariable + nerr = 0 + data['ptr'] = ptr + yield data + elif self.polling_mode.lower() == PERIODIC_POLLING.lower(): + new_ptr = self.current_pos() + if new_ptr < data_start: + raise ObservationError('bad pointer: 0x%04x' % new_ptr) + block = self.get_raw_data(new_ptr, unbuffered=True) + if len(block) != reading_len[self.data_format]: + raise ObservationError('wrong block length: expected: %d actual: %d' % (reading_len[self.data_format], len(block))) + data = _decode(block, reading_format[self.data_format]) + delay = data.get('delay', None) + if delay is None: + raise ObservationError('no delay found in observation') + if new_ptr != old_ptr and delay >= interval: + raise ObservationError('ignoring suspected bogus data from 0x%04x (delay=%s interval=%s)' % (new_ptr, delay, interval)) + old_ptr = new_ptr + data['ptr'] = new_ptr + nerr = 0 + yield data + time.sleep(self.polling_interval) + else: + raise Exception("unknown polling mode '%s'" % self.polling_mode) + + except (IndexError, usb.USBError, ObservationError) as e: + log.error('get_observations failed: %s' % e) + nerr += 1 + if nerr > self.max_tries: + raise weewx.WeeWxIOError("Max retries exceeded while fetching observations") + time.sleep(self.wait_before_retry) + +#============================================================================== +# methods for reading from and writing to usb +# +# end mark: 0x20 +# read command: 0xA1 +# write command: 0xA0 +# write command word: 0xA2 +# +# FIXME: to support multiple usb drivers, these should be abstracted to a class +# FIXME: refactor the _read_usb methods to pass read_size down the chain +#============================================================================== + + def _read_usb_block(self, address): + addr1 = (address >> 8) & 0xff + addr2 = address & 0xff + self.devh.controlMsg(usb.TYPE_CLASS + usb.RECIP_INTERFACE, + 0x0000009, + [0xA1,addr1,addr2,0x20,0xA1,addr1,addr2,0x20], + 0x0000200, + 0x0000000, + 1000) + data = self.devh.interruptRead(self.usb_endpoint, + self.usb_read_size, # bytes to read + int(self.timeout*1000)) + return list(data) + + def _read_usb_bytes(self, size): + data = self.devh.interruptRead(self.usb_endpoint, + size, + int(self.timeout*1000)) + if data is None or len(data) < size: + raise weewx.WeeWxIOError('Read from USB failed') + return list(data) + + def _write_usb(self, address, data): + addr1 = (address >> 8) & 0xff + addr2 = address & 0xff + buf = [0xA2,addr1,addr2,0x20,0xA2,data,0,0x20] + result = self.devh.controlMsg( + usb.ENDPOINT_OUT + usb.TYPE_CLASS + usb.RECIP_INTERFACE, + usb.REQ_SET_CONFIGURATION, # 0x09 + buf, + value = 0x200, + index = 0, + timeout = int(self.timeout*1000)) + if result != len(buf): + return False + buf = self._read_usb_bytes(8) + if buf is None: + return False + for byte in buf: + if byte != 0xA5: + return False + return True + +#============================================================================== +# methods for configuring the weather station +# the following were adapted from various pywws utilities +#============================================================================== + + def decode(self, raw_data): + return _decode(raw_data, reading_format[self.data_format]) + + def clear_history(self): + ptr = fixed_format['data_count'][0] + data = [] + data.append((ptr, 1)) + data.append((ptr+1, 0)) + self.write_data(data) + + def set_pressure(self, pressure): + pressure = int(float(pressure) * 10.0 + 0.5) + ptr = fixed_format['rel_pressure'][0] + data = [] + data.append((ptr, pressure % 256)) + data.append((ptr+1, pressure // 256)) + self.write_data(data) + + def set_read_period(self, read_period): + read_period = int(read_period) + data = [] + data.append((fixed_format['read_period'][0], read_period)) + self.write_data(data) + + def set_clock(self, ts=0): + if ts == 0: + now = datetime.datetime.now() + if now.second >= 55: + time.sleep(10) + now = datetime.datetime.now() + now += datetime.timedelta(minutes=1) + else: + now = datetime.datetime.fromtimestamp(ts) + ptr = fixed_format['date_time'][0] + data = [] + data.append((ptr, _bcd_encode(now.year - 2000))) + data.append((ptr+1, _bcd_encode(now.month))) + data.append((ptr+2, _bcd_encode(now.day))) + data.append((ptr+3, _bcd_encode(now.hour))) + data.append((ptr+4, _bcd_encode(now.minute))) + time.sleep(59 - now.second) + self.write_data(data) + + def get_clock(self): + tstr = self.get_fixed_block(['date_time'], True) + tt = time.strptime(tstr, '%Y-%m-%d %H:%M') + ts = time.mktime(tt) + return int(ts) + + def get_records(self, since_ts=0, num_rec=0): + """Get data from station memory. + + The weather station contains a circular buffer of data, but there is + no absolute date or time for each record, only relative offsets. So + the best we can do is to use the 'delay' and 'read_period' to guess + when each record was made. + + Use the computer clock since we cannot trust the station clock. + + Return an array of dict, with each dict containing a datetimestamp + in UTC, the pointer, the decoded data, and the raw data. Items in the + array go from oldest to newest. + """ + nerr = 0 + while True: + try: + fixed_block = self.get_fixed_block(unbuffered=True) + if fixed_block['read_period'] is None: + raise weewx.WeeWxIOError('invalid read_period in get_records') + if fixed_block['data_count'] is None: + raise weewx.WeeWxIOError('invalid data_count in get_records') + if since_ts: + dt = datetime.datetime.utcfromtimestamp(since_ts) + dt += datetime.timedelta(seconds=fixed_block['read_period']*30) + else: + dt = datetime.datetime.min + max_count = fixed_block['data_count'] - 1 + if num_rec == 0 or num_rec > max_count: + num_rec = max_count + log.debug('get %d records since %s' % (num_rec, dt)) + dts, ptr = self.sync(read_period=fixed_block['read_period']) + count = 0 + records = [] + while dts > dt and count < num_rec: + raw_data = self.get_raw_data(ptr) + data = self.decode(raw_data) + if data['delay'] is None or data['delay'] < 1 or data['delay'] > 30: + log.error('invalid data in get_records at 0x%04x, %s' % + (ptr, dts.isoformat())) + dts -= datetime.timedelta(minutes=fixed_block['read_period']) + else: + record = dict() + record['ptr'] = ptr + record['datetime'] = dts + record['data'] = data + record['raw_data'] = raw_data + record['interval'] = data['delay'] + records.insert(0, record) + count += 1 + dts -= datetime.timedelta(minutes=data['delay']) + ptr = self.dec_ptr(ptr) + return records + except (IndexError, usb.USBError, ObservationError) as e: + log.error('get_records failed: %s' % e) + nerr += 1 + if nerr > self.max_tries: + raise weewx.WeeWxIOError("Max retries exceeded while fetching records") + time.sleep(self.wait_before_retry) + + def sync(self, quality=None, read_period=None): + """Synchronise with the station to determine the date and time of the + latest record. Return the datetime stamp in UTC and the record + pointer. The quality determines the accuracy of the synchronisation. + + 0 - low quality, synchronisation to within 12 seconds + 1 - high quality, synchronisation to within 2 seconds + + The high quality synchronisation could take as long as a logging + interval to complete. + """ + if quality is None: + if read_period is not None and read_period <= 5: + quality = 1 + else: + quality = 0 + log.info('synchronising to the weather station (quality=%d)' % quality) + range_hi = datetime.datetime.max + range_lo = datetime.datetime.min + ptr = self.current_pos() + data = self.get_data(ptr, unbuffered=True) + last_delay = data['delay'] + if last_delay is None or last_delay == 0: + prev_date = datetime.datetime.min + else: + prev_date = datetime.datetime.utcnow() + maxcount = 10 + count = 0 + for data, last_ptr, logged in self.live_data(logged_only=(quality>1)): + last_date = data['idx'] + log.debug('packet timestamp is %s' % last_date.strftime('%H:%M:%S')) + if logged: + break + if data['delay'] is None: + log.error('invalid data while synchronising at 0x%04x' % last_ptr) + count += 1 + if count > maxcount: + raise weewx.WeeWxIOError('repeated invalid delay while synchronising') + continue + if quality < 2 and self._station_clock: + err = last_date - datetime.datetime.fromtimestamp(self._station_clock) + last_date -= datetime.timedelta(minutes=data['delay'], + seconds=err.seconds % 60) + log.debug('log timestamp is %s' % last_date.strftime('%H:%M:%S')) + last_ptr = self.dec_ptr(last_ptr) + break + if quality < 1: + hi = last_date - datetime.timedelta(minutes=data['delay']) + if last_date - prev_date > datetime.timedelta(seconds=50): + lo = hi - datetime.timedelta(seconds=60) + elif data['delay'] == last_delay: + lo = hi - datetime.timedelta(seconds=60) + hi = hi - datetime.timedelta(seconds=48) + else: + lo = hi - datetime.timedelta(seconds=48) + last_delay = data['delay'] + prev_date = last_date + range_hi = min(range_hi, hi) + range_lo = max(range_lo, lo) + err = (range_hi - range_lo) / 2 + last_date = range_lo + err + log.debug('estimated log time %s +/- %ds (%s..%s)' % + (last_date.strftime('%H:%M:%S'), err.seconds, + lo.strftime('%H:%M:%S'), hi.strftime('%H:%M:%S'))) + if err < datetime.timedelta(seconds=15): + last_ptr = self.dec_ptr(last_ptr) + break + log.debug('synchronised to %s for ptr 0x%04x' % (last_date, last_ptr)) + return last_date, last_ptr + +#============================================================================== +# methods for reading data from the weather station +# the following were adapted from WeatherStation.py in pywws +# +# commit 7d2e8ec700a652426c0114e7baebcf3460b1ef0f +# Author: Jim Easterbrook +# Date: Thu Oct 31 13:04:29 2013 +0000 +#============================================================================== + + def live_data(self, logged_only=False): + # There are two things we want to synchronise to - the data is + # updated every 48 seconds and the address is incremented + # every 5 minutes (or 10, 15, ..., 30). Rather than getting + # data every second or two, we sleep until one of the above is + # due. (During initialisation we get data every two seconds + # anyway.) + read_period = self.get_fixed_block(['read_period']) + if read_period is None: + raise ObservationError('invalid read_period in live_data') + log_interval = float(read_period * 60) + live_interval = 48.0 + old_ptr = self.current_pos() + old_data = self.get_data(old_ptr, unbuffered=True) + if old_data['delay'] is None: + raise ObservationError('invalid delay at 0x%04x' % old_ptr) + now = time.time() + if self._sensor_clock: + next_live = now + next_live -= (next_live - self._sensor_clock) % live_interval + next_live += live_interval + else: + next_live = None + if self._station_clock and next_live: + # set next_log + next_log = next_live - live_interval + next_log -= (next_log - self._station_clock) % 60 + next_log -= old_data['delay'] * 60 + next_log += log_interval + else: + next_log = None + self._station_clock = None + ptr_time = 0 + data_time = 0 + last_log = now - (old_data['delay'] * 60) + last_status = None + while True: + if not self._station_clock: + next_log = None + if not self._sensor_clock: + next_live = None + now = time.time() + # wake up just before next reading is due + advance = now + max(self.avoid, self.min_pause) + self.min_pause + pause = 600.0 + if next_live: + if not logged_only: + pause = min(pause, next_live - advance) + else: + pause = self.min_pause + if next_log: + pause = min(pause, next_log - advance) + elif old_data['delay'] < read_period - 1: + pause = min( + pause, ((read_period - old_data['delay']) * 60.0) - 110.0) + else: + pause = self.min_pause + pause = max(pause, self.min_pause) + if DEBUG_SYNC: + log.debug('delay %s, pause %g' % (str(old_data['delay']), pause)) + time.sleep(pause) + # get new data + last_data_time = data_time + new_data = self.get_data(old_ptr, unbuffered=True) + if new_data['delay'] is None: + raise ObservationError('invalid delay at 0x%04x' % old_ptr) + data_time = time.time() + # log any change of status + if new_data['status'] != last_status: + log.debug('status %s (%s)' % (str(decode_status(new_data['status'])), new_data['status'])) + last_status = new_data['status'] + # 'good' time stamp if we haven't just woken up from long + # pause and data read wasn't delayed + valid_time = data_time - last_data_time < (self.min_pause * 2.0) - 0.1 + # make sure changes because of logging interval aren't + # mistaken for new live data + if new_data['delay'] >= read_period: + for key in ('delay', 'hum_in', 'temp_in', 'abs_pressure'): + old_data[key] = new_data[key] + # ignore solar data which changes every 60 seconds + if self.data_format == '3080': + for key in ('illuminance', 'uv'): + old_data[key] = new_data[key] + if new_data != old_data: + log.debug('new data') + result = dict(new_data) + if valid_time: + # data has just changed, so definitely at a 48s update time + if self._sensor_clock: + diff = (data_time - self._sensor_clock) % live_interval + if diff > 2.0 and diff < (live_interval - 2.0): + log.debug('unexpected sensor clock change') + self._sensor_clock = None + if not self._sensor_clock: + self._sensor_clock = data_time + log.debug('setting sensor clock %g' % + (data_time % live_interval)) + if not next_live: + log.debug('live synchronised') + next_live = data_time + elif next_live and data_time < next_live - self.min_pause: + log.debug('lost sync %g' % (data_time - next_live)) + next_live = None + self._sensor_clock = None + if next_live and not logged_only: + while data_time > next_live + live_interval: + log.debug('missed interval') + next_live += live_interval + result['idx'] = datetime.datetime.utcfromtimestamp(int(next_live)) + next_live += live_interval + yield result, old_ptr, False + old_data = new_data + # get new pointer + if old_data['delay'] < read_period - 1: + continue + last_ptr_time = ptr_time + new_ptr = self.current_pos() + ptr_time = time.time() + valid_time = ptr_time - last_ptr_time < (self.min_pause * 2.0) - 0.1 + if new_ptr != old_ptr: + log.debug('new ptr: %06x (%06x)' % (new_ptr, old_ptr)) + last_log = ptr_time + # re-read data, to be absolutely sure it's the last + # logged data before the pointer was updated + new_data = self.get_data(old_ptr, unbuffered=True) + if new_data['delay'] is None: + raise ObservationError('invalid delay at 0x%04x' % old_ptr) + result = dict(new_data) + if valid_time: + # pointer has just changed, so definitely at a logging time + if self._station_clock: + diff = (ptr_time - self._station_clock) % 60 + if diff > 2 and diff < 58: + log.debug('unexpected station clock change') + self._station_clock = None + if not self._station_clock: + self._station_clock = ptr_time + log.debug('setting station clock %g' % (ptr_time % 60.0)) + if not next_log: + log.debug('log synchronised') + next_log = ptr_time + elif next_log and ptr_time < next_log - self.min_pause: + log.debug('lost log sync %g' % (ptr_time - next_log)) + next_log = None + self._station_clock = None + if next_log: + result['idx'] = datetime.datetime.utcfromtimestamp(int(next_log)) + next_log += log_interval + yield result, old_ptr, True + if new_ptr != self.inc_ptr(old_ptr): + log.error('unexpected ptr change %06x -> %06x' % (old_ptr, new_ptr)) + old_ptr = new_ptr + old_data['delay'] = 0 + elif ptr_time > last_log + ((new_data['delay'] + 2) * 60): + # if station stops logging data, don't keep reading + # USB until it locks up + raise ObservationError('station is not logging data') + elif valid_time and next_log and ptr_time > next_log + 6.0: + log.debug('log extended') + next_log += 60.0 + + def inc_ptr(self, ptr): + """Get next circular buffer data pointer.""" + result = ptr + reading_len[self.data_format] + if result >= 0x10000: + result = data_start + return result + + def dec_ptr(self, ptr): + """Get previous circular buffer data pointer.""" + result = ptr - reading_len[self.data_format] + if result < data_start: + result = 0x10000 - reading_len[self.data_format] + return result + + def get_raw_data(self, ptr, unbuffered=False): + """Get raw data from circular buffer. + + If unbuffered is false then a cached value that was obtained + earlier may be returned.""" + if unbuffered: + self._data_pos = None + # round down ptr to a 'block boundary' + idx = ptr - (ptr % 0x20) + ptr -= idx + count = reading_len[self.data_format] + if self._data_pos == idx: + # cache contains useful data + result = self._data_block[ptr:ptr + count] + if len(result) >= count: + return result + else: + result = list() + if ptr + count > 0x20: + # need part of next block, which may be in cache + if self._data_pos != idx + 0x20: + self._data_pos = idx + 0x20 + self._data_block = self._read_block(self._data_pos) + result += self._data_block[0:ptr + count - 0x20] + if len(result) >= count: + return result + # read current block + self._data_pos = idx + self._data_block = self._read_block(self._data_pos) + result = self._data_block[ptr:ptr + count] + result + return result + + def get_data(self, ptr, unbuffered=False): + """Get decoded data from circular buffer. + + If unbuffered is false then a cached value that was obtained + earlier may be returned.""" + return _decode(self.get_raw_data(ptr, unbuffered), + reading_format[self.data_format]) + + def current_pos(self): + """Get circular buffer location where current data is being written.""" + new_ptr = _decode(self._read_fixed_block(0x0020), + lo_fix_format['current_pos']) + if new_ptr is None: + raise ObservationError('current_pos is None') + if new_ptr == self._current_ptr: + return self._current_ptr + if self._current_ptr and new_ptr != self.inc_ptr(self._current_ptr): + for k in reading_len: + if (new_ptr - self._current_ptr) == reading_len[k]: + log.error('changing data format from %s to %s' % (self.data_format, k)) + self.data_format = k + break + self._current_ptr = new_ptr + return self._current_ptr + + def get_raw_fixed_block(self, unbuffered=False): + """Get the raw "fixed block" of settings and min/max data.""" + if unbuffered or not self._fixed_block: + self._fixed_block = self._read_fixed_block() + return self._fixed_block + + def get_fixed_block(self, keys=[], unbuffered=False): + """Get the decoded "fixed block" of settings and min/max data. + + A subset of the entire block can be selected by keys.""" + if unbuffered or not self._fixed_block: + self._fixed_block = self._read_fixed_block() + fmt = fixed_format + # navigate down list of keys to get to wanted data + for key in keys: + fmt = fmt[key] + return _decode(self._fixed_block, fmt) + + def _wait_for_station(self): + # avoid times when station is writing to memory + while True: + pause = 60.0 + if self._station_clock: + phase = time.time() - self._station_clock + if phase > 24 * 3600: + # station clock was last measured a day ago, so reset it + self._station_clock = None + else: + pause = min(pause, (self.avoid - phase) % 60) + if self._sensor_clock: + phase = time.time() - self._sensor_clock + if phase > 24 * 3600: + # sensor clock was last measured 6 hrs ago, so reset it + self._sensor_clock = None + else: + pause = min(pause, (self.avoid - phase) % 48) + if pause >= self.avoid * 2.0: + return + log.debug('avoid %s' % str(pause)) + time.sleep(pause) + + def _read_block(self, ptr, retry=True): + # Read block repeatedly until it's stable. This avoids getting corrupt + # data when the block is read as the station is updating it. + old_block = None + while True: + self._wait_for_station() + new_block = self._read_usb_block(ptr) + if new_block: + if (new_block == old_block) or not retry: + break + if old_block is not None: + log.info('unstable read: blocks differ for ptr 0x%06x' % ptr) + old_block = new_block + return new_block + + def _read_fixed_block(self, hi=0x0100): + result = [] + for mempos in range(0x0000, hi, 0x0020): + result += self._read_block(mempos) + # check 'magic number'. log each new one we encounter. + magic = '%02x%02x' % (result[0], result[1]) + if magic not in self._magic_numbers: + log.error('unrecognised magic number %s' % magic) + self._magic_numbers.append(magic) + if magic != self._last_magic: + if self._last_magic is not None: + log.error('magic number changed old=%s new=%s' % + (self._last_magic, magic)) + self._last_magic = magic + return result + + def _write_byte(self, ptr, value): + self._wait_for_station() + if not self._write_usb(ptr, value): + raise weewx.WeeWxIOError('Write to USB failed') + + def write_data(self, data): + """Write a set of single bytes to the weather station. Data must be an + array of (ptr, value) pairs.""" + # send data + for ptr, value in data: + self._write_byte(ptr, value) + # set 'data changed' + self._write_byte(fixed_format['data_changed'][0], 0xAA) + # wait for station to clear 'data changed' + while True: + ack = _decode(self._read_fixed_block(0x0020), + fixed_format['data_changed']) + if ack == 0: + break + log.debug('waiting for ack') + time.sleep(6) + +# Tables of "meanings" for raw weather station data. Each key +# specifies an (offset, type, multiplier) tuple that is understood +# by _decode. +# depends on weather station type +reading_format = {} +reading_format['1080'] = { + 'delay' : (0, 'ub', None), + 'hum_in' : (1, 'ub', None), + 'temp_in' : (2, 'ss', 0.1), + 'hum_out' : (4, 'ub', None), + 'temp_out' : (5, 'ss', 0.1), + 'abs_pressure' : (7, 'us', 0.1), + 'wind_ave' : (9, 'wa', 0.1), + 'wind_gust' : (10, 'wg', 0.1), + 'wind_dir' : (12, 'wd', None), + 'rain' : (13, 'us', 0.3), + 'status' : (15, 'pb', None), + } +reading_format['3080'] = { + 'illuminance' : (16, 'u3', 0.1), + 'uv' : (19, 'ub', None), + } +reading_format['3080'].update(reading_format['1080']) + +lo_fix_format = { + 'magic_1' : (0, 'pb', None), + 'magic_2' : (1, 'pb', None), + 'model' : (2, 'us', None), + 'version' : (4, 'pb', None), + 'id' : (5, 'us', None), + 'rain_coef' : (7, 'us', None), + 'wind_coef' : (9, 'us', None), + 'read_period' : (16, 'ub', None), + 'settings_1' : (17, 'bf', ('temp_in_F', 'temp_out_F', 'rain_in', + 'bit3', 'bit4', 'pressure_hPa', + 'pressure_inHg', 'pressure_mmHg')), + 'settings_2' : (18, 'bf', ('wind_mps', 'wind_kmph', 'wind_knot', + 'wind_mph', 'wind_bft', 'bit5', + 'bit6', 'bit7')), + 'display_1' : (19, 'bf', ('pressure_rel', 'wind_gust', 'clock_12hr', + 'date_mdy', 'time_scale_24', 'show_year', + 'show_day_name', 'alarm_time')), + 'display_2' : (20, 'bf', ('temp_out_temp', 'temp_out_chill', + 'temp_out_dew', 'rain_hour', 'rain_day', + 'rain_week', 'rain_month', 'rain_total')), + 'alarm_1' : (21, 'bf', ('bit0', 'time', 'wind_dir', 'bit3', + 'hum_in_lo', 'hum_in_hi', + 'hum_out_lo', 'hum_out_hi')), + 'alarm_2' : (22, 'bf', ('wind_ave', 'wind_gust', + 'rain_hour', 'rain_day', + 'pressure_abs_lo', 'pressure_abs_hi', + 'pressure_rel_lo', 'pressure_rel_hi')), + 'alarm_3' : (23, 'bf', ('temp_in_lo', 'temp_in_hi', + 'temp_out_lo', 'temp_out_hi', + 'wind_chill_lo', 'wind_chill_hi', + 'dew_point_lo', 'dew_point_hi')), + 'timezone' : (24, 'sb', None), + 'unknown_01' : (25, 'pb', None), + 'data_changed' : (26, 'ub', None), + 'data_count' : (27, 'us', None), + 'display_3' : (29, 'bf', ('illuminance_fc', 'bit1', 'bit2', 'bit3', + 'bit4', 'bit5', 'bit6', 'bit7')), + 'current_pos' : (30, 'us', None), + } + +fixed_format = { + 'rel_pressure' : (32, 'us', 0.1), + 'abs_pressure' : (34, 'us', 0.1), + 'lux_wm2_coeff' : (36, 'us', 0.1), + 'wind_mult' : (38, 'us', None), + 'temp_out_offset' : (40, 'us', None), + 'temp_in_offset' : (42, 'us', None), + 'hum_out_offset' : (44, 'us', None), + 'hum_in_offset' : (46, 'us', None), + 'date_time' : (43, 'dt', None), # conflict with temp_in_offset + 'unknown_18' : (97, 'pb', None), + 'alarm' : { + 'hum_in' : {'hi': (48, 'ub', None), 'lo': (49, 'ub', None)}, + 'temp_in' : {'hi': (50, 'ss', 0.1), 'lo': (52, 'ss', 0.1)}, + 'hum_out' : {'hi': (54, 'ub', None), 'lo': (55, 'ub', None)}, + 'temp_out' : {'hi': (56, 'ss', 0.1), 'lo': (58, 'ss', 0.1)}, + 'windchill' : {'hi': (60, 'ss', 0.1), 'lo': (62, 'ss', 0.1)}, + 'dewpoint' : {'hi': (64, 'ss', 0.1), 'lo': (66, 'ss', 0.1)}, + 'abs_pressure' : {'hi': (68, 'us', 0.1), 'lo': (70, 'us', 0.1)}, + 'rel_pressure' : {'hi': (72, 'us', 0.1), 'lo': (74, 'us', 0.1)}, + 'wind_ave' : {'bft': (76, 'ub', None), 'ms': (77, 'ub', 0.1)}, + 'wind_gust' : {'bft': (79, 'ub', None), 'ms': (80, 'ub', 0.1)}, + 'wind_dir' : (82, 'ub', None), + 'rain' : {'hour': (83,'us',0.3), 'day': (85,'us',0.3)}, + 'time' : (87, 'tt', None), + 'illuminance' : (89, 'u3', 0.1), + 'uv' : (92, 'ub', None), + }, + 'max' : { + 'uv' : {'val': (93, 'ub', None)}, + 'illuminance' : {'val': (94, 'u3', 0.1)}, + 'hum_in' : {'val': (98, 'ub', None), 'date' : (141, 'dt', None)}, + 'hum_out' : {'val': (100, 'ub', None), 'date': (151, 'dt', None)}, + 'temp_in' : {'val': (102, 'ss', 0.1), 'date' : (161, 'dt', None)}, + 'temp_out' : {'val': (106, 'ss', 0.1), 'date' : (171, 'dt', None)}, + 'windchill' : {'val': (110, 'ss', 0.1), 'date' : (181, 'dt', None)}, + 'dewpoint' : {'val': (114, 'ss', 0.1), 'date' : (191, 'dt', None)}, + 'abs_pressure' : {'val': (118, 'us', 0.1), 'date' : (201, 'dt', None)}, + 'rel_pressure' : {'val': (122, 'us', 0.1), 'date' : (211, 'dt', None)}, + 'wind_ave' : {'val': (126, 'us', 0.1), 'date' : (221, 'dt', None)}, + 'wind_gust' : {'val': (128, 'us', 0.1), 'date' : (226, 'dt', None)}, + 'rain' : { + 'hour' : {'val': (130, 'us', 0.3), 'date' : (231, 'dt', None)}, + 'day' : {'val': (132, 'us', 0.3), 'date' : (236, 'dt', None)}, + 'week' : {'val': (134, 'us', 0.3), 'date' : (241, 'dt', None)}, + 'month' : {'val': (136, 'us', 0.3), 'date' : (246, 'dt', None)}, + 'total' : {'val': (138, 'us', 0.3), 'date' : (251, 'dt', None)}, + }, + }, + 'min' : { + 'hum_in' : {'val': (99, 'ub', None), 'date' : (146, 'dt', None)}, + 'hum_out' : {'val': (101, 'ub', None), 'date': (156, 'dt', None)}, + 'temp_in' : {'val': (104, 'ss', 0.1), 'date' : (166, 'dt', None)}, + 'temp_out' : {'val': (108, 'ss', 0.1), 'date' : (176, 'dt', None)}, + 'windchill' : {'val': (112, 'ss', 0.1), 'date' : (186, 'dt', None)}, + 'dewpoint' : {'val': (116, 'ss', 0.1), 'date' : (196, 'dt', None)}, + 'abs_pressure' : {'val': (120, 'us', 0.1), 'date' : (206, 'dt', None)}, + 'rel_pressure' : {'val': (124, 'us', 0.1), 'date' : (216, 'dt', None)}, + }, + } +fixed_format.update(lo_fix_format) + +# start of readings / end of fixed block +data_start = 0x0100 # 256 + +# bytes per reading, depends on weather station type +reading_len = { + '1080' : 16, + '3080' : 20, + } diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/simulator.py b/dist/weewx-4.10.1/bin/weewx/drivers/simulator.py new file mode 100644 index 0000000..9ac832e --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/simulator.py @@ -0,0 +1,369 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Console simulator for the weewx weather system""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function +import math +import random +import time + +import weewx.drivers +import weeutil.weeutil + +DRIVER_NAME = 'Simulator' +DRIVER_VERSION = "3.3" + + +def loader(config_dict, engine): + + start_ts, resume_ts = extract_starts(config_dict, DRIVER_NAME) + + station = Simulator(start_time=start_ts, resume_time=resume_ts, **config_dict[DRIVER_NAME]) + + return station + + +def extract_starts(config_dict, driver_name): + """Extract the start and resume times out of the configuration dictionary""" + + # This uses a bit of a hack to have the simulator resume at a later + # time. It's not bad, but I'm not enthusiastic about having special + # knowledge about the database in a driver, albeit just the loader. + + start_ts = resume_ts = None + if 'start' in config_dict[driver_name]: + # A start has been specified. Extract the time stamp. + start_tt = time.strptime(config_dict[driver_name]['start'], "%Y-%m-%dT%H:%M") + start_ts = time.mktime(start_tt) + # If the 'resume' keyword is present and True, then get the last + # archive record out of the database and resume with that. + if weeutil.weeutil.to_bool(config_dict[driver_name].get('resume', False)): + import weewx.manager + import weedb + try: + # Resume with the last time in the database. If there is no such + # time, then fall back to the time specified in the configuration + # dictionary. + with weewx.manager.open_manager_with_config(config_dict, + 'wx_binding') as dbmanager: + resume_ts = dbmanager.lastGoodStamp() + except weedb.OperationalError: + pass + else: + # The resume keyword is not present. Start with the seed time: + resume_ts = start_ts + + return start_ts, resume_ts + + +class Simulator(weewx.drivers.AbstractDevice): + """Station simulator""" + + def __init__(self, **stn_dict): + """Initialize the simulator + + NAMED ARGUMENTS: + + loop_interval: The time (in seconds) between emitting LOOP packets. + [Optional. Default is 2.5] + + start_time: The start (seed) time for the generator in unix epoch time + [Optional. If 'None', or not present, then present time will be used.] + + resume_time: The start time for the loop. + [Optional. If 'None', or not present, then start_time will be used.] + + mode: Controls the frequency of packets. One of either: + 'simulator': Real-time simulator - sleep between LOOP packets + 'generator': Emit packets as fast as possible (useful for testing) + [Required. Default is simulator.] + + observations: Comma-separated list of observations that should be + generated. If nothing is specified, then all + observations will be generated. + [Optional. Default is not defined.] + """ + + self.loop_interval = float(stn_dict.get('loop_interval', 2.5)) + if 'start_time' in stn_dict and stn_dict['start_time'] is not None: + # A start time has been specified. We are not in real time mode. + self.real_time = False + # Extract the generator start time: + start_ts = float(stn_dict['start_time']) + # If a resume time keyword is present (and it's not None), + # then have the generator resume with that time. + if 'resume_time' in stn_dict and stn_dict['resume_time'] is not None: + self.the_time = float(stn_dict['resume_time']) + else: + self.the_time = start_ts + else: + # No start time specified. We are in realtime mode. + self.real_time = True + start_ts = self.the_time = time.time() + + # default to simulator mode + self.mode = stn_dict.get('mode', 'simulator') + + # The following doesn't make much meteorological sense, but it is + # easy to program! + self.observations = { + 'outTemp' : Observation(magnitude=20.0, average= 50.0, period=24.0, phase_lag=14.0, start=start_ts), + 'inTemp' : Observation(magnitude=5.0, average= 68.0, period=24.0, phase_lag=12.0, start=start_ts), + 'barometer' : Observation(magnitude=1.0, average= 30.1, period=48.0, phase_lag= 0.0, start=start_ts), + 'pressure' : Observation(magnitude=1.0, average= 30.1, period=48.0, phase_lag= 0.0, start=start_ts), + 'windSpeed' : Observation(magnitude=5.0, average= 5.0, period=48.0, phase_lag=24.0, start=start_ts), + 'windDir' : Observation(magnitude=180.0, average=180.0, period=48.0, phase_lag= 0.0, start=start_ts), + 'windGust' : Observation(magnitude=6.0, average= 6.0, period=48.0, phase_lag=24.0, start=start_ts), + 'windGustDir': Observation(magnitude=180.0, average=180.0, period=48.0, phase_lag= 0.0, start=start_ts), + 'outHumidity': Observation(magnitude=30.0, average= 50.0, period=48.0, phase_lag= 0.0, start=start_ts), + 'inHumidity' : Observation(magnitude=10.0, average= 20.0, period=24.0, phase_lag= 0.0, start=start_ts), + 'radiation' : Solar(magnitude=1000, solar_start=6, solar_length=12), + 'UV' : Solar(magnitude=14, solar_start=6, solar_length=12), + 'rain' : Rain(rain_start=0, rain_length=3, total_rain=0.2, loop_interval=self.loop_interval), + 'txBatteryStatus': BatteryStatus(), + 'windBatteryStatus': BatteryStatus(), + 'rainBatteryStatus': BatteryStatus(), + 'outTempBatteryStatus': BatteryStatus(), + 'inTempBatteryStatus': BatteryStatus(), + 'consBatteryVoltage': BatteryVoltage(), + 'heatingVoltage': BatteryVoltage(), + 'supplyVoltage': BatteryVoltage(), + 'referenceVoltage': BatteryVoltage(), + 'rxCheckPercent': SignalStrength()} + + self.trim_observations(stn_dict) + + def trim_observations(self, stn_dict): + """Calculate only the specified observations, or all if none specified""" + if stn_dict.get('observations'): + desired = {x.strip() for x in stn_dict['observations']} + for obs in list(self.observations): + if obs not in desired: + del self.observations[obs] + + def genLoopPackets(self): + + while True: + + # If we are in simulator mode, sleep first (as if we are gathering + # observations). If we are in generator mode, don't sleep at all. + if self.mode == 'simulator': + # Determine how long to sleep + if self.real_time: + # We are in real time mode. Try to keep synched up with the + # wall clock + sleep_time = self.the_time + self.loop_interval - time.time() + if sleep_time > 0: + time.sleep(sleep_time) + else: + # A start time was specified, so we are not in real time. + # Just sleep the appropriate interval + time.sleep(self.loop_interval) + + # Update the simulator clock: + self.the_time += self.loop_interval + + # Because a packet represents the measurements observed over the + # time interval, we want the measurement values at the middle + # of the interval. + avg_time = self.the_time - self.loop_interval/2.0 + + _packet = {'dateTime': int(self.the_time+0.5), + 'usUnits' : weewx.US } + for obs_type in self.observations: + _packet[obs_type] = self.observations[obs_type].value_at(avg_time) + yield _packet + + def getTime(self): + return self.the_time + + @property + def hardware_name(self): + return "Simulator" + + +class Observation(object): + + def __init__(self, magnitude=1.0, average=0.0, period=96.0, phase_lag=0.0, start=None): + """Initialize an observation function. + + magnitude: The value at max. The range will be twice this value + average: The average value, averaged over a full cycle. + period: The cycle period in hours. + phase_lag: The number of hours after the start time when the + observation hits its max + start: Time zero for the observation in unix epoch time.""" + + if not start: + raise ValueError("No start time specified") + self.magnitude = magnitude + self.average = average + self.period = period * 3600.0 + self.phase_lag = phase_lag * 3600.0 + self.start = start + + def value_at(self, time_ts): + """Return the observation value at the given time. + + time_ts: The time in unix epoch time.""" + + phase = 2.0 * math.pi * (time_ts - self.start - self.phase_lag) / self.period + return self.magnitude * math.cos(phase) + self.average + + +class Rain(object): + + bucket_tip = 0.01 + + def __init__(self, rain_start=0, rain_length=1, total_rain=0.1, loop_interval=None): + """Initialize a rain simulator""" + npackets = 3600 * rain_length / loop_interval + n_rain_packets = total_rain / Rain.bucket_tip + self.period = int(npackets/n_rain_packets) + self.rain_start = 3600* rain_start + self.rain_end = self.rain_start + 3600 * rain_length + self.packet_number = 0 + + def value_at(self, time_ts): + time_tt = time.localtime(time_ts) + secs_since_midnight = time_tt.tm_hour * 3600 + time_tt.tm_min * 60.0 + time_tt.tm_sec + if self.rain_start < secs_since_midnight <= self.rain_end: + amt = Rain.bucket_tip if self.packet_number % self.period == 0 else 0.0 + self.packet_number += 1 + else: + self.packet_number = 0 + amt = 0 + return amt + + +class Solar(object): + + def __init__(self, magnitude=10, solar_start=6, solar_length=12): + """Initialize a solar simulator + Simulated ob will follow a single wave sine function starting at 0 + and ending at 0. The solar day starts at time solar_start and + finishes after solar_length hours. + + magnitude: the value at max, the range will be twice + this value + solar_start: decimal hour of day that obs start + (6.75=6:45am, 6:20=6:12am) + solar_length: length of day in decimal hours + (10.75=10hr 45min, 10:10=10hr 6min) + """ + + self.magnitude = magnitude + self.solar_start = 3600 * solar_start + self.solar_end = self.solar_start + 3600 * solar_length + self.solar_length = 3600 * solar_length + + def value_at(self, time_ts): + time_tt = time.localtime(time_ts) + secs_since_midnight = time_tt.tm_hour * 3600 + time_tt.tm_min * 60.0 + time_tt.tm_sec + if self.solar_start < secs_since_midnight <= self.solar_end: + amt = self.magnitude * (1 + math.cos(math.pi * (1 + 2.0 * ((secs_since_midnight - self.solar_start) / self.solar_length - 1))))/2 + else: + amt = 0 + return amt + + +class BatteryStatus(object): + + def __init__(self, chance_of_failure=None, min_recovery_time=None): + """Initialize a battery status. + + chance_of_failure - likeliehood that the battery should fail [0,1] + min_recovery_time - minimum time until the battery recovers, seconds + """ + if chance_of_failure is None: + chance_of_failure = 0.0005 # about once every 30 minutes + if min_recovery_time is None: + min_recovery_time = random.randint(300, 1800) # 5 to 15 minutes + self.chance_of_failure = chance_of_failure + self.min_recovery_time = min_recovery_time + self.state = 0 + self.fail_ts = 0 + + def value_at(self, time_ts): + if self.state == 1: + # recover if sufficient time has passed + if time_ts - self.fail_ts > self.min_recovery_time: + self.state = 0 + else: + # see if we need a failure + if random.random() < self.chance_of_failure: + self.state = 1 + self.fail_ts = time_ts + return self.state + + +class BatteryVoltage(object): + + def __init__(self, nominal_value=None, max_variance=None): + """Initialize a battery voltage.""" + if nominal_value is None: + nominal_value = 12.0 + if max_variance is None: + max_variance = 0.1 * nominal_value + self.nominal = nominal_value + self.variance = max_variance + + def value_at(self, time_ts): + return self.nominal + self.variance * random.random() * random.randint(-1, 1) + + +class SignalStrength(object): + + def __init__(self, minval=0.0, maxval=100.0): + """Initialize a signal strength simulator.""" + self.minval = minval + self.maxval = maxval + self.max_variance = 0.1 * (self.maxval - self.minval) + self.value = self.minval + random.random() * (self.maxval - self.minval) + + def value_at(self, time_ts): + newval = self.value + self.max_variance * random.random() * random.randint(-1, 1) + newval = max(self.minval, newval) + newval = min(self.maxval, newval) + self.value = newval + return self.value + + +def confeditor_loader(): + return SimulatorConfEditor() + + +class SimulatorConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[Simulator] + # This section is for the weewx weather station simulator + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. Format is YYYY-mm-ddTHH:MM. If not specified, the default + # is to use the present time. + #start = 2011-01-01T00:00 + + # The driver to use: + driver = weewx.drivers.simulator +""" + + +if __name__ == "__main__": + station = Simulator(mode='simulator',loop_interval=2.0) + for packet in station.genLoopPackets(): + print(weeutil.weeutil.timestamp_to_string(packet['dateTime']), packet) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/te923.py b/dist/weewx-4.10.1/bin/weewx/drivers/te923.py new file mode 100644 index 0000000..e2ec078 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/te923.py @@ -0,0 +1,2432 @@ +#!/usr/bin/env python +# +# Copyright 2013-2015 Matthew Wall, Andrew Miles +# See the file LICENSE.txt for your full rights. +# +# Thanks to Andrew Miles for figuring out how to read history records +# and many station parameters. +# Thanks to Sebastian John for the te923tool written in C (v0.6.1): +# http://te923.fukz.org/ +# Thanks to Mark Teel for the te923 implementation in wview: +# http://www.wviewweather.com/ +# Thanks to mrbalky: +# https://github.com/mrbalky/te923/blob/master/README.md + +"""Classes and functions for interfacing with te923 weather stations. + +These stations were made by Hideki and branded as Honeywell, Meade, IROX Pro X, +Mebus TE923, and TFA Nexus. They date back to at least 2007 and are still +sold (sparsely in the US, more commonly in Europe) as of 2013. + +Apparently there are at least two different memory sizes. One version can +store about 200 records, a newer version can store about 3300 records. + +The firmware version of each component can be read by talking to the station, +assuming that the component has a wireless connection to the station, of +course. + +To force connection between station and sensors, press and hold DOWN button. + +To reset all station parameters: + - press and hold SNOOZE and UP for 4 seconds + - press SET button; main unit will beep + - wait until beeping stops + - remove batteries and wait 10 seconds + - reinstall batteries + +From the Meade TE9233W manual (TE923W-M_IM(ENG)_BK_010511.pdf): + + Remote temperature/humidty sampling interval: 10 seconds + Remote temperature/humidity transmit interval: about 47 seconds + Indoor temperature/humidity sampling interval: 10 seconds + Indoor pressure sampling interval: 20 minutes + Rain counter transmitting interval: 183 seconds + Wind direction transmitting interval: 33 seconds + Wind/Gust speed display update interval: 33 seconds + Wind/Gust sampling interval: 11 seconds + UV transmitting interval: 300 seconds + Rain counter resolution: 0.03 in (0.6578 mm) + (but console shows instead: 1/36 in (0.705556 mm)) + Battery status of each sensor is checked every hour + +This implementation polls the station for data. Use the polling_interval to +control the frequency of polling. Default is 10 seconds. + +The manual claims that a single bucket tip is 0.03 inches or 0.6578 mm but +neither matches the console display. In reality, a single bucket tip is +between 0.02 and 0.03 in (0.508 to 0.762 mm). This driver uses a value of 1/36 +inch as observed in 36 bucket tips per 1.0 inches displayed on the console. +1/36 = 0.02777778 inch = 0.705555556 mm, or 1.0725989 times larger than the +0.02589 inch = 0.6578 mm that was used prior to version 0.41.1. + +The station has altitude, latitude, longitude, and time. + +Setting the time does not persist. If you set the station time using weewx, +the station initially indicates that it is set to the new time, but then it +reverts. + +Notes From/About Other Implementations + +Apparently te923tool came first, then wview copied a bit from it. te923tool +provides more detail about the reason for invalid values, for example, values +out of range versus no link with sensors. However, these error states have not +yet been corroborated. + +There are some disagreements between the wview and te923tool implementations. + +From the te923tool: +- reading from usb in 8 byte chunks instead of all at once +- length of buffer is 35, but reads are 32-byte blocks +- windspeed and windgust state can never be -1 +- index 29 in rain count, also in wind dir + +From wview: +- wview does the 8-byte reads using interruptRead +- wview ignores the windchill value from the station +- wview treats the pressure reading as barometer (SLP), then calculates the + station pressure and altimeter pressure + +Memory Map + +0x020000 - Last sample: + +[00] = Month (Bits 0-3), Weekday (1 = Monday) (Bits 7:4) +[01] = Day +[02] = Hour +[03] = Minute +[04] ... reading as below + +0x020001 - Current readings: + +[00] = Temp In Low BCD +[01] = Temp In High BCD (Bit 5 = 0.05 deg, Bit 7 = -ve) +[02] = Humidity In +[03] = Temp Channel 1 Low (No link = Xa) +[04] = Temp Channel 1 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[05] = Humidity Channel 1 (No link = Xa) +[06] = Temp Channel 2 Low (No link = Xa) +[07] = Temp Channel 2 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[08] = Humidity Channel 2 (No link = Xa) +[09] = Temp Channel 3 Low (No link = Xa) +[10] = Temp Channel 3 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[11] = Humidity Channel 3 (No link = Xa) +[12] = Temp Channel 4 Low (No link = Xa) +[13] = Temp Channel 4 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[14] = Humidity Channel 4 (No link = Xa) +[15] = Temp Channel 5 Low (No link = Xa) +[16] = Temp Channel 5 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[17] = Humidity Channel 5 (No link = Xa) +[18] = UV Low (No link = ff) +[19] = UV High (No link = ff) +[20] = Sea-Level Pressure Low +[21] = Sea-Level Pressure High +[22] = Forecast (Bits 0-2) Storm (Bit 3) +[23] = Wind Chill Low (No link = ff) +[24] = Wind Chill High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve, No link = ff) +[25] = Gust Low (No link = ff) +[26] = Gust High (No link = ff) +[27] = Wind Low (No link = ff) +[28] = Wind High (No link = ff) +[29] = Wind Dir (Bits 0-3) +[30] = Rain Low +[31] = Rain High + +(1) Memory map values related to sensors use same coding as above +(2) Checksum are via subtraction: 0x100 - sum of all values, then add 0x100 + until positive i.e. 0x100 - 0x70 - 0x80 - 0x28 = -0x18, 0x18 + 0x100 = 0xE8 + +SECTION 1: Date & Local location + +0x000000 - Unknown - changes if date section is modified but still changes if + same data is written so not a checksum +0x000001 - Unknown (always 0) +0x000002 - Day (Reverse BCD) (Changes at midday!) +0x000003 - Unknown +0x000004 - Year (Reverse BCD) +0x000005 - Month (Bits 7:4), Weekday (Bits 3:1) +0x000006 - Latitude (degrees) (reverse BCD) +0x000007 - Latitude (minutes) (reverse BCD) +0x000008 - Longitude (degrees) (reverse BCD) +0x000009 - Longitude (minutes) (reverse BCD) +0x00000A - Bit 7 - Set if Latitude southerly + Bit 6 - Set if Longitude easterly + Bit 4 - Set if DST is always on + Bit 3 - Set if -ve TZ + Bits 0 & 1 - Set if half-hour TZ +0x00000B - Longitude (100 degrees) (Bits 7:4), DST zone (Bits 3:0) +0x00000C - City code (High) (Bits 7:4) + Language (Bits 3:0) + 0 - English + 1 - German + 2 - French + 3 - Italian + 4 - Spanish + 6 - Dutch +0x00000D - Timezone (hour) (Bits 7:4), City code (Low) (Bits 3:0) +0x00000E - Bit 2 - Set if 24hr time format + Bit 1 - Set if 12hr time format +0x00000F - Checksum of 00:0E + +SECTION 2: Time Alarms + +0x000010 - Weekday alarm (hour) (reverse BCD) + Bit 3 - Set if single alarm active + Bit 2 - Set if weekday-alarm active +0x000011 - Weekday alarm (minute) (reverse BCD) +0x000012 - Single alarm (hour) (reverse BCD) (Bit 3 - Set if pre-alarm active) +0x000013 - Single alarm (minute) (reverse BCD) +0x000014 - Bits 7-4: Pre-alarm (1-5 = 15,30,45,60 or 90 mins) + Bits 3-0: Snooze value +0x000015 - Checksum of 10:14 + +SECTION 3: Alternate Location + +0x000016 - Latitude (degrees) (reverse BCD) +0x000017 - Latitude (minutes) (reverse BCD) +0x000018 - Longitude (degrees) (reverse BCD) +0x000019 - Longitude (minutes) (reverse BCD) +0x00001A - Bit 7 - Set if Latitude southerly + Bit 6 - Set if Longitude easterly + Bit 4 - Set if DST is always on + Bit 3 - Set if -ve TZ + Bits 0 & 1 - Set if half-hour TZ +0x00001B - Longitude (100 degrees) (Bits 7:4), DST zone (Bits 3:0) +0x00001C - City code (High) (Bits 7:4), Unknown (Bits 3:0) +0x00001D - Timezone (hour) (Bits 7:4), City code (Low) (Bits 3:0) +0x00001E - Checksum of 16:1D + +SECTION 4: Temperature Alarms + +0x00001F:20 - High Temp Alarm Value +0x000021:22 - Low Temp Alarm Value +0x000023 - Checksum of 1F:22 + +SECTION 5: Min/Max 1 + +0x000024:25 - Min In Temp +0x000026:27 - Max in Temp +0x000028 - Min In Humidity +0x000029 - Max In Humidity +0x00002A:2B - Min Channel 1 Temp +0x00002C:2D - Max Channel 1 Temp +0x00002E - Min Channel 1 Humidity +0x00002F - Max Channel 1 Humidity +0x000030:31 - Min Channel 2 Temp +0x000032:33 - Max Channel 2 Temp +0x000034 - Min Channel 2 Humidity +0x000035 - Max Channel 2 Humidity +0x000036:37 - Min Channel 3 Temp +0x000038:39 - Max Channel 3 Temp +0x00003A - Min Channel 3 Humidity +0x00003B - Max Channel 3 Humidity +0x00003C:3D - Min Channel 4 Temp +0x00003F - Checksum of 24:3E + +SECTION 6: Min/Max 2 + +0x00003E,40 - Max Channel 4 Temp +0x000041 - Min Channel 4 Humidity +0x000042 - Max Channel 4 Humidity +0x000043:44 - Min Channel 4 Temp +0x000045:46 - Max Channel 4 Temp +0x000047 - Min Channel 4 Humidity +0x000048 - Max Channel 4 Humidity +0x000049 - ? Values rising/falling ? + Bit 5 : Chan 1 temp falling + Bit 2 : In temp falling +0x00004A:4B - 0xFF (Unused) +0x00004C - Battery status + Bit 7: Rain + Bit 6: Wind + Bit 5: UV + Bits 4:0: Channel 5:1 +0x00004D:58 - 0xFF (Unused) +0x000059 - Checksum of 3E:58 + +SECTION 7: Altitude + +0x00005A:5B - Altitude (Low:High) +0x00005C - Bit 3 - Set if altitude negative + Bit 2 - Pressure falling? + Bit 1 - Always set +0X00005D - Checksum of 5A:5C + +0x00005E:5F - Unused (0xFF) + +SECTION 8: Pressure 1 + +0x000060 - Month of last reading (Bits 0-3), Weekday (1 = Monday) (Bits 7:4) +0x000061 - Day of last reading +0x000062 - Hour of last reading +0x000063 - Minute of last reading +0x000064:65 - T -0 Hours +0x000066:67 - T -1 Hours +0x000068:69 - T -2 Hours +0x00006A:6B - T -3 Hours +0x00006C:6D - T -4 Hours +0x00006E:6F - T -5 Hours +0x000070:71 - T -6 Hours +0x000072:73 - T -7 Hours +0x000074:75 - T -8 Hours +0x000076:77 - T -9 Hours +0x000078:79 - T -10 Hours +0x00007B - Checksum of 60:7A + +SECTION 9: Pressure 2 + +0x00007A,7C - T -11 Hours +0x00007D:7E - T -12 Hours +0x00007F:80 - T -13 Hours +0x000081:82 - T -14 Hours +0x000083:84 - T -15 Hours +0x000085:86 - T -16 Hours +0x000087:88 - T -17 Hours +0x000089:90 - T -18 Hours +0x00008B:8C - T -19 Hours +0x00008D:8E - T -20 Hours +0x00008f:90 - T -21 Hours +0x000091:92 - T -22 Hours +0x000093:94 - T -23 Hours +0x000095:96 - T -24 Hours +0x000097 - Checksum of 7C:96 + +SECTION 10: Versions + +0x000098 - firmware versions (barometer) +0x000099 - firmware versions (uv) +0x00009A - firmware versions (rcc) +0x00009B - firmware versions (wind) +0x00009C - firmware versions (system) +0x00009D - Checksum of 98:9C + +0x00009E:9F - 0xFF (Unused) + +SECTION 11: Rain/Wind Alarms 1 + +0x0000A0 - Alarms + Bit2 - Set if rain alarm active + Bit 1 - Set if wind alarm active + Bit 0 - Set if gust alarm active +0x0000A1:A2 - Rain alarm value (High:Low) (BCD) +0x0000A3 - Unknown +0x0000A4:A5 - Wind speed alarm value +0x0000A6 - Unknown +0x0000A7:A8 - Gust alarm value +0x0000A9 - Checksum of A0:A8 + +SECTION 12: Rain/Wind Alarms 2 + +0x0000AA:AB - Max daily wind speed +0x0000AC:AD - Max daily gust speed +0x0000AE:AF - Rain bucket count (yesterday) (Low:High) +0x0000B0:B1 - Rain bucket count (week) (Low:High) +0x0000B2:B3 - Rain bucket count (month) (Low:High) +0x0000B4 - Checksum of AA:B3 + +0x0000B5:E0 - 0xFF (Unused) + +SECTION 13: Unknownn + +0x0000E1:F9 - 0x15 (Unknown) +0x0000FA - Checksum of E1:F9 + +SECTION 14: Archiving + +0x0000FB - Unknown +0x0000FC - Memory size (0 = 0x1fff, 2 = 0x20000) +0x0000FD - Number of records (High) +0x0000FE - Archive interval + 1-11 = 5, 10, 20, 30, 60, 90, 120, 180, 240, 360, 1440 mins +0x0000FF - Number of records (Low) +0x000100 - Checksum of FB:FF + +0x000101 - Start of historical records: + +[00] = Month (Bits 0-3), Weekday (1 = Monday) (Bits 7:4) +[01] = Day +[02] = Hour +[03] = Minute +[04] = Temp In Low BCD +[05] = Temp In High BCD (Bit 5 = 0.05 deg, Bit 7 = -ve) +[06] = Humidity In +[07] = Temp Channel 1 Low (No link = Xa) +[08] = Temp Channel 1 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[09] = Humidity Channel 1 (No link = Xa) +[10] = Temp Channel 2 Low (No link = Xa) +[11] = Temp Channel 2 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[12] = Humidity Channel 2 (No link = Xa) +[13] = Temp Channel 3 Low (No link = Xa) +[14] = Temp Channel 3 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[15] = Checksum of bytes 0:14 +[16] = Humidity Channel 3 (No link = Xa) +[17] = Temp Channel 4 Low (No link = Xa) +[18] = Temp Channel 4 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[19] = Humidity Channel 4 (No link = Xa) +[20] = Temp Channel 5 Low (No link = Xa) +[21] = Temp Channel 5 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[22] = Humidity Channel 5 (No link = Xa) +[23] = UV Low (No link = ff) +[24] = UV High (No link = ff) +[25] = Sea-Level Pressure Low +[26] = Sea-Level Pressure High +[27] = Forecast (Bits 0-2) Storm (Bit 3) +[28] = Wind Chill Low (No link = ff) +[29] = Wind Chill High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve, No link = ee) +[30] = Gust Low (No link = ff) +[31] = Gust High (No link = ff) +[32] = Wind Low (No link = ff) +[33] = Wind High (No link = ff) +[34] = Wind Dir (Bits 0-3) +[35] = Rain Low +[36] = Rain High +[37] = Checksum of bytes 16:36 + +USB Protocol + +The station shows up on the USB as a HID. Control packet is 8 bytes. + +Read from station: + 0x05 (Length) + 0xAF (Read) + Addr (Bit 17:16), Addr (Bits 15:8), Addr (Bits 7:0), CRC, Unused, Unused + +Read acknowledge: + 0x24 (Ack) + 0xAF (Read) + Addr (Bit 17:16), Addr (Bits 15:8), Addr (Bits 7:0), CRC, Unused, Unused + +Write to station: + 0x07 (Length) + 0xAE (Write) + Addr (Bit 17:16), Addr (Bits 15:8), Addr (Bits 7:0), Data1, Data2, Data3 + ... Data continue with 3 more packets of length 7 then ... + 0x02 (Length), Data32, CRC, Unused, Unused, Unused, Unused, Unused, Unused + +Reads returns 32 bytes. Write expects 32 bytes as well, but address must be +aligned to a memory-map section start address and will only write to that +section. + +Schema Additions + +The station emits more sensor data than the default schema (wview schema) can +handle. This driver includes a mapping between the sensor data and the wview +schema, plus additional fields. To use the default mapping with the wview +schema, these are the additional fields that must be added to the schema: + + ('extraTemp4', 'REAL'), + ('extraHumid3', 'REAL'), + ('extraHumid4', 'REAL'), + ('extraBatteryStatus1', 'REAL'), + ('extraBatteryStatus2', 'REAL'), + ('extraBatteryStatus3', 'REAL'), + ('extraBatteryStatus4', 'REAL'), + ('windLinkStatus', 'REAL'), + ('rainLinkStatus', 'REAL'), + ('uvLinkStatus', 'REAL'), + ('outLinkStatus', 'REAL'), + ('extraLinkStatus1', 'REAL'), + ('extraLinkStatus2', 'REAL'), + ('extraLinkStatus3', 'REAL'), + ('extraLinkStatus4', 'REAL'), + ('forecast', 'REAL'), + ('storm', 'REAL'), +""" + +# TODO: figure out how to read gauge pressure instead of slp +# TODO: figure out how to clear station memory +# TODO: add option to reset rain total + +# FIXME: set-date and sync-date do not work - something reverts the clock +# FIXME: is there any way to get rid of the bad header byte on first read? + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +import logging +import time +import usb + +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'TE923' +DRIVER_VERSION = '0.41.1' + +def loader(config_dict, engine): # @UnusedVariable + return TE923Driver(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): # @UnusedVariable + return TE923Configurator() + +def confeditor_loader(): + return TE923ConfEditor() + +DEBUG_READ = 1 +DEBUG_WRITE = 1 +DEBUG_DECODE = 0 + +# map the station data to the default database schema, plus extensions +DEFAULT_MAP = { + 'windLinkStatus': 'link_wind', + 'windBatteryStatus': 'bat_wind', + 'rainLinkStatus': 'link_rain', + 'rainBatteryStatus': 'bat_rain', + 'uvLinkStatus': 'link_uv', + 'uvBatteryStatus': 'bat_uv', + 'inTemp': 't_in', + 'inHumidity': 'h_in', + 'outTemp': 't_1', + 'outHumidity': 'h_1', + 'outTempBatteryStatus': 'bat_1', + 'outLinkStatus': 'link_1', + 'extraTemp1': 't_2', + 'extraHumid1': 'h_2', + 'extraBatteryStatus1': 'bat_2', + 'extraLinkStatus1': 'link_2', + 'extraTemp2': 't_3', + 'extraHumid2': 'h_3', + 'extraBatteryStatus2': 'bat_3', + 'extraLinkStatus2': 'link_3', + 'extraTemp3': 't_4', + 'extraHumid3': 'h_4', + 'extraBatteryStatus3': 'bat_4', + 'extraLinkStatus3': 'link_4', + 'extraTemp4': 't_5', + 'extraHumid4': 'h_5', + 'extraBatteryStatus4': 'bat_5', + 'extraLinkStatus4': 'link_5' +} + + +class TE923ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[TE923] + # This section is for the Hideki TE923 series of weather stations. + + # The station model, e.g., 'Meade TE923W' or 'TFA Nexus' + model = TE923 + + # The driver to use: + driver = weewx.drivers.te923 + + # The default configuration associates the channel 1 sensor with outTemp + # and outHumidity. To change this, or to associate other channels with + # specific columns in the database schema, use the following map. + #[[sensor_map]] +%s +""" % "\n".join([" # %s = %s" % (x, DEFAULT_MAP[x]) for x in DEFAULT_MAP]) + + +class TE923Configurator(weewx.drivers.AbstractConfigurator): + LOCSTR = "CITY|USR,LONG_DEG,LONG_MIN,E|W,LAT_DEG,LAT_MIN,N|S,TZ,DST" + ALMSTR = "WEEKDAY,SINGLE,PRE_ALARM,SNOOZE,MAXTEMP,MINTEMP,RAIN,WIND,GUST" + + idx_to_interval = { + 1: "5 min", 2: "10 min", 3: "20 min", 4: "30 min", 5: "60 min", + 6: "90 min", 7: "2 hour", 8: "3 hour", 9: "4 hour", 10: "6 hour", + 11: "1 day"} + + interval_to_idx = { + "5m": 1, "10m": 2, "20m": 3, "30m": 4, "60m": 5, "90m": 6, + "2h": 7, "3h": 8, "4h": 9, "6h": 10, "1d": 11} + + forecast_dict = { + 0: 'heavy snow', + 1: 'light snow', + 2: 'heavy rain', + 3: 'light rain', + 4: 'heavy clouds', + 5: 'light clouds', + 6: 'sunny', + } + + dst_dict = { + 0: ["NO", 'None'], + 1: ["SA", 'Australian'], + 2: ["SB", 'Brazilian'], + 3: ["SC", 'Chilian'], + 4: ["SE", 'European'], + 5: ["SG", 'Eqyptian'], + 6: ["SI", 'Cuban'], + 7: ["SJ", 'Iraq and Syria'], + 8: ["SK", 'Irkutsk and Moscow'], + 9: ["SM", 'Uruguayan'], + 10: ["SN", 'Nambian'], + 11: ["SP", 'Paraguayan'], + 12: ["SQ", 'Iranian'], + 13: ["ST", 'Tasmanian'], + 14: ["SU", 'American'], + 15: ["SZ", 'New Zealand'], + } + + city_dict = { + 0: ["ADD", 3, 0, 9, 1, "N", 38, 44, "E", "Addis Ababa, Ethiopia"], + 1: ["ADL", 9.5, 1, 34, 55, "S", 138, 36, "E", "Adelaide, Australia"], + 2: ["AKR", 2, 4, 39, 55, "N", 32, 55, "E", "Ankara, Turkey"], + 3: ["ALG", 1, 0, 36, 50, "N", 3, 0, "E", "Algiers, Algeria"], + 4: ["AMS", 1, 4, 52, 22, "N", 4, 53, "E", "Amsterdam, Netherlands"], + 5: ["ARN", 1, 4, 59, 17, "N", 18, 3, "E", "Stockholm Arlanda, Sweden"], + 6: ["ASU", -3, 11, 25, 15, "S", 57, 40, "W", "Asuncion, Paraguay"], + 7: ["ATH", 2, 4, 37, 58, "N", 23, 43, "E", "Athens, Greece"], + 8: ["ATL", -5, 14, 33, 45, "N", 84, 23, "W", "Atlanta, Ga."], + 9: ["AUS", -6, 14, 30, 16, "N", 97, 44, "W", "Austin, Tex."], + 10: ["BBU", 2, 4, 44, 25, "N", 26, 7, "E", "Bucharest, Romania"], + 11: ["BCN", 1, 4, 41, 23, "N", 2, 9, "E", "Barcelona, Spain"], + 12: ["BEG", 1, 4, 44, 52, "N", 20, 32, "E", "Belgrade, Yugoslavia"], + 13: ["BEJ", 8, 0, 39, 55, "N", 116, 25, "E", "Beijing, China"], + 14: ["BER", 1, 4, 52, 30, "N", 13, 25, "E", "Berlin, Germany"], + 15: ["BHM", -6, 14, 33, 30, "N", 86, 50, "W", "Birmingham, Ala."], + 16: ["BHX", 0, 4, 52, 25, "N", 1, 55, "W", "Birmingham, England"], + 17: ["BKK", 7, 0, 13, 45, "N", 100, 30, "E", "Bangkok, Thailand"], + 18: ["BNA", -6, 14, 36, 10, "N", 86, 47, "W", "Nashville, Tenn."], + 19: ["BNE", 10, 0, 27, 29, "S", 153, 8, "E", "Brisbane, Australia"], + 20: ["BOD", 1, 4, 44, 50, "N", 0, 31, "W", "Bordeaux, France"], + 21: ["BOG", -5, 0, 4, 32, "N", 74, 15, "W", "Bogota, Colombia"], + 22: ["BOS", -5, 14, 42, 21, "N", 71, 5, "W", "Boston, Mass."], + 23: ["BRE", 1, 4, 53, 5, "N", 8, 49, "E", "Bremen, Germany"], + 24: ["BRU", 1, 4, 50, 52, "N", 4, 22, "E", "Brussels, Belgium"], + 25: ["BUA", -3, 0, 34, 35, "S", 58, 22, "W", "Buenos Aires, Argentina"], + 26: ["BUD", 1, 4, 47, 30, "N", 19, 5, "E", "Budapest, Hungary"], + 27: ["BWI", -5, 14, 39, 18, "N", 76, 38, "W", "Baltimore, Md."], + 28: ["CAI", 2, 5, 30, 2, "N", 31, 21, "E", "Cairo, Egypt"], + 29: ["CCS", -4, 0, 10, 28, "N", 67, 2, "W", "Caracas, Venezuela"], + 30: ["CCU", 5.5, 0, 22, 34, "N", 88, 24, "E", "Calcutta, India (as Kolkata)"], + 31: ["CGX", -6, 14, 41, 50, "N", 87, 37, "W", "Chicago, IL"], + 32: ["CLE", -5, 14, 41, 28, "N", 81, 37, "W", "Cleveland, Ohio"], + 33: ["CMH", -5, 14, 40, 0, "N", 83, 1, "W", "Columbus, Ohio"], + 34: ["COR", -3, 0, 31, 28, "S", 64, 10, "W", "Cordoba, Argentina"], + 35: ["CPH", 1, 4, 55, 40, "N", 12, 34, "E", "Copenhagen, Denmark"], + 36: ["CPT", 2, 0, 33, 55, "S", 18, 22, "E", "Cape Town, South Africa"], + 37: ["CUU", -6, 14, 28, 37, "N", 106, 5, "W", "Chihuahua, Mexico"], + 38: ["CVG", -5, 14, 39, 8, "N", 84, 30, "W", "Cincinnati, Ohio"], + 39: ["DAL", -6, 14, 32, 46, "N", 96, 46, "W", "Dallas, Tex."], + 40: ["DCA", -5, 14, 38, 53, "N", 77, 2, "W", "Washington, D.C."], + 41: ["DEL", 5.5, 0, 28, 35, "N", 77, 12, "E", "New Delhi, India"], + 42: ["DEN", -7, 14, 39, 45, "N", 105, 0, "W", "Denver, Colo."], + 43: ["DKR", 0, 0, 14, 40, "N", 17, 28, "W", "Dakar, Senegal"], + 44: ["DTW", -5, 14, 42, 20, "N", 83, 3, "W", "Detroit, Mich."], + 45: ["DUB", 0, 4, 53, 20, "N", 6, 15, "W", "Dublin, Ireland"], + 46: ["DUR", 2, 0, 29, 53, "S", 30, 53, "E", "Durban, South Africa"], + 47: ["ELP", -7, 14, 31, 46, "N", 106, 29, "W", "El Paso, Tex."], + 48: ["FIH", 1, 0, 4, 18, "S", 15, 17, "E", "Kinshasa, Congo"], + 49: ["FRA", 1, 4, 50, 7, "N", 8, 41, "E", "Frankfurt, Germany"], + 50: ["GLA", 0, 4, 55, 50, "N", 4, 15, "W", "Glasgow, Scotland"], + 51: ["GUA", -6, 0, 14, 37, "N", 90, 31, "W", "Guatemala City, Guatemala"], + 52: ["HAM", 1, 4, 53, 33, "N", 10, 2, "E", "Hamburg, Germany"], + 53: ["HAV", -5, 6, 23, 8, "N", 82, 23, "W", "Havana, Cuba"], + 54: ["HEL", 2, 4, 60, 10, "N", 25, 0, "E", "Helsinki, Finland"], + 55: ["HKG", 8, 0, 22, 20, "N", 114, 11, "E", "Hong Kong, China"], + 56: ["HOU", -6, 14, 29, 45, "N", 95, 21, "W", "Houston, Tex."], + 57: ["IKT", 8, 8, 52, 30, "N", 104, 20, "E", "Irkutsk, Russia"], + 58: ["IND", -5, 0, 39, 46, "N", 86, 10, "W", "Indianapolis, Ind."], + 59: ["JAX", -5, 14, 30, 22, "N", 81, 40, "W", "Jacksonville, Fla."], + 60: ["JKT", 7, 0, 6, 16, "S", 106, 48, "E", "Jakarta, Indonesia"], + 61: ["JNB", 2, 0, 26, 12, "S", 28, 4, "E", "Johannesburg, South Africa"], + 62: ["KIN", -5, 0, 17, 59, "N", 76, 49, "W", "Kingston, Jamaica"], + 63: ["KIX", 9, 0, 34, 32, "N", 135, 30, "E", "Osaka, Japan"], + 64: ["KUL", 8, 0, 3, 8, "N", 101, 42, "E", "Kuala Lumpur, Malaysia"], + 65: ["LAS", -8, 14, 36, 10, "N", 115, 12, "W", "Las Vegas, Nev."], + 66: ["LAX", -8, 14, 34, 3, "N", 118, 15, "W", "Los Angeles, Calif."], + 67: ["LIM", -5, 0, 12, 0, "S", 77, 2, "W", "Lima, Peru"], + 68: ["LIS", 0, 4, 38, 44, "N", 9, 9, "W", "Lisbon, Portugal"], + 69: ["LON", 0, 4, 51, 32, "N", 0, 5, "W", "London, England"], + 70: ["LPB", -4, 0, 16, 27, "S", 68, 22, "W", "La Paz, Bolivia"], + 71: ["LPL", 0, 4, 53, 25, "N", 3, 0, "W", "Liverpool, England"], + 72: ["LYO", 1, 4, 45, 45, "N", 4, 50, "E", "Lyon, France"], + 73: ["MAD", 1, 4, 40, 26, "N", 3, 42, "W", "Madrid, Spain"], + 74: ["MEL", 10, 1, 37, 47, "S", 144, 58, "E", "Melbourne, Australia"], + 75: ["MEM", -6, 14, 35, 9, "N", 90, 3, "W", "Memphis, Tenn."], + 76: ["MEX", -6, 14, 19, 26, "N", 99, 7, "W", "Mexico City, Mexico"], + 77: ["MIA", -5, 14, 25, 46, "N", 80, 12, "W", "Miami, Fla."], + 78: ["MIL", 1, 4, 45, 27, "N", 9, 10, "E", "Milan, Italy"], + 79: ["MKE", -6, 14, 43, 2, "N", 87, 55, "W", "Milwaukee, Wis."], + 80: ["MNL", 8, 0, 14, 35, "N", 120, 57, "E", "Manila, Philippines"], + 81: ["MOW", 3, 8, 55, 45, "N", 37, 36, "E", "Moscow, Russia"], + 82: ["MRS", 1, 4, 43, 20, "N", 5, 20, "E", "Marseille, France"], + 83: ["MSP", -6, 14, 44, 59, "N", 93, 14, "W", "Minneapolis, Minn."], + 84: ["MSY", -6, 14, 29, 57, "N", 90, 4, "W", "New Orleans, La."], + 85: ["MUC", 1, 4, 48, 8, "N", 11, 35, "E", "Munich, Germany"], + 86: ["MVD", -3, 9, 34, 53, "S", 56, 10, "W", "Montevideo, Uruguay"], + 87: ["NAP", 1, 4, 40, 50, "N", 14, 15, "E", "Naples, Italy"], + 88: ["NBO", 3, 0, 1, 25, "S", 36, 55, "E", "Nairobi, Kenya"], + 89: ["NKG", 8, 0, 32, 3, "N", 118, 53, "E", "Nanjing (Nanking), China"], + 90: ["NYC", -5, 14, 40, 47, "N", 73, 58, "W", "New York, N.Y."], + 91: ["ODS", 2, 4, 46, 27, "N", 30, 48, "E", "Odessa, Ukraine"], + 92: ["OKC", -6, 14, 35, 26, "N", 97, 28, "W", "Oklahoma City, Okla."], + 93: ["OMA", -6, 14, 41, 15, "N", 95, 56, "W", "Omaha, Neb."], + 94: ["OSL", 1, 4, 59, 57, "N", 10, 42, "E", "Oslo, Norway"], + 95: ["PAR", 1, 4, 48, 48, "N", 2, 20, "E", "Paris, France"], + 96: ["PDX", -8, 14, 45, 31, "N", 122, 41, "W", "Portland, Ore."], + 97: ["PER", 8, 0, 31, 57, "S", 115, 52, "E", "Perth, Australia"], + 98: ["PHL", -5, 14, 39, 57, "N", 75, 10, "W", "Philadelphia, Pa."], + 99: ["PHX", -7, 0, 33, 29, "N", 112, 4, "W", "Phoenix, Ariz."], + 100: ["PIT", -5, 14, 40, 27, "N", 79, 57, "W", "Pittsburgh, Pa."], + 101: ["PRG", 1, 4, 50, 5, "N", 14, 26, "E", "Prague, Czech Republic"], + 102: ["PTY", -5, 0, 8, 58, "N", 79, 32, "W", "Panama City, Panama"], + 103: ["RGN", 6.5, 0, 16, 50, "N", 96, 0, "E", "Rangoon, Myanmar"], + 104: ["RIO", -3, 2, 22, 57, "S", 43, 12, "W", "Rio de Janeiro, Brazil"], + 105: ["RKV", 0, 0, 64, 4, "N", 21, 58, "W", "Reykjavik, Iceland"], + 106: ["ROM", 1, 4, 41, 54, "N", 12, 27, "E", "Rome, Italy"], + 107: ["SAN", -8, 14, 32, 42, "N", 117, 10, "W", "San Diego, Calif."], + 108: ["SAT", -6, 14, 29, 23, "N", 98, 33, "W", "San Antonio, Tex."], + 109: ["SCL", -4, 3, 33, 28, "S", 70, 45, "W", "Santiago, Chile"], + 110: ["SEA", -8, 14, 47, 37, "N", 122, 20, "W", "Seattle, Wash."], + 111: ["SFO", -8, 14, 37, 47, "N", 122, 26, "W", "San Francisco, Calif."], + 112: ["SHA", 8, 0, 31, 10, "N", 121, 28, "E", "Shanghai, China"], + 113: ["SIN", 8, 0, 1, 14, "N", 103, 55, "E", "Singapore, Singapore"], + 114: ["SJC", -8, 14, 37, 20, "N", 121, 53, "W", "San Jose, Calif."], + 115: ["SOF", 2, 4, 42, 40, "N", 23, 20, "E", "Sofia, Bulgaria"], + 116: ["SPL", -3, 2, 23, 31, "S", 46, 31, "W", "Sao Paulo, Brazil"], + 117: ["SSA", -3, 0, 12, 56, "S", 38, 27, "W", "Salvador, Brazil"], + 118: ["STL", -6, 14, 38, 35, "N", 90, 12, "W", "St. Louis, Mo."], + 119: ["SYD", 10, 1, 34, 0, "S", 151, 0, "E", "Sydney, Australia"], + 120: ["TKO", 9, 0, 35, 40, "N", 139, 45, "E", "Tokyo, Japan"], + 121: ["TPA", -5, 14, 27, 57, "N", 82, 27, "W", "Tampa, Fla."], + 122: ["TRP", 2, 0, 32, 57, "N", 13, 12, "E", "Tripoli, Libya"], + 123: ["USR", 0, 0, 0, 0, "N", 0, 0, "W", "User defined city"], + 124: ["VAC", -8, 14, 49, 16, "N", 123, 7, "W", "Vancouver, Canada"], + 125: ["VIE", 1, 4, 48, 14, "N", 16, 20, "E", "Vienna, Austria"], + 126: ["WAW", 1, 4, 52, 14, "N", 21, 0, "E", "Warsaw, Poland"], + 127: ["YMX", -5, 14, 45, 30, "N", 73, 35, "W", "Montreal, Que., Can."], + 128: ["YOW", -5, 14, 45, 24, "N", 75, 43, "W", "Ottawa, Ont., Can."], + 129: ["YTZ", -5, 14, 43, 40, "N", 79, 24, "W", "Toronto, Ont., Can."], + 130: ["YVR", -8, 14, 49, 13, "N", 123, 6, "W", "Vancouver, B.C., Can."], + 131: ["YYC", -7, 14, 51, 1, "N", 114, 1, "W", "Calgary, Alba., Can."], + 132: ["ZRH", 1, 4, 47, 21, "N", 8, 31, "E", "Zurich, Switzerland"] + } + + @property + def version(self): + return DRIVER_VERSION + + def add_options(self, parser): + super(TE923Configurator, self).add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N history records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display history records since N minutes ago") + parser.add_option("--minmax", dest="minmax", action="store_true", + help="display historical min/max data") + parser.add_option("--get-date", dest="getdate", action="store_true", + help="display station date") + parser.add_option("--set-date", dest="setdate", + type=str, metavar="YEAR,MONTH,DAY", + help="set station date") + parser.add_option("--sync-date", dest="syncdate", action="store_true", + help="set station date using system clock") + parser.add_option("--get-location-local", dest="loc_local", + action="store_true", + help="display local location and timezone") + parser.add_option("--set-location-local", dest="setloc_local", + type=str, metavar=self.LOCSTR, + help="set local location and timezone") + parser.add_option("--get-location-alt", dest="loc_alt", + action="store_true", + help="display alternate location and timezone") + parser.add_option("--set-location-alt", dest="setloc_alt", + type=str, metavar=self.LOCSTR, + help="set alternate location and timezone") + parser.add_option("--get-altitude", dest="getalt", action="store_true", + help="display altitude") + parser.add_option("--set-altitude", dest="setalt", type=int, + metavar="ALT", help="set altitude (meters)") + parser.add_option("--get-alarms", dest="getalarms", + action="store_true", help="display alarms") + parser.add_option("--set-alarms", dest="setalarms", type=str, + metavar=self.ALMSTR, help="set alarm state") + parser.add_option("--get-interval", dest="getinterval", + action="store_true", help="display archive interval") + parser.add_option("--set-interval", dest="setinterval", + type=str, metavar="INTERVAL", + help="set archive interval (minutes)") + parser.add_option("--format", dest="format", + type=str, metavar="FORMAT", default='table', + help="formats include: table, dict") + + def do_options(self, options, parser, config_dict, prompt): # @UnusedVariable + if (options.format.lower() != 'table' and + options.format.lower() != 'dict'): + parser.error("Unknown format '%s'. Known formats include 'table' and 'dict'." % options.format) + + with TE923Station() as station: + if options.info is not None: + self.show_info(station, fmt=options.format) + elif options.current is not None: + self.show_current(station, fmt=options.format) + elif options.nrecords is not None: + self.show_history(station, count=options.nrecords, + fmt=options.format) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(station, ts=ts, fmt=options.format) + elif options.minmax is not None: + self.show_minmax(station) + elif options.getdate is not None: + self.show_date(station) + elif options.setdate is not None: + self.set_date(station, options.setdate) + elif options.syncdate: + self.set_date(station, None) + elif options.loc_local is not None: + self.show_location(station, 0) + elif options.setloc_local is not None: + self.set_location(station, 0, options.setloc_local) + elif options.loc_alt is not None: + self.show_location(station, 1) + elif options.setloc_alt is not None: + self.set_location(station, 1, options.setloc_alt) + elif options.getalt is not None: + self.show_altitude(station) + elif options.setalt is not None: + self.set_altitude(station, options.setalt) + elif options.getalarms is not None: + self.show_alarms(station) + elif options.setalarms is not None: + self.set_alarms(station, options.setalarms) + elif options.getinterval is not None: + self.show_interval(station) + elif options.setinterval is not None: + self.set_interval(station, options.setinterval) + + @staticmethod + def show_info(station, fmt='dict'): + print('Querying the station for the configuration...') + data = station.get_config() + TE923Configurator.print_data(data, fmt) + + @staticmethod + def show_current(station, fmt='dict'): + print('Querying the station for current weather data...') + data = station.get_readings() + TE923Configurator.print_data(data, fmt) + + @staticmethod + def show_history(station, ts=0, count=None, fmt='dict'): + print("Querying the station for historical records...") + for r in station.gen_records(ts, count): + TE923Configurator.print_data(r, fmt) + + @staticmethod + def show_minmax(station): + print("Querying the station for historical min/max data") + data = station.get_minmax() + print("Console Temperature Min : %s" % data['t_in_min']) + print("Console Temperature Max : %s" % data['t_in_max']) + print("Console Humidity Min : %s" % data['h_in_min']) + print("Console Humidity Max : %s" % data['h_in_max']) + for i in range(1, 6): + print("Channel %d Temperature Min : %s" % (i, data['t_%d_min' % i])) + print("Channel %d Temperature Max : %s" % (i, data['t_%d_max' % i])) + print("Channel %d Humidity Min : %s" % (i, data['h_%d_min' % i])) + print("Channel %d Humidity Max : %s" % (i, data['h_%d_max' % i])) + print("Wind speed max since midnight : %s" % data['windspeed_max']) + print("Wind gust max since midnight : %s" % data['windgust_max']) + print("Rain yesterday : %s" % data['rain_yesterday']) + print("Rain this week : %s" % data['rain_week']) + print("Rain this month : %s" % data['rain_month']) + print("Last Barometer reading : %s" % time.strftime( + "%Y %b %d %H:%M", time.localtime(data['barometer_ts']))) + for i in range(25): + print(" T-%02d Hours : %.1f" % (i, data['barometer_%d' % i])) + + @staticmethod + def show_date(station): + ts = station.get_date() + tt = time.localtime(ts) + print("Date: %02d/%02d/%d" % (tt[2], tt[1], tt[0])) + TE923Configurator.print_alignment() + + @staticmethod + def set_date(station, datestr): + if datestr is not None: + date_list = datestr.split(',') + if len(date_list) != 3: + print("Bad date '%s', format is YEAR,MONTH,DAY" % datestr) + return + if int(date_list[0]) < 2000 or int(date_list[0]) > 2099: + print("Year must be between 2000 and 2099 inclusive") + return + if int(date_list[1]) < 1 or int(date_list[1]) > 12: + print("Month must be between 1 and 12 inclusive") + return + if int(date_list[2]) < 1 or int(date_list[2]) > 31: + print("Day must be between 1 and 31 inclusive") + return + tt = time.localtime() + offset = 1 if tt[3] < 12 else 0 + ts = time.mktime((int(date_list[0]), int(date_list[1]), int(date_list[2]) - offset, 0, 0, 0, 0, 0, 0)) + else: + ts = time.time() + station.set_date(ts) + TE923Configurator.print_alignment() + + def show_location(self, station, loc_type): + data = station.get_loc(loc_type) + print("City : %s (%s)" % (self.city_dict[data['city_time']][9], + self.city_dict[data['city_time']][0])) + degree_sign= u'\N{DEGREE SIGN}'.encode('iso-8859-1') + print("Location : %03d%s%02d'%s %02d%s%02d'%s" % ( + data['long_deg'], degree_sign, data['long_min'], data['long_dir'], + data['lat_deg'], degree_sign, data['lat_min'], data['lat_dir'])) + if data['dst_always_on']: + print("DST : Always on") + else: + print("DST : %s (%s)" % (self.dst_dict[data['dst']][1], + self.dst_dict[data['dst']][0])) + + def set_location(self, station, loc_type, location): + dst_on = 1 + dst_index = 0 + location_list = location.split(',') + if len(location_list) == 1 and location_list[0] != "USR": + city_index = None + for idx in range(len(self.city_dict)): + if self.city_dict[idx][0] == location_list[0]: + city_index = idx + break + if city_index is None: + print("City code '%s' not recognized - consult station manual for valid city codes" % location_list[0]) + return + long_deg = self.city_dict[city_index][6] + long_min = self.city_dict[city_index][7] + long_dir = self.city_dict[city_index][8] + lat_deg = self.city_dict[city_index][3] + lat_min = self.city_dict[city_index][4] + lat_dir = self.city_dict[city_index][5] + tz_hr = int(self.city_dict[city_index][1]) + tz_min = 0 if self.city_dict[city_index][1] == int(self.city_dict[city_index][1]) else 30 + dst_on = 0 + dst_index = self.city_dict[city_index][2] + elif len(location_list) == 9 and location_list[0] == "USR": + if int(location_list[1]) < 0 or int(location_list[1]) > 180: + print("Longitude degrees must be between 0 and 180 inclusive") + return + if int(location_list[2]) < 0 or int(location_list[2]) > 180: + print("Longitude minutes must be between 0 and 59 inclusive") + return + if location_list[3] != "E" and location_list[3] != "W": + print("Longitude direction must be E or W") + return + if int(location_list[4]) < 0 or int(location_list[4]) > 180: + print("Latitude degrees must be between 0 and 90 inclusive") + return + if int(location_list[5]) < 0 or int(location_list[5]) > 180: + print("Latitude minutes must be between 0 and 59 inclusive") + return + if location_list[6] != "N" and location_list[6] != "S": + print("Longitude direction must be N or S") + return + tz_list = location_list[7].split(':') + if len(tz_list) != 2: + print("Bad timezone '%s', format is HOUR:MINUTE" % location_list[7]) + return + if int(tz_list[0]) < -12 or int(tz_list[0]) > 12: + print("Timezone hour must be between -12 and 12 inclusive") + return + if int(tz_list[1]) != 0 and int(tz_list[1]) != 30: + print("Timezone minute must be 0 or 30") + return + if location_list[8].lower() != 'on': + dst_on = 0 + dst_index = None + for idx in range(16): + if self.dst_dict[idx][0] == location_list[8]: + dst_index = idx + break + if dst_index is None: + print("DST code '%s' not recognized - consult station manual for valid DST codes" % location_list[8]) + return + else: + dst_on = 1 + dst_index = 0 + city_index = 123 # user-defined city + long_deg = int(location_list[1]) + long_min = int(location_list[2]) + long_dir = location_list[3] + lat_deg = int(location_list[4]) + lat_min = int(location_list[5]) + lat_dir = location_list[6] + tz_hr = int(tz_list[0]) + tz_min = int(tz_list[1]) + else: + print("Bad location '%s'" % location) + print("Location format is: %s" % self.LOCSTR) + return + station.set_loc(loc_type, city_index, dst_on, dst_index, tz_hr, tz_min, + lat_deg, lat_min, lat_dir, + long_deg, long_min, long_dir) + + @staticmethod + def show_altitude(station): + altitude = station.get_alt() + print("Altitude: %d meters" % altitude) + + @staticmethod + def set_altitude(station, altitude): + if altitude < -200 or altitude > 5000: + print("Altitude must be between -200 and 5000 inclusive") + return + station.set_alt(altitude) + + @staticmethod + def show_alarms(station): + data = station.get_alarms() + print("Weekday alarm : %02d:%02d (%s)" % ( + data['weekday_hour'], data['weekday_min'], data['weekday_active'])) + print("Single alarm : %02d:%02d (%s)" % ( + data['single_hour'], data['single_min'], data['single_active'])) + print("Pre-alarm : %s (%s)" % ( + data['prealarm_period'], data['prealarm_active'])) + if data['snooze'] > 0: + print("Snooze : %d mins" % data['snooze']) + else: + print("Snooze : Invalid") + print("Max Temperature Alarm : %s" % data['max_temp']) + print("Min Temperature Alarm : %s" % data['min_temp']) + print("Rain Alarm : %d mm (%s)" % ( + data['rain'], data['rain_active'])) + print("Wind Speed Alarm : %s (%s)" % ( + data['windspeed'], data['windspeed_active'])) + print("Wind Gust Alarm : %s (%s)" % ( + data['windgust'], data['windgust_active'])) + + @staticmethod + def set_alarms(station, alarm): + alarm_list = alarm.split(',') + if len(alarm_list) != 9: + print("Bad alarm '%s'" % alarm) + print("Alarm format is: %s" % TE923Configurator.ALMSTR) + return + weekday = alarm_list[0] + if weekday.lower() != 'off': + weekday_list = weekday.split(':') + if len(weekday_list) != 2: + print("Bad alarm '%s', expected HOUR:MINUTE or OFF" % weekday) + return + if int(weekday_list[0]) < 0 or int(weekday_list[0]) > 23: + print("Alarm hours must be between 0 and 23 inclusive") + return + if int(weekday_list[1]) < 0 or int(weekday_list[1]) > 59: + print("Alarm minutes must be between 0 and 59 inclusive") + return + single = alarm_list[1] + if single.lower() != 'off': + single_list = single.split(':') + if len(single_list) != 2: + print("Bad alarm '%s', expected HOUR:MINUTE or OFF" % single) + return + if int(single_list[0]) < 0 or int(single_list[0]) > 23: + print("Alarm hours must be between 0 and 23 inclusive") + return + if int(single_list[1]) < 0 or int(single_list[1]) > 59: + print("Alarm minutes must be between 0 and 59 inclusive") + return + if alarm_list[2].lower() != 'off' and alarm_list[2] not in ['15', '30', '45', '60', '90']: + print("Prealarm must be 15, 30, 45, 60, 90 or OFF") + return + if int(alarm_list[3]) < 1 or int(alarm_list[3]) > 15: + print("Snooze must be between 1 and 15 inclusive") + return + if float(alarm_list[4]) < -50 or float(alarm_list[4]) > 70: + print("Temperature alarm must be between -50 and 70 inclusive") + return + if float(alarm_list[5]) < -50 or float(alarm_list[5]) > 70: + print("Temperature alarm must be between -50 and 70 inclusive") + return + if alarm_list[6].lower() != 'off' and (int(alarm_list[6]) < 1 or int(alarm_list[6]) > 9999): + print("Rain alarm must be between 1 and 999 inclusive or OFF") + return + if alarm_list[7].lower() != 'off' and (float(alarm_list[7]) < 1 or float(alarm_list[7]) > 199): + print("Wind alarm must be between 1 and 199 inclusive or OFF") + return + if alarm_list[8].lower() != 'off' and (float(alarm_list[8]) < 1 or float(alarm_list[8]) > 199): + print("Wind alarm must be between 1 and 199 inclusive or OFF") + return + station.set_alarms(alarm_list[0], alarm_list[1], alarm_list[2], + alarm_list[3], alarm_list[4], alarm_list[5], + alarm_list[6], alarm_list[7], alarm_list[8]) + print("Temperature alarms can only be modified via station controls") + + @staticmethod + def show_interval(station): + idx = station.get_interval() + print("Interval: %s" % TE923Configurator.idx_to_interval.get(idx, 'unknown')) + + @staticmethod + def set_interval(station, interval): + """accept 30s|2h|1d format or raw minutes, but only known intervals""" + idx = TE923Configurator.interval_to_idx.get(interval) + if idx is None: + try: + ival = int(interval * 60) + for i in TE923Station.idx_to_interval_sec: + if ival == TE923Station.idx_to_interval_sec[i]: + idx = i + except ValueError: + pass + if idx is None: + print("Bad interval '%s'" % interval) + print("Valid intervals are %s" % ','.join(list(TE923Configurator.interval_to_idx.keys()))) + return + station.set_interval(idx) + + @staticmethod + def print_data(data, fmt): + if fmt.lower() == 'table': + TE923Configurator.print_table(data) + else: + print(data) + + @staticmethod + def print_table(data): + for key in sorted(data): + print("%s: %s" % (key.rjust(16), data[key])) + + @staticmethod + def print_alignment(): + print(" If computer time is not aligned to station time then date") + print(" may be incorrect by 1 day") + + +class TE923Driver(weewx.drivers.AbstractDevice): + """Driver for Hideki TE923 stations.""" + + def __init__(self, **stn_dict): + """Initialize the station object. + + polling_interval: How often to poll the station, in seconds. + [Optional. Default is 10] + + model: Which station model is this? + [Optional. Default is 'TE923'] + """ + log.info('driver version is %s' % DRIVER_VERSION) + + global DEBUG_READ + DEBUG_READ = int(stn_dict.get('debug_read', DEBUG_READ)) + global DEBUG_WRITE + DEBUG_WRITE = int(stn_dict.get('debug_write', DEBUG_WRITE)) + global DEBUG_DECODE + DEBUG_DECODE = int(stn_dict.get('debug_decode', DEBUG_DECODE)) + + self._last_rain_loop = None + self._last_rain_archive = None + self._last_ts = None + + self.model = stn_dict.get('model', 'TE923') + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.retry_wait = int(stn_dict.get('retry_wait', 3)) + self.read_timeout = int(stn_dict.get('read_timeout', 10)) + self.polling_interval = int(stn_dict.get('polling_interval', 10)) + log.info('polling interval is %s' % str(self.polling_interval)) + self.sensor_map = dict(DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('sensor map is %s' % self.sensor_map) + + self.station = TE923Station(max_tries=self.max_tries, + retry_wait=self.retry_wait, + read_timeout=self.read_timeout) + self.station.open() + log.info('logger capacity %s records' % self.station.get_memory_size()) + ts = self.station.get_date() + now = int(time.time()) + log.info('station time is %s, computer time is %s' % (ts, now)) + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + +# @property +# def archive_interval(self): +# return self.station.get_interval_seconds() + + def genLoopPackets(self): + while True: + data = self.station.get_readings() + status = self.station.get_status() + packet = self.data_to_packet(data, status=status, + last_rain=self._last_rain_loop, + sensor_map=self.sensor_map) + self._last_rain_loop = packet['rainTotal'] + yield packet + time.sleep(self.polling_interval) + + # same as genStartupRecords, but insert battery status on the last record. + # when record_generation is hardware, this results in a full suit of sensor + # data, but with the archive interval calculations done by the hardware. +# def genArchiveRecords(self, since_ts=0): +# for data in self.station.gen_records(since_ts): +# # FIXME: insert battery status on the last record +# packet = self.data_to_packet(data, status=None, +# last_rain=self._last_rain_archive, +# sensor_map=self.sensor_map) +# self._last_rain_archive = packet['rainTotal'] +# if self._last_ts: +# packet['interval'] = (packet['dateTime'] - self._last_ts) / 60 +# yield packet +# self._last_ts = packet['dateTime'] + + # there is no battery status for historical records. + def genStartupRecords(self, since_ts=0): + log.info("reading records from logger since %s" % since_ts) + cnt = 0 + for data in self.station.gen_records(since_ts): + packet = self.data_to_packet(data, status=None, + last_rain=self._last_rain_archive, + sensor_map=self.sensor_map) + self._last_rain_archive = packet['rainTotal'] + if self._last_ts: + packet['interval'] = (packet['dateTime'] - self._last_ts) // 60 + if packet['interval'] > 0: + cnt += 1 + yield packet + else: + log.info("skip packet with duplidate timestamp: %s" % packet) + self._last_ts = packet['dateTime'] + if cnt % 50 == 0: + log.info("read %s records from logger" % cnt) + log.info("read %s records from logger" % cnt) + + @staticmethod + def data_to_packet(data, status, last_rain, sensor_map): + """convert raw data to format and units required by weewx + + station weewx (metric) + temperature degree C degree C + humidity percent percent + uv index unitless unitless + slp mbar mbar + wind speed mile/h km/h + wind gust mile/h km/h + wind dir degree degree + rain mm cm + rain rate cm/h + """ + + packet = dict() + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = data['dateTime'] + + # include the link status - 0 indicates ok, 1 indicates no link + data['link_wind'] = 0 if data['windspeed_state'] == STATE_OK else 1 + data['link_rain'] = 0 if data['rain_state'] == STATE_OK else 1 + data['link_uv'] = 0 if data['uv_state'] == STATE_OK else 1 + data['link_1'] = 0 if data['t_1_state'] == STATE_OK else 1 + data['link_2'] = 0 if data['t_2_state'] == STATE_OK else 1 + data['link_3'] = 0 if data['t_3_state'] == STATE_OK else 1 + data['link_4'] = 0 if data['t_4_state'] == STATE_OK else 1 + data['link_5'] = 0 if data['t_5_state'] == STATE_OK else 1 + + # map extensible sensors to database fields + for label in sensor_map: + if sensor_map[label] in data: + packet[label] = data[sensor_map[label]] + elif status is not None and sensor_map[label] in status: + packet[label] = int(status[sensor_map[label]]) + + # handle unit converstions + packet['windSpeed'] = data.get('windspeed') + if packet['windSpeed'] is not None: + packet['windSpeed'] *= 1.60934 # speed is mph; weewx wants km/h + packet['windDir'] = data.get('winddir') + if packet['windDir'] is not None: + packet['windDir'] *= 22.5 # weewx wants degrees + + packet['windGust'] = data.get('windgust') + if packet['windGust'] is not None: + packet['windGust'] *= 1.60934 # speed is mph; weewx wants km/h + + packet['rainTotal'] = data['rain'] + if packet['rainTotal'] is not None: + packet['rainTotal'] *= 0.0705555556 # weewx wants cm (1/36 inch) + packet['rain'] = weewx.wxformulas.calculate_rain( + packet['rainTotal'], last_rain) + + # some stations report uv + packet['UV'] = data['uv'] + + # station calculates windchill + packet['windchill'] = data['windchill'] + + # station reports baromter (SLP) + packet['barometer'] = data['slp'] + + # forecast and storm fields use the station's algorithms + packet['forecast'] = data['forecast'] + packet['storm'] = data['storm'] + + return packet + + +STATE_OK = 'ok' +STATE_INVALID = 'invalid' +STATE_NO_LINK = 'no_link' + +def _fmt(buf): + if buf: + return ' '.join(["%02x" % x for x in buf]) + return '' + +def bcd2int(bcd): + return int(((bcd & 0xf0) >> 4) * 10) + int(bcd & 0x0f) + +def rev_bcd2int(bcd): + return int((bcd & 0xf0) >> 4) + int((bcd & 0x0f) * 10) + +def int2bcd(num): + return int(num / 10) * 0x10 + (num % 10) + +def rev_int2bcd(num): + return (num % 10) * 0x10 + int(num / 10) + +def decode(buf): + data = dict() + for i in range(6): # console plus 5 remote channels + data.update(decode_th(buf, i)) + data.update(decode_uv(buf)) + data.update(decode_pressure(buf)) + data.update(decode_forecast(buf)) + data.update(decode_windchill(buf)) + data.update(decode_wind(buf)) + data.update(decode_rain(buf)) + return data + +def decode_th(buf, i): + if i == 0: + tlabel = 't_in' + hlabel = 'h_in' + else: + tlabel = 't_%d' % i + hlabel = 'h_%d' % i + tstate = '%s_state' % tlabel + hstate = '%s_state' % hlabel + offset = i * 3 + + if DEBUG_DECODE: + log.debug("TH%d BUF[%02d]=%02x BUF[%02d]=%02x BUF[%02d]=%02x" % + (i, 0 + offset, buf[0 + offset], 1 + offset, buf[1 + offset], + 2 + offset, buf[2 + offset])) + data = dict() + data[tlabel], data[tstate] = decode_temp(buf[0 + offset], buf[1 + offset], + i != 0) + data[hlabel], data[hstate] = decode_humid(buf[2 + offset]) + if DEBUG_DECODE: + log.debug("TH%d %s %s %s %s" % (i, data[tlabel], data[tstate], + data[hlabel], data[hstate])) + return data + +def decode_temp(byte1, byte2, remote): + """decode temperature. result is degree C.""" + if bcd2int(byte1 & 0x0f) > 9: + if byte1 & 0x0f == 0x0a: + return None, STATE_NO_LINK + else: + return None, STATE_INVALID + if byte2 & 0x40 != 0x40 and remote: + return None, STATE_INVALID + value = bcd2int(byte1) / 10.0 + bcd2int(byte2 & 0x0f) * 10.0 + if byte2 & 0x20 == 0x20: + value += 0.05 + if byte2 & 0x80 != 0x80: + value *= -1 + return value, STATE_OK + +def decode_humid(byte): + """decode humidity. result is percentage.""" + if bcd2int(byte & 0x0f) > 9: + if byte & 0x0f == 0x0a: + return None, STATE_NO_LINK + else: + return None, STATE_INVALID + return bcd2int(byte), STATE_OK + +# NB: te923tool does not include the 4-bit shift +def decode_uv(buf): + """decode data from uv sensor""" + data = dict() + if DEBUG_DECODE: + log.debug("UVX BUF[18]=%02x BUF[19]=%02x" % (buf[18], buf[19])) + if ((buf[18] == 0xaa and buf[19] == 0x0a) or + (buf[18] == 0xff and buf[19] == 0xff)): + data['uv_state'] = STATE_NO_LINK + data['uv'] = None + elif bcd2int(buf[18]) > 99 or bcd2int(buf[19]) > 99: + data['uv_state'] = STATE_INVALID + data['uv'] = None + else: + data['uv_state'] = STATE_OK + data['uv'] = bcd2int(buf[18] & 0x0f) / 10.0 \ + + bcd2int((buf[18] & 0xf0) >> 4) \ + + bcd2int(buf[19] & 0x0f) * 10.0 + if DEBUG_DECODE: + log.debug("UVX %s %s" % (data['uv'], data['uv_state'])) + return data + +def decode_pressure(buf): + """decode pressure data""" + data = dict() + if DEBUG_DECODE: + log.debug("PRS BUF[20]=%02x BUF[21]=%02x" % (buf[20], buf[21])) + if buf[21] & 0xf0 == 0xf0: + data['slp_state'] = STATE_INVALID + data['slp'] = None + else: + data['slp_state'] = STATE_OK + data['slp'] = int(buf[21] * 0x100 + buf[20]) * 0.0625 + if DEBUG_DECODE: + log.debug("PRS %s %s" % (data['slp'], data['slp_state'])) + return data + +# NB: te923tool divides speed/gust by 2.23694 (1 meter/sec = 2.23694 mile/hour) +# NB: wview does not divide speed/gust +# NB: wview multiplies winddir by 22.5, te923tool does not +def decode_wind(buf): + """decode wind speed, gust, and direction""" + data = dict() + if DEBUG_DECODE: + log.debug("WGS BUF[25]=%02x BUF[26]=%02x" % (buf[25], buf[26])) + data['windgust'], data['windgust_state'] = decode_ws(buf[25], buf[26]) + if DEBUG_DECODE: + log.debug("WGS %s %s" % (data['windgust'], data['windgust_state'])) + + if DEBUG_DECODE: + log.debug("WSP BUF[27]=%02x BUF[28]=%02x" % (buf[27], buf[28])) + data['windspeed'], data['windspeed_state'] = decode_ws(buf[27], buf[28]) + if DEBUG_DECODE: + log.debug("WSP %s %s" % (data['windspeed'], data['windspeed_state'])) + + if DEBUG_DECODE: + log.debug("WDR BUF[29]=%02x" % buf[29]) + data['winddir_state'] = data['windspeed_state'] + data['winddir'] = int(buf[29] & 0x0f) + if DEBUG_DECODE: + log.debug("WDR %s %s" % (data['winddir'], data['winddir_state'])) + + return data + +def decode_ws(byte1, byte2): + """decode wind speed, result is mph""" + if bcd2int(byte1 & 0xf0) > 90 or bcd2int(byte1 & 0x0f) > 9: + if ((byte1 == 0xee and byte2 == 0x8e) or + (byte1 == 0xff and byte2 == 0xff)): + return None, STATE_NO_LINK + else: + return None, STATE_INVALID + offset = 100 if byte2 & 0x10 == 0x10 else 0 + value = bcd2int(byte1) / 10.0 + bcd2int(byte2 & 0x0f) * 10.0 + offset + return value, STATE_OK + +# the rain counter is in the station, not the rain bucket. so if the link +# between rain bucket and station is lost, the station will miss rainfall and +# there is no way to know about it. +# FIXME: figure out how to detect link status between station and rain bucket +# NB: wview treats the raw rain count as millimeters +def decode_rain(buf): + """rain counter is number of bucket tips, each tip is about 0.03 inches""" + data = dict() + if DEBUG_DECODE: + log.debug("RAIN BUF[30]=%02x BUF[31]=%02x" % (buf[30], buf[31])) + data['rain_state'] = STATE_OK + data['rain'] = int(buf[31] * 0x100 + buf[30]) + if DEBUG_DECODE: + log.debug("RAIN %s %s" % (data['rain'], data['rain_state'])) + return data + +def decode_windchill(buf): + data = dict() + if DEBUG_DECODE: + log.debug("WCL BUF[23]=%02x BUF[24]=%02x" % (buf[23], buf[24])) + if bcd2int(buf[23] & 0xf0) > 90 or bcd2int(buf[23] & 0x0f) > 9: + if ((buf[23] == 0xee and buf[24] == 0x8e) or + (buf[23] == 0xff and buf[24] == 0xff)): + data['windchill_state'] = STATE_NO_LINK + else: + data['windchill_state'] = STATE_INVALID + data['windchill'] = None + elif buf[24] & 0x40 != 0x40: + data['windchill_state'] = STATE_INVALID + data['windchill'] = None + else: + data['windchill_state'] = STATE_OK + data['windchill'] = bcd2int(buf[23]) / 10.0 \ + + bcd2int(buf[24] & 0x0f) * 10.0 + if buf[24] & 0x20 == 0x20: + data['windchill'] += 0.05 + if buf[24] & 0x80 != 0x80: + data['windchill'] *= -1 + if DEBUG_DECODE: + log.debug("WCL %s %s" % (data['windchill'], data['windchill_state'])) + return data + +def decode_forecast(buf): + data = dict() + if DEBUG_DECODE: + log.debug("STT BUF[22]=%02x" % buf[22]) + if buf[22] & 0x0f == 0x0f: + data['storm'] = None + data['forecast'] = None + else: + data['storm'] = 1 if buf[22] & 0x08 == 0x08 else 0 + data['forecast'] = int(buf[22] & 0x07) + if DEBUG_DECODE: + log.debug("STT %s %s" % (data['storm'], data['forecast'])) + return data + + +class BadRead(weewx.WeeWxIOError): + """Bogus data length, CRC, header block, or other read failure""" + +class BadWrite(weewx.WeeWxIOError): + """Bogus data length, header block, or other write failure""" + +class BadHeader(weewx.WeeWxIOError): + """Bad header byte""" + +class TE923Station(object): + ENDPOINT_IN = 0x81 + READ_LENGTH = 0x8 + TIMEOUT = 1200 + START_ADDRESS = 0x101 + RECORD_SIZE = 0x26 + + idx_to_interval_sec = { + 1: 300, 2: 600, 3: 1200, 4: 1800, 5: 3600, 6: 5400, 7: 7200, + 8: 10800, 9: 14400, 10: 21600, 11: 86400} + + def __init__(self, vendor_id=0x1130, product_id=0x6801, + max_tries=10, retry_wait=5, read_timeout=5): + self.vendor_id = vendor_id + self.product_id = product_id + self.devh = None + self.max_tries = max_tries + self.retry_wait = retry_wait + self.read_timeout = read_timeout + + self._num_rec = None + self._num_blk = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, type_, value, traceback): # @UnusedVariable + self.close() + + def open(self, interface=0): + dev = self._find_dev(self.vendor_id, self.product_id) + if not dev: + log.critical("Cannot find USB device with VendorID=0x%04x ProductID=0x%04x" % (self.vendor_id, self.product_id)) + raise weewx.WeeWxIOError('Unable to find station on USB') + + self.devh = dev.open() + if not self.devh: + raise weewx.WeeWxIOError('Open USB device failed') + + # be sure kernel does not claim the interface + try: + self.devh.detachKernelDriver(interface) + except (AttributeError, usb.USBError): + pass + + # attempt to claim the interface + try: + self.devh.claimInterface(interface) + self.devh.setAltInterface(interface) + except usb.USBError as e: + self.close() + log.critical("Unable to claim USB interface %s: %s" % (interface, e)) + raise weewx.WeeWxIOError(e) + +# doing a reset seems to cause problems more often than it eliminates them +# self.devh.reset() + + # figure out which type of memory this station has + self.read_memory_size() + + def close(self): + try: + self.devh.releaseInterface() + except (ValueError, usb.USBError) as e: + log.error("release interface failed: %s" % e) + self.devh = None + + @staticmethod + def _find_dev(vendor_id, product_id): + """Find the vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vendor_id and dev.idProduct == product_id: + log.info('Found device on USB bus=%s device=%s' % + (bus.dirname, dev.filename)) + return dev + return None + + def _raw_read(self, addr): + reqbuf = [0x05, 0xAF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + reqbuf[4] = addr // 0x10000 + reqbuf[3] = (addr - (reqbuf[4] * 0x10000)) // 0x100 + reqbuf[2] = addr - (reqbuf[4] * 0x10000) - (reqbuf[3] * 0x100) + reqbuf[5] = (reqbuf[1] ^ reqbuf[2] ^ reqbuf[3] ^ reqbuf[4]) + ret = self.devh.controlMsg(requestType=0x21, + request=usb.REQ_SET_CONFIGURATION, + value=0x0200, + index=0x0000, + buffer=reqbuf, + timeout=self.TIMEOUT) + if ret != 8: + raise BadRead('Unexpected response to data request: %s != 8' % ret) + +# sleeping does not seem to have any effect on the reads +# time.sleep(0.1) # te923tool is 0.3 + start_ts = time.time() + rbuf = [] + while time.time() - start_ts < self.read_timeout: + try: + buf = self.devh.interruptRead( + self.ENDPOINT_IN, self.READ_LENGTH, self.TIMEOUT) + if buf: + nbytes = buf[0] + if nbytes > 7 or nbytes > len(buf) - 1: + raise BadRead("Bogus length during read: %d" % nbytes) + rbuf.extend(buf[1:1 + nbytes]) + if len(rbuf) >= 34: + break + except usb.USBError as e: + errmsg = repr(e) + if not ('No data available' in errmsg or 'No error' in errmsg): + raise +# sleeping seems to have no effect on the reads +# time.sleep(0.009) # te923tool is 0.15 + else: + log.debug("timeout while reading: ignoring bytes: %s" % _fmt(rbuf)) + raise BadRead("Timeout after %d bytes" % len(rbuf)) + + # Send acknowledgement whether or not it was a good read + reqbuf = [0x24, 0xAF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + reqbuf[4] = addr // 0x10000 + reqbuf[3] = (addr - (reqbuf[4] * 0x10000)) // 0x100 + reqbuf[2] = addr - (reqbuf[4] * 0x10000) - (reqbuf[3] * 0x100) + reqbuf[5] = (reqbuf[1] ^ reqbuf[2] ^ reqbuf[3] ^ reqbuf[4]) + ret = self.devh.controlMsg(requestType=0x21, + request=usb.REQ_SET_CONFIGURATION, + value=0x0200, + index=0x0000, + buffer=reqbuf, + timeout=self.TIMEOUT) + + # now check what we got + if len(rbuf) < 34: + raise BadRead("Not enough bytes: %d < 34" % len(rbuf)) + # there must be a header byte... + if rbuf[0] != 0x5a: + raise BadHeader("Bad header byte: %02x != %02x" % (rbuf[0], 0x5a)) + # ...and the last byte must be a valid crc + crc = 0x00 + for x in rbuf[:33]: + crc = crc ^ x + if crc != rbuf[33]: + raise BadRead("Bad crc: %02x != %02x" % (crc, rbuf[33])) + + # early versions of this driver used to get long reads, but these + # might not happen any more. log it then try to use the data anyway. + if len(rbuf) != 34: + log.info("read: wrong number of bytes: %d != 34" % len(rbuf)) + + return rbuf + + def _raw_write(self, addr, buf): + wbuf = [0] * 38 + wbuf[0] = 0xAE + wbuf[3] = addr // 0x10000 + wbuf[2] = (addr - (wbuf[3] * 0x10000)) // 0x100 + wbuf[1] = addr - (wbuf[3] * 0x10000) - (wbuf[2] * 0x100) + crc = wbuf[0] ^ wbuf[1] ^ wbuf[2] ^ wbuf[3] + for i in range(32): + wbuf[i + 4] = buf[i] + crc = crc ^ buf[i] + wbuf[36] = crc + for i in range(6): + if i == 5: + reqbuf = [0x2, + wbuf[i * 7], wbuf[1 + i * 7], + 0x00, 0x00, 0x00, 0x00, 0x00] + else: + reqbuf = [0x7, + wbuf[i * 7], wbuf[1 + i * 7], wbuf[2 + i * 7], + wbuf[3 + i * 7], wbuf[4 + i * 7], wbuf[5 + i * 7], + wbuf[6 + i * 7]] + if DEBUG_WRITE: + log.debug("write: %s" % _fmt(reqbuf)) + ret = self.devh.controlMsg(requestType=0x21, + request=usb.REQ_SET_CONFIGURATION, + value=0x0200, + index=0x0000, + buffer=reqbuf, + timeout=self.TIMEOUT) + if ret != 8: + raise BadWrite('Unexpected response: %s != 8' % ret) + + # Wait for acknowledgement + time.sleep(0.1) + start_ts = time.time() + rbuf = [] + while time.time() - start_ts < 5: + try: + tmpbuf = self.devh.interruptRead( + self.ENDPOINT_IN, self.READ_LENGTH, self.TIMEOUT) + if tmpbuf: + nbytes = tmpbuf[0] + if nbytes > 7 or nbytes > len(tmpbuf) - 1: + raise BadRead("Bogus length during read: %d" % nbytes) + rbuf.extend(tmpbuf[1:1 + nbytes]) + if len(rbuf) >= 1: + break + except usb.USBError as e: + errmsg = repr(e) + if not ('No data available' in errmsg or 'No error' in errmsg): + raise + time.sleep(0.009) + else: + raise BadWrite("Timeout after %d bytes" % len(rbuf)) + + if len(rbuf) != 1: + log.info("write: ack got wrong number of bytes: %d != 1" % len(rbuf)) + if len(rbuf) == 0: + raise BadWrite("Bad ack: zero length response") + elif rbuf[0] != 0x5a: + raise BadHeader("Bad header byte: %02x != %02x" % (rbuf[0], 0x5a)) + + def _read(self, addr): + """raw_read returns the entire 34-byte chunk, i.e., one header byte, + 32 data bytes, one checksum byte. this function simply returns it.""" + # FIXME: strip the header and checksum so that we return only the + # 32 bytes of data. this will require shifting every index + # pretty much everywhere else in this code. + if DEBUG_READ: + log.debug("read: address 0x%06x" % addr) + for cnt in range(self.max_tries): + try: + buf = self._raw_read(addr) + if DEBUG_READ: + log.debug("read: %s" % _fmt(buf)) + return buf + except (BadRead, BadHeader, usb.USBError) as e: + log.error("Failed attempt %d of %d to read data: %s" % + (cnt + 1, self.max_tries, e)) + log.debug("Waiting %d seconds before retry" % self.retry_wait) + time.sleep(self.retry_wait) + else: + raise weewx.RetriesExceeded("Read failed after %d tries" % + self.max_tries) + + def _write(self, addr, buf): + if DEBUG_WRITE: + log.debug("write: address 0x%06x: %s" % (addr, _fmt(buf))) + for cnt in range(self.max_tries): + try: + self._raw_write(addr, buf) + return + except (BadWrite, BadHeader, usb.USBError) as e: + log.error("Failed attempt %d of %d to write data: %s" % + (cnt + 1, self.max_tries, e)) + log.debug("Waiting %d seconds before retry" % self.retry_wait) + time.sleep(self.retry_wait) + else: + raise weewx.RetriesExceeded("Write failed after %d tries" % + self.max_tries) + + def read_memory_size(self): + buf = self._read(0xfc) + if DEBUG_DECODE: + log.debug("MEM BUF[1]=%s" % buf[1]) + if buf[1] == 0: + self._num_rec = 208 + self._num_blk = 256 + log.debug("detected small memory size") + elif buf[1] == 2: + self._num_rec = 3442 + self._num_blk = 4096 + log.debug("detected large memory size") + else: + msg = "Unrecognised memory size '%s'" % buf[1] + log.error(msg) + raise weewx.WeeWxIOError(msg) + + def get_memory_size(self): + return self._num_rec + + def gen_blocks(self, count=None): + """generator that returns consecutive blocks of station memory""" + if not count: + count = self._num_blk + for x in range(0, count * 32, 32): + buf = self._read(x) + yield x, buf + + def dump_memory(self): + for i in range(8): + buf = self._read(i * 32) + for j in range(4): + log.info("%02x : %02x %02x %02x %02x %02x %02x %02x %02x" % + (i * 32 + j * 8, buf[1 + j * 8], buf[2 + j * 8], + buf[3 + j * 8], buf[4 + j * 8], buf[5 + j * 8], + buf[6 + j * 8], buf[7 + j * 8], buf[8 + j * 8])) + + def get_config(self): + data = dict() + data.update(self.get_versions()) + data.update(self.get_status()) + data['latitude'], data['longitude'] = self.get_location() + data['altitude'] = self.get_altitude() + return data + + def get_versions(self): + data = dict() + buf = self._read(0x98) + if DEBUG_DECODE: + log.debug("VER BUF[1]=%s BUF[2]=%s BUF[3]=%s BUF[4]=%s BUF[5]=%s" % + (buf[1], buf[2], buf[3], buf[4], buf[5])) + data['version_bar'] = buf[1] + data['version_uv'] = buf[2] + data['version_rcc'] = buf[3] + data['version_wind'] = buf[4] + data['version_sys'] = buf[5] + if DEBUG_DECODE: + log.debug("VER bar=%s uv=%s rcc=%s wind=%s sys=%s" % + (data['version_bar'], data['version_uv'], + data['version_rcc'], data['version_wind'], + data['version_sys'])) + return data + + def get_status(self): + # map the battery status flags. 0 indicates ok, 1 indicates failure. + # FIXME: i get 1 for uv even when no uv link + # FIXME: i get 0 for th3, th4, th5 even when no link + status = dict() + buf = self._read(0x4c) + if DEBUG_DECODE: + log.debug("BAT BUF[1]=%02x" % buf[1]) + status['bat_rain'] = 0 if buf[1] & 0x80 == 0x80 else 1 + status['bat_wind'] = 0 if buf[1] & 0x40 == 0x40 else 1 + status['bat_uv'] = 0 if buf[1] & 0x20 == 0x20 else 1 + status['bat_5'] = 0 if buf[1] & 0x10 == 0x10 else 1 + status['bat_4'] = 0 if buf[1] & 0x08 == 0x08 else 1 + status['bat_3'] = 0 if buf[1] & 0x04 == 0x04 else 1 + status['bat_2'] = 0 if buf[1] & 0x02 == 0x02 else 1 + status['bat_1'] = 0 if buf[1] & 0x01 == 0x01 else 1 + if DEBUG_DECODE: + log.debug("BAT rain=%s wind=%s uv=%s th5=%s th4=%s th3=%s th2=%s th1=%s" % + (status['bat_rain'], status['bat_wind'], status['bat_uv'], + status['bat_5'], status['bat_4'], status['bat_3'], + status['bat_2'], status['bat_1'])) + return status + + # FIXME: is this any different than get_alt? + def get_altitude(self): + buf = self._read(0x5a) + if DEBUG_DECODE: + log.debug("ALT BUF[1]=%02x BUF[2]=%02x BUF[3]=%02x" % + (buf[1], buf[2], buf[3])) + altitude = buf[2] * 0x100 + buf[1] + if buf[3] & 0x8 == 0x8: + altitude *= -1 + if DEBUG_DECODE: + log.debug("ALT %s" % altitude) + return altitude + + # FIXME: is this any different than get_loc? + def get_location(self): + buf = self._read(0x06) + if DEBUG_DECODE: + log.debug("LOC BUF[1]=%02x BUF[2]=%02x BUF[3]=%02x BUF[4]=%02x BUF[5]=%02x BUF[6]=%02x" % (buf[1], buf[2], buf[3], buf[4], buf[5], buf[6])) + latitude = float(rev_bcd2int(buf[1])) + (float(rev_bcd2int(buf[2])) / 60) + if buf[5] & 0x80 == 0x80: + latitude *= -1 + longitude = float((buf[6] & 0xf0) // 0x10 * 100) + float(rev_bcd2int(buf[3])) + (float(rev_bcd2int(buf[4])) / 60) + if buf[5] & 0x40 == 0x00: + longitude *= -1 + if DEBUG_DECODE: + log.debug("LOC %s %s" % (latitude, longitude)) + return latitude, longitude + + def get_readings(self): + """get sensor readings from the station, return as dictionary""" + buf = self._read(0x020001) + data = decode(buf[1:]) + data['dateTime'] = int(time.time() + 0.5) + return data + + def _get_next_index(self): + """get the index of the next history record""" + buf = self._read(0xfb) + if DEBUG_DECODE: + log.debug("HIS BUF[3]=%02x BUF[5]=%02x" % (buf[3], buf[5])) + record_index = buf[3] * 0x100 + buf[5] + log.debug("record_index=%s" % record_index) + if record_index > self._num_rec: + msg = "record index of %d exceeds memory size of %d records" % ( + record_index, self._num_rec) + log.error(msg) + raise weewx.WeeWxIOError(msg) + return record_index + + def _get_starting_addr(self, requested): + """calculate the oldest and latest addresses""" + count = requested + if count is None: + count = self._num_rec + elif count > self._num_rec: + count = self._num_rec + log.info("too many records requested (%d), using %d instead" % + (requested, count)) + idx = self._get_next_index() + if idx < 1: + idx += self._num_rec + latest_addr = self.START_ADDRESS + (idx - 1) * self.RECORD_SIZE + oldest_addr = latest_addr - (count - 1) * self.RECORD_SIZE + log.debug("count=%s oldest_addr=0x%06x latest_addr=0x%06x" % + (count, oldest_addr, latest_addr)) + return oldest_addr, count + + def gen_records(self, since_ts=0, requested=None): + """return requested records from station from oldest to newest. If + since_ts is specified, then all records since that time. If requested + is specified, then at most that many most recent records. If both + are specified then at most requested records newer than the timestamp. + + Each historical record is 38 bytes (0x26) long. Records start at + memory address 0x101 (257). The index of the record after the latest + is at address 0xfc:0xff (253:255), indicating the offset from the + starting address. + + On small memory stations, the last 32 bytes of memory are never used. + On large memory stations, the last 20 bytes of memory are never used. + """ + + log.debug("gen_records: since_ts=%s requested=%s" % (since_ts, requested)) + # we need the current year and month since station does not track year + start_ts = time.time() + tt = time.localtime(start_ts) + # get the archive interval for use in calculations later + arcint = self.get_interval_seconds() + # if nothing specified, get everything since time began + if since_ts is None: + since_ts = 0 + # if no count specified, use interval to estimate number of records + if requested is None: + requested = int((start_ts - since_ts) / arcint) + requested += 1 # safety margin + # get the starting address for what we want to read, plus actual count + oldest_addr, count = self._get_starting_addr(requested) + # inner loop reads records, outer loop catches any added while reading + more_records = True + while more_records: + n = 0 + while n < count: + addr = oldest_addr + n * self.RECORD_SIZE + if addr < self.START_ADDRESS: + addr += self._num_rec * self.RECORD_SIZE + record = self.get_record(addr, tt.tm_year, tt.tm_mon) + n += 1 + msg = "record %d of %d addr=0x%06x" % (n, count, addr) + if record and record['dateTime'] > since_ts: + msg += " %s" % timestamp_to_string(record['dateTime']) + log.debug("gen_records: yield %s" % msg) + yield record + else: + if record: + msg += " since_ts=%d %s" % ( + since_ts, timestamp_to_string(record['dateTime'])) + log.debug("gen_records: skip %s" % msg) + # insert a sleep to simulate slow reads +# time.sleep(5) + + # see if reading has taken so much time that more records have + # arrived. read whatever records have come in since the read began. + now = time.time() + if now - start_ts > arcint: + newreq = int((now - start_ts) / arcint) + newreq += 1 # safety margin + log.debug("gen_records: reading %d more records" % newreq) + oldest_addr, count = self._get_starting_addr(newreq) + start_ts = now + else: + more_records = False + + def get_record(self, addr, now_year, now_month): + """Return a single record from station.""" + + log.debug("get_record at address 0x%06x (year=%s month=%s)" % + (addr, now_year, now_month)) + buf = self._read(addr) + if DEBUG_DECODE: + log.debug("REC %02x %02x %02x %02x" % + (buf[1], buf[2], buf[3], buf[4])) + if buf[1] == 0xff: + log.debug("get_record: no data at address 0x%06x" % addr) + return None + + year = now_year + month = buf[1] & 0x0f + if month > now_month: + year -= 1 + day = bcd2int(buf[2]) + hour = bcd2int(buf[3]) + minute = bcd2int(buf[4]) + ts = time.mktime((year, month, day, hour, minute, 0, 0, 0, -1)) + if DEBUG_DECODE: + log.debug("REC %d/%02d/%02d %02d:%02d = %d" % + (year, month, day, hour, minute, ts)) + + tmpbuf = buf[5:16] + buf = self._read(addr + 0x10) + tmpbuf.extend(buf[1:22]) + + data = decode(tmpbuf) + data['dateTime'] = int(ts) + log.debug("get_record: found record %s" % data) + return data + + def _read_minmax(self): + buf = self._read(0x24) + tmpbuf = self._read(0x40) + buf[28:37] = tmpbuf[1:10] + tmpbuf = self._read(0xaa) + buf[37:47] = tmpbuf[1:11] + tmpbuf = self._read(0x60) + buf[47:74] = tmpbuf[1:28] + tmpbuf = self._read(0x7c) + buf[74:101] = tmpbuf[1:28] + return buf + + def get_minmax(self): + buf = self._read_minmax() + data = dict() + data['t_in_min'], _ = decode_temp(buf[1], buf[2], 0) + data['t_in_max'], _ = decode_temp(buf[3], buf[4], 0) + data['h_in_min'], _ = decode_humid(buf[5]) + data['h_in_max'], _ = decode_humid(buf[6]) + for i in range(5): + label = 't_%d_%%s' % (i + 1) + data[label % 'min'], _ = decode_temp(buf[7+i*6], buf[8 +i*6], 1) + data[label % 'max'], _ = decode_temp(buf[9+i*6], buf[10+i*6], 1) + label = 'h_%d_%%s' % (i + 1) + data[label % 'min'], _ = decode_humid(buf[11+i*6]) + data[label % 'max'], _ = decode_humid(buf[12+i*6]) + data['windspeed_max'], _ = decode_ws(buf[37], buf[38]) + data['windgust_max'], _ = decode_ws(buf[39], buf[40]) + # not sure if this is the correct units here... + data['rain_yesterday'] = (buf[42] * 0x100 + buf[41]) * 0.705555556 + data['rain_week'] = (buf[44] * 0x100 + buf[43]) * 0.705555556 + data['rain_month'] = (buf[46] * 0x100 + buf[45]) * 0.705555556 + tt = time.localtime() + offset = 1 if tt[3] < 12 else 0 + month = bcd2int(buf[47] & 0xf) + day = bcd2int(buf[48]) + hour = bcd2int(buf[49]) + minute = bcd2int(buf[50]) + year = tt.tm_year + if month > tt.tm_mon: + year -= 1 + ts = time.mktime((year, month, day - offset, hour, minute, 0, 0, 0, 0)) + data['barometer_ts'] = ts + for i in range(25): + data['barometer_%d' % i] = (buf[52+i*2]*0x100 + buf[51+i*2])*0.0625 + return data + + def _read_date(self): + buf = self._read(0x0) + return buf[1:33] + + def _write_date(self, buf): + self._write(0x0, buf) + + def get_date(self): + tt = time.localtime() + offset = 1 if tt[3] < 12 else 0 + buf = self._read_date() + day = rev_bcd2int(buf[2]) + month = (buf[5] & 0xF0) // 0x10 + year = rev_bcd2int(buf[4]) + 2000 + ts = time.mktime((year, month, day + offset, 0, 0, 0, 0, 0, 0)) + return ts + + def set_date(self, ts): + tt = time.localtime(ts) + buf = self._read_date() + buf[2] = rev_int2bcd(tt[2]) + buf[4] = rev_int2bcd(tt[0] - 2000) + buf[5] = tt[1] * 0x10 + (tt[6] + 1) * 2 + (buf[5] & 1) + buf[15] = self._checksum(buf[0:15]) + self._write_date(buf) + + def _read_loc(self, loc_type): + addr = 0x0 if loc_type == 0 else 0x16 + buf = self._read(addr) + return buf[1:33] + + def _write_loc(self, loc_type, buf): + addr = 0x0 if loc_type == 0 else 0x16 + self._write(addr, buf) + + def get_loc(self, loc_type): + buf = self._read_loc(loc_type) + offset = 6 if loc_type == 0 else 0 + data = dict() + data['city_time'] = (buf[6 + offset] & 0xF0) + (buf[7 + offset] & 0xF) + data['lat_deg'] = rev_bcd2int(buf[0 + offset]) + data['lat_min'] = rev_bcd2int(buf[1 + offset]) + data['lat_dir'] = "S" if buf[4 + offset] & 0x80 == 0x80 else "N" + data['long_deg'] = (buf[5 + offset] & 0xF0) // 0x10 * 100 + rev_bcd2int(buf[2 + offset]) + data['long_min'] = rev_bcd2int(buf[3 + offset]) + data['long_dir'] = "E" if buf[4 + offset] & 0x40 == 0x40 else "W" + data['tz_hr'] = (buf[7 + offset] & 0xF0) // 0x10 + if buf[4 + offset] & 0x8 == 0x8: + data['tz_hr'] *= -1 + data['tz_min'] = 30 if buf[4 + offset] & 0x3 == 0x3 else 0 + if buf[4 + offset] & 0x10 == 0x10: + data['dst_always_on'] = True + else: + data['dst_always_on'] = False + data['dst'] = buf[5 + offset] & 0xf + return data + + def set_loc(self, loc_type, city_index, dst_on, dst_index, tz_hr, tz_min, + lat_deg, lat_min, lat_dir, long_deg, long_min, long_dir): + buf = self._read_loc(loc_type) + offset = 6 if loc_type == 0 else 0 + buf[0 + offset] = rev_int2bcd(lat_deg) + buf[1 + offset] = rev_int2bcd(lat_min) + buf[2 + offset] = rev_int2bcd(long_deg % 100) + buf[3 + offset] = rev_int2bcd(long_min) + buf[4 + offset] = (lat_dir == "S") * 0x80 + (long_dir == "E") * 0x40 + (tz_hr < 0) + dst_on * 0x10 * 0x8 + (tz_min == 30) * 3 + buf[5 + offset] = (long_deg > 99) * 0x10 + dst_index + buf[6 + offset] = (buf[28] & 0x0F) + int(city_index / 0x10) * 0x10 + buf[7 + offset] = city_index % 0x10 + abs(tz_hr) * 0x10 + if loc_type == 0: + buf[15] = self._checksum(buf[0:15]) + else: + buf[8] = self._checksum(buf[0:8]) + self._write_loc(loc_type, buf) + + def _read_alt(self): + buf = self._read(0x5a) + return buf[1:33] + + def _write_alt(self, buf): + self._write(0x5a, buf) + + def get_alt(self): + buf = self._read_alt() + altitude = buf[1] * 0x100 + buf[0] + if buf[3] & 0x8 == 0x8: + altitude *= -1 + return altitude + + def set_alt(self, altitude): + buf = self._read_alt() + buf[0] = abs(altitude) & 0xff + buf[1] = abs(altitude) // 0x100 + buf[2] = buf[2] & 0x7 + (altitude < 0) * 0x8 + buf[3] = self._checksum(buf[0:3]) + self._write_alt(buf) + + def _read_alarms(self): + buf = self._read(0x10) + tmpbuf = self._read(0x1F) + buf[33:65] = tmpbuf[1:33] + tmpbuf = self._read(0xA0) + buf[65:97] = tmpbuf[1:33] + return buf[1:97] + + def _write_alarms(self, buf): + self._write(0x10, buf[0:32]) + self._write(0x1F, buf[32:64]) + self._write(0xA0, buf[64:96]) + + def get_alarms(self): + buf = self._read_alarms() + data = dict() + data['weekday_active'] = buf[0] & 0x4 == 0x4 + data['single_active'] = buf[0] & 0x8 == 0x8 + data['prealarm_active'] = buf[2] & 0x8 == 0x8 + data['weekday_hour'] = rev_bcd2int(buf[0] & 0xF1) + data['weekday_min'] = rev_bcd2int(buf[1]) + data['single_hour'] = rev_bcd2int(buf[2] & 0xF1) + data['single_min'] = rev_bcd2int(buf[3]) + data['prealarm_period'] = (buf[4] & 0xF0) // 0x10 + data['snooze'] = buf[4] & 0xF + data['max_temp'], _ = decode_temp(buf[32], buf[33], 0) + data['min_temp'], _ = decode_temp(buf[34], buf[35], 0) + data['rain_active'] = buf[64] & 0x4 == 0x4 + data['windspeed_active'] = buf[64] & 0x2 == 0x2 + data['windgust_active'] = buf[64] & 0x1 == 0x1 + data['rain'] = bcd2int(buf[66]) * 100 + bcd2int(buf[65]) + data['windspeed'], _ = decode_ws(buf[68], buf[69]) + data['windgust'], _ = decode_ws(buf[71], buf[72]) + return data + + def set_alarms(self, weekday, single, prealarm, snooze, + maxtemp, mintemp, rain, wind, gust): + buf = self._read_alarms() + if weekday.lower() != 'off': + weekday_list = weekday.split(':') + buf[0] = rev_int2bcd(int(weekday_list[0])) | 0x4 + buf[1] = rev_int2bcd(int(weekday_list[1])) + else: + buf[0] &= 0xFB + if single.lower() != 'off': + single_list = single.split(':') + buf[2] = rev_int2bcd(int(single_list[0])) + buf[3] = rev_int2bcd(int(single_list[1])) + buf[0] |= 0x8 + else: + buf[0] &= 0xF7 + if (prealarm.lower() != 'off' and + (weekday.lower() != 'off' or single.lower() != 'off')): + if int(prealarm) == 15: + buf[4] = 0x10 + elif int(prealarm) == 30: + buf[4] = 0x20 + elif int(prealarm) == 45: + buf[4] = 0x30 + elif int(prealarm) == 60: + buf[4] = 0x40 + elif int(prealarm) == 90: + buf[4] = 0x50 + buf[2] |= 0x8 + else: + buf[2] &= 0xF7 + buf[4] = (buf[4] & 0xF0) + int(snooze) + buf[5] = self._checksum(buf[0:5]) + + buf[32] = int2bcd(int(abs(float(maxtemp)) * 10) % 100) + buf[33] = int2bcd(int(abs(float(maxtemp)) / 10)) + if float(maxtemp) >= 0: + buf[33] |= 0x80 + if (abs(float(maxtemp)) * 100) % 10 == 5: + buf[33] |= 0x20 + buf[34] = int2bcd(int(abs(float(mintemp)) * 10) % 100) + buf[35] = int2bcd(int(abs(float(mintemp)) / 10)) + if float(mintemp) >= 0: + buf[35] |= 0x80 + if (abs(float(mintemp)) * 100) % 10 == 5: + buf[35] |= 0x20 + buf[36] = self._checksum(buf[32:36]) + + if rain.lower() != 'off': + buf[65] = int2bcd(int(rain) % 100) + buf[66] = int2bcd(int(int(rain) / 100)) + buf[64] |= 0x4 + else: + buf[64] = buf[64] & 0xFB + if wind.lower() != 'off': + buf[68] = int2bcd(int(float(wind) * 10) % 100) + buf[69] = int2bcd(int(float(wind) / 10)) + buf[64] |= 0x2 + else: + buf[64] = buf[64] & 0xFD + if gust.lower() != 'off': + buf[71] = int2bcd(int(float(gust) * 10) % 100) + buf[72] = int2bcd(int(float(gust) / 10)) + buf[64] |= 0x1 + else: + buf[64] |= 0xFE + buf[73] = self._checksum(buf[64:73]) + self._write_alarms(buf) + + def get_interval(self): + buf = self._read(0xFE) + return buf[1] + + def get_interval_seconds(self): + idx = self.get_interval() + interval = self.idx_to_interval_sec.get(idx) + if interval is None: + msg = "Unrecognized archive interval '%s'" % idx + log.error(msg) + raise weewx.WeeWxIOError(msg) + return interval + + def set_interval(self, idx): + buf = self._read(0xFE) + buf = buf[1:33] + buf[0] = idx + self._write(0xFE, buf) + + @staticmethod + def _checksum(buf): + crc = 0x100 + for i in range(len(buf)): + crc -= buf[i] + if crc < 0: + crc += 0x100 + return crc + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/te923.py +# +# by default, output matches that of te923tool +# te923con display current weather readings +# te923con -d dump 208 memory records +# te923con -s display station status +# +# date; PYTHONPATH=bin python bin/user/te923.py --records 0 > c; date +# 91s +# Thu Dec 10 00:12:59 EST 2015 +# Thu Dec 10 00:14:30 EST 2015 +# date; PYTHONPATH=bin python bin/weewx/drivers/te923.py --records 0 > b; date +# 531s +# Tue Nov 26 10:37:36 EST 2013 +# Tue Nov 26 10:46:27 EST 2013 +# date; /home/mwall/src/te923tool-0.6.1/te923con -d > a; date +# 53s +# Tue Nov 26 10:46:52 EST 2013 +# Tue Nov 26 10:47:45 EST 2013 + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + FMT_TE923TOOL = 'te923tool' + FMT_DICT = 'dict' + FMT_TABLE = 'table' + + usage = """%prog [options] [--debug] [--help]""" + + def main(): + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='display diagnostic information while running') + parser.add_option('--status', dest='status', action='store_true', + help='display station status') + parser.add_option('--readings', dest='readings', action='store_true', + help='display sensor readings') + parser.add_option("--records", dest="records", type=int, metavar="N", + help="display N station records, oldest to newest") + parser.add_option('--blocks', dest='blocks', type=int, metavar="N", + help='display N 32-byte blocks of station memory') + parser.add_option("--format", dest="format", type=str,metavar="FORMAT", + default=FMT_TE923TOOL, + help="format for output: te923tool, table, or dict") + (options, _) = parser.parse_args() + + if options.version: + print("te923 driver version %s" % DRIVER_VERSION) + exit(1) + + if options.debug: + weewx.debug = 1 + + weeutil.logger.setup('te923', {}) + + if (options.format.lower() != FMT_TE923TOOL and + options.format.lower() != FMT_TABLE and + options.format.lower() != FMT_DICT): + print("Unknown format '%s'. Known formats include: %s" % ( + options.format, ','.join([FMT_TE923TOOL, FMT_TABLE, FMT_DICT]))) + exit(1) + + with TE923Station() as station: + if options.status: + data = station.get_versions() + data.update(station.get_status()) + if options.format.lower() == FMT_TE923TOOL: + print_status(data) + else: + print_data(data, options.format) + if options.readings: + data = station.get_readings() + if options.format.lower() == FMT_TE923TOOL: + print_readings(data) + else: + print_data(data, options.format) + if options.records is not None: + for data in station.gen_records(requested=options.records): + if options.format.lower() == FMT_TE923TOOL: + print_readings(data) + else: + print_data(data, options.format) + if options.blocks is not None: + for ptr, block in station.gen_blocks(count=options.blocks): + print_hex(ptr, block) + + def print_data(data, fmt): + if fmt.lower() == FMT_TABLE: + print_table(data) + else: + print(data) + + def print_hex(ptr, data): + print("0x%06x %s" % (ptr, _fmt(data))) + + def print_table(data): + """output entire dictionary contents in two columns""" + for key in sorted(data): + print("%s: %s" % (key.rjust(16), data[key])) + + def print_status(data): + """output status fields in te923tool format""" + print("0x%x:0x%x:0x%x:0x%x:0x%x:%d:%d:%d:%d:%d:%d:%d:%d" % ( + data['version_sys'], data['version_bar'], data['version_uv'], + data['version_rcc'], data['version_wind'], + data['bat_rain'], data['bat_uv'], data['bat_wind'], data['bat_5'], + data['bat_4'], data['bat_3'], data['bat_2'], data['bat_1'])) + + def print_readings(data): + """output sensor readings in te923tool format""" + output = [str(data['dateTime'])] + output.append(getvalue(data, 't_in', '%0.2f')) + output.append(getvalue(data, 'h_in', '%d')) + for i in range(1, 6): + output.append(getvalue(data, 't_%d' % i, '%0.2f')) + output.append(getvalue(data, 'h_%d' % i, '%d')) + output.append(getvalue(data, 'slp', '%0.1f')) + output.append(getvalue(data, 'uv', '%0.1f')) + output.append(getvalue(data, 'forecast', '%d')) + output.append(getvalue(data, 'storm', '%d')) + output.append(getvalue(data, 'winddir', '%d')) + output.append(getvalue(data, 'windspeed', '%0.1f')) + output.append(getvalue(data, 'windgust', '%0.1f')) + output.append(getvalue(data, 'windchill', '%0.1f')) + output.append(getvalue(data, 'rain', '%d')) + print(':'.join(output)) + + def getvalue(data, label, fmt): + if label + '_state' in data: + if data[label + '_state'] == STATE_OK: + return fmt % data[label] + else: + return data[label + '_state'] + else: + if data[label] is None: + return 'x' + else: + return fmt % data[label] + +if __name__ == '__main__': + main() diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/ultimeter.py b/dist/weewx-4.10.1/bin/weewx/drivers/ultimeter.py new file mode 100644 index 0000000..8cae85a --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/ultimeter.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python +# +# Copyright 2014-2020 Matthew Wall +# Copyright 2014 Nate Bargmann +# See the file LICENSE.txt for your rights. +# +# Credit to and contributions from: +# Jay Nugent (WB8TKL) and KRK6 for weather-2.kr6k-V2.1 +# http://server1.nuge.com/~weather/ +# Steve (sesykes71) for testing the first implementations of this driver +# Garret Power for improved decoding and proper handling of negative values +# Chris Thompstone for testing the fast-read implementation +# +# Thanks to PeetBros for publishing the communication protocols and details +# about each model they manufacture. + +"""Driver for Peet Bros Ultimeter weather stations except the Ultimeter II + +This driver assumes the Ultimeter is emitting data in Peet Bros Data Logger +mode format. This driver will set the mode automatically on stations +manufactured after 2004. Stations manufactured before 2004 must be set to +data logger mode using the buttons on the console. + +Resources for the Ultimeter stations + +Ultimeter Models 2100, 2000, 800, & 100 serial specifications: + http://www.peetbros.com/shop/custom.aspx?recid=29 + +Ultimeter 2000 Pinouts and Parsers: + http://www.webaugur.com/ham-radio/52-ultimeter-2000-pinouts-and-parsers.html + +Ultimeter II + not supported by this driver + +All models communicate over an RS-232 compatible serial port using three +wires--RXD, TXD, and Ground (except Ultimeter II which omits TXD). Port +parameters are 2400, 8N1, with no flow control. + +The Ultimeter hardware supports several "modes" for providing station data +to the serial port. This driver utilizes the "modem mode" to set the date +and time of the Ultimeter upon initialization and then sets it into Data +Logger mode for continuous updates. + +Modem Mode commands used by the driver + >Addddmmmm Set Date and Time (decimal digits dddd = day of year, + mmmm = minute of day; Jan 1 = 0000, Midnight = 0000) + + >I Set output mode to Data Logger Mode (continuous output) + +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +import logging +import time + +import serial + +import weewx.drivers +import weewx.wxformulas +from weewx.units import INHG_PER_MBAR, MILE_PER_KM +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'Ultimeter' +DRIVER_VERSION = '0.6' + + +def loader(config_dict, _): + return UltimeterDriver(**config_dict[DRIVER_NAME]) + + +def confeditor_loader(): + return UltimeterConfEditor() + + +def _fmt(x): + return ' '.join(["%0.2X" % c for c in x]) + + +class UltimeterDriver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with a Peet Bros Ultimeter station""" + + def __init__(self, **stn_dict): + """ + Args: + model (str): station model, e.g., 'Ultimeter 2000' or 'Ultimeter 100' + Optional. Default is 'Ultimeter' + + port(str): Serial port. + Required. Default is '/dev/ttyUSB0' + + max_tries(int): How often to retry serial communication before giving up + Optional. Default is 5 + + retry_wait (float): How long to wait before retrying an I/O operation. + Optional. Default is 3.0 + + debug_serial (int): Greater than one for additional debug information. + Optional. Default is 0 + """ + self.model = stn_dict.get('model', 'Ultimeter') + self.port = stn_dict.get('port', Station.DEFAULT_PORT) + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.retry_wait = float(stn_dict.get('retry_wait', 3.0)) + debug_serial = int(stn_dict.get('debug_serial', 0)) + self.last_rain = None + + log.info('Driver version is %s', DRIVER_VERSION) + log.info('Using serial port %s', self.port) + self.station = Station(self.port, debug_serial=debug_serial) + self.station.open() + + def closePort(self): + if self.station: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + + def DISABLED_getTime(self): + return self.station.get_time() + + def DISABLED_setTime(self): + self.station.set_time(int(time.time())) + + def genLoopPackets(self): + self.station.set_logger_mode() + while True: + packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + readings = self.station.get_readings_with_retry(self.max_tries, + self.retry_wait) + data = parse_readings(readings) + packet.update(data) + self._augment_packet(packet) + yield packet + + def _augment_packet(self, packet): + packet['rain'] = weewx.wxformulas.calculate_rain(packet['rain_total'], self.last_rain) + self.last_rain = packet['rain_total'] + + +class Station(object): + DEFAULT_PORT = '/dev/ttyUSB0' + + def __init__(self, port, debug_serial=0): + self.port = port + self._debug_serial = debug_serial + self.baudrate = 2400 + self.timeout = 3 # seconds + self.serial_port = None + # setting the year works only for models 2004 and later + self.can_set_year = True + # modem mode is available only on models 2004 and later + # not available on pre-2004 models 50/100/500/700/800 + self.has_modem_mode = True + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self): + log.debug("Open serial port %s", self.port) + self.serial_port = serial.Serial(self.port, self.baudrate, timeout=self.timeout) + self.serial_port.flushInput() + + def close(self): + if self.serial_port: + log.debug("Close serial port %s", self.port) + self.serial_port.close() + self.serial_port = None + + def get_time(self): + try: + self.set_logger_mode() + buf = self.get_readings_with_retry() + data = parse_readings(buf) + d = data['day_of_year'] # seems to start at 0 + m = data['minute_of_day'] # 0 is midnight before start of day + tt = time.localtime() + y = tt.tm_year + s = tt.tm_sec + ts = time.mktime((y, 1, 1, 0, 0, s, 0, 0, -1)) + d * 86400 + m * 60 + log.debug("Station time: day:%s min:%s (%s)", d, m, timestamp_to_string(ts)) + return ts + except (serial.SerialException, weewx.WeeWxIOError) as e: + log.error("get_time failed: %s", e) + return int(time.time()) + + def set_time(self, ts): + # go to modem mode so we do not get logger chatter + self.set_modem_mode() + + # set time should work on all models + tt = time.localtime(ts) + cmd = b">A%04d%04d" % (tt.tm_yday - 1, tt.tm_min + tt.tm_hour * 60) + log.debug("Set station time to %s (%s)", timestamp_to_string(ts), cmd) + self.serial_port.write(b"%s\r" % cmd) + + # year works only for models 2004 and later + if self.can_set_year: + cmd = b">U%s" % tt.tm_year + log.debug("Set station year to %s (%s)", tt.tm_year, cmd) + self.serial_port.write(b"%s\r" % cmd) + + def set_logger_mode(self): + # in logger mode, station sends logger mode records continuously + if self._debug_serial: + log.debug("Set station to logger mode") + self.serial_port.write(b">I\r") + + def set_modem_mode(self): + # setting to modem mode should stop data logger output + if self.has_modem_mode: + if self._debug_serial: + log.debug("Set station to modem mode") + self.serial_port.write(b">\r") + + def get_readings_with_retry(self, max_tries, retry_wait): + for ntries in range(max_tries): + try: + buf = get_readings(self.serial_port, self._debug_serial) + validate_string(buf) + return buf + except (serial.SerialException, weewx.WeeWxIOError) as e: + log.info("Failed attempt %d of %d to get readings: %s", + ntries + 1, max_tries, e) + time.sleep(retry_wait) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + +# ##############################################################33 +# Utilities +# ##############################################################33 + +def get_readings(serial_port, debug_serial): + """Read an Ultimeter sentence from a serial port. + + Args: + serial_port (serial.Serial): An open port + debug_serial (int): Set to greater than zero for extra debug information. + + Returns: + bytearray: A bytearray containing the sentence. + """ + + # Search for the character '!', which marks the beginning of a "sentence": + while True: + c = serial_port.read(1) + if c == b'!': + break + # Save the first '!' ... + buf = bytearray(c) + # ... then read until we get to a '\r' or '\n' + while True: + c = serial_port.read(1) + if c == b'\n' or c == b'\r': + # We found a carriage return or newline, so we have the complete sentence. + # NB: Because the Ultimeter terminates a sentence with a '\r\n', this will + # leave a newline in the buffer. We don't care: it will get skipped over when + # we search for the next sentence. + break + buf += c + if debug_serial: + log.debug("Station said: %s", _fmt(buf)) + return buf + + +def validate_string(buf, choices=None): + """Validate a data buffer. + + Args: + buf (bytes): The raw data + choices (list of int): The possible valid lengths of the buffer. + + Raises: + weewx.WeeWXIOError: A string of unexpected length, or that does not start with b'!!', + raises this error. + """ + choices = choices or [42, 46, 50] + + if len(buf) not in choices: + raise weewx.WeeWxIOError("Unexpected buffer length %d" % len(buf)) + if buf[0:2] != b'!!': + raise weewx.WeeWxIOError("Unexpected header bytes '%s'" % buf[0:2]) + + +def parse_readings(raw): + """Ultimeter stations emit data in PeetBros format. + + http://www.peetbros.com/shop/custom.aspx?recid=29 + + Each line has 52 characters - 2 header bytes, 48 data bytes, and a carriage return and line + feed (new line): + + !!000000BE02EB000027700000023A023A0025005800000000\r\n + SSSSXXDDTTTTLLLLPPPPttttHHHHhhhhddddmmmmRRRRWWWW + + SSSS - wind speed (0.1 kph) + XX - wind direction calibration + DD - wind direction (0-255) + TTTT - outdoor temperature (0.1 F) + LLLL - long term rain (0.01 in) + PPPP - pressure (0.1 mbar) + tttt - indoor temperature (0.1 F) + HHHH - outdoor humidity (0.1 %) + hhhh - indoor humidity (0.1 %) + dddd - date (day of year) + mmmm - time (minute of day) + RRRR - daily rain (0.01 in) + WWWW - one minute wind average (0.1 kph) + + "pressure" reported by the Ultimeter 2000 is correlated to the local + official barometer reading as part of the setup of the station + console so this value is assigned to the 'barometer' key and + the pressure and altimeter values are calculated from it. + + Some stations may omit daily_rain or wind_average, so check for those. + + Args: + raw (bytearray): A bytearray containing the sentence. + + Returns + dict: A dictionary containing the data. + """ + # Convert from bytearray to bytes + buf = bytes(raw[2:]) + data = { + 'windSpeed': decode(buf[0:4], 0.1 * MILE_PER_KM), # mph + 'windDir': decode(buf[6:8], 1.411764), # compass deg + 'outTemp': decode(buf[8:12], 0.1, neg=True), # degree_F + 'rain_total': decode(buf[12:16], 0.01), # inch + 'barometer': decode(buf[16:20], 0.1 * INHG_PER_MBAR), # inHg + 'inTemp': decode(buf[20:24], 0.1, neg=True), # degree_F + 'outHumidity': decode(buf[24:28], 0.1), # percent + 'inHumidity': decode(buf[28:32], 0.1), # percent + 'day_of_year': decode(buf[32:36]), + 'minute_of_day': decode(buf[36:40]) + } + if len(buf) > 40: + data['daily_rain'] = decode(buf[40:44], 0.01) # inch + if len(buf) > 44: + data['wind_average'] = decode(buf[44:48], 0.1 * MILE_PER_KM) # mph + return data + + +def decode(s, multiplier=None, neg=False): + """Decode a byte string. + + Ultimeter puts dashes in the string when a sensor is not installed. When we get a dashes, + or any other non-hex character, return None. Negative values are represented in twos + complement format. Only do the check for negative values if requested, since some + parameters use the full set of bits (e.g., wind direction) and some do not (e.g., + temperature). + + Args: + s (bytes): Encoded value as hexadecimal digits. + multiplier (float): Multiply the results by this value. + neg (bool): If True, calculate the twos-complement. + + Returns: + float: The decoded value. + """ + + # First check for all dashes. + if s == len(s) * b'-': + # All bytes are dash values, meaning a non-existent or broken sensor. Return None. + return None + + # Decode the hexadecimal number + try: + v = int(s, 16) + except ValueError as e: + log.debug("Decode failed for '%s': %s", s, e) + return None + + # If requested, calculate the twos-complement + if neg: + bits = 4 * len(s) + if v & (1 << (bits - 1)) != 0: + v -= (1 << bits) + + # If requested, scale the number + if multiplier is not None: + v *= multiplier + + return v + + +class UltimeterConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[Ultimeter] + # This section is for the PeetBros Ultimeter series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cua0 + port = %s + + # The station model, e.g., Ultimeter 2000, Ultimeter 100 + model = Ultimeter + + # The driver to use: + driver = weewx.drivers.ultimeter +""" % Station.DEFAULT_PORT + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example: /dev/ttyUSB0 or /dev/ttyS0 or /dev/cua0.") + port = self._prompt('port', Station.DEFAULT_PORT) + return {'port': port} + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/ultimeter.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='provide additional debug output in log') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected', + default=Station.DEFAULT_PORT) + (options, args) = parser.parse_args() + + if options.version: + print("ultimeter driver version %s" % DRIVER_VERSION) + exit(0) + + if options.debug: + weewx.debug = 1 + + weeutil.logger.setup('ultimeter', {}) + + with Station(options.port, debug_serial=options.debug) as station: + station.set_logger_mode() + while True: + print(time.time(), _fmt(station.get_readings())) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/vantage.py b/dist/weewx-4.10.1/bin/weewx/drivers/vantage.py new file mode 100644 index 0000000..db11066 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/vantage.py @@ -0,0 +1,2939 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes and functions for interfacing with a Davis VantagePro, VantagePro2, +or VantageVue weather station""" + + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import datetime +import logging +import struct +import sys +import time + +import six +from six import int2byte, indexbytes, byte2int +from six.moves import map +from six.moves import zip + +import weeutil.weeutil +import weewx.drivers +import weewx.engine +import weewx.units +from weeutil.weeutil import to_int, to_sorted_string +from weewx.crc16 import crc16 + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'Vantage' +DRIVER_VERSION = '3.5.2' + + +def loader(config_dict, engine): + return VantageService(engine, config_dict) + + +def configurator_loader(config_dict): # @UnusedVariable + return VantageConfigurator() + + +def confeditor_loader(): + return VantageConfEditor() + + +# A few handy constants: +_ack = b'\x06' +_resend = b'\x15' # NB: The Davis documentation gives this code as 0x21, but it's actually decimal 21 + + +#=============================================================================== +# class BaseWrapper +#=============================================================================== + +class BaseWrapper(object): + """Base class for (Serial|Ethernet)Wrapper""" + + def __init__(self, wait_before_retry, command_delay): + + self.wait_before_retry = wait_before_retry + self.command_delay = command_delay + + def read(self, nbytes=1): + raise NotImplementedError + + def write(self, buf): + raise NotImplementedError + + def flush_input(self): + raise NotImplementedError + + #=============================================================================== + # Primitives for working with the Davis Console + #=============================================================================== + + def wakeup_console(self, max_tries=3): + """Wake up a Davis Vantage console. + + This call has three purposes: + 1. Wake up a sleeping console; + 2. Cancel pending LOOP data (if any); + 3. Flush the input buffer + Note: a flushed buffer is important before sending a command; we want to make sure + the next received character is the expected ACK. + + If unsuccessful, an exception of type weewx.WakeupError is thrown""" + + for count in range(1, max_tries + 1): + try: + # Clear out any pending input or output characters: + self.flush_output() + self.flush_input() + # It can be hard to get the console's attention, particularly + # when in the middle of a LOOP command. Send a whole bunch of line feeds, + # then flush everything, then look for the \n\r acknowledgment + self.write(b'\n\n\n') + time.sleep(0.5) + self.flush_input() + self.write(b'\n') + _resp = self.read(2) + if _resp == b'\n\r': # LF, CR = 0x0a, 0x0d + # We're done; the console accepted our cancel LOOP command. + log.debug("Successfully woke up Vantage console") + return + else: + log.debug("Bad wake-up response from Vantage console: %s", _resp) + except weewx.WeeWxIOError as e: + log.debug("Wake up try %d failed. Exception: %s", e) + + log.debug("Retry #%d unable to wake up console... sleeping", count) + print("Unable to wake up console... sleeping") + time.sleep(self.wait_before_retry) + print("Unable to wake up console... retrying") + + log.error("Unable to wake up Vantage console") + raise weewx.WakeupError("Unable to wake up Vantage console") + + def send_data(self, data): + """Send data to the Davis console, waiting for an acknowledging + + If the is not received, no retry is attempted. Instead, an exception + of type weewx.WeeWxIOError is raised + + data: The data to send, as a byte string""" + + self.write(data) + + # Look for the acknowledging ACK character + _resp = self.read() + if _resp != _ack: + log.error("send_data: no received from Vantage console") + raise weewx.WeeWxIOError("No received from Vantage console") + + def send_data_with_crc16(self, data, max_tries=3): + """Send data to the Davis console along with a CRC check, waiting for an acknowledging . + If none received, resend up to max_tries times. + + data: The data to send, as a byte string""" + + # Calculate the crc for the data: + _crc = crc16(data) + + # ...and pack that on to the end of the data in big-endian order: + _data_with_crc = data + struct.pack(">H", _crc) + + # Retry up to max_tries times: + for count in range(1, max_tries + 1): + try: + self.write(_data_with_crc) + # Look for the acknowledgment. + _resp = self.read() + if _resp == _ack: + return + else: + log.debug("send_data_with_crc16 try #%d bad : %s", count, _resp) + except weewx.WeeWxIOError as e: + log.debug("send_data_with_crc16 try #%d exception: %s", count, e) + + log.error("Unable to pass CRC16 check while sending data to Vantage console") + raise weewx.CRCError("Unable to pass CRC16 check while sending data to Vantage console") + + def send_command(self, command, max_tries=3): + """Send a command to the console, then look for the byte string 'OK' in the response. + + Any response from the console is split on \n\r characters and returned as a list.""" + + for count in range(1, max_tries + 1): + try: + self.wakeup_console(max_tries=max_tries) + + self.write(command) + # Takes some time for the Vantage to react and fill up the buffer. Sleep for a bit: + time.sleep(self.command_delay) + # Can't use function serial.readline() because the VP responds with \n\r, + # not just \n. So, instead find how many bytes are waiting and fetch them all + nc = self.queued_bytes() + _buffer = self.read(nc) + # Split the buffer on the newlines + _buffer_list = _buffer.strip().split(b'\n\r') + # The first member should be the 'OK' in the VP response + if _buffer_list[0] == b'OK': + # Return the rest: + return _buffer_list[1:] + else: + log.debug("send_command; try #%d failed. Response: %s", count, _buffer_list[0]) + except weewx.WeeWxIOError as e: + # Caught an error. Log, then keep trying... + log.debug("send_command; try #%d failed. Exception: %s", count, e) + + msg = "Max retries exceeded while sending command %s" % command + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def get_data_with_crc16(self, nbytes, prompt=None, max_tries=3): + """Get a packet of data and do a CRC16 check on it, asking for retransmit if necessary. + + It is guaranteed that the length of the returned data will be of the requested length. + An exception of type CRCError will be thrown if the data cannot pass the CRC test + in the requested number of retries. + + nbytes: The number of bytes (including the 2 byte CRC) to get. + + prompt: Any string to be sent before requesting the data. Default=None + + max_tries: Number of tries before giving up. Default=3 + + returns: the packet data as a byte string. The last 2 bytes will be the CRC""" + if prompt: + self.write(prompt) + + first_time = True + _buffer = b'' + + for count in range(1, max_tries + 1): + try: + if not first_time: + self.write(_resend) + _buffer = self.read(nbytes) + if crc16(_buffer) == 0: + return _buffer + log.debug("Get_data_with_crc16; try #%d failed. CRC error", count) + except weewx.WeeWxIOError as e: + log.debug("Get_data_with_crc16; try #%d failed: %s", count, e) + first_time = False + + if _buffer: + log.error("Unable to pass CRC16 check while getting data") + raise weewx.CRCError("Unable to pass CRC16 check while getting data") + else: + log.debug("Timeout in get_data_with_crc16") + raise weewx.WeeWxIOError("Timeout in get_data_with_crc16") + +#=============================================================================== +# class Serial Wrapper +#=============================================================================== + +def guard_termios(fn): + """Decorator function that converts termios exceptions into weewx exceptions.""" + # Some functions in the module 'serial' can raise undocumented termios + # exceptions. This catches them and converts them to weewx exceptions. + try: + import termios + def guarded_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except termios.error as e: + raise weewx.WeeWxIOError(e) + except ImportError: + def guarded_fn(*args, **kwargs): + return fn(*args, **kwargs) + return guarded_fn + +class SerialWrapper(BaseWrapper): + """Wraps a serial connection returned from package serial""" + + def __init__(self, port, baudrate, timeout, wait_before_retry, command_delay): + super(SerialWrapper, self).__init__(wait_before_retry=wait_before_retry, + command_delay=command_delay) + self.port = port + self.baudrate = baudrate + self.timeout = timeout + + @guard_termios + def flush_input(self): + self.serial_port.flushInput() + + @guard_termios + def flush_output(self): + self.serial_port.flushOutput() + + @guard_termios + def queued_bytes(self): + return self.serial_port.inWaiting() + + def read(self, chars=1): + import serial + try: + _buffer = self.serial_port.read(chars) + except serial.serialutil.SerialException as e: + log.error("SerialException on read.") + log.error(" **** %s", e) + log.error(" **** Is there a competing process running??") + # Reraise as a Weewx error I/O error: + raise weewx.WeeWxIOError(e) + N = len(_buffer) + if N != chars: + raise weewx.WeeWxIOError("Expected to read %d chars; got %d instead" % (chars, N)) + return _buffer + + def write(self, data): + import serial + try: + N = self.serial_port.write(data) + except serial.serialutil.SerialException as e: + log.error("SerialException on write.") + log.error(" **** %s", e) + # Reraise as a Weewx error I/O error: + raise weewx.WeeWxIOError(e) + # Python version 2.5 and earlier returns 'None', so it cannot be used to test for completion. + if N is not None and N != len(data): + raise weewx.WeeWxIOError("Expected to write %d chars; sent %d instead" % (len(data), N)) + + def openPort(self): + import serial + # Open up the port and store it + self.serial_port = serial.Serial(self.port, self.baudrate, timeout=self.timeout) + log.debug("Opened up serial port %s; baud %d; timeout %.2f", self.port, self.baudrate, self.timeout) + + def closePort(self): + try: + # This will cancel any pending loop: + self.write(b'\n') + except: + pass + self.serial_port.close() + +#=============================================================================== +# class EthernetWrapper +#=============================================================================== + +class EthernetWrapper(BaseWrapper): + """Wrap a socket""" + + def __init__(self, host, port, timeout, tcp_send_delay, wait_before_retry, command_delay): + + super(EthernetWrapper, self).__init__(wait_before_retry=wait_before_retry, + command_delay=command_delay) + + self.host = host + self.port = port + self.timeout = timeout + self.tcp_send_delay = tcp_send_delay + + def openPort(self): + import socket + try: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(self.timeout) + self.socket.connect((self.host, self.port)) + except (socket.error, socket.timeout, socket.herror) as ex: + log.error("Socket error while opening port %d to ethernet host %s.", self.port, self.host) + # Reraise as a weewx I/O error: + raise weewx.WeeWxIOError(ex) + except: + log.error("Unable to connect to ethernet host %s on port %d.", self.host, self.port) + raise + log.debug("Opened up ethernet host %s on port %d. timeout=%s, tcp_send_delay=%s", + self.host, self.port, self.timeout, self.tcp_send_delay) + + def closePort(self): + import socket + try: + # This will cancel any pending loop: + self.write(b'\n') + except: + pass + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + + def flush_input(self): + """Flush the input buffer from WeatherLinkIP""" + import socket + try: + # This is a bit of a hack, but there is no analogue to pyserial's flushInput() + # Set socket timeout to 0 to get immediate result + self.socket.settimeout(0) + self.socket.recv(4096) + except (socket.timeout, socket.error): + pass + finally: + # set socket timeout back to original value + self.socket.settimeout(self.timeout) + + def flush_output(self): + """Flush the output buffer to WeatherLinkIP + + This function does nothing as there should never be anything left in + the buffer when using socket.sendall()""" + pass + + def queued_bytes(self): + """Determine how many bytes are in the buffer""" + import socket + length = 0 + try: + self.socket.settimeout(0) + length = len(self.socket.recv(8192, socket.MSG_PEEK)) + except socket.error: + pass + finally: + self.socket.settimeout(self.timeout) + return length + + def read(self, chars=1): + """Read bytes from WeatherLinkIP""" + import socket + _buffer = b'' + _remaining = chars + while _remaining: + _N = min(4096, _remaining) + try: + _recv = self.socket.recv(_N) + except (socket.timeout, socket.error) as ex: + log.error("ip-read error: %s", ex) + # Reraise as a weewx I/O error: + raise weewx.WeeWxIOError(ex) + _nread = len(_recv) + if _nread == 0: + raise weewx.WeeWxIOError("Expected %d characters; got zero instead" % (_N,)) + _buffer += _recv + _remaining -= _nread + return _buffer + + def write(self, data): + """Write to a WeatherLinkIP""" + import socket + try: + self.socket.sendall(data) + # A delay of 0.0 gives socket write error; 0.01 gives no ack error; 0.05 is OK for weewx program + # Note: a delay of 0.5 s is required for wee_device --logger=logger_info + time.sleep(self.tcp_send_delay) + except (socket.timeout, socket.error) as ex: + log.error("ip-write error: %s", ex) + # Reraise as a weewx I/O error: + raise weewx.WeeWxIOError(ex) + + +#=============================================================================== +# class Vantage +#=============================================================================== + +class Vantage(weewx.drivers.AbstractDevice): + """Class that represents a connection to a Davis Vantage console. + + The connection to the console will be open after initialization""" + + # Various codes used internally by the VP2: + barometer_unit_dict = {0:'inHg', 1:'mmHg', 2:'hPa', 3:'mbar'} + temperature_unit_dict = {0:'degree_F', 1:'degree_10F', 2:'degree_C', 3:'degree_10C'} + altitude_unit_dict = {0:'foot', 1:'meter'} + rain_unit_dict = {0:'inch', 1:'mm'} + wind_unit_dict = {0:'mile_per_hour', 1:'meter_per_second', 2:'km_per_hour', 3:'knot'} + wind_cup_dict = {0:'small', 1:'large'} + rain_bucket_dict = {0:'0.01 inches', 1:'0.2 mm', 2:'0.1 mm'} + transmitter_type_dict = {0:'iss', 1:'temp', 2:'hum', 3:'temp_hum', 4:'wind', + 5:'rain', 6:'leaf', 7:'soil', 8:'leaf_soil', + 9:'sensorlink', 10:'none'} + repeater_dict = {0:'none', 1:'A', 2:'B', 3:'C', 4:'D', + 5:'E', 6:'F', 7:'G', 8:'H'} + listen_dict = {0:'inactive', 1:'active'} + + def __init__(self, **vp_dict): + """Initialize an object of type Vantage. + + NAMED ARGUMENTS: + + connection_type: The type of connection (serial|ethernet) [Required] + + port: The serial port of the VP. [Required if serial/USB + communication] + + host: The Vantage network host [Required if Ethernet communication] + + baudrate: Baudrate of the port. [Optional. Default 19200] + + tcp_port: TCP port to connect to [Optional. Default 22222] + + tcp_send_delay: Block after sending data to WeatherLinkIP to allow it + to process the command [Optional. Default is 0.5] + + timeout: How long to wait before giving up on a response from the + serial port. [Optional. Default is 4] + + wait_before_retry: How long to wait before retrying. [Optional. + Default is 1.2 seconds] + + command_delay: How long to wait after sending a command before looking + for acknowledgement. [Optional. Default is 0.5 seconds] + + max_tries: How many times to try again before giving up. [Optional. + Default is 4] + + iss_id: The station number of the ISS [Optional. Default is 1] + + model_type: Vantage Pro model type. 1=Vantage Pro; 2=Vantage Pro2 + [Optional. Default is 2] + + loop_request: Requested packet type. 1=LOOP; 2=LOOP2; 3=both. + + loop_batch: How many LOOP packets to get in a single batch. + [Optional. Default is 200] + + max_batch_errors: How many errors to allow in a batch before a restart. + [Optional. Default is 3] + """ + + log.debug('Driver version is %s', DRIVER_VERSION) + + self.hardware_type = None + + # These come from the configuration dictionary: + self.max_tries = to_int(vp_dict.get('max_tries', 4)) + self.iss_id = to_int(vp_dict.get('iss_id')) + self.model_type = to_int(vp_dict.get('model_type', 2)) + if self.model_type not in (1, 2): + raise weewx.UnsupportedFeature("Unknown model_type (%d)" % self.model_type) + self.loop_request = to_int(vp_dict.get('loop_request', 1)) + log.debug("Option loop_request=%d", self.loop_request) + self.loop_batch = to_int(vp_dict.get('loop_batch', 200)) + self.max_batch_errors = to_int(vp_dict.get('max_batch_errors', 3)) + + self.save_day_rain = None + self.max_dst_jump = 7200 + + # Get an appropriate port, depending on the connection type: + self.port = Vantage._port_factory(vp_dict) + + # Open it up: + self.port.openPort() + + # Read the EEPROM and fill in properties in this instance + self._setup() + log.debug("Hardware name: %s", self.hardware_name) + + def openPort(self): + """Open up the connection to the console""" + self.port.openPort() + + def closePort(self): + """Close the connection to the console. """ + self.port.closePort() + + def genLoopPackets(self): + """Generator function that returns loop packets""" + + while True: + # Get LOOP packets in big batches This is necessary because there is + # an undocumented limit to how many LOOP records you can request + # on the VP (somewhere around 220). + for _loop_packet in self.genDavisLoopPackets(self.loop_batch): + yield _loop_packet + + def genDavisLoopPackets(self, N=1): + """Generator function to return N loop packets from a Vantage console + + N: The number of packets to generate [default is 1] + + yields: up to N loop packets (could be less in the event of a + read or CRC error). + """ + + log.debug("Requesting %d LOOP packets.", N) + + attempt = 1 + while attempt <= self.max_batch_errors: + try: + self.port.wakeup_console(self.max_tries) + if self.loop_request == 1: + # If asking for old-fashioned LOOP1 data, send the older command in case the + # station does not support the LPS command: + self.port.send_data(b"LOOP %d\n" % N) + else: + # Request N packets of type "loop_request": + self.port.send_data(b"LPS %d %d\n" % (self.loop_request, N)) + + for loop in range(N): + loop_packet = self._get_packet() + yield loop_packet + + except weewx.WeeWxIOError as e: + log.error("LOOP batch try #%d; error: %s", attempt, e) + attempt += 1 + else: + msg = "LOOP max batch errors (%d) exceeded." % self.max_batch_errors + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def _get_packet(self): + """Get a single LOOP packet""" + # Fetch a packet... + _buffer = self.port.read(99) + # ... see if it passes the CRC test ... + crc = crc16(_buffer) + if crc: + if weewx.debug > 1: + log.error("LOOP buffer failed CRC check. Calculated CRC=%d" % crc) + if six.PY2: + log.error("Buffer: " + "".join("\\x%02x" % ord(c) for c in _buffer)) + else: + log.error("Buffer: %s", _buffer) + raise weewx.CRCError("LOOP buffer failed CRC check") + # ... decode it ... + loop_packet = self._unpackLoopPacket(_buffer[:95]) + # .. then return it + return loop_packet + + def genArchiveRecords(self, since_ts): + """A generator function to return archive packets from a Davis Vantage station. + + since_ts: A timestamp. All data since (but not including) this time will be returned. + Pass in None for all data + + yields: a sequence of dictionaries containing the data + """ + + count = 1 + while count <= self.max_tries: + try: + for _record in self.genDavisArchiveRecords(since_ts): + # Successfully retrieved record. Set count back to one. + count = 1 + since_ts = _record['dateTime'] + yield _record + # The generator loop exited. We're done. + return + except weewx.WeeWxIOError as e: + # Problem. Log, then increment count + log.error("DMPAFT try #%d; error: %s", count, e) + count += 1 + + log.error("DMPAFT max tries (%d) exceeded.", self.max_tries) + raise weewx.RetriesExceeded("Max tries exceeded while getting archive data.") + + def genDavisArchiveRecords(self, since_ts): + """A generator function to return archive records from a Davis Vantage station. + + This version does not catch any exceptions.""" + + if since_ts: + since_tt = time.localtime(since_ts) + # NB: note that some of the Davis documentation gives the year offset as 1900. + # From experimentation, 2000 seems to be right, at least for the newer models: + _vantageDateStamp = since_tt[2] + (since_tt[1] << 5) + ((since_tt[0] - 2000) << 9) + _vantageTimeStamp = since_tt[3] * 100 + since_tt[4] + log.debug('Getting archive packets since %s', weeutil.weeutil.timestamp_to_string(since_ts)) + else: + _vantageDateStamp = _vantageTimeStamp = 0 + log.debug('Getting all archive packets') + + # Pack the date and time into a string, little-endian order + _datestr = struct.pack("> 9 # year + mo = (0x01e0 & datestamp) >> 5 # month + d = (0x001f & datestamp) # day + h = timestamp // 100 # hour + mn = timestamp % 100 # minute + yield (_ipage, _index, y, mo, d, h, mn, time_ts) + log.debug("Vantage: Finished logger summary.") + + def getTime(self): + """Get the current time from the console, returning it as timestamp""" + + time_dt = self.getConsoleTime() + return time.mktime(time_dt.timetuple()) + + def getConsoleTime(self): + """Return the raw time on the console, uncorrected for DST or timezone.""" + + # Try up to max_tries times: + for unused_count in range(self.max_tries): + try: + # Wake up the console... + self.port.wakeup_console(max_tries=self.max_tries) + # ... request the time... + self.port.send_data(b'GETTIME\n') + # ... get the binary data. No prompt, only one try: + _buffer = self.port.get_data_with_crc16(8, max_tries=1) + (sec, minute, hr, day, mon, yr, unused_crc) = struct.unpack(" 46: + raise weewx.ViolatedPrecondition("Invalid time zone code %d" % code) + # Set the GMT_OR_ZONE byte to use TIME_ZONE value + self.port.send_data(b"EEBWR 16 01\n") + self.port.send_data_with_crc16(int2byte(0)) + # Set the TIME_ZONE value + self.port.send_data(b"EEBWR 11 01\n") + self.port.send_data_with_crc16(int2byte(code)) + + def setTZoffset(self, offset): + """Set the console's time zone to a custom offset. + + offset: Offset. This is an integer in hundredths of hours. E.g., -175 would be 1h45m negative offset.""" + # Set the GMT_OR_ZONE byte to use GMT_OFFSET value + self.port.send_data(b"EEBWR 16 01\n") + self.port.send_data_with_crc16(int2byte(1)) + # Set the GMT_OFFSET value + self.port.send_data(b"EEBWR 14 02\n") + self.port.send_data_with_crc16(struct.pack("B', new_usetx_bits), max_tries=1) + # Then call NEWSETUP to get it all to stick: + self.port.send_data(b"NEWSETUP\n") + + self._setup() + log.info("Transmitter type for channel %d set to %d (%s), repeater: %s, %s", + new_channel, new_transmitter_type, + self.transmitter_type_dict[new_transmitter_type], + self.repeater_dict[new_repeater], self.listen_dict[usetx]) + + def setRetransmit(self, new_channel): + """Set console retransmit channel.""" + # Tell the console to put one byte in hex location 0x18 + self.port.send_data(b"EEBWR 18 01\n") + # Follow it up with the data: + self.port.send_data_with_crc16(int2byte(new_channel), max_tries=1) + # Then call NEWSETUP to get it to stick: + self.port.send_data(b"NEWSETUP\n") + + self._setup() + if new_channel != 0: + log.info("Retransmit set to 'ON' at channel: %d", new_channel) + else: + log.info("Retransmit set to 'OFF'") + + def setTempLogging(self, new_tempLogging='AVERAGE'): + """Set console temperature logging to 'AVERAGE' or 'LAST'.""" + try: + _setting = {'LAST': 1, 'AVERAGE': 0}[new_tempLogging.upper()] + except KeyError: + raise ValueError("Unknown console temperature logging setting '%s'" % new_tempLogging.upper()) + + # Tell the console to put one byte in hex location 0x2B + self.port.send_data(b"EEBWR FFC 01\n") + # Follow it up with the data: + self.port.send_data_with_crc16(int2byte(_setting), max_tries=1) + # Then call NEWSETUP to get it to stick: + self.port.send_data(b"NEWSETUP\n") + + log.info("Console temperature logging set to '%s'", new_tempLogging.upper()) + + def setCalibrationWindDir(self, offset): + """Set the on-board wind direction calibration.""" + if not -359 <= offset <= 359: + raise weewx.ViolatedPrecondition("Offset %d out of range [-359, 359]." % offset) + # Tell the console to put two bytes in hex location 0x4D + self.port.send_data(b"EEBWR 4D 02\n") + # Follow it up with the data: + self.port.send_data_with_crc16(struct.pack("> 4) - 7 if repeater > 127 else 0 + transmitter = {"transmitter_type": transmitter_type, + "repeater": self.repeater_dict[repeater], + "listen": self.listen_dict[(use_tx >> transmitter_id) & 1] } + if transmitter_type in ['temp', 'temp_hum']: + # Extra temperature is origin 0. + transmitter['temp'] = (transmitter_data[transmitter_id * 2 + 1] & 0xF) + 1 + if transmitter_type in ['hum', 'temp_hum']: + # Extra humidity is origin 1. + transmitter['hum'] = transmitter_data[transmitter_id * 2 + 1] >> 4 + transmitters.append(transmitter) + return transmitters + + def getStnCalibration(self): + """ Get the temperature/humidity/wind calibrations built into the console. """ + (inTemp, inTempComp, outTemp, + extraTemp1, extraTemp2, extraTemp3, extraTemp4, extraTemp5, extraTemp6, extraTemp7, + soilTemp1, soilTemp2, soilTemp3, soilTemp4, leafTemp1, leafTemp2, leafTemp3, leafTemp4, + inHumid, + outHumid, extraHumid1, extraHumid2, extraHumid3, extraHumid4, extraHumid5, extraHumid6, extraHumid7, + wind) = self._getEEPROM_value(0x32, "<27bh") + # inTempComp is 1's complement of inTemp. + if inTemp + inTempComp != -1: + log.error("Inconsistent EEPROM calibration values") + return None + # Temperatures are in tenths of a degree F; Humidity in 1 percent. + return { + "inTemp": inTemp / 10.0, + "outTemp": outTemp / 10.0, + "extraTemp1": extraTemp1 / 10.0, + "extraTemp2": extraTemp2 / 10.0, + "extraTemp3": extraTemp3 / 10.0, + "extraTemp4": extraTemp4 / 10.0, + "extraTemp5": extraTemp5 / 10.0, + "extraTemp6": extraTemp6 / 10.0, + "extraTemp7": extraTemp7 / 10.0, + "soilTemp1": soilTemp1 / 10.0, + "soilTemp2": soilTemp2 / 10.0, + "soilTemp3": soilTemp3 / 10.0, + "soilTemp4": soilTemp4 / 10.0, + "leafTemp1": leafTemp1 / 10.0, + "leafTemp2": leafTemp2 / 10.0, + "leafTemp3": leafTemp3 / 10.0, + "leafTemp4": leafTemp4 / 10.0, + "inHumid": inHumid, + "outHumid": outHumid, + "extraHumid1": extraHumid1, + "extraHumid2": extraHumid2, + "extraHumid3": extraHumid3, + "extraHumid4": extraHumid4, + "extraHumid5": extraHumid5, + "extraHumid6": extraHumid6, + "extraHumid7": extraHumid7, + "wind": wind + } + + def startLogger(self): + self.port.send_command(b"START\n") + + def stopLogger(self): + self.port.send_command(b'STOP\n') + + #=========================================================================== + # Davis Vantage utility functions + #=========================================================================== + + @property + def hardware_name(self): + if self.hardware_type == 16: + if self.model_type == 1: + return "Vantage Pro" + else: + return "Vantage Pro2" + elif self.hardware_type == 17: + return "Vantage Vue" + else: + raise weewx.UnsupportedFeature("Unknown hardware type %d" % self.hardware_type) + + @property + def archive_interval(self): + return self.archive_interval_ + + def _determine_hardware(self): + # Determine the type of hardware: + for count in range(self.max_tries): + try: + self.port.send_data(b"WRD\x12\x4d\n") + self.hardware_type = byte2int(self.port.read()) + log.debug("Hardware type is %d", self.hardware_type) + # 16 = Pro, Pro2, 17 = Vue + return self.hardware_type + except weewx.WeeWxIOError as e: + log.error("_determine_hardware; retry #%d: '%s'", count, e) + + log.error("Unable to read hardware type; raise WeeWxIOError") + raise weewx.WeeWxIOError("Unable to read hardware type") + + def _setup(self): + """Retrieve the EEPROM data block from a VP2 and use it to set various properties""" + + self.port.wakeup_console(max_tries=self.max_tries) + + # Get hardware type, if not done yet. + if self.hardware_type is None: + self.hardware_type = self._determine_hardware() + # Overwrite model_type if we have Vantage Vue. + if self.hardware_type == 17: + self.model_type = 2 + + unit_bits = self._getEEPROM_value(0x29)[0] + setup_bits = self._getEEPROM_value(0x2B)[0] + self.rain_year_start = self._getEEPROM_value(0x2C)[0] + self.archive_interval_ = self._getEEPROM_value(0x2D)[0] * 60 + self.altitude = self._getEEPROM_value(0x0F, "> 2 + altitude_unit_code = (unit_bits & 0x10) >> 4 + rain_unit_code = (unit_bits & 0x20) >> 5 + wind_unit_code = (unit_bits & 0xC0) >> 6 + + self.wind_cup_type = (setup_bits & 0x08) >> 3 + self.rain_bucket_type = (setup_bits & 0x30) >> 4 + + self.barometer_unit = Vantage.barometer_unit_dict[barometer_unit_code] + self.temperature_unit = Vantage.temperature_unit_dict[temperature_unit_code] + self.altitude_unit = Vantage.altitude_unit_dict[altitude_unit_code] + self.rain_unit = Vantage.rain_unit_dict[rain_unit_code] + self.wind_unit = Vantage.wind_unit_dict[wind_unit_code] + self.wind_cup_size = Vantage.wind_cup_dict[self.wind_cup_type] + self.rain_bucket_size = Vantage.rain_bucket_dict[self.rain_bucket_type] + + # Try to guess the ISS ID for gauging reception strength. + if self.iss_id is None: + stations = self.getStnTransmitters() + # Wind retransmitter is best candidate. + for station_id in range(0, 8): + if stations[station_id]['transmitter_type'] == 'wind': + self.iss_id = station_id + 1 # Origin 1. + break + else: + # ISS is next best candidate. + for station_id in range(0, 8): + if stations[station_id]['transmitter_type'] == 'iss': + self.iss_id = station_id + 1 # Origin 1. + break + else: + # On Vue, can use VP2 ISS, which reports as "rain" + for station_id in range(0, 8): + if stations[station_id]['transmitter_type'] == 'rain': + self.iss_id = station_id + 1 # Origin 1. + break + else: + self.iss_id = 1 # Pick a reasonable default. + + log.debug("ISS ID is %s", self.iss_id) + + def _getEEPROM_value(self, offset, v_format="B"): + """Return a list of values from the EEPROM starting at a specified offset, using a specified format""" + + nbytes = struct.calcsize(v_format) + # Don't bother waking up the console for the first try. It's probably + # already awake from opening the port. However, if we fail, then do a + # wake up. + firsttime = True + + command = b"EEBRD %X %X\n" % (offset, nbytes) + for unused_count in range(self.max_tries): + try: + if not firsttime: + self.port.wakeup_console(max_tries=self.max_tries) + firsttime = False + self.port.send_data(command) + _buffer = self.port.get_data_with_crc16(nbytes + 2, max_tries=1) + _value = struct.unpack(v_format, _buffer[:-2]) + return _value + except weewx.WeeWxIOError: + continue + + msg = "While getting EEPROM data value at address 0x%X" % offset + log.error(msg) + raise weewx.RetriesExceeded(msg) + + @staticmethod + def _port_factory(vp_dict): + """Produce a serial or ethernet port object""" + + timeout = float(vp_dict.get('timeout', 4.0)) + wait_before_retry = float(vp_dict.get('wait_before_retry', 1.2)) + command_delay = float(vp_dict.get('command_delay', 0.5)) + + # Get the connection type. If it is not specified, assume 'serial': + connection_type = vp_dict.get('type', 'serial').lower() + + if connection_type == "serial": + port = vp_dict['port'] + baudrate = int(vp_dict.get('baudrate', 19200)) + return SerialWrapper(port, baudrate, timeout, + wait_before_retry, command_delay) + elif connection_type == "ethernet": + hostname = vp_dict['host'] + tcp_port = int(vp_dict.get('tcp_port', 22222)) + tcp_send_delay = float(vp_dict.get('tcp_send_delay', 0.5)) + return EthernetWrapper(hostname, tcp_port, timeout, tcp_send_delay, + wait_before_retry, command_delay) + raise weewx.UnsupportedFeature(vp_dict['type']) + + def _unpackLoopPacket(self, raw_loop_buffer): + """Decode a raw Davis LOOP packet, returning the results as a dictionary in physical units. + + raw_loop_buffer: The loop packet data buffer, passed in as + a string (Python 2), or a byte array (Python 3). + + returns: + + A dictionary. The key will be an observation type, the value will be + the observation in physical units.""" + + # Get the packet type. It's in byte 4. + packet_type = indexbytes(raw_loop_buffer, 4) + if packet_type == 0: + loop_struct = loop1_struct + loop_types = loop1_types + elif packet_type == 1: + loop_struct = loop2_struct + loop_types = loop2_types + else: + raise weewx.WeeWxIOError("Unknown LOOP packet type %s" % packet_type) + + # Unpack the data, using the appropriate compiled stuct.Struct buffer. + # The result will be a long tuple with just the raw values from the console. + data_tuple = loop_struct.unpack(raw_loop_buffer) + + # Combine it with the data types. The result will be a long iterable of 2-way + # tuples: (type, raw-value) + raw_loop_tuples = zip(loop_types, data_tuple) + + # Convert to a dictionary: + raw_loop_packet = dict(raw_loop_tuples) + # Add the bucket type. It's needed to decode rain bucket tips. + raw_loop_packet['bucket_type'] = self.rain_bucket_type + + loop_packet = { + 'dateTime': int(time.time() + 0.5), + 'usUnits' : weewx.US + } + # Now we need to map the raw values to physical units. + for _type in raw_loop_packet: + if _type in extra_sensors and self.hardware_type == 17: + # Vantage Vues do not support extra sensors. Skip them. + continue + # Get the mapping function for this type. If there is + # no such function, supply a lambda function that returns None + func = _loop_map.get(_type, lambda p, k: None) + # Apply the function + val = func(raw_loop_packet, _type) + # Ignore None values: + if val is not None: + loop_packet[_type] = val + + # Adjust sunrise and sunset: + start_of_day = weeutil.weeutil.startOfDay(loop_packet['dateTime']) + if 'sunrise' in loop_packet: + loop_packet['sunrise'] += start_of_day + if 'sunset' in loop_packet: + loop_packet['sunset'] += start_of_day + + # Because the Davis stations do not offer bucket tips in LOOP data, we + # must calculate it by looking for changes in rain totals. This won't + # work for the very first rain packet. + if self.save_day_rain is None: + delta = None + else: + delta = loop_packet['dayRain'] - self.save_day_rain + # If the difference is negative, we're at the beginning of a month. + if delta < 0: delta = None + loop_packet['rain'] = delta + self.save_day_rain = loop_packet['dayRain'] + + return loop_packet + + def _unpackArchivePacket(self, raw_archive_buffer): + """Decode a Davis archive packet, returning the results as a dictionary. + + raw_archive_buffer: The archive record data buffer, passed in as + a string (Python 2), or a byte array (Python 3). + + returns: + + A dictionary. The key will be an observation type, the value will be + the observation in physical units.""" + + # Get the record type. It's in byte 42. + record_type = indexbytes(raw_archive_buffer, 42) + + if record_type == 0xff: + # Rev A packet type: + rec_struct = rec_A_struct + rec_types = rec_types_A + elif record_type == 0x00: + # Rev B packet type: + rec_struct = rec_B_struct + rec_types = rec_types_B + else: + raise weewx.UnknownArchiveType("Unknown archive type = 0x%x" % (record_type,)) + + data_tuple = rec_struct.unpack(raw_archive_buffer) + + raw_archive_record = dict(zip(rec_types, data_tuple)) + raw_archive_record['bucket_type'] = self.rain_bucket_type + + archive_record = { + 'dateTime': _archive_datetime(raw_archive_record['date_stamp'], + raw_archive_record['time_stamp']), + 'usUnits': weewx.US, + # Divide archive interval by 60 to keep consistent with wview + 'interval': int(self.archive_interval // 60), + } + + archive_record['rxCheckPercent'] = _rxcheck(self.model_type, + archive_record['interval'], + self.iss_id, + raw_archive_record['wind_samples']) + + for _type in raw_archive_record: + if _type in extra_sensors and self.hardware_type == 17: + # VantageVues do not support extra sensors. Skip them. + continue + # Get the mapping function for this type. If there is no such + # function, supply a lambda function that will just return None + func = _archive_map.get(_type, lambda p, k: None) + # Call the function: + val = func(raw_archive_record, _type) + # Skip all null values + if val is not None: + archive_record[_type] = val + + return archive_record + +#=============================================================================== +# LOOP packet +#=============================================================================== + + +# A list of all the types held in a Vantage LOOP packet in their native order. +loop1_schema = [ + ('loop', '3s'), ('rev_type', 'b'), ('packet_type', 'B'), + ('next_record', 'H'), ('barometer', 'H'), ('inTemp', 'h'), + ('inHumidity', 'B'), ('outTemp', 'h'), ('windSpeed', 'B'), + ('windSpeed10', 'B'), ('windDir', 'H'), ('extraTemp1', 'B'), + ('extraTemp2', 'B'), ('extraTemp3', 'B'), ('extraTemp4', 'B'), + ('extraTemp5', 'B'), ('extraTemp6', 'B'), ('extraTemp7', 'B'), + ('soilTemp1', 'B'), ('soilTemp2', 'B'), ('soilTemp3', 'B'), + ('soilTemp4', 'B'), ('leafTemp1', 'B'), ('leafTemp2', 'B'), + ('leafTemp3', 'B'), ('leafTemp4', 'B'), ('outHumidity', 'B'), + ('extraHumid1', 'B'), ('extraHumid2', 'B'), ('extraHumid3', 'B'), + ('extraHumid4', 'B'), ('extraHumid5', 'B'), ('extraHumid6', 'B'), + ('extraHumid7', 'B'), ('rainRate', 'H'), ('UV', 'B'), + ('radiation', 'H'), ('stormRain', 'H'), ('stormStart', 'H'), + ('dayRain', 'H'), ('monthRain', 'H'), ('yearRain', 'H'), + ('dayET', 'H'), ('monthET', 'H'), ('yearET', 'H'), + ('soilMoist1', 'B'), ('soilMoist2', 'B'), ('soilMoist3', 'B'), + ('soilMoist4', 'B'), ('leafWet1', 'B'), ('leafWet2', 'B'), + ('leafWet3', 'B'), ('leafWet4', 'B'), ('insideAlarm', 'B'), + ('rainAlarm', 'B'), ('outsideAlarm1', 'B'), ('outsideAlarm2', 'B'), + ('extraAlarm1', 'B'), ('extraAlarm2', 'B'), ('extraAlarm3', 'B'), + ('extraAlarm4', 'B'), ('extraAlarm5', 'B'), ('extraAlarm6', 'B'), + ('extraAlarm7', 'B'), ('extraAlarm8', 'B'), ('soilLeafAlarm1', 'B'), + ('soilLeafAlarm2', 'B'), ('soilLeafAlarm3', 'B'), ('soilLeafAlarm4', 'B'), + ('txBatteryStatus', 'B'), ('consBatteryVoltage', 'H'), ('forecastIcon', 'B'), + ('forecastRule', 'B'), ('sunrise', 'H'), ('sunset', 'H') +] + + +loop2_schema = [ + ('loop', '3s'), ('trendIcon', 'b'), ('packet_type', 'B'), + ('_unused', 'H'), ('barometer', 'H'), ('inTemp', 'h'), + ('inHumidity', 'B'), ('outTemp', 'h'), ('windSpeed', 'B'), + ('_unused', 'B'), ('windDir', 'H'), ('windSpeed10', 'H'), + ('windSpeed2', 'H'), ('windGust10', 'H'), ('windGustDir10', 'H'), + ('_unused', 'H'), ('_unused', 'H'), ('dewpoint', 'h'), + ('_unused', 'B'), ('outHumidity', 'B'), ('_unused', 'B'), + ('heatindex', 'h'), ('windchill', 'h'), ('THSW', 'h'), + ('rainRate', 'H'), ('UV', 'B'), ('radiation', 'H'), + ('stormRain', 'H'), ('stormStart', 'H'), ('dayRain', 'H'), + ('rain15', 'H'), ('hourRain', 'H'), ('dayET', 'H'), + ('rain24', 'H'), ('bar_reduction', 'B'), ('bar_offset', 'h'), + ('bar_calibration', 'h'), ('pressure_raw', 'H'), ('pressure', 'H'), + ('altimeter', 'H'), ('_unused', 'B'), ('_unused', 'B'), + ('_unused_graph', 'B'), ('_unused_graph', 'B'), ('_unused_graph', 'B'), + ('_unused_graph', 'B'), ('_unused_graph', 'B'), ('_unused_graph', 'B'), + ('_unused_graph', 'B'), ('_unused_graph', 'B'), ('_unused_graph', 'B'), + ('_unused_graph', 'B'), ('_unused', 'H'), ('_unused', 'H'), + ('_unused', 'H'), ('_unused', 'H'), ('_unused', 'H'), + ('_unused', 'H') +] + +# Extract the types and struct.Struct formats for the two types of LOOP packets +loop1_types, loop1_code = list(zip(*loop1_schema)) +loop1_struct = struct.Struct('<' + ''.join(loop1_code)) +loop2_types, loop2_code = list(zip(*loop2_schema)) +loop2_struct = struct.Struct('<' + ''.join(loop2_code)) + +#=============================================================================== +# archive packet +#=============================================================================== + +rec_A_schema =[ + ('date_stamp', 'H'), ('time_stamp', 'H'), ('outTemp', 'h'), + ('highOutTemp', 'h'), ('lowOutTemp', 'h'), ('rain', 'H'), + ('rainRate', 'H'), ('barometer', 'H'), ('radiation', 'H'), + ('wind_samples', 'H'), ('inTemp', 'h'), ('inHumidity', 'B'), + ('outHumidity', 'B'), ('windSpeed', 'B'), ('windGust', 'B'), + ('windGustDir', 'B'), ('windDir', 'B'), ('UV', 'B'), + ('ET', 'B'), ('invalid_data', 'B'), ('soilMoist1', 'B'), + ('soilMoist2', 'B'), ('soilMoist3', 'B'), ('soilMoist4', 'B'), + ('soilTemp1', 'B'), ('soilTemp2', 'B'), ('soilTemp3', 'B'), + ('soilTemp4', 'B'), ('leafWet1', 'B'), ('leafWet2', 'B'), + ('leafWet3', 'B'), ('leafWet4', 'B'), ('extraTemp1', 'B'), + ('extraTemp2', 'B'), ('extraHumid1', 'B'), ('extraHumid2','B'), + ('readClosed', 'H'), ('readOpened', 'H'), ('unused', 'B') +] + +rec_B_schema = [ + ('date_stamp', 'H'), ('time_stamp', 'H'), ('outTemp', 'h'), + ('highOutTemp', 'h'), ('lowOutTemp', 'h'), ('rain', 'H'), + ('rainRate', 'H'), ('barometer', 'H'), ('radiation', 'H'), + ('wind_samples', 'H'), ('inTemp', 'h'), ('inHumidity', 'B'), + ('outHumidity', 'B'), ('windSpeed', 'B'), ('windGust', 'B'), + ('windGustDir', 'B'), ('windDir', 'B'), ('UV', 'B'), + ('ET', 'B'), ('highRadiation', 'H'), ('highUV', 'B'), + ('forecastRule', 'B'), ('leafTemp1', 'B'), ('leafTemp2', 'B'), + ('leafWet1', 'B'), ('leafWet2', 'B'), ('soilTemp1', 'B'), + ('soilTemp2', 'B'), ('soilTemp3', 'B'), ('soilTemp4', 'B'), + ('download_record_type', 'B'), ('extraHumid1', 'B'), ('extraHumid2','B'), + ('extraTemp1', 'B'), ('extraTemp2', 'B'), ('extraTemp3', 'B'), + ('soilMoist1', 'B'), ('soilMoist2', 'B'), ('soilMoist3', 'B'), + ('soilMoist4', 'B') +] + +# Extract the types and struct.Struct formats for the two types of archive packets: +rec_types_A, fmt_A = list(zip(*rec_A_schema)) +rec_types_B, fmt_B = list(zip(*rec_B_schema)) +rec_A_struct = struct.Struct('<' + ''.join(fmt_A)) +rec_B_struct = struct.Struct('<' + ''.join(fmt_B)) + +# These are extra sensors, not found on the Vues. +extra_sensors = { + 'leafTemp1', 'leafTemp2', 'leafWet1', 'leafWet2', + 'soilTemp1', 'soilTemp2', 'soilTemp3', 'soilTemp4', + 'extraHumid1', 'extraHumid2', 'extraTemp1', 'extraTemp2', 'extraTemp3', + 'soilMoist1', 'soilMoist2', 'soildMoist3', 'soilMoist4' +} + + +def _rxcheck(model_type, interval, iss_id, number_of_wind_samples): + """Gives an estimate of the fraction of packets received. + + Ref: Vantage Serial Protocol doc, V2.1.0, released 25-Jan-05; p42""" + # The formula for the expected # of packets varies with model number. + if model_type == 1: + _expected_packets = float(interval * 60) / ( 2.5 + (iss_id-1) / 16.0) -\ + float(interval * 60) / (50.0 + (iss_id-1) * 1.25) + elif model_type == 2: + _expected_packets = 960.0 * interval / float(41 + iss_id - 1) + else: + return None + _frac = number_of_wind_samples * 100.0 / _expected_packets + if _frac > 100.0: + _frac = 100.0 + return _frac + +#=============================================================================== +# Decoding routines +#=============================================================================== + + +def _archive_datetime(datestamp, timestamp): + """Returns the epoch time of the archive packet.""" + try: + # Construct a time tuple from Davis time. Unfortunately, as timestamps come + # off the Vantage logger, there is no way of telling whether or not DST is + # in effect. So, have the operating system guess by using a '-1' in the last + # position of the time tuple. It's the best we can do... + time_tuple = (((0xfe00 & datestamp) >> 9) + 2000, # year + (0x01e0 & datestamp) >> 5, # month + (0x001f & datestamp), # day + timestamp // 100, # hour + timestamp % 100, # minute + 0, # second + 0, 0, -1) # have OS guess DST + # Convert to epoch time: + ts = int(time.mktime(time_tuple)) + except (OverflowError, ValueError, TypeError): + ts = None + return ts + + +def _loop_date(p, k): + """Returns the epoch time stamp of a time encoded in the LOOP packet, + which, for some reason, uses a different encoding scheme than the archive packet. + Also, the Davis documentation isn't clear whether "bit 0" refers to the least-significant + bit, or the most-significant bit. I'm assuming the former, which is the usual + in little-endian machines.""" + v = p[k] + if v == 0xffff: + return None + time_tuple = ((0x007f & v) + 2000, # year + (0xf000 & v) >> 12, # month + (0x0f80 & v) >> 7, # day + 0, 0, 0, # h, m, s + 0, 0, -1) + # Convert to epoch time: + try: + ts = int(time.mktime(time_tuple)) + except (OverflowError, ValueError): + ts = None + return ts + + +def _decode_rain(p, k): + if p['bucket_type'] == 0: + # 0.01 inch bucket + return p[k] / 100.0 + elif p['bucket_type'] == 1: + # 0.2 mm bucket + return p[k] * 0.0078740157 + elif p['bucket_type'] == 2: + # 0.1 mm bucket + return p[k] * 0.00393700787 + else: + log.warning("Unknown bucket type $s" % p['bucket_type']) + + +def _decode_windSpeed_H(p, k): + """Decode 10-min average wind speed. It is encoded slightly + differently between type 0 and type 1 LOOP packets.""" + if p['packet_type'] == 0: + return float(p[k]) if p[k] != 0xff else None + elif p['packet_type'] == 1: + return float(p[k]) / 10.0 if p[k] != 0xffff else None + else: + log.warning("Unknown LOOP packet type %s" % p['packet_type']) + + +# This dictionary maps a type key to a function. The function should be able to +# decode a sensor value held in the LOOP packet in the internal, Davis form into US +# units and return it. +# NB: 5/28/2022. In a private email with Davis support, they say that leafWet3 and leafWet4 should +# always be ignored. They are not supported. +_loop_map = { + 'altimeter' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'bar_calibration' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'bar_offset' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'bar_reduction' : lambda p, k: p[k], + 'barometer' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'consBatteryVoltage': lambda p, k: float((p[k] * 300) >> 9) / 100.0, + 'dayET' : lambda p, k: float(p[k]) / 1000.0, + 'dayRain' : _decode_rain, + 'dewpoint' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'extraAlarm1' : lambda p, k: p[k], + 'extraAlarm2' : lambda p, k: p[k], + 'extraAlarm3' : lambda p, k: p[k], + 'extraAlarm4' : lambda p, k: p[k], + 'extraAlarm5' : lambda p, k: p[k], + 'extraAlarm6' : lambda p, k: p[k], + 'extraAlarm7' : lambda p, k: p[k], + 'extraAlarm8' : lambda p, k: p[k], + 'extraHumid1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid5' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid6' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid7' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp5' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp6' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp7' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'forecastIcon' : lambda p, k: p[k], + 'forecastRule' : lambda p, k: p[k], + 'heatindex' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'hourRain' : _decode_rain, + 'inHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'insideAlarm' : lambda p, k: p[k], + 'inTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'leafTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafWet1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet3' : lambda p, k: None, # Vantage supports only 2 leaf wetness sensors + 'leafWet4' : lambda p, k: None, + 'monthET' : lambda p, k: float(p[k]) / 100.0, + 'monthRain' : _decode_rain, + 'outHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'outsideAlarm1' : lambda p, k: p[k], + 'outsideAlarm2' : lambda p, k: p[k], + 'outTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'pressure' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'pressure_raw' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'radiation' : lambda p, k: float(p[k]) if p[k] != 0x7fff else None, + 'rain15' : _decode_rain, + 'rain24' : _decode_rain, + 'rainAlarm' : lambda p, k: p[k], + 'rainRate' : _decode_rain, + 'soilLeafAlarm1' : lambda p, k: p[k], + 'soilLeafAlarm2' : lambda p, k: p[k], + 'soilLeafAlarm3' : lambda p, k: p[k], + 'soilLeafAlarm4' : lambda p, k: p[k], + 'soilMoist1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'stormRain' : _decode_rain, + 'stormStart' : _loop_date, + 'sunrise' : lambda p, k: 3600 * (p[k] // 100) + 60 * (p[k] % 100), + 'sunset' : lambda p, k: 3600 * (p[k] // 100) + 60 * (p[k] % 100), + 'THSW' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'trendIcon' : lambda p, k: p[k], + 'txBatteryStatus' : lambda p, k: int(p[k]), + 'UV' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0xff else None, + 'windchill' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'windDir' : lambda p, k: (float(p[k]) if p[k] != 360 else 0) if p[k] and p[k] != 0x7fff else None, + 'windGust10' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'windGustDir10' : lambda p, k: (float(p[k]) if p[k] != 360 else 0) if p[k] and p[k] != 0x7fff else None, + 'windSpeed' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'windSpeed10' : _decode_windSpeed_H, + 'windSpeed2' : _decode_windSpeed_H, + 'yearET' : lambda p, k: float(p[k]) / 100.0, + 'yearRain' : _decode_rain, +} + +# This dictionary maps a type key to a function. The function should be able to +# decode a sensor value held in the archive packet in the internal, Davis form into US +# units and return it. +_archive_map = { + 'barometer' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'ET' : lambda p, k: float(p[k]) / 1000.0, + 'extraHumid1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'forecastRule' : lambda p, k: p[k] if p[k] != 193 else None, + 'highOutTemp' : lambda p, k: float(p[k] / 10.0) if p[k] != -32768 else None, + 'highRadiation' : lambda p, k: float(p[k]) if p[k] != 0x7fff else None, + 'highUV' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0xff else None, + 'inHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'inTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'leafTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafWet1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'lowOutTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'outHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'outTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'radiation' : lambda p, k: float(p[k]) if p[k] != 0x7fff else None, + 'rain' : _decode_rain, + 'rainRate' : _decode_rain, + 'readClosed' : lambda p, k: p[k], + 'readOpened' : lambda p, k: p[k], + 'soilMoist1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'UV' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0xff else None, + 'wind_samples' : lambda p, k: float(p[k]) if p[k] else None, + 'windDir' : lambda p, k: float(p[k]) * 22.5 if p[k] != 0xff else None, + 'windGust' : lambda p, k: float(p[k]), + 'windGustDir' : lambda p, k: float(p[k]) * 22.5 if p[k] != 0xff else None, + 'windSpeed' : lambda p, k: float(p[k]) if p[k] != 0xff else None, +} + +#=============================================================================== +# class VantageService +#=============================================================================== + +# This class uses multiple inheritance: + +class VantageService(Vantage, weewx.engine.StdService): + """Weewx service for the Vantage weather stations.""" + + def __init__(self, engine, config_dict): + Vantage.__init__(self, **config_dict[DRIVER_NAME]) + weewx.engine.StdService.__init__(self, engine, config_dict) + + self.max_loop_gust = 0.0 + self.max_loop_gustdir = None + + self.bind(weewx.STARTUP, self.startup) + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.END_ARCHIVE_PERIOD, self.end_archive_period) + + def startup(self, event): # @UnusedVariable + self.max_loop_gust = 0.0 + self.max_loop_gustdir = None + + def closePort(self): + # Now close my superclass's port: + Vantage.closePort(self) + + def new_loop_packet(self, event): + """Calculate the max gust seen since the last archive record.""" + + # Calculate the max gust seen since the start of this archive record + # and put it in the packet. + windSpeed = event.packet.get('windSpeed') + windDir = event.packet.get('windDir') + if windSpeed is not None and windSpeed > self.max_loop_gust: + self.max_loop_gust = windSpeed + self.max_loop_gustdir = windDir + event.packet['windGust'] = self.max_loop_gust + event.packet['windGustDir'] = self.max_loop_gustdir + + def end_archive_period(self, event): + """Zero out the max gust seen since the start of the record""" + self.max_loop_gust = 0.0 + self.max_loop_gustdir = None + + +#=============================================================================== +# Class VantageConfigurator +#=============================================================================== + +class VantageConfigurator(weewx.drivers.AbstractConfigurator): + @property + def description(self): + return "Configures the Davis Vantage weather station." + + @property + def usage(self): + return """%prog --help + %prog --info [config_file] + %prog --current [config_file] + %prog --clear-memory [config_file] [-y] + %prog --set-interval=MINUTES [config_file] [-y] + %prog --set-latitude=DEGREE [config_file] [-y] + %prog --set-longitude=DEGREE [config_file] [-y] + %prog --set-altitude=FEET [config_file] [-y] + %prog --set-barometer=inHg [config_file] [-y] + %prog --set-wind-cup=CODE [config_file] [-y] + %prog --set-bucket=CODE [config_file] [-y] + %prog --set-rain-year-start=MM [config_file] [-y] + %prog --set-offset=VARIABLE,OFFSET [config_file] [-y] + %prog --set-transmitter-type=CHANNEL,TYPE,TEMP,HUM,REPEATER_ID [config_file] [-y] + %prog --set-retransmit=[OFF|ON|ON,CHANNEL] [config_file] [-y] + %prog --set-temperature-logging=[LAST|AVERAGE] [config_file] [-y] + %prog --set-time [config_file] [-y] + %prog --set-dst=[AUTO|ON|OFF] [config_file] [-y] + %prog --set-tz-code=TZCODE [config_file] [-y] + %prog --set-tz-offset=HHMM [config_file] [-y] + %prog --set-lamp=[ON|OFF] [config_file] + %prog --dump [--batch-size=BATCH_SIZE] [config_file] [-y] + %prog --logger-summary=FILE [config_file] [-y] + %prog [--start | --stop] [config_file]""" + + def add_options(self, parser): + super(VantageConfigurator, self).add_options(parser) + parser.add_option("--info", action="store_true", dest="info", + help="To print configuration, reception, and barometer " + "calibration information about your weather station.") + parser.add_option("--current", action="store_true", + help="To print current LOOP information.") + parser.add_option("--clear-memory", action="store_true", dest="clear_memory", + help="To clear the memory of your weather station.") + parser.add_option("--set-interval", type=int, dest="set_interval", + metavar="MINUTES", + help="Sets the archive interval to the specified number of minutes. " + "Valid values are 1, 5, 10, 15, 30, 60, or 120.") + parser.add_option("--set-latitude", type=float, dest="set_latitude", + metavar="DEGREE", + help="Sets the latitude of the station to the specified number of tenth degree.") + parser.add_option("--set-longitude", type=float, dest="set_longitude", + metavar="DEGREE", + help="Sets the longitude of the station to the specified number of tenth degree.") + parser.add_option("--set-altitude", type=float, dest="set_altitude", + metavar="FEET", + help="Sets the altitude of the station to the specified number of feet.") + parser.add_option("--set-barometer", type=float, dest="set_barometer", + metavar="inHg", + help="Sets the barometer reading of the station to a known correct " + "value in inches of mercury. Specify 0 (zero) to have the console " + "pick a sensible value.") + parser.add_option("--set-wind-cup", type=int, dest="set_wind_cup", + metavar="CODE", + help="Set the type of wind cup. Specify '0' for small size; '1' for large size") + parser.add_option("--set-bucket", type=int, dest="set_bucket", + metavar="CODE", + help="Set the type of rain bucket. Specify '0' for 0.01 inches; " + "'1' for 0.2 mm; '2' for 0.1 mm") + parser.add_option("--set-rain-year-start", type=int, + dest="set_rain_year_start", metavar="MM", + help="Set the rain year start (1=Jan, 2=Feb, etc.).") + parser.add_option("--set-offset", type=str, + dest="set_offset", metavar="VARIABLE,OFFSET", + help="Set the onboard offset for VARIABLE inTemp, outTemp, extraTemp[1-7], " + "inHumid, outHumid, extraHumid[1-7], soilTemp[1-4], leafTemp[1-4], windDir) " + "to OFFSET (Fahrenheit, %, degrees)") + parser.add_option("--set-transmitter-type", type=str, + dest="set_transmitter_type", + metavar="CHANNEL,TYPE,TEMP,HUM,REPEATER_ID", + help="Set the transmitter type for CHANNEL (1-8), TYPE (0=iss, 1=temp, 2=hum, " + "3=temp_hum, 4=wind, 5=rain, 6=leaf, 7=soil, 8=leaf_soil, 9=sensorlink, 10=none), " + "as extra TEMP station and extra HUM station (both 1-7, if applicable), " + "REPEATER_ID ('A'-'H', if used)") + parser.add_option("--set-retransmit", type=str, dest="set_retransmit", + metavar="OFF|ON|ON,CHANNEL", + help="Turn console retransmit function 'ON' or 'OFF'.") + parser.add_option("--set-temperature-logging", dest="set_temp_logging", + metavar="LAST|AVERAGE", + help="Set console temperature logging to either 'LAST' or 'AVERAGE'.") + parser.add_option("--set-time", action="store_true", dest="set_time", + help="Set the onboard clock to the current time.") + parser.add_option("--set-dst", dest="set_dst", + metavar="AUTO|ON|OFF", + help="Set DST to 'ON', 'OFF', or 'AUTO'") + parser.add_option("--set-tz-code", type=int, dest="set_tz_code", + metavar="TZCODE", + help="Set timezone code to TZCODE. See your Vantage manual for " + "valid codes.") + parser.add_option("--set-tz-offset", dest="set_tz_offset", + help="Set timezone offset to HHMM. E.g. '-0800' for U.S. Pacific Time.", + metavar="HHMM") + parser.add_option("--set-lamp", dest="set_lamp", + metavar="ON|OFF", + help="Turn the console lamp 'ON' or 'OFF'.") + parser.add_option("--dump", action="store_true", + help="Dump all data to the archive. " + "NB: This may result in many duplicate primary key errors.") + parser.add_option("--batch-size", type=int, default=1, metavar="BATCH_SIZE", + help="Use with option --dump. Pages are read off the console in batches " + "of BATCH_SIZE. A BATCH_SIZE of zero means dump all data first, " + "then put it in the database. This can improve performance in " + "high-latency environments, but requires sufficient memory to " + "hold all station data. Default is 1 (one).") + parser.add_option("--logger-summary", type="string", + dest="logger_summary", metavar="FILE", + help="Save diagnostic summary to FILE (for debugging the logger).") + parser.add_option("--start", action="store_true", + help="Start the logger.") + parser.add_option("--stop", action="store_true", + help="Stop the logger.") + + def do_options(self, options, parser, config_dict, prompt): + if options.start and options.stop: + parser.error("Cannot specify both --start and --stop") + if options.set_tz_code and options.set_tz_offset: + parser.error("Cannot specify both --set-tz-code and --set-tz-offset") + + station = Vantage(**config_dict[DRIVER_NAME]) + if options.info: + self.show_info(station) + if options.current: + self.current(station) + if options.set_interval is not None: + self.set_interval(station, options.set_interval, options.noprompt) + if options.set_latitude is not None: + self.set_latitude(station, options.set_latitude, options.noprompt) + if options.set_longitude is not None: + self.set_longitude(station, options.set_longitude, options.noprompt) + if options.set_altitude is not None: + self.set_altitude(station, options.set_altitude, options.noprompt) + if options.set_barometer is not None: + self.set_barometer(station, options.set_barometer, options.noprompt) + if options.clear_memory: + self.clear_memory(station, options.noprompt) + if options.set_wind_cup is not None: + self.set_wind_cup(station, options.set_wind_cup, options.noprompt) + if options.set_bucket is not None: + self.set_bucket(station, options.set_bucket, options.noprompt) + if options.set_rain_year_start is not None: + self.set_rain_year_start(station, options.set_rain_year_start, options.noprompt) + if options.set_offset is not None: + self.set_offset(station, options.set_offset, options.noprompt) + if options.set_transmitter_type is not None: + self.set_transmitter_type(station, options.set_transmitter_type, options.noprompt) + if options.set_retransmit is not None: + self.set_retransmit(station, options.set_retransmit, options.noprompt) + if options.set_temp_logging is not None: + self.set_temp_logging(station, options.set_temp_logging, options.noprompt) + if options.set_time: + self.set_time(station) + if options.set_dst: + self.set_dst(station, options.set_dst) + if options.set_tz_code: + self.set_tz_code(station, options.set_tz_code) + if options.set_tz_offset: + self.set_tz_offset(station, options.set_tz_offset) + if options.set_lamp: + self.set_lamp(station, options.set_lamp) + if options.dump: + self.dump_logger(station, config_dict, options.noprompt, options.batch_size) + if options.logger_summary: + self.logger_summary(station, options.logger_summary) + if options.start: + self.start_logger(station) + if options.stop: + self.stop_logger(station) + + @staticmethod + def show_info(station, dest=sys.stdout): + """Query the configuration of the Vantage, printing out status + information""" + + print("Querying...") + try: + _firmware_date = station.getFirmwareDate().decode('ascii') + except weewx.RetriesExceeded: + _firmware_date = "" + try: + _firmware_version = station.getFirmwareVersion().decode('ascii') + except weewx.RetriesExceeded: + _firmware_version = '' + + console_time = station.getConsoleTime() + altitude_converted = weewx.units.convert(station.altitude_vt, station.altitude_unit)[0] + + print("""Davis Vantage EEPROM settings: + + CONSOLE TYPE: %s + + CONSOLE FIRMWARE: + Date: %s + Version: %s + + CONSOLE SETTINGS: + Archive interval: %d (seconds) + Altitude: %d (%s) + Wind cup type: %s + Rain bucket type: %s + Rain year start: %d + Onboard time: %s + + CONSOLE DISPLAY UNITS: + Barometer: %s + Temperature: %s + Rain: %s + Wind: %s + """ % (station.hardware_name, _firmware_date, _firmware_version, + station.archive_interval, + altitude_converted, station.altitude_unit, + station.wind_cup_size, station.rain_bucket_size, + station.rain_year_start, console_time, + station.barometer_unit, station.temperature_unit, + station.rain_unit, station.wind_unit), file=dest) + + try: + (stnlat, stnlon, man_or_auto, dst, gmt_or_zone, zone_code, gmt_offset, + tempLogging, retransmit_channel) = station.getStnInfo() + if man_or_auto == 'AUTO': + dst = 'N/A' + if gmt_or_zone == 'ZONE_CODE': + gmt_offset_str = 'N/A' + else: + gmt_offset_str = "%+.1f hours" % gmt_offset + zone_code = 'N/A' + on_off = "ON" if retransmit_channel else "OFF" + print(""" CONSOLE STATION INFO: + Latitude (onboard): %+0.1f + Longitude (onboard): %+0.1f + Use manual or auto DST? %s + DST setting: %s + Use GMT offset or zone code? %s + Time zone code: %s + GMT offset: %s + Temperature logging: %s + Retransmit channel: %s (%d) + """ % (stnlat, stnlon, man_or_auto, dst, gmt_or_zone, zone_code, gmt_offset_str, + tempLogging, on_off, retransmit_channel), file=dest) + except weewx.RetriesExceeded: + pass + + # Add transmitter types for each channel, if we can: + transmitter_list = None + try: + transmitter_list = station.getStnTransmitters() + print(" TRANSMITTERS: ", file=dest) + print(" Channel Receive Repeater Type", file=dest) + for transmitter_id in range(0, 8): + comment = "" + transmitter_type = transmitter_list[transmitter_id]["transmitter_type"] + repeater = transmitter_list[transmitter_id]["repeater"] + listen = transmitter_list[transmitter_id]["listen"] + if transmitter_type == 'temp_hum': + comment = "(as extra temperature %d and extra humidity %d)" % \ + (transmitter_list[transmitter_id]["temp"], transmitter_list[transmitter_id]["hum"]) + elif transmitter_type == 'temp': + comment = "(as extra temperature %d)" % transmitter_list[transmitter_id]["temp"] + elif transmitter_type == 'hum': + comment = "(as extra humidity %d)" % transmitter_list[transmitter_id]["hum"] + elif transmitter_type == 'none': + transmitter_type = "(N/A)" + print(" %d %-8s %-4s %s %s" + % (transmitter_id + 1, listen, repeater, transmitter_type, comment), file=dest) + print("", file=dest) + except weewx.RetriesExceeded: + pass + + # Add reception statistics if we can: + try: + _rx_list = station.getRX() + print(""" RECEPTION STATS: + Total packets received: %d + Total packets missed: %d + Number of resynchronizations: %d + Longest good stretch: %d + Number of CRC errors: %d + """ % _rx_list, file=dest) + except: + pass + + # Add barometer calibration data if we can. + try: + _bar_list = station.getBarData() + print(""" BAROMETER CALIBRATION DATA: + Current barometer reading: %.3f inHg + Altitude: %.0f feet + Dew point: %.0f F + Virtual temperature: %.0f F + Humidity correction factor: %.1f + Correction ratio: %.3f + Correction constant: %+.3f inHg + Gain: %.3f + Offset: %.3f + """ % _bar_list, file=dest) + except weewx.RetriesExceeded: + pass + + # Add temperature/humidity/wind calibration if we can. + calibration_dict = station.getStnCalibration() + print(""" OFFSETS: + Wind direction: %(wind)+.0f deg + Inside Temperature: %(inTemp)+.1f F + Inside Humidity: %(inHumid)+.0f %% + Outside Temperature: %(outTemp)+.1f F + Outside Humidity: %(outHumid)+.0f %%""" % calibration_dict, file=dest) + if transmitter_list is not None: + # Only print the calibrations for channels that we are + # listening to. + for extraTemp in range(1, 8): + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['temp', 'temp_hum'] and \ + extraTemp == transmitter_list[t_id]["temp"]: + print(" Extra Temperature %d: %+.1f F" + % (extraTemp, calibration_dict["extraTemp%d" % extraTemp]), file=dest) + for extraHumid in range(1, 8): + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['hum', 'temp_hum'] and \ + extraHumid == transmitter_list[t_id]["hum"]: + print(" Extra Humidity %d: %+.1f F" + % (extraHumid, calibration_dict["extraHumid%d" % extraHumid]), file=dest) + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['soil', 'leaf_soil']: + for soil in range(1, 5): + print(" Soil Temperature %d: %+.1f F" + % (soil, calibration_dict["soilTemp%d" % soil]), file=dest) + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['leaf', 'leaf_soil']: + for leaf in range(1, 5): + print(" Leaf Temperature %d: %+.1f F" + % (leaf, calibration_dict["leafTemp%d" % leaf]), file=dest) + print("", file=dest) + + @staticmethod + def current(station): + """Print a single, current LOOP packet.""" + print('Querying the station for current weather data...') + for pack in station.genDavisLoopPackets(1): + print(weeutil.weeutil.timestamp_to_string(pack['dateTime']), + to_sorted_string(pack)) + + @staticmethod + def set_interval(station, new_interval_minutes, noprompt): + """Set the console archive interval.""" + + old_interval_minutes = station.archive_interval // 60 + print("Old archive interval is %d minutes, new one will be %d minutes." + % (station.archive_interval // 60, new_interval_minutes)) + if old_interval_minutes == new_interval_minutes: + print("Old and new archive intervals are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the archive interval " + "as well as erase all old archive records.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setArchiveInterval(new_interval_minutes * 60) + print("Archive interval now set to %d seconds." % (station.archive_interval,)) + # The Davis documentation implies that the log is + # cleared after changing the archive interval, but that + # doesn't seem to be the case. Clear it explicitly: + station.clearLog() + print("Archive records erased.") + else: + print("Nothing done.") + + @staticmethod + def set_latitude(station, latitude_dg, noprompt): + """Set the console station latitude""" + + ans = weeutil.weeutil.y_or_n("Proceeding will set the latitude value to %.1f degree.\n" + "Are you sure you wish to proceed (y/n)? " % latitude_dg, + noprompt) + if ans == 'y': + station.setLatitude(latitude_dg) + print("Station latitude set to %.1f degree." % latitude_dg) + else: + print("Nothing done.") + + @staticmethod + def set_longitude(station, longitude_dg, noprompt): + """Set the console station longitude""" + + ans = weeutil.weeutil.y_or_n("Proceeding will set the longitude value to %.1f degree.\n" + "Are you sure you wish to proceed (y/n)? " % longitude_dg, + noprompt) + if ans == 'y': + station.setLongitude(longitude_dg) + print("Station longitude set to %.1f degree." % longitude_dg) + else: + print("Nothing done.") + + @staticmethod + def set_altitude(station, altitude_ft, noprompt): + """Set the console station altitude""" + ans = weeutil.weeutil.y_or_n("Proceeding will set the station altitude to %.0f feet.\n" + "Are you sure you wish to proceed (y/n)? " % altitude_ft, + noprompt) + if ans == 'y': + # Hit the console to get the current barometer calibration data and preserve it: + _bardata = station.getBarData() + _barcal = _bardata[6] + # Set new altitude to station and clear previous _barcal value + station.setBarData(0.0, altitude_ft) + if _barcal != 0.0: + # Hit the console again to get the new barometer data: + _bardata = station.getBarData() + # Set previous _barcal value + station.setBarData(_bardata[0] + _barcal, altitude_ft) + else: + print("Nothing done.") + + @staticmethod + def set_barometer(station, barometer_inHg, noprompt): + """Set the barometer reading to a known correct value.""" + # Hit the console to get the current barometer calibration data: + _bardata = station.getBarData() + + if barometer_inHg: + msg = "Proceeding will set the barometer value to %.3f and " \ + "the station altitude to %.0f feet.\n" % (barometer_inHg, _bardata[1]) + else: + msg = "Proceeding will have the console pick a sensible barometer " \ + "calibration and set the station altitude to %.0f feet.\n" % (_bardata[1],) + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you wish to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setBarData(barometer_inHg, _bardata[1]) + else: + print("Nothing done.") + + @staticmethod + def clear_memory(station, noprompt): + """Clear the archive memory of a VantagePro""" + + ans = weeutil.weeutil.y_or_n("Proceeding will erase all archive records.\n" + "Are you sure you wish to proceed (y/n)? ", + noprompt) + if ans == 'y': + print("Erasing all archive records ...") + station.clearLog() + print("Archive records erased.") + else: + print("Nothing done.") + + @staticmethod + def set_wind_cup(station, new_wind_cup_type, noprompt): + """Set the wind cup type on the console.""" + + if station.hardware_type != 16: + print("Unable to set new wind cup type.") + print ("Reason: command only valid with Vantage Pro or Vantage Pro2 station.", file=sys.stderr) + return + + print("Old rain wind cup type is %d (%s), new one is %d (%s)." + % (station.wind_cup_type, + station.wind_cup_size, + new_wind_cup_type, + Vantage.wind_cup_dict[new_wind_cup_type])) + + if station.wind_cup_type == new_wind_cup_type: + print("Old and new wind cup types are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the wind cup type.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setWindCupType(new_wind_cup_type) + print("Wind cup type set to %d (%s)." % (station.wind_cup_type, station.wind_cup_size)) + else: + print("Nothing done.") + + @staticmethod + def set_bucket(station, new_bucket_type, noprompt): + """Set the bucket type on the console.""" + + print("Old rain bucket type is %d (%s), new one is %d (%s)." + % (station.rain_bucket_type, + station.rain_bucket_size, + new_bucket_type, + Vantage.rain_bucket_dict[new_bucket_type])) + + if station.rain_bucket_type == new_bucket_type: + print("Old and new bucket types are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the rain bucket type.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setBucketType(new_bucket_type) + print("Bucket type now set to %d." % (station.rain_bucket_type,)) + else: + print("Nothing done.") + + @staticmethod + def set_rain_year_start(station, rain_year_start, noprompt): + + print("Old rain season start is %d, new one is %d." % (station.rain_year_start, rain_year_start)) + + if station.rain_year_start == rain_year_start: + print("Old and new rain season starts are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the rain season start.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setRainYearStart(rain_year_start) + print("Rain year start now set to %d." % (station.rain_year_start,)) + else: + print("Nothing done.") + + @staticmethod + def set_offset(station, offset_list, noprompt): + """Set the on-board offset for a temperature, humidity or wind direction variable.""" + (variable, offset_str) = offset_list.split(',') + # These variables may be calibrated. + temp_variables = ['inTemp', 'outTemp' ] + \ + ['extraTemp%d' % i for i in range(1, 8)] + \ + ['soilTemp%d' % i for i in range(1, 5)] + \ + ['leafTemp%d' % i for i in range(1, 5)] + + humid_variables = ['inHumid', 'outHumid'] + \ + ['extraHumid%d' % i for i in range(1, 8)] + + # Wind direction can also be calibrated. + if variable == "windDir": + offset = int(offset_str) + if not -359 <= offset <= 359: + print("Wind direction offset %d is out of range." % offset, file=sys.stderr) + else: + ans = weeutil.weeutil.y_or_n("Proceeding will set offset for wind direction to %+d.\n" % offset + + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setCalibrationWindDir(offset) + print("Wind direction offset now set to %+d." % offset) + else: + print("Nothing done.") + elif variable in temp_variables: + offset = float(offset_str) + if not -12.8 <= offset <= 12.7: + print("Temperature offset %+.1f is out of range." % (offset), file=sys.stderr) + else: + ans = weeutil.weeutil.y_or_n("Proceeding will set offset for " + "temperature %s to %+.1f.\n" % (variable, offset) + + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setCalibrationTemp(variable, offset) + print("Temperature offset %s now set to %+.1f." % (variable, offset)) + else: + print("Nothing done.") + elif variable in humid_variables: + offset = int(offset_str) + if not 0 <= offset <= 100: + print("Humidity offset %+d is out of range." % (offset), file=sys.stderr) + else: + ans = weeutil.weeutil.y_or_n("Proceeding will set offset for " + "humidity %s to %+d.\n" % (variable, offset) + + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setCalibrationHumid(variable, offset) + print("Humidity offset %s now set to %+d." % (variable, offset)) + else: + print("Nothing done.") + else: + print("Unknown variable %s" % variable, file=sys.stderr) + + @staticmethod + def set_transmitter_type(station, transmitter_list, noprompt): + """Set the transmitter type for one of the eight channels.""" + + transmitter_list = list(map((lambda x: int(x) if x.isdigit() else x if x != "" else None), + transmitter_list.split(','))) + channel = transmitter_list[0] + if not 1 <= channel <= 8: + print("Channel number must be between 1 and 8.") + return + + # Check new channel against retransmit channel. + # Warn and stop if new channel is used as retransmit channel. + retransmit_channel = station._getEEPROM_value(0x18)[0] + if retransmit_channel == channel: + print("This channel is used as retransmit channel. " + "Please turn off retransmit function or choose another channel.") + return + + # Init repeater to 'no repeater' + repeater = 0 + # Check the last entry in transmitter_list to see if it is a repeater letter + try: + if transmitter_list[len(transmitter_list)-1].isalpha(): + repeater_id = transmitter_list[len(transmitter_list)-1].upper() + del transmitter_list[len(transmitter_list)-1] + # Check with repeater_dict and get the ID number + for key in list(station.repeater_dict.keys()): + if station.repeater_dict[key] == repeater_id: + repeater = key + break + if repeater == 0: + print("Repeater ID must be between 'A' and 'H'.") + return + except AttributeError: + # No repeater letter + pass + + transmitter_type = transmitter_list[1] + extra_temp = transmitter_list[2] if len(transmitter_list) > 2 else None + extra_hum = transmitter_list[3] if len(transmitter_list) > 3 else None + usetx = 1 if transmitter_type != 10 else 0 + + try: + transmitter_type_name = station.transmitter_type_dict[transmitter_type] + except KeyError: + print("Unknown transmitter type (%s)" % transmitter_type) + return + + if transmitter_type_name in ['temp', 'temp_hum'] and extra_temp not in list(range(1, 8)): + print("Transmitter type %s requires extra_temp in range 1-7'" % transmitter_type_name) + return + + if transmitter_type_name in ['hum', 'temp_hum'] and extra_hum not in list(range(1, 8)): + print("Transmitter type %s requires extra_hum in range 1-7'" % transmitter_type_name) + return + + msg = "Proceeding will set channel %d to type %d (%s), repeater: %s, %s.\n" \ + % (channel, + transmitter_type, + transmitter_type_name, + station.repeater_dict[repeater], + station.listen_dict[usetx]) + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setTransmitterType(channel, transmitter_type, extra_temp, extra_hum, repeater) + print("Transmitter type for channel %d set to %d (%s), repeater: %s, %s." + % (channel, + transmitter_type, + transmitter_type_name, + station.repeater_dict[repeater], + station.listen_dict[usetx])) + else: + print("Nothing done.") + + @staticmethod + def set_retransmit(station, channel_on_off, noprompt): + """Set console retransmit channel.""" + + channel = 0 + channel_on_off = channel_on_off.strip().upper() + channel_on_off_list = channel_on_off.split(',') + on_off = channel_on_off_list[0] + if on_off != "OFF": + if len(channel_on_off_list) > 1: + channel = map((lambda x: int(x) if x != "" else None), channel_on_off_list[1])[0] + if not 0 < channel < 9: + print("Channel out of range 1..8. Nothing done.") + return + + transmitter_list = station.getStnTransmitters() + if channel: + if transmitter_list[channel-1]["listen"] == "active": + print("Channel %d in use. Please select another channel. Nothing done." % channel) + return + else: + for i in range(0, 7): + if transmitter_list[i]["listen"] == "inactive": + channel = i+1 + break + if channel == 0: + print("All Channels in use. Retransmit can't be enabled. Nothing done.") + return + + old_channel = station._getEEPROM_value(0x18)[0] + if old_channel == channel: + print("Old and new retransmit settings are the same. Nothing done.") + return + + if channel: + msg = "Proceeding will set retransmit to 'ON' at channel: %d.\n" % channel + else: + msg = "Proceeding will set retransmit to 'OFF'\n." + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setRetransmit(channel) + if channel: + print("Retransmit set to 'ON' at channel: %d." % channel) + else: + print("Retransmit set to 'OFF'.") + else: + print("Nothing done.") + + @staticmethod + def set_temp_logging(station, tempLogging, noprompt): + """Set console temperature logging to 'LAST' or 'AVERAGE'.""" + + msg = "Proceeding will change the console temperature logging to '%s'.\n" % tempLogging.upper() + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setTempLogging(tempLogging) + print("Console temperature logging set to '%s'." % (tempLogging.upper())) + else: + print("Nothing done.") + + @staticmethod + def set_time(station): + print("Setting time on console...") + station.setTime() + newtime_ts = station.getTime() + print("Current console time is %s" % weeutil.weeutil.timestamp_to_string(newtime_ts)) + + @staticmethod + def set_dst(station, dst): + station.setDST(dst) + print("Set DST on console to '%s'" % dst) + + @staticmethod + def set_tz_code(station, tz_code): + print("Setting time zone code to %d..." % tz_code) + station.setTZcode(tz_code) + new_tz_code = station.getStnInfo()[5] + print("Set time zone code to %s" % new_tz_code) + + @staticmethod + def set_tz_offset(station, tz_offset): + offset_int = int(tz_offset) + h = abs(offset_int) // 100 + m = abs(offset_int) % 100 + if h > 12 or m >= 60: + raise ValueError("Invalid time zone offset: %s" % tz_offset) + offset = h * 100 + (100 * m // 60) + if offset_int < 0: + offset = -offset + station.setTZoffset(offset) + new_offset = station.getStnInfo()[6] + print("Set time zone offset to %+.1f hours" % new_offset) + + @staticmethod + def set_lamp(station, onoff): + print("Setting lamp on console...") + station.setLamp(onoff) + + @staticmethod + def start_logger(station): + print("Starting logger ...") + station.startLogger() + print("Logger started") + + @staticmethod + def stop_logger(station): + print("Stopping logger ...") + station.stopLogger() + print("Logger stopped") + + @staticmethod + def dump_logger(station, config_dict, noprompt, batch_size=1): + import weewx.manager + ans = weeutil.weeutil.y_or_n("Proceeding will dump all data in the logger.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + with weewx.manager.open_manager_with_config(config_dict, 'wx_binding', + initialize=True) as archive: + nrecs = 0 + # Determine whether to use something to show our progress: + progress_fn = print_page if batch_size == 0 else None + + # Wrap the Vantage generator function in a converter, which will convert the units + # to the same units used by the database: + converted_generator = weewx.units.GenWithConvert( + station.genArchiveDump(progress_fn=progress_fn), + archive.std_unit_system) + + # Wrap it again, to dump in the requested batch size + converted_generator = weeutil.weeutil.GenByBatch(converted_generator, batch_size) + + print("Starting dump ...") + + for record in converted_generator: + archive.addRecord(record) + nrecs += 1 + print("Records processed: %d; Timestamp: %s\r" + % (nrecs, weeutil.weeutil.timestamp_to_string(record['dateTime'])), + end=' ', + file=sys.stdout) + sys.stdout.flush() + print("\nFinished dump. %d records added" % (nrecs,)) + else: + print("Nothing done.") + + @staticmethod + def logger_summary(station, dest_path): + + with open(dest_path, mode="w") as dest: + + VantageConfigurator.show_info(station, dest) + + print("Starting download of logger summary...") + + nrecs = 0 + for (page, index, y, mo, d, h, mn, time_ts) in station.genLoggerSummary(): + if time_ts: + print("%4d %4d %4d | %4d-%02d-%02d %02d:%02d | %s" + % (nrecs, page, index, y + 2000, mo, d, h, mn, + weeutil.weeutil.timestamp_to_string(time_ts)), file=dest) + else: + print("%4d %4d %4d [*** Unused index ***]" + % (nrecs, page, index), file=dest) + nrecs += 1 + if nrecs % 10 == 0: + print("Records processed: %d; Timestamp: %s\r" + % (nrecs, weeutil.weeutil.timestamp_to_string(time_ts)), end=' ', file=sys.stdout) + sys.stdout.flush() + print("\nFinished download of logger summary to file '%s'. %d records processed." % (dest_path, nrecs)) + + +# ============================================================================= +# Class VantageConfEditor +# ============================================================================= + +class VantageConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[Vantage] + # This section is for the Davis Vantage series of weather stations. + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP or Serial-Ethernet bridge) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 0.5 + + # The type of LOOP packet to request: 1 = LOOP1; 2 = LOOP2; 3 = both + loop_request = 1 + + # The id of your ISS station (usually 1). If you use a wind meter connected + # to a anemometer transmitter kit, use its id + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 4 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # Vantage model Type: 1 = Vantage Pro; 2 = Vantage Pro2 + model_type = 2 + + # The driver to use: + driver = weewx.drivers.vantage +""" + + def prompt_for_settings(self): + settings = dict() + print("Specify the hardware interface, either 'serial' or 'ethernet'.") + print("If the station is connected by serial, USB, or serial-to-USB") + print("adapter, specify serial. Specify ethernet for stations with") + print("WeatherLinkIP interface.") + settings['type'] = self._prompt('type', 'serial', ['serial', 'ethernet']) + if settings['type'] == 'serial': + print("Specify a port for stations with a serial interface, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + settings['port'] = self._prompt('port', '/dev/ttyUSB0') + else: + print("Specify the IP address (e.g., 192.168.0.10) or hostname") + print("(e.g., console or console.example.com) for stations with") + print("an ethernet interface.") + settings['host'] = self._prompt('host') + return settings + + +def print_page(ipage): + print("Requesting page %d/512\r" % ipage, end=' ', file=sys.stdout) + sys.stdout.flush() + + +# Define a main entry point for basic testing of the station without weewx +# engine and service overhead. Invoke this as follows from the weewx root directory: +# +# PYTHONPATH=bin python -m weewx.drivers.vantage + + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('vantage', {}) + + usage = """Usage: python -m weewx.drivers.vantage --help + python -m weewx.drivers.vantage --version + python -m weewx.drivers.vantage [--port=PORT]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', action='store_true', + help='Display driver version') + parser.add_option('--port', default='/dev/ttyUSB0', + help='Serial port to use. Default is "/dev/ttyUSB0"', + metavar="PORT") + (options, args) = parser.parse_args() + + if options.version: + print("Vantage driver version %s" % DRIVER_VERSION) + exit(0) + + vantage = Vantage(connection_type = 'serial', port=options.port) + + for packet in vantage.genLoopPackets(): + print(packet) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/wmr100.py b/dist/weewx-4.10.1/bin/weewx/drivers/wmr100.py new file mode 100644 index 0000000..cf7af82 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/wmr100.py @@ -0,0 +1,443 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classees and functions for interfacing with an Oregon Scientific WMR100 +station. The WMRS200 reportedly works with this driver (NOT the WMR200, which +is a different beast). + +The wind sensor reports wind speed, wind direction, and wind gust. It does +not report wind gust direction. + +WMR89: + - data logger + - up to 3 channels + - protocol 3 sensors + - THGN800, PRCR800, WTG800 + +WMR86: + - no data logger + - protocol 3 sensors + - THGR800, WGR800, PCR800, UVN800 + +The following references were useful for figuring out the WMR protocol: + +From Per Ejeklint: + https://github.com/ejeklint/WLoggerDaemon/blob/master/Station_protocol.md + +From Rainer Finkeldeh: + http://www.bashewa.com/wmr200-protocol.php + +The WMR driver for the wfrog weather system: + http://code.google.com/p/wfrog/source/browse/trunk/wfdriver/station/wmrs200.py + +Unfortunately, there is no documentation for PyUSB v0.4, so you have to back +it out of the source code, available at: + https://pyusb.svn.sourceforge.net/svnroot/pyusb/branches/0.4/pyusb.c +""" + +from __future__ import absolute_import +from __future__ import print_function +import logging +import time +import operator +from functools import reduce + +import usb + +import weewx.drivers +import weewx.wxformulas +import weeutil.weeutil + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WMR100' +DRIVER_VERSION = "3.5.0" + +def loader(config_dict, engine): # @UnusedVariable + return WMR100(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WMR100ConfEditor() + + +class WMR100(weewx.drivers.AbstractDevice): + """Driver for the WMR100 station.""" + + DEFAULT_MAP = { + 'pressure': 'pressure', + 'windSpeed': 'wind_speed', + 'windDir': 'wind_dir', + 'windGust': 'wind_gust', + 'windBatteryStatus': 'battery_status_wind', + 'inTemp': 'temperature_0', + 'outTemp': 'temperature_1', + 'extraTemp1': 'temperature_2', + 'extraTemp2': 'temperature_3', + 'extraTemp3': 'temperature_4', + 'extraTemp4': 'temperature_5', + 'extraTemp5': 'temperature_6', + 'extraTemp6': 'temperature_7', + 'extraTemp7': 'temperature_8', + 'inHumidity': 'humidity_0', + 'outHumidity': 'humidity_1', + 'extraHumid1': 'humidity_2', + 'extraHumid2': 'humidity_3', + 'extraHumid3': 'humidity_4', + 'extraHumid4': 'humidity_5', + 'extraHumid5': 'humidity_6', + 'extraHumid6': 'humidity_7', + 'extraHumid7': 'humidity_8', + 'inTempBatteryStatus': 'battery_status_0', + 'outTempBatteryStatus': 'battery_status_1', + 'extraBatteryStatus1': 'battery_status_2', + 'extraBatteryStatus2': 'battery_status_3', + 'extraBatteryStatus3': 'battery_status_4', + 'extraBatteryStatus4': 'battery_status_5', + 'extraBatteryStatus5': 'battery_status_6', + 'extraBatteryStatus6': 'battery_status_7', + 'extraBatteryStatus7': 'battery_status_8', + 'rain': 'rain', + 'rainTotal': 'rain_total', + 'rainRate': 'rain_rate', + 'hourRain': 'rain_hour', + 'rain24': 'rain_24', + 'rainBatteryStatus': 'battery_status_rain', + 'UV': 'uv', + 'uvBatteryStatus': 'battery_status_uv'} + + def __init__(self, **stn_dict): + """Initialize an object of type WMR100. + + NAMED ARGUMENTS: + + model: Which station model is this? + [Optional. Default is 'WMR100'] + + timeout: How long to wait, in seconds, before giving up on a response + from the USB port. + [Optional. Default is 15 seconds] + + wait_before_retry: How long to wait before retrying. + [Optional. Default is 5 seconds] + + max_tries: How many times to try before giving up. + [Optional. Default is 3] + + vendor_id: The USB vendor ID for the WMR + [Optional. Default is 0xfde] + + product_id: The USB product ID for the WM + [Optional. Default is 0xca01] + + interface: The USB interface + [Optional. Default is 0] + + IN_endpoint: The IN USB endpoint used by the WMR. + [Optional. Default is usb.ENDPOINT_IN + 1] + """ + + log.info('Driver version is %s' % DRIVER_VERSION) + self.model = stn_dict.get('model', 'WMR100') + # TODO: Consider putting these in the driver loader instead: + self.record_generation = stn_dict.get('record_generation', 'software') + self.timeout = float(stn_dict.get('timeout', 15.0)) + self.wait_before_retry = float(stn_dict.get('wait_before_retry', 5.0)) + self.max_tries = int(stn_dict.get('max_tries', 3)) + self.vendor_id = int(stn_dict.get('vendor_id', '0x0fde'), 0) + self.product_id = int(stn_dict.get('product_id', '0xca01'), 0) + self.interface = int(stn_dict.get('interface', 0)) + self.IN_endpoint = int(stn_dict.get('IN_endpoint', usb.ENDPOINT_IN + 1)) + self.sensor_map = dict(self.DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('Sensor map is %s' % self.sensor_map) + self.last_rain_total = None + self.devh = None + self.openPort() + + def openPort(self): + dev = self._findDevice() + if not dev: + log.error("Unable to find USB device (0x%04x, 0x%04x)" + % (self.vendor_id, self.product_id)) + raise weewx.WeeWxIOError("Unable to find USB device") + self.devh = dev.open() + # Detach any old claimed interfaces + try: + self.devh.detachKernelDriver(self.interface) + except usb.USBError: + pass + try: + self.devh.claimInterface(self.interface) + except usb.USBError as e: + self.closePort() + log.error("Unable to claim USB interface: %s" % e) + raise weewx.WeeWxIOError(e) + + def closePort(self): + try: + self.devh.releaseInterface() + except usb.USBError: + pass + try: + self.devh.detachKernelDriver(self.interface) + except usb.USBError: + pass + + def genLoopPackets(self): + """Generator function that continuously returns loop packets""" + + # Get a stream of raw packets, then convert them, depending on the + # observation type. + + for _packet in self.genPackets(): + try: + _packet_type = _packet[1] + if _packet_type in WMR100._dispatch_dict: + # get the observations from the packet + _raw = WMR100._dispatch_dict[_packet_type](self, _packet) + if _raw: + # map the packet labels to schema fields + _record = dict() + for k in self.sensor_map: + if self.sensor_map[k] in _raw: + _record[k] = _raw[self.sensor_map[k]] + # if there are any observations, add time and units + if _record: + for k in ['dateTime', 'usUnits']: + _record[k] = _raw[k] + yield _record + except IndexError: + log.error("Malformed packet: %s" % _packet) + + def genPackets(self): + """Generate measurement packets. These are 8 to 17 byte long packets containing + the raw measurement data. + + For a pretty good summary of what's in these packets see + https://github.com/ejeklint/WLoggerDaemon/blob/master/Station_protocol.md + """ + + # Wrap the byte generator function in GenWithPeek so we + # can peek at the next byte in the stream. The result, the variable + # genBytes, will be a generator function. + genBytes = weeutil.weeutil.GenWithPeek(self._genBytes_raw()) + + # Start by throwing away any partial packets: + for ibyte in genBytes: + if genBytes.peek() != 0xff: + break + + buff = [] + # March through the bytes generated by the generator function genBytes: + for ibyte in genBytes: + # If both this byte and the next one are 0xff, then we are at the end of a record + if ibyte == 0xff and genBytes.peek() == 0xff: + # We are at the end of a packet. + # Compute its checksum. This can throw an exception if the packet is empty. + try: + computed_checksum = reduce(operator.iadd, buff[:-2]) + except TypeError as e: + log.debug("Exception while calculating checksum: %s" % e) + else: + actual_checksum = (buff[-1] << 8) + buff[-2] + if computed_checksum == actual_checksum: + # Looks good. Yield the packet + yield buff + else: + log.debug("Bad checksum on buffer of length %d" % len(buff)) + # Throw away the next character (which will be 0xff): + next(genBytes) + # Start with a fresh buffer + buff = [] + else: + buff.append(ibyte) + + @property + def hardware_name(self): + return self.model + + #=============================================================================== + # USB functions + #=============================================================================== + + def _findDevice(self): + """Find the given vendor and product IDs on the USB bus""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == self.vendor_id and dev.idProduct == self.product_id: + return dev + + def _genBytes_raw(self): + """Generates a sequence of bytes from the WMR USB reports.""" + + try: + # Only need to be sent after a reset or power failure of the station: + self.devh.controlMsg(usb.TYPE_CLASS + usb.RECIP_INTERFACE, # requestType + 0x0000009, # request + [0x20,0x00,0x08,0x01,0x00,0x00,0x00,0x00], # buffer + 0x0000200, # value + 0x0000000, # index + 1000) # timeout + except usb.USBError as e: + log.error("Unable to send USB control message: %s" % e) + # Convert to a Weewx error: + raise weewx.WakeupError(e) + + nerrors = 0 + while True: + try: + # Continually loop, retrieving "USB reports". They are 8 bytes long each. + report = self.devh.interruptRead(self.IN_endpoint, + 8, # bytes to read + int(self.timeout * 1000)) + # While the report is 8 bytes long, only a smaller, variable portion of it + # has measurement data. This amount is given by byte zero. Return each + # byte, starting with byte one: + for i in range(1, report[0] + 1): + yield report[i] + nerrors = 0 + except (IndexError, usb.USBError) as e: + log.debug("Bad USB report received: %s" % e) + nerrors += 1 + if nerrors > self.max_tries: + log.error("Max retries exceeded while fetching USB reports") + raise weewx.RetriesExceeded("Max retries exceeded while fetching USB reports") + time.sleep(self.wait_before_retry) + + # ========================================================================= + # LOOP packet decoding functions + #========================================================================== + + def _rain_packet(self, packet): + + # NB: in my experiments with the WMR100, it registers in increments of + # 0.04 inches. Per Ejeklint's notes have you divide the packet values + # by 10, but this would result in an 0.4 inch bucket --- too big. So, + # I'm dividing by 100. + _record = { + 'rain_rate' : ((packet[3] << 8) + packet[2]) / 100.0, + 'rain_hour' : ((packet[5] << 8) + packet[4]) / 100.0, + 'rain_24' : ((packet[7] << 8) + packet[6]) / 100.0, + 'rain_total' : ((packet[9] << 8) + packet[8]) / 100.0, + 'battery_status_rain': packet[0] >> 4, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + + # Because the WMR does not offer anything like bucket tips, we must + # calculate it by looking for the change in total rain. Of course, this + # won't work for the very first rain packet. + _record['rain'] = weewx.wxformulas.calculate_rain( + _record['rain_total'], self.last_rain_total) + self.last_rain_total = _record['rain_total'] + return _record + + def _temperature_packet(self, packet): + _record = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + # Per Ejeklint's notes don't mention what to do if temperature is + # negative. I think the following is correct. Also, from experience, we + # know that the WMR has problems measuring dewpoint at temperatures + # below about 20F. So ignore dewpoint and let weewx calculate it. + T = (((packet[4] & 0x7f) << 8) + packet[3]) / 10.0 + if packet[4] & 0x80: + T = -T + R = float(packet[5]) + channel = packet[2] & 0x0f + _record['temperature_%d' % channel] = T + _record['humidity_%d' % channel] = R + _record['battery_status_%d' % channel] = (packet[0] & 0x40) >> 6 + return _record + + def _temperatureonly_packet(self, packet): + # function added by fstuyk to manage temperature-only sensor THWR800 + _record = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + # Per Ejeklint's notes don't mention what to do if temperature is + # negative. I think the following is correct. + T = (((packet[4] & 0x7f) << 8) + packet[3])/10.0 + if packet[4] & 0x80: + T = -T + channel = packet[2] & 0x0f + _record['temperature_%d' % channel] = T + _record['battery_status_%d' % channel] = (packet[0] & 0x40) >> 6 + return _record + + def _pressure_packet(self, packet): + # Although the WMR100 emits SLP, not all consoles in the series + # (notably, the WMRS200) allow the user to set altitude. So we + # record only the station pressure (raw gauge pressure). + SP = float(((packet[3] & 0x0f) << 8) + packet[2]) + _record = {'pressure': SP, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + return _record + + def _uv_packet(self, packet): + _record = {'uv': float(packet[3]), + 'battery_status_uv': packet[0] >> 4, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + return _record + + def _wind_packet(self, packet): + """Decode a wind packet. Wind speed will be in kph""" + + _record = { + 'wind_speed': ((packet[6] << 4) + ((packet[5]) >> 4)) / 10.0, + 'wind_gust': (((packet[5] & 0x0f) << 8) + packet[4]) / 10.0, + 'wind_dir': (packet[2] & 0x0f) * 360.0 / 16.0, + 'battery_status_wind': (packet[0] >> 4), + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRICWX} + + # Sometimes the station emits a wind gust that is less than the + # average wind. If this happens, ignore it. + if _record['wind_gust'] < _record['wind_speed']: + _record['wind_gust'] = None + + return _record + + def _clock_packet(self, packet): + """The clock packet is not used by weewx. However, the last time is + saved in case getTime() is called.""" + tt = (2000 + packet[8], packet[7], packet[6], packet[5], packet[4], 0, 0, 0, -1) + try: + self.last_time = time.mktime(tt) + except OverflowError: + log.error("Bad clock packet: %s", packet) + log.error("**** ignored.") + return None + + # Dictionary that maps a measurement code, to a function that can decode it + _dispatch_dict = {0x41: _rain_packet, + 0x42: _temperature_packet, + 0x46: _pressure_packet, + 0x47: _uv_packet, + 0x48: _wind_packet, + 0x60: _clock_packet, + 0x44: _temperatureonly_packet} + + +class WMR100ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WMR100] + # This section is for the Oregon Scientific WMR100 + + # The driver to use + driver = weewx.drivers.wmr100 + + # The station model, e.g., WMR100, WMR100N, WMRS200 + model = WMR100 +""" + + def modify_config(self, config_dict): + print(""" +Setting rainRate calculation to hardware.""") + config_dict.setdefault('StdWXCalculate', {}) + config_dict['StdWXCalculate'].setdefault('Calculations', {}) + config_dict['StdWXCalculate']['Calculations']['rainRate'] = 'hardware' diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/wmr300.py b/dist/weewx-4.10.1/bin/weewx/drivers/wmr300.py new file mode 100644 index 0000000..53c2745 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/wmr300.py @@ -0,0 +1,2031 @@ +#!/usr/bin/env python +# Copyright 2015 Matthew Wall +# See the file LICENSE.txt for your rights. +# +# Credits: +# Thanks to Cameron for diving deep into USB timing issues +# +# Thanks to Benji for the identification and decoding of 7 packet types +# +# Thanks to Eric G for posting USB captures and providing hardware for testing +# https://groups.google.com/forum/#!topic/weewx-development/5R1ahy2NFsk +# +# Thanks to Zahlii +# https://bsweather.myworkbook.de/category/weather-software/ +# +# No thanks to oregon scientific - repeated requests for hardware and/or +# specifications resulted in no response at all. + +# TODO: figure out battery level for each sensor +# TODO: figure out signal strength for each sensor + +# FIXME: These all seem to offer low value return and unlikely to be done. +# In decreasing order of usefulness: +# 1. warn if altitude in pressure packet does not match weewx altitude +# 2. If rain is being accumulated when weewx is stopped, and we need to retrieve +# the history record then the current code returns None for the rain in +# the earliest new time interval and the rain increment for that time will +# get added to and reported at the next interval. +# 3. figure out unknown bytes in history packet (probably nothing useful) +# 4. decode the 0xdb (forecast) packets + +"""Driver for Oregon Scientific WMR300 weather stations. + +Sensor data transmission frequencies: + wind: 2.5 to 3 seconds + TH: 10 to 12 seconds + rain: 20 to 24 seconds + +The station supports 1 wind, 1 rain, 1 UV, and up to 8 temperature/humidity +sensors. + +The station ships with "Weather OS PRO" software for windows. This was used +for the USB sniffing. + +Sniffing USB traffic shows all communication is interrupt. The endpoint +descriptors for the device show this as well. Response timing is 1. + +It appears that communication must be initiated with an interrupted write. +After that, the station will spew data. Sending commands to the station is +not reliable - like other Oregon Scientific hardware, there is a certain +amount of "send a command, wait to see what happens, then maybe send it again" +in order to communicate with the hardware. Once the station does start sending +data, the driver basically just processes it based on the message type. It +must also send "heartbeat" messages to keep the data flowing from the hardware. + +Communication is confounded somewhat by the various libusb implementations. +Some communication "fails" with a "No data available" usb error. But in other +libusb versions, this error does not appear. USB timeouts are also tricky. +Since the WMR protocol seems to expect timeouts, it is necessary for the +driver to ignore them at some times, but not at others. Since not every libusb +version includes a code/class to indicate that a USB error is a timeout, the +driver must do more work to figure it out. + +The driver ignores USB timeouts. It would seem that a timeout just means that +the station is not ready to communicate; it does not indicate a communication +failure. + +Internal observation names use the convention name_with_specifier. These are +mapped to the wview or other schema as needed with a configuration setting. +For example, for the wview schema, wind_speed maps to windSpeed, temperature_0 +maps to inTemp, and humidity_1 maps to outHumidity. + +Maximum value for rain counter is 400 in (10160 mm) (40000 = 0x9c 0x40). The +counter does not wrap; it must be reset when it hits maximum value otherwise +rain data will not be recorded. + + +Message types ----------------------------------------------------------------- + +packet types from station: +57 - station type/model; history count + other status +41 - ACK +D2 - history; 128 bytes +D3 - temperature/humidity/dewpoint/heatindex; 61 bytes +D4 - wind/windchill; 54 bytes +D5 - rain; 40 bytes +D6 - pressure; 46 bytes +DB - forecast; 32 bytes +DC - temperature/humidity ranges; 62 bytes + +packet types from host: +A6 - heartbeat - response is 57 (usually) +41 - ACK +65 - do not delete history when it is reported. each of these is ack-ed by + the station +b3 - delete history after you give it to me. +cd - start history request. last two bytes are one after most recent read +35 - finish history request. last two bytes are latest record index that + was read. +73 - some sort of initialisation packet +72 - ? on rare occasions will be used in place of 73, in both observed cases + the console was already free-running transmitting data + +WOP sends A6 message every 20 seconds +WOP requests history at startup, then again every 120 minutes +each A6 is followed by a 57 from the station, except the one initiating history +each data packet D* from the station is followed by an ack packet 41 from host +D2 (history) records are recorded every minute +D6 (pressure) packets seem to come every 15 minutes (900 seconds) +4,5 of 7x match 12,13 of 57 + +examples of 72/73 initialization packets: + 73 e5 0a 26 0e c1 + 73 e5 0a 26 88 8b + 72 a9 c1 60 52 00 + +---- cameron's extra notes: + +Station will free-run transmitting data for about 100s without seeing an ACK. +a6 will always be followed by 91 ca 45 52 but final byte may be 0, 20, 32, 67, +8b, d6, df, or... + 0 - when first packet after connection or program startup. + It looks like the final byte is just the last character that was previously + written to a static output buffer. and hence it is meaningless. +41 - ack in - 2 types +41 - ack out - numerous types. combinations of packet type, channel, last byte. + For a6, it looks like the last byte is just uncleared residue. +b3 59 0a 17 01 - when you give me history, delete afterwards + - final 2 bytes are probably ignored + response: ACK b3 59 0a 17 +65 19 e5 04 52 - when you give me history, do not delete afterwards + - final 2 bytes are probably ignored + response: ACK 65 19 e5 04 +cd 18 30 62 nn mm - start history, starting at record 0xnnmm + response - if preceeded by 65, the ACK is the same string: ACK 65 19 e5 04 + - if preceeded by b3 then there is NO ACK. + +Initialisation: +out: a6 91 ca 45 52 + - note: null 6th byte. +in: 57: WMR300,A004,\0\0,,,, + - where numbered bytes are unknown content +then either... +out: 73 e5 0a 26 + - b5 is set to b13 of pkt 57, b6 <= b14 +in: 41 43 4b 73 e5 0a 26 + - this is the full packet 73 prefixed by "ACK" +or... +out: 72 a9 c1 60 52 + - occurs when console is already free-running (but how does WOsP know?) +NO ACK + +Message field decodings ------------------------------------------------------- + +Values are stored in 1 to 3 bytes in big endian order. Negative numbers are +stored as Two's Complement (if the first byte starts with F it is a negative +number). Count values are unsigned. + +no data: + 7f ff + +values for channel number: +0 - console sensor +1 - sensor 1 +2 - sensor 2 +... +8 - sensor 8 + +values for trend: +0 - steady +1 - rising +2 - falling +3 - no sensor data + +bitwise transformation for compass direction: +1000 0000 0000 0000 = NNW +0100 0000 0000 0000 = NW +0010 0000 0000 0000 = WNW +0001 0000 0000 0000 = W +0000 1000 0000 0000 = WSW +0000 0100 0000 0000 = SW +0000 0010 0000 0000 = SSW +0000 0001 0000 0000 = S +0000 0000 1000 0000 = SSE +0000 0000 0100 0000 = SE +0000 0000 0010 0000 = ESE +0000 0000 0001 0000 = E +0000 0000 0000 1000 = ENE +0000 0000 0000 0100 = NE +0000 0000 0000 0010 = NNE +0000 0000 0000 0001 = N + +values for forecast: +0x08 - cloudy +0x0c - rainy +0x1e - partly cloudy +0x0e - partly cloudy at night +0x70 - sunny +0x00 - clear night + + +Message decodings ------------------------------------------------------------- + +message: ACK +byte hex dec description decoded value + 0 41 A acknowledgement ACK + 1 43 C + 2 4b K + 3 73 command sent from PC + 4 e5 + 5 0a + 6 26 + 7 0e + 8 c1 + +examples: + 41 43 4b 73 e5 0a 26 0e c1 last 2 bytes differ + 41 43 4b 65 19 e5 04 always same + + +message: station info +byte hex dec description decoded value + 0 57 W station type WMR300 + 1 4d M + 2 52 R + 3 33 3 + 4 30 0 + 5 30 0 + 6 2c , + 7 41 A station model A002 + 8 30 0 + 9 30 0 +10 32 2 or 0x34 +11 2c , +12 0e (3777 dec) or mine always 88 8b (34955) +13 c1 +14 00 always? +15 00 +16 2c , +17 67 next history record 26391 (0x67*256 0x17) (0x7fe0 (32736) is full) + The value at this index has not been used yet. +18 17 +19 2c , +20 4b usually 'K' (0x4b). occasionally was 0x43 when history is set to + or 43 delete after downloading. This is a 1-bit change (1<<3) + NB: Does not return to 4b when latest history record is reset to + 0x20 after history is deleted. +21 2c , +22 52 0x52 (82, 'R'), occasionally 0x49(73, 'G') + 0b 0101 0010 (0x52) vs + 0b 0100 1001 (0x49) lots of bits flipped! + or 49 this maybe has some link with one or other battery, but does not + make sense +23 2c , + +examples: + 57 4d 52 33 30 30 2c 41 30 30 32 2c 0e c1 00 00 2c 67 17 2c 4b 2c 52 2c + 57 4d 52 33 30 30 2c 41 30 30 32 2c 88 8b 00 00 2c 2f b5 2c 4b 2c 52 2c + 57 4d 52 33 30 30 2c 41 30 30 34 2c 0e c1 00 00 2c 7f e0 2c 4b 2c 49 2c + 57 4d 52 33 30 30 2c 41 30 30 34 2c 88 8b 00 00 2c 7f e0 2c 4b 2c 49 2c + + +message: history +byte hex dec description decoded value + 0 d2 packet type + 1 80 128 packet length + 2 31 count (hi) 12694 - index number of this packet + 3 96 count (lo) + 4 0f 15 year ee if not set + 5 08 8 month ee if not set + 6 0a 10 day ee if not set + 7 06 6 hour + 8 02 2 minute + 9 00 temperature 0 21.7 C +10 d9 +11 00 temperature 1 25.4 C +12 fe +13 7f temperature 2 +14 ff +15 7f temperature 3 +16 ff +17 7f temperature 4 +18 ff +19 7f temperature 5 +20 ff +21 7f temperature 6 +22 ff +23 7f temperature 7 +24 ff +25 7f temperature 8 +26 ff (a*256 + b)/10 +27 26 humidity 0 38 % +28 49 humidity 1 73 % +29 7f humidity 2 +30 7f humidity 3 +31 7f humidity 4 +32 7f humidity 5 +33 7f humidity 6 +34 7f humidity 7 +35 7f humidity 8 +36 00 dewpoint 1 20.0 C +37 c8 (a*256 + b)/10 +38 7f dewpoint 2 +39 ff +40 7f dewpoint 3 +41 ff +42 7f dewpoint 4 +43 ff +44 7f dewpoint 5 +45 ff +46 7f dewpoint 6 +47 ff +48 7f dewpoint 7 +49 ff +50 7f dewpoint 8 +51 ff +52 7f heat index 1 C +53 fd (a*256 + b)/10 +54 7f heat index 2 +55 ff +56 7f heat index 3 +57 ff +58 7f heat index 4 +59 ff +60 7f heat index 5 +61 ff +62 7f heat index 6 +63 ff +64 7f heat index 7 +65 ff +66 7f heat index 8 +67 ff +68 7f wind chill C +69 fd (a*256 + b)/10 +70 7f ? +71 ff ? +72 00 wind gust speed 0.0 m/s +73 00 (a*256 + b)/10 +74 00 wind average speed 0.0 m/s +75 00 (a*256 + b)/10 +76 01 wind gust direction 283 degrees +77 1b (a*256 + b) +78 01 wind average direction 283 degrees +78 1b (a*256 + b) +80 30 forecast +81 00 ? +82 00 ? +83 00 hourly rain hundredths_of_inch +84 00 (a*256 + b) +85 00 ? +86 00 accumulated rain hundredths_of_inch +87 03 (a*256 + b) +88 0f accumulated rain start year +89 07 accumulated rain start month +90 09 accumulated rain start day +91 13 accumulated rain start hour +92 09 accumulated rain start minute +93 00 rain rate hundredths_of_inch/hour +94 00 (a*256 + b) +95 26 pressure mbar +96 ab (a*256 + b)/10 +97 01 pressure trend +98 7f ? +99 ff ? +100 7f ? +101 ff ? +102 7f ? +103 ff ? +104 7f ? +105 ff ? +106 7f ? +107 7f ? +108 7f ? +109 7f ? +110 7f ? +111 7f ? +112 7f ? +113 7f ? +114 ff ? +115 7f ? +116 ff ? +117 7f ? +118 ff ? +119 00 ? +120 00 ? +121 00 ? +122 00 ? +123 00 ? +124 00 ? +125 00 ? +126 f8 checksum +127 3b + + +message: temperature/humidity/dewpoint +byte hex dec description decoded value + 0 D3 packet type + 1 3D 61 packet length + 2 0E 14 year + 3 05 5 month + 4 09 9 day + 5 12 12 hour + 6 14 20 minute + 7 01 1 channel number + 8 00 temperature 19.5 C + 9 C3 +10 2D humidity 45 % +11 00 dewpoint 7.0 C +12 46 +13 7F heat index N/A +14 FD +15 00 temperature trend +16 00 humidity trend? not sure - never saw a falling value +17 0E 14 max_dewpoint_last_day year +18 05 5 month +19 09 9 day +20 0A 10 hour +21 24 36 minute +22 00 max_dewpoint_last_day 13.0 C +23 82 +24 0E 14 min_dewpoint_last_day year +25 05 5 month +26 09 9 day +27 10 16 hour +28 1F 31 minute +29 00 min_dewpoint_last_day 6.0 C +30 3C +31 0E 14 max_dewpoint_last_month year +32 05 5 month +33 01 1 day +34 0F 15 hour +35 1B 27 minute +36 00 max_dewpoint_last_month 13.0 C +37 82 +38 0E 14 min_dewpoint_last_month year +39 05 5 month +40 04 4 day +41 0B 11 hour +42 08 8 minute +43 FF min_dewpoint_last_month -1.0 C +44 F6 +45 0E 14 max_heat_index year +46 05 5 month +47 09 9 day +48 00 0 hour +49 00 0 minute +50 7F max_heat_index N/A +51 FF +52 0E 14 min_heat_index year +53 05 5 month +54 01 1 day +55 00 0 hour +56 00 0 minute +57 7F min_heat_index N/A +58 FF +59 0B checksum +60 63 + + 0 41 ACK + 1 43 + 2 4B + 3 D3 packet type + 4 01 channel number + 5 8B sometimes DF and others + +examples: + 41 43 4b d3 00 20 - for last byte: 32, 67, 8b, d6 + 41 43 4b d3 01 20 - for last byte: same + 20, df + for unused temps, last byte always 8b (or is it byte 14 of pkt 57?) + + +message: wind +byte hex dec description decoded value + 0 D4 packet type + 1 36 54 packet length + 2 0E 14 year + 3 05 5 month + 4 09 9 day + 5 12 18 hour + 6 14 20 minute + 7 01 1 channel number + 8 00 gust speed 1.4 m/s + 9 0E +10 00 gust direction 168 degrees +11 A8 +12 00 average speed 2.9 m/s +13 1D +14 00 average direction 13 degrees +15 0D +16 00 compass direction 3 N/NNE +17 03 +18 7F windchill 32765 N/A +19 FD +20 0E 14 gust today year +21 05 5 month +22 09 9 day +23 10 16 hour +24 3B 59 minute +25 00 gust today 10 m/s +26 64 +27 00 gust direction today 39 degree +28 27 +29 0E 14 gust this month year +30 05 5 month +31 09 9 day +32 10 16 hour +33 3B 59 minute +34 00 gust this month 10 m/s +35 64 +36 00 gust direction this month 39 degree +37 27 +38 0E 14 wind chill today year +39 05 5 month +40 09 9 day +41 00 0 hour +42 00 0 minute +43 7F windchill today N/A +44 FF +45 0E 14 windchill this month year +46 05 5 month +47 03 3 day +48 09 9 hour +49 04 4 minute +50 00 windchill this month 2.9 C +51 1D +52 07 checksum +53 6A + + 0 41 ACK + 1 43 + 2 4B + 3 D4 packet type + 4 01 channel number + 5 8B variable + +examples: + 41 43 4b d4 01 20 - last byte: 20, 32, 67, 8b, d6, df + 41 43 4b d4 01 16 + + +message: rain +byte hex dec description decoded value + 0 D5 packet type + 1 28 40 packet length + 2 0E 14 year + 3 05 5 month + 4 09 9 day + 5 12 18 hour + 6 15 21 minute + 7 01 1 channel number + 8 00 + 9 00 rainfall this hour 0 inch +10 00 +11 00 +12 00 rainfall last 24 hours 0.12 inch +13 0C 12 +14 00 +15 00 rainfall accumulated 1.61 inch +16 A1 161 +17 00 rainfall rate 0 inch/hr +18 00 +19 0E 14 accumulated start year +20 04 4 month +21 1D 29 day +22 12 18 hour +23 00 0 minute +24 0E 14 max rate last 24 hours year +25 05 5 month +26 09 9 day +27 01 1 hour +28 0C 12 minute +29 00 0 max rate last 24 hours 0.11 inch/hr ((0x00<<8)+0x0b)/100.0 +30 0B 11 +31 0E 14 max rate last month year +32 05 5 month +33 02 2 day +34 04 4 hour +35 0C 12 minute +36 00 0 max rate last month 1.46 inch/hr ((0x00<<8)+0x92)/100.0 +37 92 146 +38 03 checksum 794 = (0x03<<8) + 0x1a +39 1A + + 0 41 ACK + 1 43 + 2 4B + 3 D5 packet type + 4 01 channel number + 5 8B + +examples: + 41 43 4b d5 01 20 - last byte: 20, 32, 67, 8b, d6, df + 41 43 4b d5 01 16 + + +message: pressure +byte hex dec description decoded value + 0 D6 packet type + 1 2E 46 packet length + 2 0E 14 year + 3 05 5 month + 4 0D 13 day + 5 0E 14 hour + 6 30 48 minute + 7 00 1 channel number + 8 26 station pressure 981.7 mbar ((0x26<<8)+0x59)/10.0 + 9 59 +10 27 sea level pressure 1015.3 mbar ((0x27<<8)+0xa9)/10.0 +11 A9 +12 01 altitude meter 300 m (0x01<<8)+0x2c +13 2C +14 03 barometric trend have seen 0,1,2, and 3 +15 00 only ever observed 0 or 2. is this battery? +16 0E 14 max pressure today year +17 05 5 max pressure today month +18 0D 13 max pressure today day +19 0C 12 max pressure today hour +20 33 51 max pressure today minute +21 27 max pressure today 1015.7 mbar +22 AD +23 0E 14 min pressure today year +24 05 5 min pressure today month +25 0D 13 min pressure today day +26 00 0 min pressure today hour +27 06 6 min pressure today minute +28 27 min pressure today 1014.1 mbar +29 9D +30 0E 14 max pressure month year +31 05 5 max pressure month month +32 04 4 max pressure month day +33 01 1 max pressure month hour +34 15 21 max pressure month minute +35 27 max pressure month 1022.5 mbar +36 F1 +37 0E 14 min pressure month year +38 05 5 min pressure month month +39 0B 11 min pressure month day +40 00 0 min pressure month hour +41 06 6 min pressure month minute +42 27 min pressure month 1007.8 mbar +43 5E +44 06 checksum +45 EC + + 0 41 ACK + 1 43 + 2 4B + 3 D6 packet type + 4 00 channel number + 5 8B + +examples: + 41 43 4b d6 00 20 - last byte: 32, 67, 8b + + +message: forecast +byte hex dec description decoded value + 0 DB + 1 20 pkt length + 2 0F 15 year + 3 07 7 month + 4 09 9 day + 5 12 18 hour + 6 23 35 minute + 7 00 below are alternate observations - little overlap + 8 FA 0a + 9 79 02, 22, 82, a2 +10 FC 05 +11 40 f9 +12 01 fe +13 4A fc +14 06 variable +15 17 variable +16 14 variable +17 23 variable +18 06 00 to 07 (no 01) +19 01 +20 00 00 or 01 +21 00 remainder same +22 01 +23 01 +24 01 +25 00 +26 00 +27 00 +28 FE +29 00 +30 05 checksum (hi) +31 A5 checksum (lo) + + 0 41 ACK + 1 43 + 2 4B + 3 D6 packet type + 4 00 channel number + 5 20 + +examples: + 41 43 4b db 00 20 - last byte: 32, 67, 8b, d6 + + +message: temperature/humidity ranges +byte hex dec description decoded value + 0 DC packet type + 1 3E 62 packet length + 2 0E 14 year + 3 05 5 month + 4 0D 13 day + 5 0E 14 hour + 6 30 48 minute + 7 00 0 channel number + 8 0E 14 max temp today year + 9 05 5 month +10 0D 13 day +11 00 0 hour +12 00 0 minute +13 00 max temp today 20.8 C +14 D0 +15 0E 14 min temp today year +16 05 5 month +17 0D 13 day +18 0B 11 hour +19 34 52 minute +20 00 min temp today 19.0 C +21 BE +22 0E 14 max temp month year +23 05 5 month +24 0A 10 day +25 0D 13 hour +26 19 25 minute +27 00 max temp month 21.4 C +28 D6 +29 0E 14 min temp month year +30 05 5 month +31 04 4 day +32 03 3 hour +33 2A 42 minute +34 00 min temp month 18.1 C +35 B5 +36 0E 14 max humidity today year +37 05 5 month +38 0D 13 day +39 05 5 hour +40 04 4 minute +41 45 max humidity today 69 % +42 0E 14 min numidity today year +43 05 5 month +44 0D 13 day +45 0B 11 hour +46 32 50 minute +47 41 min humidity today 65 % +48 0E 14 max humidity month year +49 05 5 month +50 0C 12 day +51 13 19 hour +52 32 50 minute +53 46 max humidity month 70 % +54 0E 14 min humidity month year +55 05 5 month +56 04 4 day +57 14 20 hour +58 0E 14 minute +59 39 min humidity month 57 % +60 07 checksum +61 BF + + 0 41 ACK + 1 43 + 2 4B + 3 DC packet type + 4 00 0 channel number + 5 8B + +examples: + 41 43 4b dc 00 20 - last byte: 32, 67, 8b, d6 + 41 43 4b dc 01 20 - last byte: 20, 32, 67, 8b, d6, df + 41 43 4b dc 00 16 + 41 43 4b dc 01 16 + +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function +import logging +import time +import usb + +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WMR300' +DRIVER_VERSION = '0.33' + +DEBUG_COMM = 0 +DEBUG_PACKET = 0 +DEBUG_COUNTS = 0 +DEBUG_DECODE = 0 +DEBUG_HISTORY = 1 +DEBUG_RAIN = 0 +DEBUG_TIMING = 1 + + +def loader(config_dict, _): + return WMR300Driver(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WMR300ConfEditor() + + +def _fmt_bytes(data): + return ' '.join(['%02x' % x for x in data]) + +def _lo(x): + return x - 256 * (x >> 8) + +def _hi(x): + return x >> 8 + +# pyusb 0.4.x does not provide an errno or strerror with the usb errors that +# it wraps into USBError. so we have to compare strings to figure out exactly +# what type of USBError we are dealing with. unfortunately, those strings are +# localized, so we must compare in every language. +USB_NOERR_MESSAGES = [ + 'No data available', 'No error', + 'Nessun dato disponibile', 'Nessun errore', + 'Keine Daten verf', + 'No hay datos disponibles', + 'Pas de donn', + 'Ingen data er tilgjengelige'] + +# these are the usb 'errors' that should be ignored +def is_noerr(e): + errmsg = repr(e) + for msg in USB_NOERR_MESSAGES: + if msg in errmsg: + return True + return False + +# strings for the timeout error +USB_TIMEOUT_MESSAGES = [ + 'Connection timed out', + 'Operation timed out'] + +# detect usb timeout error (errno 110) +def is_timeout(e): + if hasattr(e, 'errno') and e.errno == 110: + return True + errmsg = repr(e) + for msg in USB_TIMEOUT_MESSAGES: + if msg in errmsg: + return True + return False + +def get_usb_info(): + pyusb_version = '0.4.x' + try: + pyusb_version = usb.__version__ + except AttributeError: + pass + return "pyusb_version=%s" % pyusb_version + + +class WMR300Driver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with a WMR300 weather station.""" + + # map sensor values to the database schema fields + # the default map is for the wview schema + DEFAULT_MAP = { + 'pressure': 'pressure', + 'barometer': 'barometer', + 'windSpeed': 'wind_avg', + 'windDir': 'wind_dir', + 'windGust': 'wind_gust', + 'windGustDir': 'wind_gust_dir', + 'inTemp': 'temperature_0', + 'outTemp': 'temperature_1', + 'extraTemp1': 'temperature_2', + 'extraTemp2': 'temperature_3', + 'extraTemp3': 'temperature_4', + 'extraTemp4': 'temperature_5', + 'extraTemp5': 'temperature_6', + 'extraTemp6': 'temperature_7', + 'extraTemp7': 'temperature_8', + 'inHumidity': 'humidity_0', + 'outHumidity': 'humidity_1', + 'extraHumid1': 'humidity_2', + 'extraHumid2': 'humidity_3', + 'extraHumid3': 'humidity_4', + 'extraHumid4': 'humidity_5', + 'extraHumid5': 'humidity_6', + 'extraHumid6': 'humidity_7', + 'extraHumid7': 'humidity_8', + 'dewpoint': 'dewpoint_1', + 'extraDewpoint1': 'dewpoint_2', + 'extraDewpoint2': 'dewpoint_3', + 'extraDewpoint3': 'dewpoint_4', + 'extraDewpoint4': 'dewpoint_5', + 'extraDewpoint5': 'dewpoint_6', + 'extraDewpoint6': 'dewpoint_7', + 'extraDewpoint7': 'dewpoint_8', + 'heatindex': 'heatindex_1', + 'extraHeatindex1': 'heatindex_2', + 'extraHeatindex2': 'heatindex_3', + 'extraHeatindex3': 'heatindex_4', + 'extraHeatindex4': 'heatindex_5', + 'extraHeatindex5': 'heatindex_6', + 'extraHeatindex6': 'heatindex_7', + 'extraHeatindex7': 'heatindex_8', + 'windchill': 'windchill', + 'rainRate': 'rain_rate'} + + # threshold at which the history will be cleared, specified as an integer + # between 5 and 95, inclusive. + DEFAULT_HIST_LIMIT = 20 + + # threshold at which warning will be emitted. if the rain counter exceeds + # this percentage, then a warning will be emitted to remind user to reset + # the rain counter. + DEFAULT_RAIN_WARNING = 90 + + # if DEBUG_COUNTS is set then this defines how many seconds elapse between each print/reset of the counters + COUNT_SUMMARY_INTERVAL = 20 + + def __init__(self, **stn_dict): + log.info('driver version is %s' % DRIVER_VERSION) + log.info('usb info: %s' % get_usb_info()) + self.model = stn_dict.get('model', DRIVER_NAME) + self.sensor_map = dict(self.DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('sensor map is %s' % self.sensor_map) + hlimit = int(stn_dict.get('history_limit', self.DEFAULT_HIST_LIMIT)) + # clear history seems to fail if tried from below 5% + # 1 day at 1 min intervals is ~4.5% + if hlimit < 5: + hlimit = 5 + if hlimit > 95: + hlimit = 95 + self.history_limit_index = Station.get_history_pct_as_index(hlimit) + log.info('history limit is %d%% at index %d' % (hlimit, self.history_limit_index) ) + frac = int(stn_dict.get('rain_warning', self.DEFAULT_RAIN_WARNING)) + self.rain_warn = frac / 100.0 + + global DEBUG_COMM + DEBUG_COMM = int(stn_dict.get('debug_comm', DEBUG_COMM)) + global DEBUG_PACKET + DEBUG_PACKET = int(stn_dict.get('debug_packet', DEBUG_PACKET)) + global DEBUG_COUNTS + DEBUG_COUNTS = int(stn_dict.get('debug_counts', DEBUG_COUNTS)) + global DEBUG_DECODE + DEBUG_DECODE = int(stn_dict.get('debug_decode', DEBUG_DECODE)) + global DEBUG_HISTORY + DEBUG_HISTORY = int(stn_dict.get('debug_history', DEBUG_HISTORY)) + global DEBUG_TIMING + DEBUG_TIMING = int(stn_dict.get('debug_timing', DEBUG_TIMING)) + global DEBUG_RAIN + DEBUG_RAIN = int(stn_dict.get('debug_rain', DEBUG_RAIN)) + + self.logged_rain_counter = 0 + self.logged_history_usage = 0 + self.log_interval = 24 * 3600 # how often to log station status, in seconds + + self.heartbeat = 20 # how often to send a6 messages, in seconds + self.history_retry = 60 # how often to retry history, in seconds + self.last_rain = None # last rain total + self.last_a6 = 0 # timestamp of last 0xa6 message + self.data_since_heartbeat = 0 # packets of loop data seen + self.last_65 = 0 # timestamp of last 0x65 message + self.last_7x = 0 # timestamp of last 0x7x message + self.last_record = Station.HISTORY_START_REC - 1 + self.pressure_cache = dict() # FIXME: make the cache values age + self.station = Station() + self.station.open() + pkt = self.init_comm() + log.info("communication established: %s" % pkt) + self.history_end_index = pkt['history_end_index'] + self.magic0 = pkt['magic0'] + self.magic1 = pkt['magic1'] + self.mystery0 = pkt['mystery0'] + self.mystery1 = pkt['mystery1'] + if DEBUG_COUNTS: + self.last_countsummary = time.time() + + def closePort(self): + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + + def init_comm(self, max_tries=3, max_read_tries=10): + """initiate communication with the station: + 1 send a special a6 packet + 2 read the packet 57 + 3 send a type 73 packet + 4 read the ack + """ + + cnt = 0 + while cnt < max_tries: + cnt += 1 + try: + buf = None + self.station.flush_read_buffer() + if DEBUG_COMM: + log.info("init_comm: send initial heartbeat 0xa6") + self.send_heartbeat() + if DEBUG_COMM: + log.info("init_comm: try to read 0x57") + read_cnt = 0 + while read_cnt < max_read_tries: + buf = self.station.read() + read_cnt += 1 + if buf and buf[0] == 0x57: + break + if not buf or buf[0] != 0x57: + raise ProtocolError("failed to read pkt 0x57") + pkt = Station._decode_57(buf) + if DEBUG_COMM: + log.info("init_comm: send initialization 0x73") + cmd = [0x73, 0xe5, 0x0a, 0x26, pkt['magic0'], pkt['magic1']] + self.station.write(cmd) + self.last_7x = time.time() + if DEBUG_COMM: + log.info("init_comm: try to read 0x41") + read_cnt = 0 + while read_cnt < max_read_tries: + buf = self.station.read() + read_cnt += 1 + if buf and buf[0] == 0x41: + break + if not buf or buf[0] != 0x41: + raise ProtocolError("failed to read ack 0x41 for pkt 0x73") + if DEBUG_COMM: + log.info("initialization completed in %s tries" % cnt) + return pkt + except ProtocolError as e: + if DEBUG_COMM: + log.info("init_comm: failed attempt %d of %d: %s" % + (cnt, max_tries, e)) + time.sleep(0.1) + raise ProtocolError("Init comm failed after %d tries" % max_tries) + + def init_history(self, clear_logger=False, max_tries=5, max_read_tries=10): + """initiate streaming of history records from the station: + + 1 if clear logger: + 1a send 0xb3 packet + 1 if not clear logger: + 1a send special 0xa6 (like other 0xa6 packets, but no 0x57 reply) + 1b send 0x65 packet + 2 read the ACK + 3 send a 0xcd packet + 4 do not wait for ACK - it might not come + + then return to reading packets + """ + + cnt = 0 + while cnt < max_tries: + cnt += 1 + try: + if DEBUG_HISTORY: + log.info("init history attempt %d of %d" % (cnt, max_tries)) + # eliminate anything that might be in the buffer + self.station.flush_read_buffer() + # send the sequence for initiating history packets + if clear_logger: + cmd = [0xb3, 0x59, 0x0a, 0x17, 0x01, 0xeb] + self.station.write(cmd) + # a partial read is sufficient. No need to read stuff we are not going to save + start_rec = Station.clip_index( self.history_end_index - 1200 ) + else: + self.send_heartbeat() + cmd = [0x65, 0x19, 0xe5, 0x04, 0x52, 0x8b] + self.station.write(cmd) + self.last_65 = time.time() + start_rec = self.last_record + + # read the ACK. there might be regular packets here, so be + # ready to read a few - just ignore them. + read_cnt = 0 + while read_cnt < max_read_tries: + buf = self.station.read() + read_cnt += 1 + if buf and buf[0] == 0x41 and buf[3] == cmd[0]: + break + if not buf or buf[0] != 0x41: + raise ProtocolError("failed to read ack to %02x" % cmd[0]) + + # send the request to start history packets + nxt = Station.clip_index(start_rec) + if DEBUG_HISTORY: + log.info("init history cmd=0x%02x rec=%d" % (cmd[0], nxt)) + cmd = [0xcd, 0x18, 0x30, 0x62, _hi(nxt), _lo(nxt)] + self.station.write(cmd) + + # do NOT wait for an ACK. the console should start spewing + # history packets, and any ACK or 0x57 packets will be out of + # sequence. so just drop into the normal reading loop and + # process whatever comes. + if DEBUG_HISTORY: + log.info("init history completed after attempt %d of %d" % + (cnt, max_tries)) + return + except ProtocolError as e: + if DEBUG_HISTORY: + log.info("init_history: failed attempt %d of %d: %s" % + (cnt, max_tries, e)) + time.sleep(0.1) + raise ProtocolError("Init history failed after %d tries" % max_tries) + + def finish_history(self, max_tries=3): + """conclude reading of history records. + 1 final 0xa6 has been sent and 0x57 has been seen + 2 send 0x35 packet + 3 no ACK, but sometimes another 57:? - ignore it + """ + + cnt = 0 + while cnt < max_tries: + cnt += 1 + try: + if DEBUG_HISTORY: + log.info("finish history attempt %d of %d" % (cnt, max_tries)) + # eliminate anything that might be in the buffer + self.station.flush_read_buffer() + # send packet 0x35 + cmd = [0x35, 0x0b, 0x1a, 0x87, + _hi(self.last_record), _lo(self.last_record)] + self.station.write(cmd) + # do NOT wait for an ACK + if DEBUG_HISTORY: + log.info("finish history completed after attempt %d of %d" % + (cnt, max_tries)) + return + except ProtocolError as e: + if DEBUG_HISTORY: + log.info("finish history failed attempt %d of %d: %s" % + (cnt, max_tries, e)) + time.sleep(0.1) + raise ProtocolError("Finish history failed after %d tries" % max_tries) + + def dump_history(self): + log.info("dump history") + if DEBUG_HISTORY: + reread_start_time = time.time() + for rec in self.get_history(time.time(), clear_logger=True): + pass + if DEBUG_HISTORY: + reread_duration = time.time() - reread_start_time + log.info( "History clear completed in %.1f sec" % reread_duration ) + + def get_history(self, since_ts, clear_logger=False): + if self.history_end_index is None: + log.info("read history skipped: index has not been set") + return + if self.history_end_index < 1: + # this should never happen. if it does, then either no 0x57 packet + # was received or the index provided by the station was bogus. + log.error("read history failed: bad index: %s" % self.history_end_index) + return + + log.info("%s records since %s (last_index=%s history_end_index=%s)" % + ("Clearing" if clear_logger else "Reading", + timestamp_to_string(since_ts) if since_ts is not None else "the start", + self.last_record, self.history_end_index)) + self.init_history(clear_logger) + half_buf = None + last_ts = None + processed = 0 + loop_ignored = 0 + state = "reading history" + # there is sometimes a series of bogus history records reported + # these are to keep a track of them + bogus_count = 0 + bogus_first = 0 + bogus_last = 0 + # since_ts of None implies DB just created, so read all... + if since_ts is None: + since_ts = 0 + + while True: + try: + buf = self.station.read() + if buf: + # The message length is 64 bytes, but historical records + # are 128 bytes. So we have to assemble the two 64-byte + # parts of each historical record into a single 128-byte + # message for processing. We have to assume we do not get any + # non-historical records interspersed between parts. + # This code will fail if any user has 7 or 8 + # temperature sensors installed, as it is relying on seeing + # 0x7f in byte 64, which just means "no data" + if buf[0] == 0xd2: + pktlength = buf[1] + if pktlength != 128: + raise WMR300Error("History record unexpected length: assumed 128, found %d" % pktlength ) + half_buf = buf + buf = None + # jump immediately to read the 2nd half of the packet + # we don't want other possibilities intervening + continue + elif buf[0] == 0x7f and half_buf is not None: + buf = half_buf + buf + half_buf = None + if buf[0] == 0xd2: + next_record = Station.get_record_index(buf) + if last_ts is not None and next_record != self.last_record + 1: + log.info("missing record: skipped from %d to %d" % + (self.last_record, next_record)) + self.last_record = next_record + ts = Station._extract_ts(buf[4:9]) + if ts is None: + if bogus_count == 0 : + bogus_first = next_record + bogus_count += 1 + bogus_last = next_record + if DEBUG_HISTORY: + log.info("Bogus historical record index: %d " % (next_record)) + #log.info(" content: %s" % _fmt_bytes(buf)) + else: + if ts > since_ts: + pkt = Station.decode(buf) + packet = self.convert_historical(pkt, ts, last_ts) + last_ts = ts + if 'interval' in packet: + if DEBUG_HISTORY: + log.info("New historical record for %s: %s: %s" % + (timestamp_to_string(ts), pkt['index'], packet)) + processed += 1 + yield packet + else: + last_ts = ts + + elif buf[0] == 0x57: + self.history_end_index = Station.get_history_end_index(buf) + msg = " count=%s updated; last_index rcvd=%s; final_index=%s; state = %s" % ( + processed, self.last_record, self.history_end_index, state) + if state == "wait57": + log.info("History read completed: %s" % msg) + break + else: + log.info("History read in progress: %s" % msg) + + elif buf[0] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]: + # ignore any packets other than history records. this + # means there will be no current data while the history + # is being read. + loop_ignored += 1 + if DEBUG_HISTORY: + log.info("ignored packet type 0x%2x" % buf[0]) + # do not ACK data packets. the PC software does send ACKs + # here, but they are ignored anyway. so we just ignore. + else: + log.error("get_history: unexpected packet, content: %s" % _fmt_bytes(buf)) + + if self.last_record + 1 >= self.history_end_index : + if state == "reading history": + if DEBUG_HISTORY: + msg = "count=%s kept, last_received=%s final=%s" % ( + processed, self.last_record, self.history_end_index ) + log.info("History read nearly complete: %s; state=%s" % (msg, state) ) + state = "finishing" + + if (state == "finishing") or (time.time() - self.last_a6 > self.heartbeat): + if DEBUG_HISTORY: + log.info("request station status at index: %s; state: %s" % + (self.last_record, state ) ) + self.send_heartbeat() + if state == "finishing" : + # It is possible that another history packet has been created between the + # most recent 0x57 status and now. So, we have to stay in the loop + # to read the possible next packet. + # Evidence suggests that such history packet will arrive before the + # 0x57 reply to this request, so presumably it was already in some output queue. + state = "wait57" + + + except usb.USBError as e: + raise weewx.WeeWxIOError(e) + except DecodeError as e: + log.info("get_history: %s" % e) + time.sleep(0.001) + if loop_ignored > 0 : + log.info( "During history read, %d loop data packets were ignored" % loop_ignored ) + if bogus_count > 0 : + log.info( "During history read, %d bogus entries found from %d to %d" % + (bogus_count, bogus_first, bogus_last)) + self.finish_history() + + def genLoopPackets(self): + + while True: + try: + read_enter_time = time.time() + buf = self.station.read() + read_return_delta = time.time() - read_enter_time + if buf: + if buf[0] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]: + # compose ack for most data packets + # cmd = [0x41, 0x43, 0x4b, buf[0], buf[7]] + # do not bother to send the ACK - console does not care + #self.station.write(cmd) + # we only care about packets with loop data + if buf[0] in [0xd3, 0xd4, 0xd5, 0xd6]: + pkt = Station.decode(buf) + self.data_since_heartbeat += 1 + packet = self.convert_loop(pkt) + if DEBUG_COUNTS: + if "Loop" in self.station.recv_counts: + self.station.recv_counts["Loop"] += 1 + else: + self.station.recv_counts["Loop"] = 1 + if DEBUG_TIMING: + yield_rtn_time = time.time() + yield packet + if DEBUG_TIMING: + yield_return_delta = time.time() - yield_rtn_time + if yield_return_delta > 5: + log.info( "Yield delayed for = %d s" % yield_return_delta ) + elif buf[0] == 0x57: + self.history_end_index = Station.get_history_end_index(buf) + if time.time() - self.logged_history_usage > self.log_interval: + pct = Station.get_history_usage_pct(self.history_end_index) + log.info("history buffer at %.2f%% (%d)" % (pct, self.history_end_index)) + self.logged_history_usage = time.time() + if DEBUG_TIMING and read_return_delta > 5: + log.info( "USB Read delayed for = %d s" % read_return_delta ) + + if DEBUG_COUNTS: + now = time.time() + # we just print a summary each chosen interval + if (now - self.last_countsummary) > self.COUNT_SUMMARY_INTERVAL: + Station.print_count( "read", self.station.recv_counts ) + self.station.recv_counts.clear() + Station.print_count( "write", self.station.send_counts ) + self.station.send_counts.clear() + self.last_countsummary = now + + time_since_heartbeat = time.time() - self.last_a6 + if time_since_heartbeat > self.heartbeat: + if DEBUG_TIMING and self.data_since_heartbeat < 10 : + log.info( "Loop data packets in heartbeat interval = %d" % self.data_since_heartbeat ) + needs_restart = False + if time_since_heartbeat > 60: + log.error( "Excessive heartbeat delay: %ds, restarting" % (time_since_heartbeat) ) + needs_restart = True + if self.data_since_heartbeat <= 0 : + log.error( "No loop data in heartbeat interval, restarting" ) + needs_restart = True + + if needs_restart: + # I think the 0x73 starts the data transmission, but not sure if the + # a6 / 73 order is important. + cmd = [0x73, 0xe5, 0x0a, 0x26, self.magic0, self.magic1 ] + self.station.write(cmd) + + + self.send_heartbeat() + if self.history_end_index is not None: + if self.history_end_index >= self.history_limit_index: + # if the logger usage exceeds the limit, clear it + self.dump_history() + self.history_end_index = None + except usb.USBError as e: + raise weewx.WeeWxIOError(e) + except (DecodeError, ProtocolError) as e: + log.info("genLoopPackets: %s" % e) + time.sleep(0.001) + + def genStartupRecords(self, since_ts): + for rec in self.get_history(since_ts): + yield rec + + def convert(self, pkt, ts): + # if debugging packets, log everything we got + if DEBUG_PACKET: + log.info("raw packet: %s" % pkt) + # timestamp and unit system are the same no matter what + p = {'dateTime': ts, 'usUnits': weewx.METRICWX} + # map hardware names to the requested database schema names + for label in self.sensor_map: + if self.sensor_map[label] in pkt: + p[label] = pkt[self.sensor_map[label]] + # single variable to track last_rain assumes that any historical reads + # will happen before any loop reads, and no historical reads will + # happen after any loop reads. Such double-counting of rain + # events is avoided by deliberately ignoring all loop packets during history read. + if 'rain_total' in pkt: + p['rain'] = self.calculate_rain(pkt['rain_total'], self.last_rain) + if DEBUG_RAIN and pkt['rain_total'] != self.last_rain: + log.info("rain=%s rain_total=%s last_rain=%s" % + (p['rain'], pkt['rain_total'], self.last_rain)) + self.last_rain = pkt['rain_total'] + if pkt['rain_total'] == Station.MAX_RAIN_MM: + if time.time() - self.logged_rain_counter > self.log_interval: + log.info("rain counter at maximum, reset required") + self.logged_rain_counter = time.time() + if pkt['rain_total'] >= Station.MAX_RAIN_MM * self.rain_warn: + if time.time() - self.logged_rain_counter > self.log_interval: + log.info("rain counter is above warning level, reset recommended") + self.logged_rain_counter = time.time() + if DEBUG_PACKET: + log.info("converted packet: %s" % p) + return p + + def send_heartbeat( self ): + cmd = [0xa6, 0x91, 0xca, 0x45, 0x52] + self.station.write(cmd) + self.last_a6 = time.time() + self.data_since_heartbeat = 0 + + def convert_historical(self, pkt, ts, last_ts): + p = self.convert(pkt, ts) + if last_ts is not None: + x = (ts - last_ts) / 60 # interval is in minutes + if x > 0: + p['interval'] = x + else: + log.info("ignoring record: bad interval %s (%s)" % (x, p)) + return p + + def convert_loop(self, pkt): + p = self.convert(pkt, int(time.time() + 0.5)) + if DEBUG_HISTORY and self.history_end_index is not None: + p['rxCheckPercent'] = float(self.history_end_index) # fake value as easiest way to return it. + if 'pressure' in p: + # cache any pressure-related values + for x in ['pressure', 'barometer']: + self.pressure_cache[x] = p[x] + else: + # apply any cached pressure-related values + p.update(self.pressure_cache) + return p + + @staticmethod + def calculate_rain(newtotal, oldtotal): + """Calculate the rain difference given two cumulative measurements.""" + if newtotal is not None and oldtotal is not None: + if newtotal >= oldtotal: + delta = newtotal - oldtotal + else: + log.info("rain counter decrement detected: new=%s old=%s" % (newtotal, oldtotal)) + delta = None + else: + log.info("possible missed rain event: new=%s old=%s" % (newtotal, oldtotal)) + delta = None + return delta + + +class WMR300Error(weewx.WeeWxIOError): + """map station errors to weewx io errors""" + +class ProtocolError(WMR300Error): + """communication protocol error""" + +class DecodeError(WMR300Error): + """decoding error""" + +class WrongLength(DecodeError): + """bad packet length""" + +class BadChecksum(DecodeError): + """bogus checksum""" + +class BadTimestamp(DecodeError): + """bogus timestamp""" + +class BadBuffer(DecodeError): + """bogus buffer""" + +class UnknownPacketType(DecodeError): + """unknown packet type""" + +class Station(object): + # these identify the weather station on the USB + VENDOR_ID = 0x0FDE + PRODUCT_ID = 0xCA08 + # standard USB endpoint identifiers + EP_IN = 0x81 + EP_OUT = 0x01 + # all USB messages for this device have the same length + MESSAGE_LENGTH = 64 + + HISTORY_START_REC = 0x20 # index to first history record + HISTORY_MAX_REC = 0x7fe0 # index to history record when full + HISTORY_N_RECORDS = 32704 # maximum number of records (MAX_REC - START_REC) + MAX_RAIN_MM = 10160 # maximum value of rain counter, in mm + + def __init__(self, vend_id=VENDOR_ID, prod_id=PRODUCT_ID): + self.vendor_id = vend_id + self.product_id = prod_id + self.handle = None + self.timeout = 500 + self.interface = 0 # device has only the one interface + self.recv_counts = dict() + self.send_counts = dict() + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): # @UnusedVariable + self.close() + + def open(self): + dev = self._find_dev(self.vendor_id, self.product_id) + if not dev: + raise WMR300Error("Unable to find station on USB: " + "cannot find device with " + "VendorID=0x%04x ProductID=0x%04x" % + (self.vendor_id, self.product_id)) + + self.handle = dev.open() + if not self.handle: + raise WMR300Error('Open USB device failed') + + # FIXME: reset is actually a no-op for some versions of libusb/pyusb? + self.handle.reset() + + # for HID devices on linux, be sure kernel does not claim the interface + try: + self.handle.detachKernelDriver(self.interface) + except (AttributeError, usb.USBError): + pass + + # attempt to claim the interface + try: + self.handle.claimInterface(self.interface) + except usb.USBError as e: + self.close() + raise WMR300Error("Unable to claim interface %s: %s" % + (self.interface, e)) + + def close(self): + if self.handle is not None: + try: + self.handle.releaseInterface() + except (ValueError, usb.USBError) as e: + log.debug("Release interface failed: %s" % e) + self.handle = None + + def reset(self): + self.handle.reset() + + def _read(self, count=True, timeout=None): + if timeout is None: + timeout = self.timeout + buf = self.handle.interruptRead( + Station.EP_IN, self.MESSAGE_LENGTH, timeout) + if DEBUG_COMM: + log.info("read: %s" % _fmt_bytes(buf)) + if DEBUG_COUNTS and count: + self.update_count(buf, self.recv_counts) + return buf + + def read(self, count=True, timeout=None, ignore_non_errors=True, ignore_timeouts=True): + try: + return self._read(count, timeout) + except usb.USBError as e: + if DEBUG_COMM: + log.info("read: e.errno=%s e.strerror=%s e.message=%s repr=%s" % + (e.errno, e.strerror, e.message, repr(e))) + if ignore_timeouts and is_timeout(e): + return [] + if ignore_non_errors and is_noerr(e): + return [] + raise + + def _write(self, buf): + if DEBUG_COMM: + log.info("write: %s" % _fmt_bytes(buf)) + # pad with zeros up to the standard message length + while len(buf) < self.MESSAGE_LENGTH: + buf.append(0x00) + sent = self.handle.interruptWrite(Station.EP_OUT, buf, self.timeout) + if DEBUG_COUNTS: + self.update_count(buf, self.send_counts) + return sent + + def write(self, buf, ignore_non_errors=True, ignore_timeouts=True): + try: + return self._write(buf) + except usb.USBError as e: + if ignore_timeouts and is_timeout(e): + return 0 + if ignore_non_errors and is_noerr(e): + return 0 + raise + + def flush_read_buffer(self): + """discard anything read from the device""" + if DEBUG_COMM: + log.info("flush buffer") + cnt = 0 + buf = self.read(False, 100) + while buf is not None and len(buf) > 0: + cnt += len(buf) + buf = self.read(False, 100) + if DEBUG_COMM: + log.info("flush: discarded %d bytes" % cnt) + return cnt + + # keep track of the message types for debugging purposes + @staticmethod + def update_count(buf, count_dict): + label = 'empty' + if buf and len(buf) > 0: + #if buf[0] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]: + # message type and channel for data packets + #label = '%02x_%d' % (buf[0], buf[7]) + if buf[0] == 0xd3: + # message type and channel for data packets + label = 'TH_%d' % (buf[7]) + elif buf[0] == 0xdc: + label = 'THrng_%d' % (buf[7]) + # ignore this for the moment... + return + elif buf[0] == 0xd4: + label = 'wind' + elif buf[0] == 0xd5: + label = 'rain' + elif buf[0] == 0xd6: + label = 'barom' + elif buf[0] == 0xdb: + label = 'forecast' + elif (buf[0] in [0x41] and + buf[3] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]): + # message type and channel for data ack packets + # these are no longer sent. + label = '%02x_%02x_%d' % (buf[0], buf[3], buf[4]) + else: + # otherwise just track the message type + # prefix with x to place at end + label = 'x%02x' % buf[0] + if label in count_dict: + count_dict[label] += 1 + else: + count_dict[label] = 1 + #Station.print_count( "unknown", count_dict) + + # print the count type summary for debugging + @staticmethod + def print_count( direction, count_dict): + cstr = [] + for k in sorted(count_dict): + cstr.append('%s: %s' % (k, count_dict[k])) + log.info('%s counts; %s' % ( direction, '; '.join(cstr))) + + @staticmethod + def _find_dev(vendor_id, product_id): + """Find the first device with vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vendor_id and dev.idProduct == product_id: + log.debug('Found station at bus=%s device=%s' % + (bus.dirname, dev.filename)) + return dev + return None + + @staticmethod + def _verify_length(label, length, buf): + if buf[1] != length: + raise WrongLength("%s: wrong length: expected %02x, got %02x" % + (label, length, buf[1])) + + @staticmethod + def _verify_checksum(label, buf, msb_first=True): + """Calculate and compare checksum""" + try: + cs1 = Station._calc_checksum(buf) + cs2 = Station._extract_checksum(buf, msb_first) + if cs1 != cs2: + raise BadChecksum("%s: bad checksum: %04x != %04x" % + (label, cs1, cs2)) + except IndexError as e: + raise BadChecksum("%s: not enough bytes for checksum: %s" % + (label, e)) + + @staticmethod + def _calc_checksum(buf): + cs = 0 + for x in buf[:-2]: + cs += x + return cs + + @staticmethod + def _extract_checksum(buf, msb_first): + if msb_first: + return (buf[-2] << 8) | buf[-1] + return (buf[-1] << 8) | buf[-2] + + @staticmethod + def _extract_ts(buf): + if buf[0] == 0xee and buf[1] == 0xee and buf[2] == 0xee: + # year, month, and day are 0xee when timestamp is unset + return None + try: + year = int(buf[0]) + 2000 + month = int(buf[1]) + day = int(buf[2]) + hour = int(buf[3]) + minute = int(buf[4]) + return time.mktime((year, month, day, hour, minute, 0, -1, -1, -1)) + except IndexError: + raise BadTimestamp("buffer too short for timestamp") + except (OverflowError, ValueError) as e: + raise BadTimestamp( + "cannot create timestamp from y:%s m:%s d:%s H:%s M:%s: %s" % + (buf[0], buf[1], buf[2], buf[3], buf[4], e)) + + @staticmethod + def _extract_signed(hi, lo, m): + if hi == 0x7f: + return None + s = 0 + if hi & 0xf0 == 0xf0: + s = 0x10000 + return ((hi << 8) + lo - s) * m + + @staticmethod + def _extract_value(buf, m): + if buf[0] == 0x7f: + return None + if len(buf) == 2: + return ((buf[0] << 8) + buf[1]) * m + return buf[0] * m + + @staticmethod + def get_history_end_index(buf): + """ get the index value reported in the 0x57 packet. + It is the index of the first free history record, + and so is one more than the most recent history record stored in the console + """ + if buf[0] != 0x57: + return None + idx = (buf[17] << 8) + buf[18] + #if idx < Station.HISTORY_START_REC: + # raise WMR300Error("History index: %d below limit of %d" % (idx, Station.HISTORY_START_REC) ) + #elif idx > Station.HISTORY_MAX_REC: + # raise WMR300Error("History index: %d above limit of %d" % (idx, Station.HISTORY_MAX_REC) ) + #self.history_pct=Station.get_history_usage( idx ) + return Station.clip_index(idx) + + @staticmethod + def get_next_index(n): + ## this code is currently UNUSED + # return the index of the record after indicated index + if n == 0: + return 0x20 + if n + 1 > Station.MAX_RECORDS: + return 0x20 # FIXME: verify the wraparound + return n + 1 + + @staticmethod + def clip_index(n): + # given a history record index, clip it to a valid value + # the HISTORY_MAX_REC value is what it returned in packet 0x57 when the + # buffer is full. You cannot ask for it, only the one before. + if n < Station.HISTORY_START_REC: + return Station.HISTORY_START_REC + if n >= Station.HISTORY_MAX_REC - 1: + return Station.HISTORY_MAX_REC - 1 # wraparound never happens + return n + + @staticmethod + def get_record_index(buf): + # extract the index from the history record + if buf[0] != 0xd2: + return None + return (buf[2] << 8) + buf[3] + + @staticmethod + def get_history_pct_as_index( pct ): + # return history buffer index corresponding to a given percentage + if pct is None: + return Station.HISTORY_START_REC + return int(pct * 0.01 * Station.HISTORY_N_RECORDS + Station.HISTORY_START_REC) + + @staticmethod + def get_history_usage_pct(index): + """ return the console's history buffer use corresponding to the given index expressed as a percent + index = index value in console's history buffer + normally the next free history location as returned in 0x57 status packet + """ + if index is None: + return -1.0 + return 100.0 * float(index - Station.HISTORY_START_REC) / Station.HISTORY_N_RECORDS + + @staticmethod + def decode(buf): + try: + pkt = getattr(Station, '_decode_%02x' % buf[0])(buf) + if DEBUG_DECODE: + log.info('decode: %s %s' % (_fmt_bytes(buf), pkt)) + return pkt + except IndexError as e: + raise BadBuffer("cannot decode buffer: %s" % e) + except AttributeError: + raise UnknownPacketType("unknown packet type %02x: %s" % + (buf[0], _fmt_bytes(buf))) + + @staticmethod + def _decode_57(buf): + """57 packet contains station information""" + pkt = dict() + pkt['packet_type'] = 0x57 + pkt['station_type'] = ''.join("%s" % chr(x) for x in buf[0:6]) + pkt['station_model'] = ''.join("%s" % chr(x) for x in buf[7:11]) + pkt['magic0'] = buf[12] + pkt['magic1'] = buf[13] + pkt['history_cleared'] = (buf[20] == 0x43) # FIXME: verify this + pkt['mystery0'] = buf[22] + pkt['mystery1'] = buf[23] + pkt['history_end_index'] = Station.get_history_end_index( buf ) + if DEBUG_HISTORY: + log.info("history index: %s" % pkt['history_end_index']) + return pkt + + @staticmethod + def _decode_41(_): + """41 43 4b is ACK""" + pkt = dict() + pkt['packet_type'] = 0x41 + return pkt + + @staticmethod + def _decode_d2(buf): + """D2 packet contains history data""" + Station._verify_length("D2", 0x80, buf) + Station._verify_checksum("D2", buf[:0x80], msb_first=False) + pkt = dict() + pkt['packet_type'] = 0xd2 + pkt['index'] = Station.get_record_index(buf) + pkt['ts'] = Station._extract_ts(buf[4:9]) + for i in range(9): + pkt['temperature_%d' % i] = Station._extract_signed( + buf[9 + 2 * i], buf[10 + 2 * i], 0.1) # C + pkt['humidity_%d' % i] = Station._extract_value( + buf[27 + i:28 + i], 1.0) # % + for i in range(1, 9): + pkt['dewpoint_%d' % i] = Station._extract_signed( + buf[36 + 2 * i], buf[37 + 2 * i], 0.1) # C + pkt['heatindex_%d' % i] = Station._extract_signed( + buf[52 + 2 * i], buf[53 + 2 * i], 0.1) # C + pkt['windchill'] = Station._extract_signed(buf[68], buf[69], 0.1) # C + pkt['wind_gust'] = Station._extract_value(buf[72:74], 0.1) # m/s + pkt['wind_avg'] = Station._extract_value(buf[74:76], 0.1) # m/s + pkt['wind_gust_dir'] = Station._extract_value(buf[76:78], 1.0) # degree + pkt['wind_dir'] = Station._extract_value(buf[78:80], 1.0) # degree + pkt['forecast'] = Station._extract_value(buf[80:81], 1.0) + pkt['rain_hour'] = Station._extract_value(buf[83:85], 0.254) # mm + pkt['rain_total'] = Station._extract_value(buf[86:88], 0.254) # mm + pkt['rain_start_dateTime'] = Station._extract_ts(buf[88:93]) + pkt['rain_rate'] = Station._extract_value(buf[93:95], 0.254) # mm/hour + pkt['barometer'] = Station._extract_value(buf[95:97], 0.1) # mbar + pkt['pressure_trend'] = Station._extract_value(buf[97:98], 1.0) + return pkt + + @staticmethod + def _decode_d3(buf): + """D3 packet contains temperature/humidity data""" + Station._verify_length("D3", 0x3d, buf) + Station._verify_checksum("D3", buf[:0x3d]) + pkt = dict() + pkt['packet_type'] = 0xd3 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['temperature_%d' % pkt['channel']] = Station._extract_signed( + buf[8], buf[9], 0.1) # C + pkt['humidity_%d' % pkt['channel']] = Station._extract_value( + buf[10:11], 1.0) # % + pkt['dewpoint_%d' % pkt['channel']] = Station._extract_signed( + buf[11], buf[12], 0.1) # C + pkt['heatindex_%d' % pkt['channel']] = Station._extract_signed( + buf[13], buf[14], 0.1) # C + return pkt + + @staticmethod + def _decode_d4(buf): + """D4 packet contains wind data""" + Station._verify_length("D4", 0x36, buf) + Station._verify_checksum("D4", buf[:0x36]) + pkt = dict() + pkt['packet_type'] = 0xd4 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['wind_gust'] = Station._extract_value(buf[8:10], 0.1) # m/s + pkt['wind_gust_dir'] = Station._extract_value(buf[10:12], 1.0) # degree + pkt['wind_avg'] = Station._extract_value(buf[12:14], 0.1) # m/s + pkt['wind_dir'] = Station._extract_value(buf[14:16], 1.0) # degree + pkt['windchill'] = Station._extract_signed(buf[18], buf[19], 0.1) # C + return pkt + + @staticmethod + def _decode_d5(buf): + """D5 packet contains rain data""" + Station._verify_length("D5", 0x28, buf) + Station._verify_checksum("D5", buf[:0x28]) + pkt = dict() + pkt['packet_type'] = 0xd5 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['rain_hour'] = Station._extract_value(buf[9:11], 0.254) # mm + pkt['rain_24_hour'] = Station._extract_value(buf[12:14], 0.254) # mm + pkt['rain_total'] = Station._extract_value(buf[15:17], 0.254) # mm + pkt['rain_rate'] = Station._extract_value(buf[17:19], 0.254) # mm/hour + pkt['rain_start_dateTime'] = Station._extract_ts(buf[19:24]) + return pkt + + @staticmethod + def _decode_d6(buf): + """D6 packet contains pressure data""" + Station._verify_length("D6", 0x2e, buf) + Station._verify_checksum("D6", buf[:0x2e]) + pkt = dict() + pkt['packet_type'] = 0xd6 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['pressure'] = Station._extract_value(buf[8:10], 0.1) # mbar + pkt['barometer'] = Station._extract_value(buf[10:12], 0.1) # mbar + pkt['altitude'] = Station._extract_value(buf[12:14], 1.0) # meter + return pkt + + @staticmethod + def _decode_dc(buf): + """DC packet contains temperature/humidity range data""" + Station._verify_length("DC", 0x3e, buf) + Station._verify_checksum("DC", buf[:0x3e]) + pkt = dict() + pkt['packet_type'] = 0xdc + pkt['ts'] = Station._extract_ts(buf[2:7]) + return pkt + + @staticmethod + def _decode_db(buf): + """DB packet is forecast""" + Station._verify_length("DB", 0x20, buf) + Station._verify_checksum("DB", buf[:0x20]) + pkt = dict() + pkt['packet_type'] = 0xdb + return pkt + + +class WMR300ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WMR300] + # This section is for WMR300 weather stations. + + # The station model, e.g., WMR300A + model = WMR300 + + # The driver to use: + driver = weewx.drivers.wmr300 + + # The console history buffer will be emptied each + # time it gets to this percent full + # Allowed range 5 to 95. + history_limit = 10 + +""" + + def modify_config(self, config_dict): + print(""" +Setting rainRate, windchill, heatindex calculations to hardware. +Dewpoint from hardware is truncated to integer so use software""") + config_dict.setdefault('StdWXCalculate', {}) + config_dict['StdWXCalculate'].setdefault('Calculations', {}) + config_dict['StdWXCalculate']['Calculations']['rainRate'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['windchill'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['heatindex'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['dewpoint'] = 'software' + + +# define a main entry point for basic testing of the station. +# invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/user/wmr300.py + +if __name__ == '__main__': + import optparse + + from weeutil.weeutil import to_sorted_string + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('wmr300', {}) + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', action='store_true', + help='display driver version') + parser.add_option('--get-current', action='store_true', + help='get current packets') + parser.add_option('--get-history', action='store_true', + help='get history records from station') + (options, args) = parser.parse_args() + + if options.version: + print("%s driver version %s" % (DRIVER_NAME, DRIVER_VERSION)) + exit(0) + + driver_dict = { + 'debug_comm': 0, + 'debug_packet': 0, + 'debug_counts': 0, + 'debug_decode': 0, + 'debug_history': 1, + 'debug_timing': 0, + 'debug_rain': 0} + stn = WMR300Driver(**driver_dict) + + if options.get_history: + ts = time.time() - 3600 # get last hour of data + for pkt in stn.genStartupRecords(ts): + print(to_sorted_string(pkt).encode('utf-8')) + + if options.get_current: + for packet in stn.genLoopPackets(): + print(to_sorted_string(packet).encode('utf-8')) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/wmr9x8.py b/dist/weewx-4.10.1/bin/weewx/drivers/wmr9x8.py new file mode 100644 index 0000000..00ca2aa --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/wmr9x8.py @@ -0,0 +1,758 @@ +# Copyright (c) 2012 Will Page +# See the file LICENSE.txt for your full rights. +# +# Derivative of vantage.py and wmr100.py, credit to Tom Keffer + +"""Classes and functions for interfacing with Oregon Scientific WM-918, WMR9x8, +and WMR-968 weather stations + +See + http://wx200.planetfall.com/wx200.txt + http://www.qsl.net/zl1vfo/wx200/wx200.txt + http://ed.toton.org/projects/weather/station-protocol.txt +for documentation on the WM-918 / WX-200 serial protocol + +See + http://www.netsky.org/WMR/Protocol.htm +for documentation on the WMR9x8 serial protocol, and + http://code.google.com/p/wmr968/source/browse/trunk/src/edu/washington/apl/weather/packet/ +for sample (java) code. +""" + +from __future__ import absolute_import +from __future__ import print_function + +import logging +import time +import operator +from functools import reduce + +import serial +from six.moves import map + +import weewx.drivers + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WMR9x8' +DRIVER_VERSION = "3.4.1" +DEFAULT_PORT = '/dev/ttyS0' + +def loader(config_dict, engine): # @UnusedVariable + return WMR9x8(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WMR9x8ConfEditor() + + +class WMR9x8ProtocolError(weewx.WeeWxIOError): + """Used to signal a protocol error condition""" + +def channel_decoder(chan): + if 1 <= chan <= 2: + outchan = chan + elif chan == 4: + outchan = 3 + else: + raise WMR9x8ProtocolError("Bad channel number %d" % chan) + return outchan + +# Dictionary that maps a measurement code, to a function that can decode it: +# packet_type_decoder_map and packet_type_size_map are filled out using the @_registerpackettype +# decorator below +wmr9x8_packet_type_decoder_map = {} +wmr9x8_packet_type_size_map = {} + +wm918_packet_type_decoder_map = {} +wm918_packet_type_size_map = {} + +def wmr9x8_registerpackettype(typecode, size): + """ Function decorator that registers the function as a handler + for a particular packet type. Parameters to the decorator + are typecode and size (in bytes). """ + def wrap(dispatcher): + wmr9x8_packet_type_decoder_map[typecode] = dispatcher + wmr9x8_packet_type_size_map[typecode] = size + return wrap + +def wm918_registerpackettype(typecode, size): + """ Function decorator that registers the function as a handler + for a particular packet type. Parameters to the decorator + are typecode and size (in bytes). """ + def wrap(dispatcher): + wm918_packet_type_decoder_map[typecode] = dispatcher + wm918_packet_type_size_map[typecode] = size + return wrap + + +class SerialWrapper(object): + """Wraps a serial connection returned from package serial""" + + def __init__(self, port): + self.port = port + # WMR9x8 specific settings + self.serialconfig = { + "bytesize": serial.EIGHTBITS, + "parity": serial.PARITY_NONE, + "stopbits": serial.STOPBITS_ONE, + "timeout": None, + "rtscts": 1 + } + + def flush_input(self): + self.serial_port.flushInput() + + def queued_bytes(self): + return self.serial_port.inWaiting() + + def read(self, chars=1): + _buffer = self.serial_port.read(chars) + N = len(_buffer) + if N != chars: + raise weewx.WeeWxIOError("Expected to read %d chars; got %d instead" % (chars, N)) + return _buffer + + def openPort(self): + # Open up the port and store it + self.serial_port = serial.Serial(self.port, **self.serialconfig) + log.debug("Opened up serial port %s" % self.port) + + def closePort(self): + self.serial_port.close() + +#============================================================================== +# Class WMR9x8 +#============================================================================== + +class WMR9x8(weewx.drivers.AbstractDevice): + """Driver for the Oregon Scientific WMR9x8 console. + + The connection to the console will be open after initialization""" + + DEFAULT_MAP = { + 'barometer': 'barometer', + 'pressure': 'pressure', + 'windSpeed': 'wind_speed', + 'windDir': 'wind_dir', + 'windGust': 'wind_gust', + 'windGustDir': 'wind_gust_dir', + 'windBatteryStatus': 'battery_status_wind', + 'inTemp': 'temperature_in', + 'outTemp': 'temperature_out', + 'extraTemp1': 'temperature_1', + 'extraTemp2': 'temperature_2', + 'extraTemp3': 'temperature_3', + 'extraTemp4': 'temperature_4', + 'extraTemp5': 'temperature_5', + 'extraTemp6': 'temperature_6', + 'extraTemp7': 'temperature_7', + 'extraTemp8': 'temperature_8', + 'inHumidity': 'humidity_in', + 'outHumidity': 'humidity_out', + 'extraHumid1': 'humidity_1', + 'extraHumid2': 'humidity_2', + 'extraHumid3': 'humidity_3', + 'extraHumid4': 'humidity_4', + 'extraHumid5': 'humidity_5', + 'extraHumid6': 'humidity_6', + 'extraHumid7': 'humidity_7', + 'extraHumid8': 'humidity_8', + 'inTempBatteryStatus': 'battery_status_in', + 'outTempBatteryStatus': 'battery_status_out', + 'extraBatteryStatus1': 'battery_status_1', # was batteryStatusTHx + 'extraBatteryStatus2': 'battery_status_2', # or batteryStatusTx + 'extraBatteryStatus3': 'battery_status_3', + 'extraBatteryStatus4': 'battery_status_4', + 'extraBatteryStatus5': 'battery_status_5', + 'extraBatteryStatus6': 'battery_status_6', + 'extraBatteryStatus7': 'battery_status_7', + 'extraBatteryStatus8': 'battery_status_8', + 'inDewpoint': 'dewpoint_in', + 'dewpoint': 'dewpoint_out', + 'dewpoint0': 'dewpoint_0', + 'dewpoint1': 'dewpoint_1', + 'dewpoint2': 'dewpoint_2', + 'dewpoint3': 'dewpoint_3', + 'dewpoint4': 'dewpoint_4', + 'dewpoint5': 'dewpoint_5', + 'dewpoint6': 'dewpoint_6', + 'dewpoint7': 'dewpoint_7', + 'dewpoint8': 'dewpoint_8', + 'rain': 'rain', + 'rainTotal': 'rain_total', + 'rainRate': 'rain_rate', + 'hourRain': 'rain_hour', + 'rain24': 'rain_24', + 'yesterdayRain': 'rain_yesterday', + 'rainBatteryStatus': 'battery_status_rain', + 'windchill': 'windchill'} + + def __init__(self, **stn_dict): + """Initialize an object of type WMR9x8. + + NAMED ARGUMENTS: + + model: Which station model is this? + [Optional. Default is 'WMR968'] + + port: The serial port of the WM918/WMR918/WMR968. + [Required if serial communication] + + baudrate: Baudrate of the port. + [Optional. Default 9600] + + timeout: How long to wait before giving up on a response from the + serial port. + [Optional. Default is 5] + """ + + log.info('driver version is %s' % DRIVER_VERSION) + self.model = stn_dict.get('model', 'WMR968') + self.sensor_map = dict(self.DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('sensor map is %s' % self.sensor_map) + self.last_rain_total = None + + # Create the specified port + self.port = WMR9x8._port_factory(stn_dict) + + # Open it up: + self.port.openPort() + + @property + def hardware_name(self): + return self.model + + def openPort(self): + """Open up the connection to the console""" + self.port.openPort() + + def closePort(self): + """Close the connection to the console. """ + self.port.closePort() + + def genLoopPackets(self): + """Generator function that continuously returns loop packets""" + buf = [] + # We keep a buffer the size of the largest supported packet + wmr9x8max = max(list(wmr9x8_packet_type_size_map.items()), key=operator.itemgetter(1))[1] + wm918max = max(list(wm918_packet_type_size_map.items()), key=operator.itemgetter(1))[1] + preBufferSize = max(wmr9x8max, wm918max) + while True: + buf.extend(bytearray(self.port.read(preBufferSize - len(buf)))) + # WMR-9x8/968 packets are framed by 0xFF characters + if buf[0] == 0xFF and buf[1] == 0xFF and buf[2] in wmr9x8_packet_type_size_map: + # Look up packet type, the expected size of this packet type + ptype = buf[2] + psize = wmr9x8_packet_type_size_map[ptype] + # Capture only the data belonging to this packet + pdata = buf[0:psize] + if weewx.debug >= 2: + self.log_packet(pdata) + # Validate the checksum + sent_checksum = pdata[-1] + calc_checksum = reduce(operator.add, pdata[0:-1]) & 0xFF + if sent_checksum == calc_checksum: + log.debug("Received WMR9x8 data packet.") + payload = pdata[2:-1] + _record = wmr9x8_packet_type_decoder_map[ptype](self, payload) + _record = self._sensors_to_fields(_record, self.sensor_map) + if _record is not None: + yield _record + # Eliminate all packet data from the buffer + buf = buf[psize:] + else: + log.debug("Invalid data packet (%s)." % pdata) + # Drop the first byte of the buffer and start scanning again + buf.pop(0) + # WM-918 packets have no framing + elif buf[0] in wm918_packet_type_size_map: + # Look up packet type, the expected size of this packet type + ptype = buf[0] + psize = wm918_packet_type_size_map[ptype] + # Capture only the data belonging to this packet + pdata = buf[0:psize] + # Validate the checksum + sent_checksum = pdata[-1] + calc_checksum = reduce(operator.add, pdata[0:-1]) & 0xFF + if sent_checksum == calc_checksum: + log.debug("Received WM-918 data packet.") + payload = pdata[0:-1] # send all of packet but crc + _record = wm918_packet_type_decoder_map[ptype](self, payload) + _record = self._sensors_to_fields(_record, self.sensor_map) + if _record is not None: + yield _record + # Eliminate all packet data from the buffer + buf = buf[psize:] + else: + log.debug("Invalid data packet (%s)." % pdata) + # Drop the first byte of the buffer and start scanning again + buf.pop(0) + else: + log.debug("Advancing buffer by one for the next potential packet") + buf.pop(0) + + @staticmethod + def _sensors_to_fields(oldrec, sensor_map): + # map a record with observation names to a record with db field names + if oldrec: + newrec = dict() + for k in sensor_map: + if sensor_map[k] in oldrec: + newrec[k] = oldrec[sensor_map[k]] + if newrec: + newrec['dateTime'] = oldrec['dateTime'] + newrec['usUnits'] = oldrec['usUnits'] + return newrec + return None + + #========================================================================== + # Oregon Scientific WMR9x8 utility functions + #========================================================================== + + @staticmethod + def _port_factory(stn_dict): + """Produce a serial port object""" + + # Get the connection type. If it is not specified, assume 'serial': + connection_type = stn_dict.get('type', 'serial').lower() + + if connection_type == "serial": + port = stn_dict['port'] + return SerialWrapper(port) + raise weewx.UnsupportedFeature(stn_dict['type']) + + @staticmethod + def _get_nibble_data(packet): + nibbles = bytearray() + for byte in packet: + nibbles.extend([(byte & 0x0F), (byte & 0xF0) >> 4]) + return nibbles + + def log_packet(self, packet): + packet_str = ','.join(["x%x" % v for v in packet]) + print("%d, %s, %s" % (int(time.time() + 0.5), time.asctime(), packet_str)) + + @wmr9x8_registerpackettype(typecode=0x00, size=11) + def _wmr9x8_wind_packet(self, packet): + """Decode a wind packet. Wind speed will be in kph""" + null, status, dir1, dir10, dir100, gust10th, gust1, gust10, avg10th, avg1, avg10, chillstatus, chill1, chill10 = self._get_nibble_data(packet[1:]) # @UnusedVariable + + battery = (status & 0x04) >> 2 + + # The console returns wind speeds in m/s. Our metric system requires + # kph, so the result needs to be multiplied by 3.6 + _record = { + 'battery_status_wind': battery, + 'wind_speed': ((avg10th / 10.0) + avg1 + (avg10 * 10)) * 3.6, + 'wind_dir': dir1 + (dir10 * 10) + (dir100 * 100), + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Sometimes the station emits a wind gust that is less than the + # average wind. Ignore it if this is the case. + windGustSpeed = ((gust10th / 10.0) + gust1 + (gust10 * 10)) * 3.6 + if windGustSpeed >= _record['wind_speed']: + _record['wind_gust'] = windGustSpeed + + # Bit 1 of chillstatus is on if there is no wind chill data; + # Bit 2 is on if it has overflowed. Check them both: + if chillstatus & 0x6 == 0: + chill = chill1 + (10 * chill10) + if chillstatus & 0x8: + chill = -chill + _record['windchill'] = chill + else: + _record['windchill'] = None + + return _record + + @wmr9x8_registerpackettype(typecode=0x01, size=16) + def _wmr9x8_rain_packet(self, packet): + null, status, cur1, cur10, cur100, tot10th, tot1, tot10, tot100, tot1000, yest1, yest10, yest100, yest1000, totstartmin1, totstartmin10, totstarthr1, totstarthr10, totstartday1, totstartday10, totstartmonth1, totstartmonth10, totstartyear1, totstartyear10 = self._get_nibble_data(packet[1:]) # @UnusedVariable + battery = (status & 0x04) >> 2 + + # station units are mm and mm/hr while the internal metric units are + # cm and cm/hr. It is reported that total rainfall is biased by +0.5 mm + _record = { + 'battery_status_rain': battery, + 'rain_rate': (cur1 + (cur10 * 10) + (cur100 * 100)) / 10.0, + 'rain_yesterday': (yest1 + (yest10 * 10) + (yest100 * 100) + (yest1000 * 1000)) / 10.0, + 'rain_total': (tot10th / 10.0 + tot1 + 10.0 * tot10 + 100.0 * tot100 + 1000.0 * tot1000) / 10.0, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Because the WMR does not offer anything like bucket tips, we must + # calculate it by looking for the change in total rain. Of course, + # this won't work for the very first rain packet. + _record['rain'] = (_record['rain_total'] - self.last_rain_total) if self.last_rain_total is not None else None + self.last_rain_total = _record['rain_total'] + return _record + + @wmr9x8_registerpackettype(typecode=0x02, size=9) + def _wmr9x8_thermohygro_packet(self, packet): + chan, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10 = self._get_nibble_data(packet[1:]) + + chan = channel_decoder(chan) + + battery = (status & 0x04) >> 2 + _record = { + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC, + 'battery_status_%d' % chan :battery + } + + _record['humidity_%d' % chan] = hum1 + (hum10 * 10) + + tempoverunder = temp100etc & 0x04 + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + _record['temperature_%d' % chan] = temp + else: + _record['temperature_%d' % chan] = None + + dewunder = bool(status & 0x01) + # If dew point is valid, save it. + if not dewunder: + _record['dewpoint_%d' % chan] = dew1 + (dew10 * 10) + + return _record + + @wmr9x8_registerpackettype(typecode=0x03, size=9) + def _wmr9x8_mushroom_packet(self, packet): + _, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10 = self._get_nibble_data(packet[1:]) + + battery = (status & 0x04) >> 2 + _record = { + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC, + 'battery_status_out': battery, + 'humidity_out': hum1 + (hum10 * 10) + } + + tempoverunder = temp100etc & 0x04 + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + _record['temperature_out'] = temp + else: + _record['temperature_out'] = None + + dewunder = bool(status & 0x01) + # If dew point is valid, save it. + if not dewunder: + _record['dewpoint_out'] = dew1 + (dew10 * 10) + + return _record + + @wmr9x8_registerpackettype(typecode=0x04, size=7) + def _wmr9x8_therm_packet(self, packet): + chan, status, temp10th, temp1, temp10, temp100etc = self._get_nibble_data(packet[1:]) + + chan = channel_decoder(chan) + battery = (status & 0x04) >> 2 + + _record = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC, + 'battery_status_%d' % chan: battery} + + temp = temp10th / 10.0 + temp1 + 10.0 * temp10 + 100.0 * (temp100etc & 0x03) + if temp100etc & 0x08: + temp = -temp + tempoverunder = temp100etc & 0x04 + _record['temperature_%d' % chan] = temp if not tempoverunder else None + + return _record + + @wmr9x8_registerpackettype(typecode=0x05, size=13) + def _wmr9x8_in_thermohygrobaro_packet(self, packet): + null, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10, baro1, baro10, wstatus, null2, slpoff10th, slpoff1, slpoff10, slpoff100 = self._get_nibble_data(packet[1:]) # @UnusedVariable + + battery = (status & 0x04) >> 2 + hum = hum1 + (hum10 * 10) + + tempoverunder = bool(temp100etc & 0x04) + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + else: + temp = None + + dewunder = bool(status & 0x01) + if not dewunder: + dew = dew1 + (dew10 * 10) + else: + dew = None + + rawsp = ((baro10 & 0xF) << 4) | baro1 + sp = rawsp + 795 + pre_slpoff = (slpoff10th / 10.0) + slpoff1 + (slpoff10 * 10) + (slpoff100 * 100) + slpoff = (1000 + pre_slpoff) if pre_slpoff < 400.0 else pre_slpoff + + _record = { + 'battery_status_in': battery, + 'humidity_in': hum, + 'temperature_in': temp, + 'dewpoint_in': dew, + 'barometer': rawsp + slpoff, + 'pressure': sp, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + @wmr9x8_registerpackettype(typecode=0x06, size=14) + def _wmr9x8_in_ext_thermohygrobaro_packet(self, packet): + null, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10, baro1, baro10, baro100, wstatus, null2, slpoff10th, slpoff1, slpoff10, slpoff100, slpoff1000 = self._get_nibble_data(packet[1:]) # @UnusedVariable + + battery = (status & 0x04) >> 2 + hum = hum1 + (hum10 * 10) + + tempoverunder = bool(temp100etc & 0x04) + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + else: + temp = None + + dewunder = bool(status & 0x01) + if not dewunder: + dew = dew1 + (dew10 * 10) + else: + dew = None + + rawsp = ((baro100 & 0x01) << 8) | ((baro10 & 0xF) << 4) | baro1 + sp = rawsp + 600 + slpoff = (slpoff10th / 10.0) + slpoff1 + (slpoff10 * 10) + (slpoff100 * 100) + (slpoff1000 * 1000) + + _record = { + 'battery_status_in': battery, + 'humidity_in': hum, + 'temperature_in': temp, + 'dewpoint_in': dew, + 'barometer': rawsp + slpoff, + 'pressure': sp, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + @wmr9x8_registerpackettype(typecode=0x0e, size=5) + def _wmr9x8_time_packet(self, packet): + """The (partial) time packet is not used by weewx. + However, the last time is saved in case getTime() is called.""" + min1, min10 = self._get_nibble_data(packet[1:]) + minutes = min1 + ((min10 & 0x07) * 10) + + cur = time.gmtime() + self.last_time = time.mktime( + (cur.tm_year, cur.tm_mon, cur.tm_mday, + cur.tm_hour, minutes, 0, + cur.tm_wday, cur.tm_yday, cur.tm_isdst)) + return None + + @wmr9x8_registerpackettype(typecode=0x0f, size=9) + def _wmr9x8_clock_packet(self, packet): + """The clock packet is not used by weewx. + However, the last time is saved in case getTime() is called.""" + min1, min10, hour1, hour10, day1, day10, month1, month10, year1, year10 = self._get_nibble_data(packet[1:]) + year = year1 + (year10 * 10) + # The station initializes itself to "1999" as the first year + # Thus 99 = 1999, 00 = 2000, 01 = 2001, etc. + year += 1900 if year == 99 else 2000 + month = month1 + (month10 * 10) + day = day1 + (day10 * 10) + hour = hour1 + (hour10 * 10) + minutes = min1 + ((min10 & 0x07) * 10) + cur = time.gmtime() + # TODO: not sure if using tm_isdst is correct here + self.last_time = time.mktime( + (year, month, day, + hour, minutes, 0, + cur.tm_wday, cur.tm_yday, cur.tm_isdst)) + return None + + @wm918_registerpackettype(typecode=0xcf, size=27) + def _wm918_wind_packet(self, packet): + """Decode a wind packet. Wind speed will be in m/s""" + gust10th, gust1, gust10, dir1, dir10, dir100, avg10th, avg1, avg10, avgdir1, avgdir10, avgdir100 = self._get_nibble_data(packet[1:7]) + _chill10, _chill1 = self._get_nibble_data(packet[16:17]) + + # The console returns wind speeds in m/s. Our metric system requires + # kph, so the result needs to be multiplied by 3.6 + _record = { + 'wind_speed': ((avg10th / 10.0) + avg1 + (avg10 * 10)) * 3.6, + 'wind_dir': avgdir1 + (avgdir10 * 10) + (avgdir100 * 100), + 'wind_gust': ((gust10th / 10.0) + gust1 + (gust10 * 10)) * 3.6, + 'wind_gust_dir': dir1 + (dir10 * 10) + (dir100 * 100), + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Sometimes the station emits a wind gust that is less than the + # average wind. Ignore it if this is the case. + if _record['wind_gust'] < _record['wind_speed']: + _record['wind_gust'] = _record['wind_speed'] + return _record + + @wm918_registerpackettype(typecode=0xbf, size=14) + def _wm918_rain_packet(self, packet): + cur1, cur10, cur100, _stat, yest1, yest10, yest100, yest1000, tot1, tot10, tot100, tot1000 = self._get_nibble_data(packet[1:7]) + + # It is reported that total rainfall is biased by +0.5 mm + _record = { + 'rain_rate': (cur1 + (cur10 * 10) + (cur100 * 100)) / 10.0, + 'rain_yesterday': (yest1 + (yest10 * 10) + (yest100 * 100) + (yest1000 * 1000)) / 10.0, + 'rain_total': (tot1 + (tot10 * 10) + (tot100 * 100) + (tot1000 * 1000)) / 10.0, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Because the WM does not offer anything like bucket tips, we must + # calculate it by looking for the change in total rain. Of course, this + # won't work for the very first rain packet. + # the WM reports rain rate as rain_rate, rain yesterday (updated by + # wm at midnight) and total rain since last reset + # weewx needs rain since last packet we need to divide by 10 to mimic + # Vantage reading + _record['rain'] = (_record['rain_total'] - self.last_rain_total) if self.last_rain_total is not None else None + self.last_rain_total = _record['rain_total'] + return _record + + @wm918_registerpackettype(typecode=0x8f, size=35) + def _wm918_humidity_packet(self, packet): + hum1, hum10 = self._get_nibble_data(packet[8:9]) + humout1, humout10 = self._get_nibble_data(packet[20:21]) + + hum = hum1 + (hum10 * 10) + humout = humout1 + (humout10 * 10) + _record = { + 'humidity_out': humout, + 'humidity_in': hum, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + return _record + + @wm918_registerpackettype(typecode=0x9f, size=34) + def _wm918_therm_packet(self, packet): + temp10th, temp1, temp10, null = self._get_nibble_data(packet[1:3]) # @UnusedVariable + tempout10th, tempout1, tempout10, null = self._get_nibble_data(packet[16:18]) # @UnusedVariable + + temp = (temp10th / 10.0) + temp1 + ((temp10 & 0x7) * 10) + temp *= -1 if (temp10 & 0x08) else 1 + tempout = (tempout10th / 10.0) + tempout1 + ((tempout10 & 0x7) * 10) + tempout *= -1 if (tempout10 & 0x08) else 1 + _record = { + 'temperature_in': temp, + 'temperature_out': tempout, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + @wm918_registerpackettype(typecode=0xaf, size=31) + def _wm918_baro_dew_packet(self, packet): + baro1, baro10, baro100, baro1000, slp10th, slp1, slp10, slp100, slp1000, fmt, prediction, trend, dewin1, dewin10 = self._get_nibble_data(packet[1:8]) # @UnusedVariable + dewout1, dewout10 = self._get_nibble_data(packet[18:19]) # @UnusedVariable + + #dew = dewin1 + (dewin10 * 10) + #dewout = dewout1 + (dewout10 *10) + sp = baro1 + (baro10 * 10) + (baro100 * 100) + (baro1000 * 1000) + slp = (slp10th / 10.0) + slp1 + (slp10 * 10) + (slp100 * 100) + (slp1000 * 1000) + _record = { + 'barometer': slp, + 'pressure': sp, + #'inDewpoint': dew, + #'outDewpoint': dewout, + #'dewpoint': dewout, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + +class WMR9x8ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WMR9x8] + # This section is for the Oregon Scientific WMR918/968 + + # Connection type. For now, 'serial' is the only option. + type = serial + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., WMR918, Radio Shack 63-1016 + model = WMR968 + + # The driver to use: + driver = weewx.drivers.wmr9x8 +""" + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + port = self._prompt('port', '/dev/ttyUSB0') + return {'port': port} + + def modify_config(self, config_dict): + print(""" +Setting rainRate, windchill, and dewpoint calculations to hardware.""") + config_dict.setdefault('StdWXCalculate', {}) + config_dict['StdWXCalculate'].setdefault('Calculations', {}) + config_dict['StdWXCalculate']['Calculations']['rainRate'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['windchill'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['dewpoint'] = 'hardware' + +# Define a main entry point for basic testing without the weewx engine. +# Invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/wmr9x8.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + weewx.debug = 2 + + weeutil.logger.setup('wmr9x8', {}) + + usage = """Usage: %prog --help + %prog --version + %prog --gen-packets [--port=PORT]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='Display driver version') + parser.add_option('--port', dest='port', metavar='PORT', + help='The port to use. Default is %s' % DEFAULT_PORT, + default=DEFAULT_PORT) + parser.add_option('--gen-packets', dest='gen_packets', action='store_true', + help="Generate packets indefinitely") + + (options, args) = parser.parse_args() + + if options.version: + print("WMR9x8 driver version %s" % DRIVER_VERSION) + exit(0) + + if options.gen_packets: + log.debug("wmr9x8: Running genLoopPackets()") + stn_dict = {'port': options.port} + stn = WMR9x8(**stn_dict) + + for packet in stn.genLoopPackets(): + print(packet) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/ws1.py b/dist/weewx-4.10.1/bin/weewx/drivers/ws1.py new file mode 100644 index 0000000..3443051 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/ws1.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python +# +# Copyright 2014-2020 Matthew Wall +# See the file LICENSE.txt for your rights. + +"""Driver for ADS WS1 weather stations. + +Thanks to Kevin and Paul Caccamo for adding the serial-to-tcp capability. + +Thanks to Steve (sesykes71) for the testing that made this driver possible. + +Thanks to Jay Nugent (WB8TKL) and KRK6 for weather-2.kr6k-V2.1 + http://server1.nuge.com/~weather/ +""" + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +import logging +import time + +from six import byte2int + +import weewx.drivers +from weewx.units import INHG_PER_MBAR, MILE_PER_KM +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WS1' +DRIVER_VERSION = '0.5' + + +def loader(config_dict, _): + return WS1Driver(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WS1ConfEditor() + + +DEFAULT_SER_PORT = '/dev/ttyS0' +DEFAULT_TCP_ADDR = '192.168.36.25' +DEFAULT_TCP_PORT = 3000 +PACKET_SIZE = 50 +DEBUG_READ = 0 + + +class WS1Driver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with an ADS-WS1 station + + mode - Communication mode - TCP, UDP, or Serial. + [Required. Default is serial] + + port - Serial port or network address. + [Required. Default is /dev/ttyS0 for serial, + and 192.168.36.25:3000 for TCP/IP] + + max_tries - how often to retry serial communication before giving up. + [Optional. Default is 5] + + wait_before_retry - how long to wait, in seconds, before retrying after a failure. + [Optional. Default is 10] + + timeout - The amount of time, in seconds, before the connection fails if + there is no response. + [Optional. Default is 3] + + debug_read - The level of message logging. The higher this number, the more + information is logged. + [Optional. Default is 0] + """ + def __init__(self, **stn_dict): + log.info('driver version is %s' % DRIVER_VERSION) + + con_mode = stn_dict.get('mode', 'serial').lower() + if con_mode == 'tcp' or con_mode == 'udp': + port = stn_dict.get('port', '%s:%d' % (DEFAULT_TCP_ADDR, DEFAULT_TCP_PORT)) + elif con_mode == 'serial': + port = stn_dict.get('port', DEFAULT_SER_PORT) + else: + raise ValueError("Invalid driver connection mode %s" % con_mode) + + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.wait_before_retry = float(stn_dict.get('wait_before_retry', 10)) + timeout = int(stn_dict.get('timeout', 3)) + + self.last_rain = None + + log.info('using %s port %s' % (con_mode, port)) + + global DEBUG_READ + DEBUG_READ = int(stn_dict.get('debug_read', DEBUG_READ)) + + if con_mode == 'tcp' or con_mode == 'udp': + self.station = StationSocket(port, protocol=con_mode, + timeout=timeout, + max_tries=self.max_tries, + wait_before_retry=self.wait_before_retry) + else: + self.station = StationSerial(port, timeout=timeout) + self.station.open() + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return "WS1" + + def genLoopPackets(self): + while True: + packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + readings = self.station.get_readings_with_retry(self.max_tries, + self.wait_before_retry) + data = StationData.parse_readings(readings) + packet.update(data) + self._augment_packet(packet) + yield packet + + def _augment_packet(self, packet): + # calculate the rain delta from rain total + packet['rain'] = weewx.wxformulas.calculate_rain(packet.get('rain_total'), self.last_rain) + self.last_rain = packet.get('rain_total') + + +# =========================================================================== # +# Station data class - parses and validates data from the device # +# =========================================================================== # + + +class StationData(object): + def __init__(self): + pass + + @staticmethod + def validate_string(buf): + if len(buf) != PACKET_SIZE: + raise weewx.WeeWxIOError("Unexpected buffer length %d" % len(buf)) + if buf[0:2] != b'!!': + raise weewx.WeeWxIOError("Unexpected header bytes '%s'" % buf[0:2]) + return buf + + @staticmethod + def parse_readings(raw): + """WS1 station emits data in PeetBros format: + + http://www.peetbros.com/shop/custom.aspx?recid=29 + + Each line has 50 characters - 2 header bytes and 48 data bytes: + + !!000000BE02EB000027700000023A023A0025005800000000 + SSSSXXDDTTTTLLLLPPPPttttHHHHhhhhddddmmmmRRRRWWWW + + SSSS - wind speed (0.1 kph) + XX - wind direction calibration + DD - wind direction (0-255) + TTTT - outdoor temperature (0.1 F) + LLLL - long term rain (0.01 in) + PPPP - barometer (0.1 mbar) + tttt - indoor temperature (0.1 F) + HHHH - outdoor humidity (0.1 %) + hhhh - indoor humidity (0.1 %) + dddd - date (day of year) + mmmm - time (minute of day) + RRRR - daily rain (0.01 in) + WWWW - one minute wind average (0.1 kph) + """ + # FIXME: peetbros could be 40 bytes or 44 bytes, what about ws1? + # FIXME: peetbros uses two's complement for temp, what about ws1? + buf = raw[2:].decode('ascii') + data = dict() + data['windSpeed'] = StationData._decode(buf[0:4], 0.1 * MILE_PER_KM) # mph + data['windDir'] = StationData._decode(buf[6:8], 1.411764) # compass deg + data['outTemp'] = StationData._decode(buf[8:12], 0.1, True) # degree_F + data['rain_total'] = StationData._decode(buf[12:16], 0.01) # inch + data['barometer'] = StationData._decode(buf[16:20], 0.1 * INHG_PER_MBAR) # inHg + data['inTemp'] = StationData._decode(buf[20:24], 0.1, True) # degree_F + data['outHumidity'] = StationData._decode(buf[24:28], 0.1) # percent + data['inHumidity'] = StationData._decode(buf[28:32], 0.1) # percent + data['day_of_year'] = StationData._decode(buf[32:36]) + data['minute_of_day'] = StationData._decode(buf[36:40]) + data['daily_rain'] = StationData._decode(buf[40:44], 0.01) # inch + data['wind_average'] = StationData._decode(buf[44:48], 0.1 * MILE_PER_KM) # mph + return data + + @staticmethod + def _decode(s, multiplier=None, neg=False): + v = None + try: + v = int(s, 16) + if neg: + bits = 4 * len(s) + if v & (1 << (bits - 1)) != 0: + v -= (1 << bits) + if multiplier is not None: + v *= multiplier + except ValueError as e: + if s != '----': + log.debug("decode failed for '%s': %s" % (s, e)) + return v + + +# =========================================================================== # +# Station Serial class - Gets data through a serial port # +# =========================================================================== # + + +class StationSerial(object): + def __init__(self, port, timeout=3): + self.port = port + self.baudrate = 2400 + self.timeout = timeout + self.serial_port = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): # @UnusedVariable + self.close() + + def open(self): + import serial + log.debug("open serial port %s" % self.port) + self.serial_port = serial.Serial(self.port, self.baudrate, + timeout=self.timeout) + + def close(self): + if self.serial_port is not None: + log.debug("close serial port %s" % self.port) + self.serial_port.close() + self.serial_port = None + + # FIXME: use either CR or LF as line terminator. apparently some ws1 + # hardware occasionally ends a line with only CR instead of the standard + # CR-LF, resulting in a line that is too long. + def get_readings(self): + buf = self.serial_port.readline() + if DEBUG_READ >= 2: + log.debug("bytes: '%s'" % ' '.join(["%0.2X" % byte2int(c) for c in buf])) + buf = buf.strip() + return buf + + def get_readings_with_retry(self, max_tries=5, wait_before_retry=10): + import serial + for ntries in range(max_tries): + try: + buf = self.get_readings() + StationData.validate_string(buf) + return buf + except (serial.serialutil.SerialException, weewx.WeeWxIOError) as e: + log.info("Failed attempt %d of %d to get readings: %s" % + (ntries + 1, max_tries, e)) + time.sleep(wait_before_retry) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + +# =========================================================================== # +# Station TCP class - Gets data through a TCP/IP connection # +# For those users with a serial->TCP adapter # +# =========================================================================== # + + +class StationSocket(object): + def __init__(self, addr, protocol='tcp', timeout=3, max_tries=5, + wait_before_retry=10): + import socket + + self.max_tries = max_tries + self.wait_before_retry = wait_before_retry + + if addr.find(':') != -1: + self.conn_info = addr.split(':') + self.conn_info[1] = int(self.conn_info[1], 10) + self.conn_info = tuple(self.conn_info) + else: + self.conn_info = (addr, DEFAULT_TCP_PORT) + + try: + if protocol == 'tcp': + self.net_socket = socket.socket( + socket.AF_INET, socket.SOCK_STREAM) + elif protocol == 'udp': + self.net_socket = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM) + except (socket.error, socket.herror) as ex: + log.error("Cannot create socket for some reason: %s" % ex) + raise weewx.WeeWxIOError(ex) + + self.net_socket.settimeout(timeout) + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): # @UnusedVariable + self.close() + + def open(self): + import socket + + log.debug("Connecting to %s:%d." % (self.conn_info[0], self.conn_info[1])) + + for conn_attempt in range(self.max_tries): + try: + if conn_attempt > 1: + log.debug("Retrying connection...") + self.net_socket.connect(self.conn_info) + break + except (socket.error, socket.timeout, socket.herror) as ex: + log.error("Cannot connect to %s:%d for some reason: %s. %d tries left." % ( + self.conn_info[0], self.conn_info[1], ex, + self.max_tries - (conn_attempt + 1))) + log.debug("Will retry in %.2f seconds..." % self.wait_before_retry) + time.sleep(self.wait_before_retry) + else: + log.error("Max tries (%d) exceeded for connection." % self.max_tries) + raise weewx.RetriesExceeded("Max tries exceeding while attempting connection") + + def close(self): + import socket + + log.debug("Closing connection to %s:%d." % (self.conn_info[0], self.conn_info[1])) + try: + self.net_socket.close() + except (socket.error, socket.herror, socket.timeout) as ex: + log.error("Cannot close connection to %s:%d. Reason: %s" + % (self.conn_info[0], self.conn_info[1], ex)) + raise weewx.WeeWxIOError(ex) + + def get_data(self, num_bytes=8): + """Get data from the socket connection + Args: + num_bytes: The number of bytes to request. + Returns: + bytes: The data from the remote device. + """ + + import socket + try: + data = self.net_socket.recv(num_bytes, socket.MSG_WAITALL) + except Exception as ex: + raise weewx.WeeWxIOError(ex) + else: + if len(data) == 0: + raise weewx.WeeWxIOError("No data recieved") + + return data + + def find_record_start(self): + """Find the start of a data record by requesting data from the remote + device until we find it. + Returns: + bytes: The start of a data record from the remote device. + """ + if DEBUG_READ >= 2: + log.debug("Attempting to find record start..") + + buf = bytes("", "utf-8") + while True: + data = self.get_data() + + if DEBUG_READ >= 2: + log.debug("(searching...) buf: %s" % buf.decode('utf-8')) + # split on line breaks and take everything after the line break + data = data.splitlines()[-1] + if b"!!" in data: + # if it contains !!, take everything after the last occurance of !! (we sometimes see a whole bunch of !) + buf = data.rpartition(b"!!")[-1] + if len(buf) > 0: + # if there is anything left, add the !! back on and break + # we have effectively found everything between a line break and !! + buf = b"!!" + buf + if DEBUG_READ >= 2: + log.debug("Record start found!") + break + return buf + + + def fill_buffer(self, buf): + """Get the remainder of the data record from the remote device. + Args: + buf: The beginning of the data record. + Returns: + bytes: The data from the remote device. + """ + if DEBUG_READ >= 2: + log.debug("filling buffer with rest of record") + while True: + data = self.get_data() + + # split on line breaks and take everything before it + data = data.splitlines()[0] + buf = buf + data + if DEBUG_READ >= 2: + log.debug("buf is %s" % buf.decode('utf-8')) + if len(buf) == 50: + if DEBUG_READ >= 2: + log.debug("filled record %s" % buf.decode('utf-8')) + break + return buf + + def get_readings(self): + buf = self.find_record_start() + if DEBUG_READ >= 2: + log.debug("record start: %s" % buf.decode('utf-8')) + buf = self.fill_buffer(buf) + if DEBUG_READ >= 1: + log.debug("Got data record: %s" % buf.decode('utf-8')) + return buf + + def get_readings_with_retry(self, max_tries=5, wait_before_retry=10): + for _ in range(max_tries): + buf = bytes("", "utf-8") + try: + buf = self.get_readings() + StationData.validate_string(buf) + return buf + except (weewx.WeeWxIOError) as e: + log.debug("Failed to get data. Reason: %s" % e) + + # NOTE: WeeWx IO Errors may not always occur because of + # invalid data. These kinds of errors are also caused by socket + # errors and timeouts. + + if DEBUG_READ >= 1: + log.debug("buf: %s (%d bytes)" % (buf.decode('utf-8'), len(buf))) + + time.sleep(wait_before_retry) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + +class WS1ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WS1] + # This section is for the ADS WS1 series of weather stations. + + # Driver mode - tcp, udp, or serial + mode = serial + + # If serial, specify the serial port device. (ex. /dev/ttyS0, /dev/ttyUSB0, + # or /dev/cuaU0) + # If TCP, specify the IP address and port number. (ex. 192.168.36.25:3000) + port = /dev/ttyUSB0 + + # The amount of time, in seconds, before the connection fails if there is + # no response + timeout = 3 + + # The driver to use: + driver = weewx.drivers.ws1 +""" + + def prompt_for_settings(self): + print("How is the station connected? tcp, udp, or serial.") + con_mode = self._prompt('mode', 'serial') + con_mode = con_mode.lower() + + if con_mode == 'serial': + print("Specify the serial port on which the station is connected, ") + "for example: /dev/ttyUSB0 or /dev/ttyS0." + port = self._prompt('port', '/dev/ttyUSB0') + elif con_mode == 'tcp' or con_mode == 'udp': + print("Specify the IP address and port of the station. For ") + "example: 192.168.36.40:3000." + port = self._prompt('port', '192.168.36.40:3000') + + print("Specify how long to wait for a response, in seconds.") + timeout = self._prompt('timeout', 3) + + return {'mode': con_mode, 'port': port, 'timeout': timeout} + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/ws1.py +# PYTHONPATH=/usr/share/weewx python3 /usr/share/weewx/weewx/drivers/ws1.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='provide additional debug output in log') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected to use Serial mode', + default=DEFAULT_SER_PORT) + parser.add_option('--addr', dest='addr', metavar='ADDR', + help='ip address and port to use TCP mode', + default=DEFAULT_TCP_ADDR) + + (options, args) = parser.parse_args() + + if options.version: + print("ADS WS1 driver version %s" % DRIVER_VERSION) + exit(0) + + if options.debug: + weewx.debug = 2 + DEBUG_READ = 2 + + weeutil.logger.setup('ws1', {}) + + Station = StationSerial + if options.addr is not None: + Station = StationSocket + + with Station(options.addr) as s: + while True: + print(time.time(), s.get_readings().decode("utf-8")) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/ws23xx.py b/dist/weewx-4.10.1/bin/weewx/drivers/ws23xx.py new file mode 100644 index 0000000..8045bcc --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/ws23xx.py @@ -0,0 +1,2134 @@ +#!usr/bin/env python +# +# Copyright 2013 Matthew Wall +# See the file LICENSE.txt for your full rights. +# +# Thanks to Kenneth Lavrsen for the Open2300 implementation: +# http://www.lavrsen.dk/foswiki/bin/view/Open2300/WebHome +# description of the station communication interface: +# http://www.lavrsen.dk/foswiki/bin/view/Open2300/OpenWSAPI +# memory map: +# http://www.lavrsen.dk/foswiki/bin/view/Open2300/OpenWSMemoryMap +# +# Thanks to Russell Stuart for the ws2300 python implementation: +# http://ace-host.stuart.id.au/russell/files/ws2300/ +# and the map of the station memory: +# http://ace-host.stuart.id.au/russell/files/ws2300/memory_map_2300.txt +# +# This immplementation copies directly from Russell Stuart's implementation, +# but only the parts required to read from and write to the weather station. + +"""Classes and functions for interfacing with WS-23xx weather stations. + +LaCrosse made a number of stations in the 23xx series, including: + + WS-2300, WS-2308, WS-2310, WS-2315, WS-2317, WS-2357 + +The stations were also sold as the TFA Matrix and TechnoLine 2350. + +The WWVB receiver is located in the console. + +To synchronize the console and sensors, press and hold the PLUS key for 2 +seconds. When console is not synchronized no data will be received. + +To do a factory reset, press and hold PRESSURE and WIND for 5 seconds. + +A single bucket tip is 0.0204 in (0.518 mm). + +The station has 175 history records. That is just over 7 days of data with +the default history recording interval of 60 minutes. + +The station supports both wireless and wired communication between the +sensors and a station console. Wired connection updates data every 8 seconds. +Wireless connection updates data in 16 to 128 second intervals, depending on +wind speed and rain activity. + +The connection type can be one of 0=cable, 3=lost, 15=wireless + +sensor update frequency: + + 32 seconds when wind speed > 22.36 mph (wireless) + 128 seconds when wind speed < 22.36 mph (wireless) + 10 minutes (wireless after 5 failed attempts) + 8 seconds (wired) + +console update frequency: + + 15 seconds (pressure/temperature) + 20 seconds (humidity) + +It is possible to increase the rate of wireless updates: + + http://www.wxforum.net/index.php?topic=2196.0 + +Sensors are connected by unshielded phone cables. RF interference can cause +random spikes in data, with one symptom being values of 25.5 m/s or 91.8 km/h +for the wind speed. Unfortunately those values are within the sensor limits +of 0-113 mph (50.52 m/s or 181.9 km/h). To reduce the number of spikes in +data, replace with shielded cables: + + http://www.lavrsen.dk/sources/weather/windmod.htm + +The station records wind speed and direction, but has no notion of gust. + +The station calculates windchill and dewpoint. + +The station has a serial connection to the computer. + +This driver does not keep the serial port open for long periods. Instead, the +driver opens the serial port, reads data, then closes the port. + +This driver polls the station. Use the polling_interval parameter to specify +how often to poll for data. If not specified, the polling interval will adapt +based on connection type and status. + +USB-Serial Converters + +With a USB-serial converter one can connect the station to a computer with +only USB ports, but not every converter will work properly. Perhaps the two +most common converters are based on the Prolific and FTDI chipsets. Many +people report better luck with the FTDI-based converters. Some converters +that use the Prolific chipset (PL2303) will work, but not all of them. + +Known to work: ATEN UC-232A + +Bounds checking + + wind speed: 0-113 mph + wind direction: 0-360 + humidity: 0-100 + temperature: ok if not -22F and humidity is valid + dewpoint: ok if not -22F and humidity is valid + barometer: 25-35 inHg + rain rate: 0-10 in/hr + +Discrepancies Between Implementations + +As of December 2013, there are significant differences between the open2300, +wview, and ws2300 implementations. Current version numbers are as follows: + + open2300 1.11 + ws2300 1.8 + wview 5.20.2 + +History Interval + +The factory default is 60 minutes. The value stored in the console is one +less than the actual value (in minutes). So for the factory default of 60, +the console stores 59. The minimum interval is 1. + +ws2300.py reports the actual value from the console, e.g., 59 when the +interval is 60. open2300 reports the interval, e.g., 60 when the interval +is 60. wview ignores the interval. + +Detecting Bogus Sensor Values + +wview queries the station 3 times for each sensor then accepts the value only +if the three values were close to each other. + +open2300 sleeps 10 seconds if a wind measurement indicates invalid or overflow. + +The ws2300.py implementation includes overflow and validity flags for values +from the wind sensors. It does not retry based on invalid or overflow. + +Wind Speed + +There is disagreement about how to calculate wind speed and how to determine +whether the wind speed is valid. + +This driver introduces a WindConversion object that uses open2300/wview +decoding so that wind speeds match that of open2300/wview. ws2300 1.8 +incorrectly uses bcd2num instead of bin2num. This bug is fixed in this driver. + +The memory map indicates the following: + +addr smpl description +0x527 0 Wind overflow flag: 0 = normal +0x528 0 Wind minimum code: 0=min, 1=--.-, 2=OFL +0x529 0 Windspeed: binary nibble 0 [m/s * 10] +0x52A 0 Windspeed: binary nibble 1 [m/s * 10] +0x52B 0 Windspeed: binary nibble 2 [m/s * 10] +0x52C 8 Wind Direction = nibble * 22.5 degrees +0x52D 8 Wind Direction 1 measurement ago +0x52E 9 Wind Direction 2 measurement ago +0x52F 8 Wind Direction 3 measurement ago +0x530 7 Wind Direction 4 measurement ago +0x531 7 Wind Direction 5 measurement ago +0x532 0 + +wview 5.20.2 implementation (wview apparently copied from open2300): + +read 3 bytes starting at 0x527 + +0x527 x[0] +0x528 x[1] +0x529 x[2] + +if ((x[0] != 0x00) || + ((x[1] == 0xff) && (((x[2] & 0xf) == 0) || ((x[2] & 0xf) == 1)))) { + fail +} else { + dir = (x[2] >> 4) * 22.5 + speed = ((((x[2] & 0xf) << 8) + (x[1])) / 10.0 * 2.23693629) + maxdir = dir + maxspeed = speed +} + +open2300 1.10 implementation: + +read 6 bytes starting at 0x527 + +0x527 x[0] +0x528 x[1] +0x529 x[2] +0x52a x[3] +0x52b x[4] +0x52c x[5] + +if ((x[0] != 0x00) || + ((x[1] == 0xff) && (((x[2] & 0xf) == 0) || ((x[2] & 0xf) == 1)))) { + sleep 10 +} else { + dir = x[2] >> 4 + speed = ((((x[2] & 0xf) << 8) + (x[1])) / 10.0) + dir0 = (x[2] >> 4) * 22.5 + dir1 = (x[3] & 0xf) * 22.5 + dir2 = (x[3] >> 4) * 22.5 + dir3 = (x[4] & 0xf) * 22.5 + dir4 = (x[4] >> 4) * 22.5 + dir5 = (x[5] & 0xf) * 22.5 +} + +ws2300.py 1.8 implementation: + +read 1 nibble starting at 0x527 +read 1 nibble starting at 0x528 +read 4 nibble starting at 0x529 +read 3 nibble starting at 0x529 +read 1 nibble starting at 0x52c +read 1 nibble starting at 0x52d +read 1 nibble starting at 0x52e +read 1 nibble starting at 0x52f +read 1 nibble starting at 0x530 +read 1 nibble starting at 0x531 + +0x527 overflow +0x528 validity +0x529 speed[0] +0x52a speed[1] +0x52b speed[2] +0x52c dir[0] + +speed: ((x[2] * 100 + x[1] * 10 + x[0]) % 1000) / 10 +velocity: (x[2] * 100 + x[1] * 10 + x[0]) / 10 + +dir = data[0] * 22.5 +speed = (bcd2num(data) % 10**3 + 0) / 10**1 +velocity = (bcd2num(data[:3])/10.0, bin2num(data[3:4]) * 22.5) + +bcd2num([a,b,c]) -> c*100+b*10+a + +""" + +# TODO: use pyserial instead of LinuxSerialPort +# TODO: put the __enter__ and __exit__ scaffolding on serial port, not Station +# FIXME: unless we can get setTime to work, just ignore the console clock +# FIXME: detect bogus wind speed/direction +# i see these when the wind instrument is disconnected: +# ws 26.399999 +# wsh 21 +# w0 135 + +from __future__ import with_statement +from __future__ import absolute_import +from __future__ import print_function + +import logging +import time +import string +import fcntl +import os +import select +import struct +import termios +import tty +from functools import reduce + +import six +from six.moves import zip +from six.moves import input + +import weeutil.weeutil +import weewx.drivers +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WS23xx' +DRIVER_VERSION = '0.41' + + +def loader(config_dict, _): + return WS23xxDriver(config_dict=config_dict, **config_dict[DRIVER_NAME]) + +def configurator_loader(_): + return WS23xxConfigurator() + +def confeditor_loader(): + return WS23xxConfEditor() + + +DEFAULT_PORT = '/dev/ttyUSB0' + + +class WS23xxConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super(WS23xxConfigurator, self).add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N history records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display history records since N minutes ago") + parser.add_option("--clear-memory", dest="clear", action="store_true", + help="clear station memory") + parser.add_option("--set-time", dest="settime", action="store_true", + help="set the station clock to the current time") + parser.add_option("--set-interval", dest="interval", + type=int, metavar="N", + help="set the station archive interval to N minutes") + + def do_options(self, options, parser, config_dict, prompt): + self.station = WS23xxDriver(**config_dict[DRIVER_NAME]) + if options.current: + self.show_current() + elif options.nrecords is not None: + self.show_history(count=options.nrecords) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(ts=ts) + elif options.settime: + self.set_clock(prompt) + elif options.interval is not None: + self.set_interval(options.interval, prompt) + elif options.clear: + self.clear_history(prompt) + else: + self.show_info() + self.station.closePort() + + def show_info(self): + """Query the station then display the settings.""" + print('Querying the station for the configuration...') + config = self.station.getConfig() + for key in sorted(config): + print('%s: %s' % (key, config[key])) + + def show_current(self): + """Get current weather observation.""" + print('Querying the station for current weather data...') + for packet in self.station.genLoopPackets(): + print(packet) + break + + def show_history(self, ts=None, count=0): + """Show the indicated number of records or records since timestamp""" + print("Querying the station for historical records...") + for i, r in enumerate(self.station.genArchiveRecords(since_ts=ts, + count=count)): + print(r) + if count and i > count: + break + + def set_clock(self, prompt): + """Set station clock to current time.""" + ans = None + while ans not in ['y', 'n']: + v = self.station.getTime() + vstr = weeutil.weeutil.timestamp_to_string(v) + print("Station clock is", vstr) + if prompt: + ans = input("Set station clock (y/n)? ") + else: + print("Setting station clock") + ans = 'y' + if ans == 'y': + self.station.setTime() + v = self.station.getTime() + vstr = weeutil.weeutil.timestamp_to_string(v) + print("Station clock is now", vstr) + elif ans == 'n': + print("Set clock cancelled.") + + def set_interval(self, interval, prompt): + print("Changing the interval will clear the station memory.") + v = self.station.getArchiveInterval() + ans = None + while ans not in ['y', 'n']: + print("Interval is", v) + if prompt: + ans = input("Set interval to %d minutes (y/n)? " % interval) + else: + print("Setting interval to %d minutes" % interval) + ans = 'y' + if ans == 'y': + self.station.setArchiveInterval(interval) + v = self.station.getArchiveInterval() + print("Interval is now", v) + elif ans == 'n': + print("Set interval cancelled.") + + def clear_history(self, prompt): + ans = None + while ans not in ['y', 'n']: + v = self.station.getRecordCount() + print("Records in memory:", v) + if prompt: + ans = input("Clear console memory (y/n)? ") + else: + print('Clearing console memory') + ans = 'y' + if ans == 'y': + self.station.clearHistory() + v = self.station.getRecordCount() + print("Records in memory:", v) + elif ans == 'n': + print("Clear memory cancelled.") + + +class WS23xxDriver(weewx.drivers.AbstractDevice): + """Driver for LaCrosse WS23xx stations.""" + + def __init__(self, **stn_dict): + """Initialize the station object. + + port: The serial port, e.g., /dev/ttyS0 or /dev/ttyUSB0 + [Required. Default is /dev/ttyS0] + + polling_interval: How often to poll the station, in seconds. + [Optional. Default is 8 (wired) or 30 (wireless)] + + model: Which station model is this? + [Optional. Default is 'LaCrosse WS23xx'] + """ + self._last_rain = None + self._last_cn = None + self._poll_wait = 60 + + self.model = stn_dict.get('model', 'LaCrosse WS23xx') + self.port = stn_dict.get('port', DEFAULT_PORT) + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.retry_wait = int(stn_dict.get('retry_wait', 30)) + self.polling_interval = stn_dict.get('polling_interval', None) + if self.polling_interval is not None: + self.polling_interval = int(self.polling_interval) + self.enable_startup_records = stn_dict.get('enable_startup_records', + True) + self.enable_archive_records = stn_dict.get('enable_archive_records', + True) + self.mode = stn_dict.get('mode', 'single_open') + + log.info('driver version is %s' % DRIVER_VERSION) + log.info('serial port is %s' % self.port) + log.info('polling interval is %s' % self.polling_interval) + + if self.mode == 'single_open': + self.station = WS23xx(self.port) + else: + self.station = None + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + + # weewx wants the archive interval in seconds, but the console uses minutes + @property + def archive_interval(self): + if not self.enable_startup_records and not self.enable_archive_records: + raise NotImplementedError + return self.getArchiveInterval() * 60 + + def genLoopPackets(self): + ntries = 0 + while ntries < self.max_tries: + ntries += 1 + try: + if self.station: + data = self.station.get_raw_data(SENSOR_IDS) + else: + with WS23xx(self.port) as s: + data = s.get_raw_data(SENSOR_IDS) + packet = data_to_packet(data, int(time.time() + 0.5), + last_rain=self._last_rain) + self._last_rain = packet['rainTotal'] + ntries = 0 + yield packet + + if self.polling_interval is not None: + self._poll_wait = self.polling_interval + if data['cn'] != self._last_cn: + conn_info = get_conn_info(data['cn']) + log.info("connection changed from %s to %s" + % (get_conn_info(self._last_cn)[0], conn_info[0])) + self._last_cn = data['cn'] + if self.polling_interval is None: + log.info("using %s second polling interval for %s connection" + % (conn_info[1], conn_info[0])) + self._poll_wait = conn_info[1] + time.sleep(self._poll_wait) + except Ws2300.Ws2300Exception as e: + log.error("Failed attempt %d of %d to get LOOP data: %s" + % (ntries, self.max_tries, e)) + log.debug("Waiting %d seconds before retry" % self.retry_wait) + time.sleep(self.retry_wait) + else: + msg = "Max retries (%d) exceeded for LOOP data" % self.max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def genStartupRecords(self, since_ts): + if not self.enable_startup_records: + raise NotImplementedError + if self.station: + return self.genRecords(self.station, since_ts) + else: + with WS23xx(self.port) as s: + return self.genRecords(s, since_ts) + + def genArchiveRecords(self, since_ts, count=0): + if not self.enable_archive_records: + raise NotImplementedError + if self.station: + return self.genRecords(self.station, since_ts, count) + else: + with WS23xx(self.port) as s: + return self.genRecords(s, since_ts, count) + + def genRecords(self, s, since_ts, count=0): + last_rain = None + for ts, data in s.gen_records(since_ts=since_ts, count=count): + record = data_to_packet(data, ts, last_rain=last_rain) + record['interval'] = data['interval'] + last_rain = record['rainTotal'] + yield record + +# def getTime(self) : +# with WS23xx(self.port) as s: +# return s.get_time() + +# def setTime(self): +# with WS23xx(self.port) as s: +# s.set_time() + + def getArchiveInterval(self): + if self.station: + return self.station.get_archive_interval() + else: + with WS23xx(self.port) as s: + return s.get_archive_interval() + + def setArchiveInterval(self, interval): + if self.station: + self.station.set_archive_interval(interval) + else: + with WS23xx(self.port) as s: + s.set_archive_interval(interval) + + def getConfig(self): + fdata = dict() + if self.station: + data = self.station.get_raw_data(list(Measure.IDS.keys())) + else: + with WS23xx(self.port) as s: + data = s.get_raw_data(list(Measure.IDS.keys())) + for key in data: + fdata[Measure.IDS[key].name] = data[key] + return fdata + + def getRecordCount(self): + if self.station: + return self.station.get_record_count() + else: + with WS23xx(self.port) as s: + return s.get_record_count() + + def clearHistory(self): + if self.station: + self.station.clear_memory() + else: + with WS23xx(self.port) as s: + s.clear_memory() + + +# ids for current weather conditions and connection type +SENSOR_IDS = ['it','ih','ot','oh','pa','wind','rh','rt','dp','wc','cn'] +# polling interval, in seconds, for various connection types +POLLING_INTERVAL = {0: ("cable", 8), 3: ("lost", 60), 15: ("wireless", 30)} + +def get_conn_info(conn_type): + return POLLING_INTERVAL.get(conn_type, ("unknown", 60)) + +def data_to_packet(data, ts, last_rain=None): + """Convert raw data to format and units required by weewx. + + station weewx (metric) + temperature degree C degree C + humidity percent percent + uv index unitless unitless + pressure mbar mbar + wind speed m/s km/h + wind dir degree degree + wind gust None + wind gust dir None + rain mm cm + rain rate cm/h + """ + + packet = dict() + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = ts + packet['inTemp'] = data['it'] + packet['inHumidity'] = data['ih'] + packet['outTemp'] = data['ot'] + packet['outHumidity'] = data['oh'] + packet['pressure'] = data['pa'] + + ws, wd, wso, wsv = data['wind'] + if wso == 0 and wsv == 0: + packet['windSpeed'] = ws + if packet['windSpeed'] is not None: + packet['windSpeed'] *= 3.6 # weewx wants km/h + packet['windDir'] = wd + else: + log.info('invalid wind reading: speed=%s dir=%s overflow=%s invalid=%s' + % (ws, wd, wso, wsv)) + packet['windSpeed'] = None + packet['windDir'] = None + + packet['windGust'] = None + packet['windGustDir'] = None + + packet['rainTotal'] = data['rt'] + if packet['rainTotal'] is not None: + packet['rainTotal'] /= 10 # weewx wants cm + packet['rain'] = weewx.wxformulas.calculate_rain( + packet['rainTotal'], last_rain) + + # station provides some derived variables + packet['rainRate'] = data['rh'] + if packet['rainRate'] is not None: + packet['rainRate'] /= 10 # weewx wants cm/hr + packet['dewpoint'] = data['dp'] + packet['windchill'] = data['wc'] + + return packet + + +class WS23xx(object): + """Wrap the Ws2300 object so we can easily open serial port, read/write, + close serial port without all of the try/except/finally scaffolding.""" + + def __init__(self, port): + log.debug('create LinuxSerialPort') + self.serial_port = LinuxSerialPort(port) + log.debug('create Ws2300') + self.ws = Ws2300(self.serial_port) + + def __enter__(self): + log.debug('station enter') + return self + + def __exit__(self, type_, value, traceback): + log.debug('station exit') + self.ws = None + self.close() + + def close(self): + log.debug('close LinuxSerialPort') + self.serial_port.close() + self.serial_port = None + + def set_time(self, ts): + """Set station time to indicated unix epoch.""" + log.debug('setting station clock to %s' + % weeutil.weeutil.timestamp_to_string(ts)) + for m in [Measure.IDS['sd'], Measure.IDS['st']]: + data = m.conv.value2binary(ts) + cmd = m.conv.write(data, None) + self.ws.write_safe(m.address, *cmd[1:]) + + def get_time(self): + """Return station time as unix epoch.""" + data = self.get_raw_data(['sw']) + ts = int(data['sw']) + log.debug('station clock is %s' % weeutil.weeutil.timestamp_to_string(ts)) + return ts + + def set_archive_interval(self, interval): + """Set the archive interval in minutes.""" + if int(interval) < 1: + raise ValueError('archive interval must be greater than zero') + log.debug('setting hardware archive interval to %s minutes' % interval) + interval -= 1 + for m,v in [(Measure.IDS['hi'],interval), # archive interval in minutes + (Measure.IDS['hc'],1), # time till next sample in minutes + (Measure.IDS['hn'],0)]: # number of valid records + data = m.conv.value2binary(v) + cmd = m.conv.write(data, None) + self.ws.write_safe(m.address, *cmd[1:]) + + def get_archive_interval(self): + """Return archive interval in minutes.""" + data = self.get_raw_data(['hi']) + x = 1 + int(data['hi']) + log.debug('station archive interval is %s minutes' % x) + return x + + def clear_memory(self): + """Clear station memory.""" + log.debug('clearing console memory') + for m,v in [(Measure.IDS['hn'],0)]: # number of valid records + data = m.conv.value2binary(v) + cmd = m.conv.write(data, None) + self.ws.write_safe(m.address, *cmd[1:]) + + def get_record_count(self): + data = self.get_raw_data(['hn']) + x = int(data['hn']) + log.debug('record count is %s' % x) + return x + + def gen_records(self, since_ts=None, count=None, use_computer_clock=True): + """Get latest count records from the station from oldest to newest. If + count is 0 or None, return all records. + + The station has a history interval, and it records when the last + history sample was saved. So as long as the interval does not change + between the first and last records, we are safe to infer timestamps + for each record. This assumes that if the station loses power then + the memory will be cleared. + + There is no timestamp associated with each record - we have to guess. + The station tells us the time until the next record and the epoch of + the latest record, based on the station's clock. So we can use that + or use the computer clock to guess the timestamp for each record. + + To ensure accurate data, the first record must be read within one + minute of the initial read and the remaining records must be read + within numrec * interval minutes. + """ + + log.debug("gen_records: since_ts=%s count=%s clock=%s" + % (since_ts, count, use_computer_clock)) + measures = [Measure.IDS['hi'], Measure.IDS['hw'], + Measure.IDS['hc'], Measure.IDS['hn']] + raw_data = read_measurements(self.ws, measures) + interval = 1 + int(measures[0].conv.binary2value(raw_data[0])) # minute + latest_ts = int(measures[1].conv.binary2value(raw_data[1])) # epoch + time_to_next = int(measures[2].conv.binary2value(raw_data[2])) # minute + numrec = int(measures[3].conv.binary2value(raw_data[3])) + + now = int(time.time()) + cstr = 'station' + if use_computer_clock: + latest_ts = now - (interval - time_to_next) * 60 + cstr = 'computer' + log.debug("using %s clock with latest_ts of %s" + % (cstr, weeutil.weeutil.timestamp_to_string(latest_ts))) + + if not count: + count = HistoryMeasure.MAX_HISTORY_RECORDS + if since_ts is not None: + count = int((now - since_ts) / (interval * 60)) + log.debug("count is %d to satisfy timestamp of %s" + % (count, weeutil.weeutil.timestamp_to_string(since_ts))) + if count == 0: + return + if count > numrec: + count = numrec + if count > HistoryMeasure.MAX_HISTORY_RECORDS: + count = HistoryMeasure.MAX_HISTORY_RECORDS + + # station is about to overwrite first record, so skip it + if time_to_next <= 1 and count == HistoryMeasure.MAX_HISTORY_RECORDS: + count -= 1 + + log.debug("downloading %d records from station" % count) + HistoryMeasure.set_constants(self.ws) + measures = [HistoryMeasure(n) for n in range(count-1, -1, -1)] + raw_data = read_measurements(self.ws, measures) + last_ts = latest_ts - (count-1) * interval * 60 + for measure, nybbles in zip(measures, raw_data): + value = measure.conv.binary2value(nybbles) + data_dict = { + 'interval': interval, + 'it': value.temp_indoor, + 'ih': value.humidity_indoor, + 'ot': value.temp_outdoor, + 'oh': value.humidity_outdoor, + 'pa': value.pressure_absolute, + 'rt': value.rain, + 'wind': (value.wind_speed/10, value.wind_direction, 0, 0), + 'rh': None, # no rain rate in history + 'dp': None, # no dewpoint in history + 'wc': None, # no windchill in history + } + yield last_ts, data_dict + last_ts += interval * 60 + + def get_raw_data(self, labels): + """Get raw data from the station, return as dictionary.""" + measures = [Measure.IDS[m] for m in labels] + raw_data = read_measurements(self.ws, measures) + data_dict = dict(list(zip(labels, [m.conv.binary2value(d) for m, d in zip(measures, raw_data)]))) + return data_dict + + +# ============================================================================= +# The following code was adapted from ws2300.py by Russell Stuart +# ============================================================================= + +VERSION = "1.8 2013-08-26" + +# +# Debug options. +# +DEBUG_SERIAL = False + +# +# A fatal error. +# +class FatalError(Exception): + source = None + message = None + cause = None + def __init__(self, source, message, cause=None): + self.source = source + self.message = message + self.cause = cause + Exception.__init__(self, message) + +# +# The serial port interface. We can talk to the Ws2300 over anything +# that implements this interface. +# +class SerialPort(object): + # + # Discard all characters waiting to be read. + # + def clear(self): raise NotImplementedError() + # + # Close the serial port. + # + def close(self): raise NotImplementedError() + # + # Wait for all characters to be sent. + # + def flush(self): raise NotImplementedError() + # + # Read a character, waiting for a most timeout seconds. Return the + # character read, or None if the timeout occurred. + # + def read_byte(self, timeout): raise NotImplementedError() + # + # Release the serial port. Closes it until it is used again, when + # it is automatically re-opened. It need not be implemented. + # + def release(self): pass + # + # Write characters to the serial port. + # + def write(self, data): raise NotImplementedError() + +# +# A Linux Serial port. Implements the Serial interface on Linux. +# +class LinuxSerialPort(SerialPort): + SERIAL_CSIZE = { + "7": tty.CS7, + "8": tty.CS8, } + SERIAL_PARITIES= { + "e": tty.PARENB, + "n": 0, + "o": tty.PARENB|tty.PARODD, } + SERIAL_SPEEDS = { + "300": tty.B300, + "600": tty.B600, + "1200": tty.B1200, + "2400": tty.B2400, + "4800": tty.B4800, + "9600": tty.B9600, + "19200": tty.B19200, + "38400": tty.B38400, + "57600": tty.B57600, + "115200": tty.B115200, } + SERIAL_SETTINGS = "2400,n,8,1" + device = None # string, the device name. + orig_settings = None # class, the original ports settings. + select_list = None # list, The serial ports + serial_port = None # int, OS handle to device. + settings = None # string, the settings on the command line. + # + # Initialise ourselves. + # + def __init__(self,device,settings=SERIAL_SETTINGS): + self.device = device + self.settings = settings.split(",") + self.settings.extend([None,None,None]) + self.settings[0] = self.__class__.SERIAL_SPEEDS.get(self.settings[0], None) + self.settings[1] = self.__class__.SERIAL_PARITIES.get(self.settings[1].lower(), None) + self.settings[2] = self.__class__.SERIAL_CSIZE.get(self.settings[2], None) + if len(self.settings) != 7 or None in self.settings[:3]: + raise FatalError(self.device, 'Bad serial settings "%s".' % settings) + self.settings = self.settings[:4] + # + # Open the port. + # + try: + self.serial_port = os.open(self.device, os.O_RDWR) + except EnvironmentError as e: + raise FatalError(self.device, "can't open tty device - %s." % str(e)) + try: + fcntl.flock(self.serial_port, fcntl.LOCK_EX) + self.orig_settings = tty.tcgetattr(self.serial_port) + setup = self.orig_settings[:] + setup[0] = tty.INPCK + setup[1] = 0 + setup[2] = tty.CREAD|tty.HUPCL|tty.CLOCAL|reduce(lambda x,y: x|y, self.settings[:3]) + setup[3] = 0 # tty.ICANON + setup[4] = self.settings[0] + setup[5] = self.settings[0] + setup[6] = [b'\000']*len(setup[6]) + setup[6][tty.VMIN] = 1 + setup[6][tty.VTIME] = 0 + tty.tcflush(self.serial_port, tty.TCIOFLUSH) + # + # Restart IO if stopped using software flow control (^S/^Q). This + # doesn't work on FreeBSD. + # + try: + tty.tcflow(self.serial_port, tty.TCOON|tty.TCION) + except termios.error: + pass + tty.tcsetattr(self.serial_port, tty.TCSAFLUSH, setup) + # + # Set DTR low and RTS high and leave other control lines untouched. + # + arg = struct.pack('I', 0) + arg = fcntl.ioctl(self.serial_port, tty.TIOCMGET, arg) + portstatus = struct.unpack('I', arg)[0] + portstatus = portstatus & ~tty.TIOCM_DTR | tty.TIOCM_RTS + arg = struct.pack('I', portstatus) + fcntl.ioctl(self.serial_port, tty.TIOCMSET, arg) + self.select_list = [self.serial_port] + except Exception: + os.close(self.serial_port) + raise + def close(self): + if self.orig_settings: + tty.tcsetattr(self.serial_port, tty.TCSANOW, self.orig_settings) + os.close(self.serial_port) + def read_byte(self, timeout): + ready = select.select(self.select_list, [], [], timeout) + if not ready[0]: + return None + return os.read(self.serial_port, 1) + # + # Write a string to the port. + # + def write(self, data): + os.write(self.serial_port, data) + # + # Flush the input buffer. + # + def clear(self): + tty.tcflush(self.serial_port, tty.TCIFLUSH) + # + # Flush the output buffer. + # + def flush(self): + tty.tcdrain(self.serial_port) + +# +# This class reads and writes bytes to a Ws2300. It is passed something +# that implements the Serial interface. The major routines are: +# +# Ws2300() - Create one of these objects that talks over the serial port. +# read_batch() - Reads data from the device using an scatter/gather interface. +# write_safe() - Writes data to the device. +# +class Ws2300(object): + # + # An exception for us. + # + class Ws2300Exception(weewx.WeeWxIOError): + def __init__(self, *args): + weewx.WeeWxIOError.__init__(self, *args) + # + # Constants we use. + # + MAXBLOCK = 30 + MAXRETRIES = 50 + MAXWINDRETRIES= 20 + WRITENIB = 0x42 + SETBIT = 0x12 + UNSETBIT = 0x32 + WRITEACK = 0x10 + SETACK = 0x04 + UNSETACK = 0x0C + RESET_MIN = 0x01 + RESET_MAX = 0x02 + MAX_RESETS = 100 + # + # Instance data. + # + log_buffer = None # list, action log + log_mode = None # string, Log mode + long_nest = None # int, Nesting of log actions + serial_port = None # string, SerialPort port to use + # + # Initialise ourselves. + # + def __init__(self, serial_port): + self.log_buffer = [] + self.log_nest = 0 + self.serial_port = serial_port + # + # Write data to the device. + # + def write_byte(self, data): + """Write a single-element byte string. + + data: In Python 2, type 'str'; in Python 3, either type 'bytes', or 'bytearray'. It should + hold only one element. + """ + if self.log_mode != 'w': + if self.log_mode != 'e': + self.log(' ') + self.log_mode = 'w' + self.log("%02x" % ord(data)) + self.serial_port.write(data) + # + # Read a byte from the device. + # + def read_byte(self, timeout=1.0): + if self.log_mode != 'r': + self.log_mode = 'r' + self.log(':') + result = self.serial_port.read_byte(timeout) + if not result: + self.log("--") + else: + self.log("%02x" % ord(result)) + time.sleep(0.01) # reduce chance of data spike by avoiding contention + return result + # + # Remove all pending incoming characters. + # + def clear_device(self): + if self.log_mode != 'e': + self.log(' ') + self.log_mode = 'c' + self.log("C") + self.serial_port.clear() + # + # Write a reset string and wait for a reply. + # + def reset_06(self): + self.log_enter("re") + try: + for _ in range(self.__class__.MAX_RESETS): + self.clear_device() + self.write_byte(b'\x06') + # + # Occasionally 0, then 2 is returned. If 0 comes back, + # continue reading as this is more efficient than sending + # an out-of sync reset and letting the data reads restore + # synchronization. Occasionally, multiple 2's are returned. + # Read with a fast timeout until all data is exhausted, if + # we got a 2 back at all, we consider it a success. + # + success = False + answer = self.read_byte() + while answer != None: + if answer == b'\x02': + success = True + answer = self.read_byte(0.05) + if success: + return + msg = "Reset failed, %d retries, no response" % self.__class__.MAX_RESETS + raise self.Ws2300Exception(msg) + finally: + self.log_exit() + # + # Encode the address. + # + def write_address(self,address): + for digit in range(4): + byte = six.int2byte((address >> (4 * (3-digit)) & 0xF) * 4 + 0x82) + self.write_byte(byte) + ack = six.int2byte(digit * 16 + (ord(byte) - 0x82) // 4) + answer = self.read_byte() + if ack != answer: + self.log("??") + return False + return True + # + # Write data, checking the reply. + # + def write_data(self,nybble_address,nybbles,encode_constant=None): + self.log_enter("wd") + try: + if not self.write_address(nybble_address): + return None + if encode_constant == None: + encode_constant = self.WRITENIB + encoded_data = b''.join([ + six.int2byte(nybbles[i]*4 + encode_constant) + for i in range(len(nybbles))]) + ack_constant = { + self.SETBIT: self.SETACK, + self.UNSETBIT: self.UNSETACK, + self.WRITENIB: self.WRITEACK + }[encode_constant] + self.log(",") + for i in range(len(encoded_data)): + self.write_byte(bytearray([encoded_data[i]])) + answer = self.read_byte() + if six.int2byte(nybbles[i] + ack_constant) != answer: + self.log("??") + return None + return True + finally: + self.log_exit() + # + # Reset the device and write a command, verifing it was written correctly. + # + def write_safe(self,nybble_address,nybbles,encode_constant=None): + self.log_enter("ws") + try: + for _ in range(self.MAXRETRIES): + self.reset_06() + command_data = self.write_data(nybble_address,nybbles,encode_constant) + if command_data != None: + return command_data + raise self.Ws2300Exception("write_safe failed, retries exceeded") + finally: + self.log_exit() + # + # A total kuldge this, but its the easiest way to force the 'computer + # time' to look like a normal ws2300 variable, which it most definitely + # isn't, of course. + # + def read_computer_time(self,nybble_address,nybble_count): + now = time.time() + tm = time.localtime(now) + tu = time.gmtime(now) + year2 = tm[0] % 100 + datetime_data = ( + tu[5]%10, tu[5]//10, tu[4]%10, tu[4]//10, tu[3]%10, tu[3]//10, + tm[5]%10, tm[5]//10, tm[4]%10, tm[4]//10, tm[3]%10, tm[3]//10, + tm[2]%10, tm[2]//10, tm[1]%10, tm[1]//10, year2%10, year2//10) + address = nybble_address+18 + return datetime_data[address:address+nybble_count] + # + # Read 'length' nybbles at address. Returns: (nybble_at_address, ...). + # Can't read more than MAXBLOCK nybbles at a time. + # + def read_data(self,nybble_address,nybble_count): + if nybble_address < 0: + return self.read_computer_time(nybble_address,nybble_count) + self.log_enter("rd") + try: + if nybble_count < 1 or nybble_count > self.MAXBLOCK: + Exception("Too many nybbles requested") + bytes_ = (nybble_count + 1) // 2 + if not self.write_address(nybble_address): + return None + # + # Write the number bytes we want to read. + # + encoded_data = six.int2byte(0xC2 + bytes_*4) + self.write_byte(encoded_data) + answer = self.read_byte() + check = six.int2byte(0x30 + bytes_) + if answer != check: + self.log("??") + return None + # + # Read the response. + # + self.log(", :") + response = b"" + for _ in range(bytes_): + answer = self.read_byte() + if answer == None: + return None + response += answer + # + # Read and verify checksum + # + answer = self.read_byte() + checksum = sum(b for b in six.iterbytes(response)) % 256 + if six.int2byte(checksum) != answer: + self.log("??") + return None + r = () + for b in six.iterbytes(response): + r += (b % 16, b // 16) + return r[:nybble_count] + finally: + self.log_exit() + # + # Read a batch of blocks. Batches is a list of data to be read: + # [(address_of_first_nybble, length_in_nybbles), ...] + # returns: + # [(nybble_at_address, ...), ...] + # + def read_batch(self,batches): + self.log_enter("rb start") + self.log_exit() + try: + if [b for b in batches if b[0] >= 0]: + self.reset_06() + result = [] + for batch in batches: + address = batch[0] + data = () + for start_pos in range(0,batch[1],self.MAXBLOCK): + for _ in range(self.MAXRETRIES): + bytes_ = min(self.MAXBLOCK, batch[1]-start_pos) + response = self.read_data(address + start_pos, bytes_) + if response != None: + break + self.reset_06() + if response == None: + raise self.Ws2300Exception("read failed, retries exceeded") + data += response + result.append(data) + return result + finally: + self.log_enter("rb end") + self.log_exit() + # + # Reset the device, read a block of nybbles at the passed address. + # + def read_safe(self,nybble_address,nybble_count): + self.log_enter("rs") + try: + return self.read_batch([(nybble_address,nybble_count)])[0] + finally: + self.log_exit() + # + # Debug logging of serial IO. + # + def log(self, s): + if not DEBUG_SERIAL: + return + self.log_buffer[-1] = self.log_buffer[-1] + s + def log_enter(self, action): + if not DEBUG_SERIAL: + return + self.log_nest += 1 + if self.log_nest == 1: + if len(self.log_buffer) > 1000: + del self.log_buffer[0] + self.log_buffer.append("%5.2f %s " % (time.time() % 100, action)) + self.log_mode = 'e' + def log_exit(self): + if not DEBUG_SERIAL: + return + self.log_nest -= 1 + +# +# Print a data block. +# +def bcd2num(nybbles): + digits = list(nybbles)[:] + digits.reverse() + return reduce(lambda a,b: a*10 + b, digits, 0) + +def num2bcd(number, nybble_count): + result = [] + for _ in range(nybble_count): + result.append(int(number % 10)) + number //= 10 + return tuple(result) + +def bin2num(nybbles): + digits = list(nybbles) + digits.reverse() + return reduce(lambda a,b: a*16 + b, digits, 0) + +def num2bin(number, nybble_count): + result = [] + number = int(number) + for _ in range(nybble_count): + result.append(number % 16) + number //= 16 + return tuple(result) + +# +# A "Conversion" encapsulates a unit of measurement on the Ws2300. Eg +# temperature, or wind speed. +# +class Conversion(object): + description = None # Description of the units. + nybble_count = None # Number of nybbles used on the WS2300 + units = None # Units name (eg hPa). + # + # Initialise ourselves. + # units - text description of the units. + # nybble_count- Size of stored value on ws2300 in nybbles + # description - Description of the units + # + def __init__(self, units, nybble_count, description): + self.description = description + self.nybble_count = nybble_count + self.units = units + # + # Convert the nybbles read from the ws2300 to our internal value. + # + def binary2value(self, data): raise NotImplementedError() + # + # Convert our internal value to nybbles that can be written to the ws2300. + # + def value2binary(self, value): raise NotImplementedError() + # + # Print value. + # + def str(self, value): raise NotImplementedError() + # + # Convert the string produced by "str()" back to the value. + # + def parse(self, s): raise NotImplementedError() + # + # Transform data into something that can be written. Returns: + # (new_bytes, ws2300.write_safe_args, ...) + # This only becomes tricky when less than a nybble is written. + # + def write(self, data, nybble): + return (data, data) + # + # Test if the nybbles read from the Ws2300 is sensible. Sometimes a + # communications error will make it past the weak checksums the Ws2300 + # uses. This optional function implements another layer of checking - + # does the value returned make sense. Returns True if the value looks + # like garbage. + # + def garbage(self, data): + return False + +# +# For values stores as binary numbers. +# +class BinConversion(Conversion): + mult = None + scale = None + units = None + def __init__(self, units, nybble_count, scale, description, mult=1, check=None): + Conversion.__init__(self, units, nybble_count, description) + self.mult = mult + self.scale = scale + self.units = units + def binary2value(self, data): + return (bin2num(data) * self.mult) / 10.0**self.scale + def value2binary(self, value): + return num2bin(int(value * 10**self.scale) // self.mult, self.nybble_count) + def str(self, value): + return "%.*f" % (self.scale, value) + def parse(self, s): + return float(s) + +# +# For values stored as BCD numbers. +# +class BcdConversion(Conversion): + offset = None + scale = None + units = None + def __init__(self, units, nybble_count, scale, description, offset=0): + Conversion.__init__(self, units, nybble_count, description) + self.offset = offset + self.scale = scale + self.units = units + def binary2value(self, data): + num = bcd2num(data) % 10**self.nybble_count + self.offset + return float(num) / 10**self.scale + def value2binary(self, value): + return num2bcd(int(value * 10**self.scale) - self.offset, self.nybble_count) + def str(self, value): + return "%.*f" % (self.scale, value) + def parse(self, s): + return float(s) + +# +# For pressures. Add a garbage check. +# +class PressureConversion(BcdConversion): + def __init__(self): + BcdConversion.__init__(self, "hPa", 5, 1, "pressure") + def garbage(self, data): + value = self.binary2value(data) + return value < 900 or value > 1200 + +# +# For values the represent a date. +# +class ConversionDate(Conversion): + format = None + def __init__(self, nybble_count, format_): + description = format_ + for xlate in "%Y:yyyy,%m:mm,%d:dd,%H:hh,%M:mm,%S:ss".split(","): + description = description.replace(*xlate.split(":")) + Conversion.__init__(self, "", nybble_count, description) + self.format = format_ + def str(self, value): + return time.strftime(self.format, time.localtime(value)) + def parse(self, s): + return time.mktime(time.strptime(s, self.format)) + +class DateConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 6, "%Y-%m-%d") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[2] + tm[1] * 100 + (tm[0]-2000) * 10000 + return num2bcd(dt, self.nybble_count) + +class DatetimeConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 11, "%Y-%m-%d %H:%M") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x // 1000000000 % 100 + 2000, + x // 10000000 % 100, + x // 100000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dow = tm[6] + 1 + dt = tm[4]+(tm[3]+(dow+(tm[2]+(tm[1]+(tm[0]-2000)*100)*100)*10)*100)*100 + return num2bcd(dt, self.nybble_count) + +class UnixtimeConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 12, "%Y-%m-%d %H:%M:%S") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x //10000000000 % 100 + 2000, + x // 100000000 % 100, + x // 1000000 % 100, + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[5]+(tm[4]+(tm[3]+(tm[2]+(tm[1]+(tm[0]-2000)*100)*100)*100)*100)*100 + return num2bcd(dt, self.nybble_count) + +class TimestampConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 10, "%Y-%m-%d %H:%M") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x // 100000000 % 100 + 2000, + x // 1000000 % 100, + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[4] + (tm[3] + (tm[2] + (tm[1] + (tm[0]-2000)*100)*100)*100)*100 + return num2bcd(dt, self.nybble_count) + +class TimeConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 6, "%H:%M:%S") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + 0, + 0, + 0, + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0)) - time.timezone + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[5] + tm[4]*100 + tm[3]*10000 + return num2bcd(dt, self.nybble_count) + def parse(self, s): + return time.mktime((0,0,0) + time.strptime(s, self.format)[3:]) + time.timezone + +class WindDirectionConversion(Conversion): + def __init__(self): + Conversion.__init__(self, "deg", 1, "North=0 clockwise") + def binary2value(self, data): + return data[0] * 22.5 + def value2binary(self, value): + return (int((value + 11.25) / 22.5),) + def str(self, value): + return "%g" % value + def parse(self, s): + return float(s) + +class WindVelocityConversion(Conversion): + def __init__(self): + Conversion.__init__(self, "ms,d", 4, "wind speed and direction") + def binary2value(self, data): + return (bin2num(data[:3])/10.0, bin2num(data[3:4]) * 22.5) + def value2binary(self, value): + return num2bin(value[0]*10, 3) + num2bin((value[1] + 11.5) / 22.5, 1) + def str(self, value): + return "%.1f,%g" % value + def parse(self, s): + return tuple([float(x) for x in s.split(",")]) + +# The ws2300 1.8 implementation does not calculate wind speed correctly - +# it uses bcd2num instead of bin2num. This conversion object uses bin2num +# decoding and it reads all wind data in a single transcation so that we do +# not suffer coherency problems. +class WindConversion(Conversion): + def __init__(self): + Conversion.__init__(self, "ms,d,o,v", 12, "wind speed, dir, validity") + def binary2value(self, data): + overflow = data[0] + validity = data[1] + speed = bin2num(data[2:5]) / 10.0 + direction = data[5] * 22.5 + return (speed, direction, overflow, validity) + def str(self, value): + return "%.1f,%g,%s,%s" % value + def parse(self, s): + return tuple([float(x) for x in s.split(",")]) + +# +# For non-numerical values. +# +class TextConversion(Conversion): + constants = None + def __init__(self, constants): + items = list(constants.items())[:] + items.sort() + fullname = ",".join([c[1]+"="+str(c[0]) for c in items]) + ",unknown-X" + Conversion.__init__(self, "", 1, fullname) + self.constants = constants + def binary2value(self, data): + return data[0] + def value2binary(self, value): + return (value,) + def str(self, value): + result = self.constants.get(value, None) + if result != None: + return result + return "unknown-%d" % value + def parse(self, s): + result = [c[0] for c in self.constants.items() if c[1] == s] + if result: + return result[0] + return None + +# +# For values that are represented by one bit. +# +class ConversionBit(Conversion): + bit = None + desc = None + def __init__(self, bit, desc): + self.bit = bit + self.desc = desc + Conversion.__init__(self, "", 1, desc[0] + "=0," + desc[1] + "=1") + def binary2value(self, data): + return data[0] & (1 << self.bit) and 1 or 0 + def value2binary(self, value): + return (value << self.bit,) + def str(self, value): + return self.desc[value] + def parse(self, s): + return [c[0] for c in self.desc.items() if c[1] == s][0] + +class BitConversion(ConversionBit): + def __init__(self, bit, desc): + ConversionBit.__init__(self, bit, desc) + # + # Since Ws2300.write_safe() only writes nybbles and we have just one bit, + # we have to insert that bit into the data_read so it can be written as + # a nybble. + # + def write(self, data, nybble): + data = (nybble & ~(1 << self.bit) | data[0],) + return (data, data) + +class AlarmSetConversion(BitConversion): + bit = None + desc = None + def __init__(self, bit): + BitConversion.__init__(self, bit, {0:"off", 1:"on"}) + +class AlarmActiveConversion(BitConversion): + bit = None + desc = None + def __init__(self, bit): + BitConversion.__init__(self, bit, {0:"inactive", 1:"active"}) + +# +# For values that are represented by one bit, and must be written as +# a single bit. +# +class SetresetConversion(ConversionBit): + bit = None + def __init__(self, bit, desc): + ConversionBit.__init__(self, bit, desc) + # + # Setreset bits use a special write mode. + # + def write(self, data, nybble): + if data[0] == 0: + operation = Ws2300.UNSETBIT + else: + operation = Ws2300.SETBIT + return ((nybble & ~(1 << self.bit) | data[0],), [self.bit], operation) + +# +# Conversion for history. This kludge makes history fit into the framework +# used for all the other measures. +# +class HistoryConversion(Conversion): + class HistoryRecord(object): + temp_indoor = None + temp_outdoor = None + pressure_absolute = None + humidity_indoor = None + humidity_outdoor = None + rain = None + wind_speed = None + wind_direction = None + def __str__(self): + return "%4.1fc %2d%% %4.1fc %2d%% %6.1fhPa %6.1fmm %2dm/s %5g" % ( + self.temp_indoor, self.humidity_indoor, + self.temp_outdoor, self.humidity_outdoor, + self.pressure_absolute, self.rain, + self.wind_speed, self.wind_direction) + def parse(cls, s): + rec = cls() + toks = [tok.rstrip(string.ascii_letters + "%/") for tok in s.split()] + rec.temp_indoor = float(toks[0]) + rec.humidity_indoor = int(toks[1]) + rec.temp_outdoor = float(toks[2]) + rec.humidity_outdoor = int(toks[3]) + rec.pressure_absolute = float(toks[4]) + rec.rain = float(toks[5]) + rec.wind_speed = int(toks[6]) + rec.wind_direction = int((float(toks[7]) + 11.25) / 22.5) % 16 + return rec + parse = classmethod(parse) + def __init__(self): + Conversion.__init__(self, "", 19, "history") + def binary2value(self, data): + value = self.__class__.HistoryRecord() + n = bin2num(data[0:5]) + value.temp_indoor = (n % 1000) / 10.0 - 30 + value.temp_outdoor = (n - (n % 1000)) / 10000.0 - 30 + n = bin2num(data[5:10]) + value.pressure_absolute = (n % 10000) / 10.0 + if value.pressure_absolute < 500: + value.pressure_absolute += 1000 + value.humidity_indoor = (n - (n % 10000)) / 10000.0 + value.humidity_outdoor = bcd2num(data[10:12]) + value.rain = bin2num(data[12:15]) * 0.518 + value.wind_speed = bin2num(data[15:18]) + value.wind_direction = bin2num(data[18:19]) * 22.5 + return value + def value2binary(self, value): + result = () + n = int((value.temp_indoor + 30) * 10.0 + (value.temp_outdoor + 30) * 10000.0 + 0.5) + result = result + num2bin(n, 5) + n = value.pressure_absolute % 1000 + n = int(n * 10.0 + value.humidity_indoor * 10000.0 + 0.5) + result = result + num2bin(n, 5) + result = result + num2bcd(value.humidity_outdoor, 2) + result = result + num2bin(int((value.rain + 0.518/2) / 0.518), 3) + result = result + num2bin(value.wind_speed, 3) + result = result + num2bin(value.wind_direction, 1) + return result + # + # Print value. + # + def str(self, value): + return str(value) + # + # Convert the string produced by "str()" back to the value. + # + def parse(self, s): + return self.__class__.HistoryRecord.parse(s) + +# +# Various conversions we know about. +# +conv_ala0 = AlarmActiveConversion(0) +conv_ala1 = AlarmActiveConversion(1) +conv_ala2 = AlarmActiveConversion(2) +conv_ala3 = AlarmActiveConversion(3) +conv_als0 = AlarmSetConversion(0) +conv_als1 = AlarmSetConversion(1) +conv_als2 = AlarmSetConversion(2) +conv_als3 = AlarmSetConversion(3) +conv_buzz = SetresetConversion(3, {0:'on', 1:'off'}) +conv_lbck = SetresetConversion(0, {0:'off', 1:'on'}) +conv_date = DateConversion() +conv_dtme = DatetimeConversion() +conv_utme = UnixtimeConversion() +conv_hist = HistoryConversion() +conv_stmp = TimestampConversion() +conv_time = TimeConversion() +conv_wdir = WindDirectionConversion() +conv_wvel = WindVelocityConversion() +conv_conn = TextConversion({0:"cable", 3:"lost", 15:"wireless"}) +conv_fore = TextConversion({0:"rainy", 1:"cloudy", 2:"sunny"}) +conv_spdu = TextConversion({0:"m/s", 1:"knots", 2:"beaufort", 3:"km/h", 4:"mph"}) +conv_tend = TextConversion({0:"steady", 1:"rising", 2:"falling"}) +conv_wovr = TextConversion({0:"no", 1:"overflow"}) +conv_wvld = TextConversion({0:"ok", 1:"invalid", 2:"overflow"}) +conv_lcon = BinConversion("", 1, 0, "contrast") +conv_rec2 = BinConversion("", 2, 0, "record number") +conv_humi = BcdConversion("%", 2, 0, "humidity") +conv_pres = PressureConversion() +conv_rain = BcdConversion("mm", 6, 2, "rain") +conv_temp = BcdConversion("C", 4, 2, "temperature", -3000) +conv_per2 = BinConversion("s", 2, 1, "time interval", 5) +conv_per3 = BinConversion("min", 3, 0, "time interval") +conv_wspd = BinConversion("m/s", 3, 1, "speed") +conv_wind = WindConversion() + +# +# Define a measurement on the Ws2300. This encapsulates: +# - The names (abbrev and long) of the thing being measured, eg wind speed. +# - The location it can be found at in the Ws2300's memory map. +# - The Conversion used to represent the figure. +# +class Measure(object): + IDS = {} # map, Measures defined. {id: Measure, ...} + NAMES = {} # map, Measures defined. {name: Measure, ...} + address = None # int, Nybble address in the Ws2300 + conv = None # object, Type of value + id = None # string, Short name + name = None # string, Long name + reset = None # string, Id of measure used to reset this one + def __init__(self, address, id_, conv, name, reset=None): + self.address = address + self.conv = conv + self.reset = reset + if id_ != None: + self.id = id_ + assert not id_ in self.__class__.IDS + self.__class__.IDS[id_] = self + if name != None: + self.name = name + assert not name in self.__class__.NAMES + self.__class__.NAMES[name] = self + def __hash__(self): + return hash(self.id) + def __cmp__(self, other): + if isinstance(other, Measure): + return cmp(self.id, other.id) + return cmp(type(self), type(other)) + + +# +# Conversion for raw Hex data. These are created as needed. +# +class HexConversion(Conversion): + def __init__(self, nybble_count): + Conversion.__init__(self, "", nybble_count, "hex data") + def binary2value(self, data): + return data + def value2binary(self, value): + return value + def str(self, value): + return ",".join(["%x" % nybble for nybble in value]) + def parse(self, s): + toks = s.replace(","," ").split() + for i in range(len(toks)): + s = list(toks[i]) + s.reverse() + toks[i] = ''.join(s) + list_str = list(''.join(toks)) + self.nybble_count = len(list_str) + return tuple([int(nybble) for nybble in list_str]) + +# +# The raw nybble measure. +# +class HexMeasure(Measure): + def __init__(self, address, id_, conv, name): + self.address = address + self.name = name + self.conv = conv + +# +# A History record. Again a kludge to make history fit into the framework +# developed for the other measurements. History records are identified +# by their record number. Record number 0 is the most recently written +# record, record number 1 is the next most recently written and so on. +# +class HistoryMeasure(Measure): + HISTORY_BUFFER_ADDR = 0x6c6 # int, Address of the first history record + MAX_HISTORY_RECORDS = 0xaf # string, Max number of history records stored + LAST_POINTER = None # int, Pointer to last record + RECORD_COUNT = None # int, Number of records in use + recno = None # int, The record number this represents + conv = conv_hist + def __init__(self, recno): + self.recno = recno + def set_constants(cls, ws2300): + measures = [Measure.IDS["hp"], Measure.IDS["hn"]] + data = read_measurements(ws2300, measures) + cls.LAST_POINTER = int(measures[0].conv.binary2value(data[0])) + cls.RECORD_COUNT = int(measures[1].conv.binary2value(data[1])) + set_constants = classmethod(set_constants) + def id(self): + return "h%03d" % self.recno + id = property(id) + def name(self): + return "history record %d" % self.recno + name = property(name) + def offset(self): + if self.LAST_POINTER is None: + raise Exception("HistoryMeasure.set_constants hasn't been called") + return (self.LAST_POINTER - self.recno) % self.MAX_HISTORY_RECORDS + offset = property(offset) + def address(self): + return self.HISTORY_BUFFER_ADDR + self.conv.nybble_count * self.offset + address = property(address) + +# +# The measurements we know about. This is all of them documented in +# memory_map_2300.txt, bar the history. History is handled specially. +# And of course, the "c?"'s aren't real measures at all - its the current +# time on this machine. +# +Measure( -18, "ct", conv_time, "this computer's time") +Measure( -12, "cw", conv_utme, "this computer's date time") +Measure( -6, "cd", conv_date, "this computer's date") +Measure(0x006, "bz", conv_buzz, "buzzer") +Measure(0x00f, "wsu", conv_spdu, "wind speed units") +Measure(0x016, "lb", conv_lbck, "lcd backlight") +Measure(0x019, "sss", conv_als2, "storm warn alarm set") +Measure(0x019, "sts", conv_als0, "station time alarm set") +Measure(0x01a, "phs", conv_als3, "pressure max alarm set") +Measure(0x01a, "pls", conv_als2, "pressure min alarm set") +Measure(0x01b, "oths", conv_als3, "out temp max alarm set") +Measure(0x01b, "otls", conv_als2, "out temp min alarm set") +Measure(0x01b, "iths", conv_als1, "in temp max alarm set") +Measure(0x01b, "itls", conv_als0, "in temp min alarm set") +Measure(0x01c, "dphs", conv_als3, "dew point max alarm set") +Measure(0x01c, "dpls", conv_als2, "dew point min alarm set") +Measure(0x01c, "wchs", conv_als1, "wind chill max alarm set") +Measure(0x01c, "wcls", conv_als0, "wind chill min alarm set") +Measure(0x01d, "ihhs", conv_als3, "in humidity max alarm set") +Measure(0x01d, "ihls", conv_als2, "in humidity min alarm set") +Measure(0x01d, "ohhs", conv_als1, "out humidity max alarm set") +Measure(0x01d, "ohls", conv_als0, "out humidity min alarm set") +Measure(0x01e, "rhhs", conv_als1, "rain 1h alarm set") +Measure(0x01e, "rdhs", conv_als0, "rain 24h alarm set") +Measure(0x01f, "wds", conv_als2, "wind direction alarm set") +Measure(0x01f, "wshs", conv_als1, "wind speed max alarm set") +Measure(0x01f, "wsls", conv_als0, "wind speed min alarm set") +Measure(0x020, "siv", conv_ala2, "icon alarm active") +Measure(0x020, "stv", conv_ala0, "station time alarm active") +Measure(0x021, "phv", conv_ala3, "pressure max alarm active") +Measure(0x021, "plv", conv_ala2, "pressure min alarm active") +Measure(0x022, "othv", conv_ala3, "out temp max alarm active") +Measure(0x022, "otlv", conv_ala2, "out temp min alarm active") +Measure(0x022, "ithv", conv_ala1, "in temp max alarm active") +Measure(0x022, "itlv", conv_ala0, "in temp min alarm active") +Measure(0x023, "dphv", conv_ala3, "dew point max alarm active") +Measure(0x023, "dplv", conv_ala2, "dew point min alarm active") +Measure(0x023, "wchv", conv_ala1, "wind chill max alarm active") +Measure(0x023, "wclv", conv_ala0, "wind chill min alarm active") +Measure(0x024, "ihhv", conv_ala3, "in humidity max alarm active") +Measure(0x024, "ihlv", conv_ala2, "in humidity min alarm active") +Measure(0x024, "ohhv", conv_ala1, "out humidity max alarm active") +Measure(0x024, "ohlv", conv_ala0, "out humidity min alarm active") +Measure(0x025, "rhhv", conv_ala1, "rain 1h alarm active") +Measure(0x025, "rdhv", conv_ala0, "rain 24h alarm active") +Measure(0x026, "wdv", conv_ala2, "wind direction alarm active") +Measure(0x026, "wshv", conv_ala1, "wind speed max alarm active") +Measure(0x026, "wslv", conv_ala0, "wind speed min alarm active") +Measure(0x027, None, conv_ala3, "pressure max alarm active alias") +Measure(0x027, None, conv_ala2, "pressure min alarm active alias") +Measure(0x028, None, conv_ala3, "out temp max alarm active alias") +Measure(0x028, None, conv_ala2, "out temp min alarm active alias") +Measure(0x028, None, conv_ala1, "in temp max alarm active alias") +Measure(0x028, None, conv_ala0, "in temp min alarm active alias") +Measure(0x029, None, conv_ala3, "dew point max alarm active alias") +Measure(0x029, None, conv_ala2, "dew point min alarm active alias") +Measure(0x029, None, conv_ala1, "wind chill max alarm active alias") +Measure(0x029, None, conv_ala0, "wind chill min alarm active alias") +Measure(0x02a, None, conv_ala3, "in humidity max alarm active alias") +Measure(0x02a, None, conv_ala2, "in humidity min alarm active alias") +Measure(0x02a, None, conv_ala1, "out humidity max alarm active alias") +Measure(0x02a, None, conv_ala0, "out humidity min alarm active alias") +Measure(0x02b, None, conv_ala1, "rain 1h alarm active alias") +Measure(0x02b, None, conv_ala0, "rain 24h alarm active alias") +Measure(0x02c, None, conv_ala2, "wind direction alarm active alias") +Measure(0x02c, None, conv_ala2, "wind speed max alarm active alias") +Measure(0x02c, None, conv_ala2, "wind speed min alarm active alias") +Measure(0x200, "st", conv_time, "station set time", reset="ct") +Measure(0x23b, "sw", conv_dtme, "station current date time") +Measure(0x24d, "sd", conv_date, "station set date", reset="cd") +Measure(0x266, "lc", conv_lcon, "lcd contrast (ro)") +Measure(0x26b, "for", conv_fore, "forecast") +Measure(0x26c, "ten", conv_tend, "tendency") +Measure(0x346, "it", conv_temp, "in temp") +Measure(0x34b, "itl", conv_temp, "in temp min", reset="it") +Measure(0x350, "ith", conv_temp, "in temp max", reset="it") +Measure(0x354, "itlw", conv_stmp, "in temp min when", reset="sw") +Measure(0x35e, "ithw", conv_stmp, "in temp max when", reset="sw") +Measure(0x369, "itla", conv_temp, "in temp min alarm") +Measure(0x36e, "itha", conv_temp, "in temp max alarm") +Measure(0x373, "ot", conv_temp, "out temp") +Measure(0x378, "otl", conv_temp, "out temp min", reset="ot") +Measure(0x37d, "oth", conv_temp, "out temp max", reset="ot") +Measure(0x381, "otlw", conv_stmp, "out temp min when", reset="sw") +Measure(0x38b, "othw", conv_stmp, "out temp max when", reset="sw") +Measure(0x396, "otla", conv_temp, "out temp min alarm") +Measure(0x39b, "otha", conv_temp, "out temp max alarm") +Measure(0x3a0, "wc", conv_temp, "wind chill") +Measure(0x3a5, "wcl", conv_temp, "wind chill min", reset="wc") +Measure(0x3aa, "wch", conv_temp, "wind chill max", reset="wc") +Measure(0x3ae, "wclw", conv_stmp, "wind chill min when", reset="sw") +Measure(0x3b8, "wchw", conv_stmp, "wind chill max when", reset="sw") +Measure(0x3c3, "wcla", conv_temp, "wind chill min alarm") +Measure(0x3c8, "wcha", conv_temp, "wind chill max alarm") +Measure(0x3ce, "dp", conv_temp, "dew point") +Measure(0x3d3, "dpl", conv_temp, "dew point min", reset="dp") +Measure(0x3d8, "dph", conv_temp, "dew point max", reset="dp") +Measure(0x3dc, "dplw", conv_stmp, "dew point min when", reset="sw") +Measure(0x3e6, "dphw", conv_stmp, "dew point max when", reset="sw") +Measure(0x3f1, "dpla", conv_temp, "dew point min alarm") +Measure(0x3f6, "dpha", conv_temp, "dew point max alarm") +Measure(0x3fb, "ih", conv_humi, "in humidity") +Measure(0x3fd, "ihl", conv_humi, "in humidity min", reset="ih") +Measure(0x3ff, "ihh", conv_humi, "in humidity max", reset="ih") +Measure(0x401, "ihlw", conv_stmp, "in humidity min when", reset="sw") +Measure(0x40b, "ihhw", conv_stmp, "in humidity max when", reset="sw") +Measure(0x415, "ihla", conv_humi, "in humidity min alarm") +Measure(0x417, "ihha", conv_humi, "in humidity max alarm") +Measure(0x419, "oh", conv_humi, "out humidity") +Measure(0x41b, "ohl", conv_humi, "out humidity min", reset="oh") +Measure(0x41d, "ohh", conv_humi, "out humidity max", reset="oh") +Measure(0x41f, "ohlw", conv_stmp, "out humidity min when", reset="sw") +Measure(0x429, "ohhw", conv_stmp, "out humidity max when", reset="sw") +Measure(0x433, "ohla", conv_humi, "out humidity min alarm") +Measure(0x435, "ohha", conv_humi, "out humidity max alarm") +Measure(0x497, "rd", conv_rain, "rain 24h") +Measure(0x49d, "rdh", conv_rain, "rain 24h max", reset="rd") +Measure(0x4a3, "rdhw", conv_stmp, "rain 24h max when", reset="sw") +Measure(0x4ae, "rdha", conv_rain, "rain 24h max alarm") +Measure(0x4b4, "rh", conv_rain, "rain 1h") +Measure(0x4ba, "rhh", conv_rain, "rain 1h max", reset="rh") +Measure(0x4c0, "rhhw", conv_stmp, "rain 1h max when", reset="sw") +Measure(0x4cb, "rhha", conv_rain, "rain 1h max alarm") +Measure(0x4d2, "rt", conv_rain, "rain total", reset=0) +Measure(0x4d8, "rtrw", conv_stmp, "rain total reset when", reset="sw") +Measure(0x4ee, "wsl", conv_wspd, "wind speed min", reset="ws") +Measure(0x4f4, "wsh", conv_wspd, "wind speed max", reset="ws") +Measure(0x4f8, "wslw", conv_stmp, "wind speed min when", reset="sw") +Measure(0x502, "wshw", conv_stmp, "wind speed max when", reset="sw") +Measure(0x527, "wso", conv_wovr, "wind speed overflow") +Measure(0x528, "wsv", conv_wvld, "wind speed validity") +Measure(0x529, "wv", conv_wvel, "wind velocity") +Measure(0x529, "ws", conv_wspd, "wind speed") +Measure(0x52c, "w0", conv_wdir, "wind direction") +Measure(0x52d, "w1", conv_wdir, "wind direction 1") +Measure(0x52e, "w2", conv_wdir, "wind direction 2") +Measure(0x52f, "w3", conv_wdir, "wind direction 3") +Measure(0x530, "w4", conv_wdir, "wind direction 4") +Measure(0x531, "w5", conv_wdir, "wind direction 5") +Measure(0x533, "wsla", conv_wspd, "wind speed min alarm") +Measure(0x538, "wsha", conv_wspd, "wind speed max alarm") +Measure(0x54d, "cn", conv_conn, "connection type") +Measure(0x54f, "cc", conv_per2, "connection time till connect") +Measure(0x5d8, "pa", conv_pres, "pressure absolute") +Measure(0x5e2, "pr", conv_pres, "pressure relative") +Measure(0x5ec, "pc", conv_pres, "pressure correction") +Measure(0x5f6, "pal", conv_pres, "pressure absolute min", reset="pa") +Measure(0x600, "prl", conv_pres, "pressure relative min", reset="pr") +Measure(0x60a, "pah", conv_pres, "pressure absolute max", reset="pa") +Measure(0x614, "prh", conv_pres, "pressure relative max", reset="pr") +Measure(0x61e, "plw", conv_stmp, "pressure min when", reset="sw") +Measure(0x628, "phw", conv_stmp, "pressure max when", reset="sw") +Measure(0x63c, "pla", conv_pres, "pressure min alarm") +Measure(0x650, "pha", conv_pres, "pressure max alarm") +Measure(0x6b2, "hi", conv_per3, "history interval") +Measure(0x6b5, "hc", conv_per3, "history time till sample") +Measure(0x6b8, "hw", conv_stmp, "history last sample when") +Measure(0x6c2, "hp", conv_rec2, "history last record pointer",reset=0) +Measure(0x6c4, "hn", conv_rec2, "history number of records", reset=0) +# get all of the wind info in a single invocation +Measure(0x527, "wind", conv_wind, "wind") + +# +# Read the requests. +# +def read_measurements(ws2300, read_requests): + if not read_requests: + return [] + # + # Optimise what we have to read. + # + batches = [(m.address, m.conv.nybble_count) for m in read_requests] + batches.sort() + index = 1 + addr = {batches[0][0]: 0} + while index < len(batches): + same_sign = (batches[index-1][0] < 0) == (batches[index][0] < 0) + same_area = batches[index-1][0] + batches[index-1][1] + 6 >= batches[index][0] + if not same_sign or not same_area: + addr[batches[index][0]] = index + index += 1 + continue + addr[batches[index][0]] = index-1 + batches[index-1] = batches[index-1][0], batches[index][0] + batches[index][1] - batches[index-1][0] + del batches[index] + # + # Read the data. + # + nybbles = ws2300.read_batch(batches) + # + # Return the data read in the order it was requested. + # + results = [] + for measure in read_requests: + index = addr[measure.address] + offset = measure.address - batches[index][0] + results.append(nybbles[index][offset:offset+measure.conv.nybble_count]) + return results + + +class WS23xxConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WS23xx] + # This section is for the La Crosse WS-2300 series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., 'LaCrosse WS2317' or 'TFA Primus' + model = LaCrosse WS23xx + + # The driver to use: + driver = weewx.drivers.ws23xx +""" + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + port = self._prompt('port', '/dev/ttyUSB0') + return {'port': port} + + def modify_config(self, config_dict): + print(""" +Setting record_generation to software.""") + config_dict['StdArchive']['record_generation'] = 'software' + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/ws23xx.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--debug] [--help]""" + + port = DEFAULT_PORT + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='display diagnostic information while running') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected') + parser.add_option('--readings', dest='readings', action='store_true', + help='display sensor readings') + parser.add_option("--records", dest="records", type=int, metavar="N", + help="display N station records, oldest to newest") + parser.add_option('--help-measures', dest='hm', action='store_true', + help='display measure names') + parser.add_option('--measure', dest='measure', type=str, + metavar="MEASURE", help='display single measure') + + (options, args) = parser.parse_args() + + if options.version: + print("ws23xx driver version %s" % DRIVER_VERSION) + exit(1) + + if options.debug: + weewx.debug = 1 + + weeutil.logger.setup('ws23xx', {}) + + if options.port: + port = options.port + + with WS23xx(port) as s: + if options.readings: + data = s.get_raw_data(SENSOR_IDS) + print(data) + if options.records is not None: + for ts,record in s.gen_records(count=options.records): + print(ts,record) + if options.measure: + data = s.get_raw_data([options.measure]) + print(data) + if options.hm: + for m in Measure.IDS: + print("%s\t%s" % (m, Measure.IDS[m].name)) diff --git a/dist/weewx-4.10.1/bin/weewx/drivers/ws28xx.py b/dist/weewx-4.10.1/bin/weewx/drivers/ws28xx.py new file mode 100644 index 0000000..2db9ce0 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/drivers/ws28xx.py @@ -0,0 +1,4159 @@ +# Copyright 2013 Matthew Wall +# See the file LICENSE.txt for your full rights. +# +# Thanks to Eddie De Pieri for the first Python implementation for WS-28xx. +# Eddie did the difficult work of decompiling HeavyWeather then converting +# and reverse engineering into a functional Python implementation. Eddie's +# work was based on reverse engineering of HeavyWeather 2800 v 1.54 +# +# Thanks to Lucas Heijst for enumerating the console message types and for +# debugging the transceiver/console communication timing issues. + +"""Classes and functions for interfacing with WS-28xx weather stations. + +LaCrosse makes a number of stations in the 28xx series, including: + + WS-2810, WS-2810U-IT + WS-2811, WS-2811SAL-IT, WS-2811BRN-IT, WS-2811OAK-IT + WS-2812, WS-2812U-IT + WS-2813 + WS-2814, WS-2814U-IT + WS-2815, WS-2815U-IT + C86234 + +The station is also sold as the TFA Primus, TFA Opus, and TechnoLine. + +HeavyWeather is the software provided by LaCrosse. + +There are two versions of HeavyWeather for the WS-28xx series: 1.5.4 and 1.5.4b +Apparently there is a difference between TX59UN-1-IT and TX59U-IT models (this +identifier is printed on the thermo-hygro sensor). + + HeavyWeather Version Firmware Version Thermo-Hygro Model + 1.54 333 or 332 TX59UN-1-IT + 1.54b 288, 262, 222 TX59U-IT + +HeavyWeather provides the following weather station settings: + + time display: 12|24 hour + temperature display: C|F + air pressure display: inhg|hpa + wind speed display: m/s|knots|bft|km/h|mph + rain display: mm|inch + recording interval: 1m + keep weather station in hi-speed communication mode: true/false + +According to the HeavyWeatherPro User Manual (1.54, rev2), "Hi speed mode wears +down batteries on your display much faster, and similarly consumes more power +on the PC. We do not believe most users need to enable this setting. It was +provided at the request of users who prefer ultra-frequent uploads." + +The HeavyWeatherPro 'CurrentWeather' view is updated as data arrive from the +console. The console sends current weather data approximately every 13 +seconds. + +Historical data are updated less frequently - every 2 hours in the default +HeavyWeatherPro configuration. + +According to the User Manual, "The 2800 series weather station uses the +'original' wind chill calculation rather than the 2001 'North American' +formula because the original formula is international." + +Apparently the station console determines when data will be sent, and, once +paired, the transceiver is always listening. The station console sends a +broadcast on the hour. If the transceiver responds, the station console may +continue to broadcast data, depending on the transceiver response and the +timing of the transceiver response. + +According to the C86234 Operations Manual (Revision 7): + - Temperature and humidity data are sent to the console every 13 seconds. + - Wind data are sent to the temperature/humidity sensor every 17 seconds. + - Rain data are sent to the temperature/humidity sensor every 19 seconds. + - Air pressure is measured every 15 seconds. + +Each tip of the rain bucket is 0.26 mm of rain. + +The following information was obtained by logging messages from the ws28xx.py +driver in weewx and by capturing USB messages between Heavy Weather Pro for +ws2800 and the TFA Primus Weather Station via windows program USB sniffer +busdog64_v0.2.1. + +Pairing + +The transceiver must be paired with a console before it can receive data. Each +frame sent by the console includes the device identifier of the transceiver +with which it is paired. + +Synchronizing + +When the console and transceiver stop communicating, they can be synchronized +by one of the following methods: + +- Push the SET button on the console +- Wait till the next full hour when the console sends a clock message + +In each case a Request Time message is received by the transceiver from the +console. The 'Send Time to WS' message should be sent within ms (10 ms +typical). The transceiver should handle the 'Time SET' message then send a +'Time/Config written' message about 85 ms after the 'Send Time to WS' message. +When complete, the console and transceiver will have been synchronized. + +Timing + +Current Weather messages, History messages, getConfig/setConfig messages, and +setTime messages each have their own timing. Missed History messages - as a +result of bad timing - result in console and transceiver becoming out of synch. + +Current Weather + +The console periodically sends Current Weather messages, each with the latest +values from the sensors. The CommModeInterval determines how often the console +will send Current Weather messages. + +History + +The console records data periodically at an interval defined by the +HistoryInterval parameter. The factory default setting is 2 hours. +Each history record contains a timestamp. Timestamps use the time from the +console clock. The console can record up to 1797 history records. + +Reading 1795 history records took about 110 minutes on a raspberry pi, for +an average of 3.6 seconds per history record. + +Reading 1795 history records took 65 minutes on a synology ds209+ii, for +an average of 2.2 seconds per history record. + +Reading 1750 history records took 19 minutes using HeavyWeatherPro on a +Windows 7 64-bit laptop. + +Message Types + +The first byte of a message determines the message type. + +ID Type Length + +01 ? 0x0f (15) +d0 SetRX 0x15 (21) +d1 SetTX 0x15 (21) +d5 SetFrame 0x111 (273) +d6 GetFrame 0x111 (273) +d7 SetState 0x15 (21) +d8 SetPreamblePattern 0x15 (21) +d9 Execute 0x0f (15) +dc ReadConfigFlash< 0x15 (21) +dd ReadConfigFlash> 0x15 (21) +de GetState 0x0a (10) +f0 WriteReg 0x05 (5) + +In the following sections, some messages are decomposed using the following +structure: + + start position in message buffer + hi-lo data starts on first (hi) or second (lo) nibble + chars data length in characters (nibbles) + rem remark + name variable + +------------------------------------------------------------------------------- +1. 01 message (15 bytes) + +000: 01 15 00 0b 08 58 3f 53 00 00 00 00 ff 15 0b (detected via USB sniffer) +000: 01 15 00 57 01 92 3f 53 00 00 00 00 ff 15 0a (detected via USB sniffer) + +00: messageID +02-15: ?? + +------------------------------------------------------------------------------- +2. SetRX message (21 bytes) + +000: d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +020: 00 + +00: messageID +01-20: 00 + +------------------------------------------------------------------------------- +3. SetTX message (21 bytes) + +000: d1 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +020: 00 + +00: messageID +01-20: 00 + +------------------------------------------------------------------------------- +4. SetFrame message (273 bytes) + +Action: +00: rtGetHistory - Ask for History message +01: rtSetTime - Ask for Send Time to weather station message +02: rtSetConfig - Ask for Send Config to weather station message +03: rtGetConfig - Ask for Config message +05: rtGetCurrent - Ask for Current Weather message +c0: Send Time - Send Time to WS +40: Send Config - Send Config to WS + +000: d5 00 09 DevID 00 CfgCS cIntThisAdr xx xx xx rtGetHistory +000: d5 00 09 DevID 01 CfgCS cIntThisAdr xx xx xx rtReqSetTime +000: d5 00 09 f0 f0 02 CfgCS cIntThisAdr xx xx xx rtReqFirstConfig +000: d5 00 09 DevID 02 CfgCS cIntThisAdr xx xx xx rtReqSetConfig +000: d5 00 09 DevID 03 CfgCS cIntThisAdr xx xx xx rtGetConfig +000: d5 00 09 DevID 05 CfgCS cIntThisAdr xx xx xx rtGetCurrent +000: d5 00 0c DevID c0 CfgCS [TimeData . .. .. .. Send Time +000: d5 00 30 DevID 40 CfgCS [ConfigData .. .. .. Send Config + +All SetFrame messages: +00: messageID +01: 00 +02: Message Length (starting with next byte) +03-04: DeviceID [DevID] +05: Action +06-07: Config checksum [CfgCS] + +Additional bytes rtGetCurrent, rtGetHistory, rtSetTime messages: +08-09hi: ComInt [cINT] 1.5 bytes (high byte first) +09lo-11: ThisHistoryAddress [ThisAdr] 2.5 bytes (high byte first) + +Additional bytes Send Time message: +08: seconds +09: minutes +10: hours +11hi: DayOfWeek +11lo: day_lo (low byte) +12hi: month_lo (low byte) +12lo: day_hi (high byte) +13hi: (year-2000)_lo (low byte) +13lo: month_hi (high byte) +14lo: (year-2000)_hi (high byte) + +------------------------------------------------------------------------------- +5. GetFrame message + +Response type: +20: WS SetTime / SetConfig - Data written +40: GetConfig +60: Current Weather +80: Actual / Outstanding History +a1: Request First-Time Config +a2: Request SetConfig +a3: Request SetTime + +000: 00 00 06 DevID 20 64 CfgCS xx xx xx xx xx xx xx xx xx Time/Config written +000: 00 00 30 DevID 40 64 [ConfigData .. .. .. .. .. .. .. GetConfig +000: 00 00 d7 DevID 60 64 CfgCS [CurData .. .. .. .. .. .. Current Weather +000: 00 00 1e DevID 80 64 CfgCS 0LateAdr 0ThisAdr [HisData Outstanding History +000: 00 00 1e DevID 80 64 CfgCS 0LateAdr 0ThisAdr [HisData Actual History +000: 00 00 06 DevID a1 64 CfgCS xx xx xx xx xx xx xx xx xx Request FirstConfig +000: 00 00 06 DevID a2 64 CfgCS xx xx xx xx xx xx xx xx xx Request SetConfig +000: 00 00 06 DevID a3 64 CfgCS xx xx xx xx xx xx xx xx xx Request SetTime + +ReadConfig example: +000: 01 2e 40 5f 36 53 02 00 00 00 00 81 00 04 10 00 82 00 04 20 +020: 00 71 41 72 42 00 05 00 00 00 27 10 00 02 83 60 96 01 03 07 +040: 21 04 01 00 00 00 CfgCS + +WriteConfig example: +000: 01 2e 40 64 36 53 02 00 00 00 00 00 10 04 00 81 00 20 04 00 +020: 82 41 71 42 72 00 00 05 00 00 00 10 27 01 96 60 83 02 01 04 +040: 21 07 03 10 00 00 CfgCS + +00: messageID +01: 00 +02: Message Length (starting with next byte) +03-04: DeviceID [devID] +05hi: responseType +06: Quality (in steps of 5) + +Additional byte GetFrame messages except Request SetConfig and Request SetTime: +05lo: BatteryStat 8=WS bat low; 4=TMP bat low; 2=RAIN bat low; 1=WIND bat low + +Additional byte Request SetConfig and Request SetTime: +05lo: RequestID + +Additional bytes all GetFrame messages except ReadConfig and WriteConfig +07-08: Config checksum [CfgCS] + +Additional bytes Outstanding History: +09lo-11: LatestHistoryAddress [LateAdr] 2.5 bytes (Latest to sent) +12lo-14: ThisHistoryAddress [ThisAdr] 2.5 bytes (Outstanding) + +Additional bytes Actual History: +09lo-11: LatestHistoryAddress [ThisAdr] 2.5 bytes (LatestHistoryAddress is the) +12lo-14: ThisHistoryAddress [ThisAdr] 2.5 bytes (same as ThisHistoryAddress) + +Additional bytes ReadConfig and WriteConfig +43-45: ResetMinMaxFlags (Output only; not included in checksum calculation) +46-47: Config checksum [CfgCS] (CheckSum = sum of bytes (00-42) + 7) + +------------------------------------------------------------------------------- +6. SetState message + +000: d7 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +00: messageID +01-14: 00 + +------------------------------------------------------------------------------- +7. SetPreamblePattern message + +000: d8 aa 00 00 00 00 00 00 00 00 00 00 00 00 00 + +00: messageID +01: ?? +02-14: 00 + +------------------------------------------------------------------------------- +8. Execute message + +000: d9 05 00 00 00 00 00 00 00 00 00 00 00 00 00 + +00: messageID +01: ?? +02-14: 00 + +------------------------------------------------------------------------------- +9. ReadConfigFlash in - receive data + +000: dc 0a 01 f5 00 01 78 a0 01 02 0a 0c 0c 01 2e ff ff ff ff ff - freq correction +000: dc 0a 01 f9 01 02 0a 0c 0c 01 2e ff ff ff ff ff ff ff ff ff - transceiver data + +00: messageID +01: length +02-03: address + +Additional bytes frequency correction +05lo-07hi: frequency correction + +Additional bytes transceiver data +05-10: serial number +09-10: DeviceID [devID] + +------------------------------------------------------------------------------- +10. ReadConfigFlash out - ask for data + +000: dd 0a 01 f5 cc cc cc cc cc cc cc cc cc cc cc - Ask for freq correction +000: dd 0a 01 f9 cc cc cc cc cc cc cc cc cc cc cc - Ask for transceiver data + +00: messageID +01: length +02-03: address +04-14: cc + +------------------------------------------------------------------------------- +11. GetState message + +000: de 14 00 00 00 00 (between SetPreamblePattern and first de16 message) +000: de 15 00 00 00 00 Idle message +000: de 16 00 00 00 00 Normal message +000: de 0b 00 00 00 00 (detected via USB sniffer) + +00: messageID +01: stateID +02-05: 00 + +------------------------------------------------------------------------------- +12. Writereg message + +000: f0 08 01 00 00 - AX5051RegisterNames.IFMODE +000: f0 10 01 41 00 - AX5051RegisterNames.MODULATION +000: f0 11 01 07 00 - AX5051RegisterNames.ENCODING +... +000: f0 7b 01 88 00 - AX5051RegisterNames.TXRATEMID +000: f0 7c 01 23 00 - AX5051RegisterNames.TXRATELO +000: f0 7d 01 35 00 - AX5051RegisterNames.TXDRIVER + +00: messageID +01: register address +02: 01 +03: AX5051RegisterName +04: 00 + +------------------------------------------------------------------------------- +13. Current Weather message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality +4 hi 4 DeviceCS +6 hi 4 6 _AlarmRingingFlags +8 hi 1 _WeatherTendency +8 lo 1 _WeatherState +9 hi 1 not used +9 lo 10 _TempIndoorMinMax._Max._Time +14 lo 10 _TempIndoorMinMax._Min._Time +19 lo 5 _TempIndoorMinMax._Max._Value +22 hi 5 _TempIndoorMinMax._Min._Value +24 lo 5 _TempIndoor (C) +27 lo 10 _TempOutdoorMinMax._Max._Time +32 lo 10 _TempOutdoorMinMax._Min._Time +37 lo 5 _TempOutdoorMinMax._Max._Value +40 hi 5 _TempOutdoorMinMax._Min._Value +42 lo 5 _TempOutdoor (C) +45 hi 1 not used +45 lo 10 1 _WindchillMinMax._Max._Time +50 lo 10 2 _WindchillMinMax._Min._Time +55 lo 5 1 _WindchillMinMax._Max._Value +57 hi 5 1 _WindchillMinMax._Min._Value +60 lo 6 _Windchill (C) +63 hi 1 not used +63 lo 10 _DewpointMinMax._Max._Time +68 lo 10 _DewpointMinMax._Min._Time +73 lo 5 _DewpointMinMax._Max._Value +76 hi 5 _DewpointMinMax._Min._Value +78 lo 5 _Dewpoint (C) +81 hi 10 _HumidityIndoorMinMax._Max._Time +86 hi 10 _HumidityIndoorMinMax._Min._Time +91 hi 2 _HumidityIndoorMinMax._Max._Value +92 hi 2 _HumidityIndoorMinMax._Min._Value +93 hi 2 _HumidityIndoor (%) +94 hi 10 _HumidityOutdoorMinMax._Max._Time +99 hi 10 _HumidityOutdoorMinMax._Min._Time +104 hi 2 _HumidityOutdoorMinMax._Max._Value +105 hi 2 _HumidityOutdoorMinMax._Min._Value +106 hi 2 _HumidityOutdoor (%) +107 hi 10 3 _RainLastMonthMax._Time +112 hi 6 3 _RainLastMonthMax._Max._Value +115 hi 6 _RainLastMonth (mm) +118 hi 10 3 _RainLastWeekMax._Time +123 hi 6 3 _RainLastWeekMax._Max._Value +126 hi 6 _RainLastWeek (mm) +129 hi 10 _Rain24HMax._Time +134 hi 6 _Rain24HMax._Max._Value +137 hi 6 _Rain24H (mm) +140 hi 10 _Rain24HMax._Time +145 hi 6 _Rain24HMax._Max._Value +148 hi 6 _Rain24H (mm) +151 hi 1 not used +152 lo 10 _LastRainReset +158 lo 7 _RainTotal (mm) +160 hi 1 _WindDirection5 +160 lo 1 _WindDirection4 +161 hi 1 _WindDirection3 +161 lo 1 _WindDirection2 +162 hi 1 _WindDirection1 +162 lo 1 _WindDirection (0-15) +163 hi 18 unknown data +172 hi 6 _WindSpeed (km/h) +175 hi 1 _GustDirection5 +175 lo 1 _GustDirection4 +176 hi 1 _GustDirection3 +176 lo 1 _GustDirection2 +177 hi 1 _GustDirection1 +177 lo 1 _GustDirection (0-15) +178 hi 2 not used +179 hi 10 _GustMax._Max._Time +184 hi 6 _GustMax._Max._Value +187 hi 6 _Gust (km/h) +190 hi 10 4 _PressureRelative_MinMax._Max/Min._Time +195 hi 5 5 _PressureRelative_inHgMinMax._Max._Value +197 lo 5 5 _PressureRelative_hPaMinMax._Max._Value +200 hi 5 _PressureRelative_inHgMinMax._Max._Value +202 lo 5 _PressureRelative_hPaMinMax._Max._Value +205 hi 5 _PressureRelative_inHgMinMax._Min._Value +207 lo 5 _PressureRelative_hPaMinMax._Min._Value +210 hi 5 _PressureRelative_inHg +212 lo 5 _PressureRelative_hPa + +214 lo 430 end + +Remarks + 1 since factory reset + 2 since software reset + 3 not used? + 4 should be: _PressureRelative_MinMax._Max._Time + 5 should be: _PressureRelative_MinMax._Min._Time + 6 _AlarmRingingFlags (values in hex) + 80 00 = Hi Al Gust + 40 00 = Al WindDir + 20 00 = One or more WindDirs set + 10 00 = Hi Al Rain24H + 08 00 = Hi Al Outdoor Humidity + 04 00 = Lo Al Outdoor Humidity + 02 00 = Hi Al Indoor Humidity + 01 00 = Lo Al Indoor Humidity + 00 80 = Hi Al Outdoor Temp + 00 40 = Lo Al Outdoor Temp + 00 20 = Hi Al Indoor Temp + 00 10 = Lo Al Indoor Temp + 00 08 = Hi Al Pressure + 00 04 = Lo Al Pressure + 00 02 = not used + 00 01 = not used + +------------------------------------------------------------------------------- +14. History Message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality (%) +4 hi 4 DeviceCS +6 hi 6 LatestAddress +9 hi 6 ThisAddress +12 hi 1 not used +12 lo 3 Gust (m/s) +14 hi 1 WindDirection (0-15, also GustDirection) +14 lo 3 WindSpeed (m/s) +16 hi 3 RainCounterRaw (total in period in 0.1 inch) +17 lo 2 HumidityOutdoor (%) +18 lo 2 HumidityIndoor (%) +19 lo 5 PressureRelative (hPa) +22 hi 3 TempOutdoor (C) +23 lo 3 TempIndoor (C) +25 hi 10 Time + +29 lo 60 end + +------------------------------------------------------------------------------- +15. Set Config Message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality +4 hi 1 1 _WindspeedFormat +4 lo 0,25 2 _RainFormat +4 lo 0,25 3 _PressureFormat +4 lo 0,25 4 _TemperatureFormat +4 lo 0,25 5 _ClockMode +5 hi 1 _WeatherThreshold +5 lo 1 _StormThreshold +6 hi 1 _LowBatFlags +6 lo 1 6 _LCDContrast +7 hi 4 7 _WindDirAlarmFlags (reverse group 1) +9 hi 4 8 _OtherAlarmFlags (reverse group 1) +11 hi 10 _TempIndoorMinMax._Min._Value (reverse group 2) + _TempIndoorMinMax._Max._Value (reverse group 2) +16 hi 10 _TempOutdoorMinMax._Min._Value (reverse group 3) + _TempOutdoorMinMax._Max._Value (reverse group 3) +21 hi 2 _HumidityIndoorMinMax._Min._Value +22 hi 2 _HumidityIndoorMinMax._Max._Value +23 hi 2 _HumidityOutdoorMinMax._Min._Value +24 hi 2 _HumidityOutdoorMinMax._Max._Value +25 hi 1 not used +25 lo 7 _Rain24HMax._Max._Value (reverse bytes) +29 hi 2 _HistoryInterval +30 hi 1 not used +30 lo 5 _GustMax._Max._Value (reverse bytes) +33 hi 10 _PressureRelative_hPaMinMax._Min._Value (rev grp4) + _PressureRelative_inHgMinMax._Min._Value(rev grp4) +38 hi 10 _PressureRelative_hPaMinMax._Max._Value (rev grp5) + _PressureRelative_inHgMinMax._Max._Value(rev grp5) +43 hi 6 9 _ResetMinMaxFlags +46 hi 4 10 _InBufCS + +47 lo 96 end + +Remarks + 1 0=m/s 1=knots 2=bft 3=km/h 4=mph + 2 0=mm 1=inch + 3 0=inHg 2=hPa + 4 0=F 1=C + 5 0=24h 1=12h + 6 values 0-7 => LCD contrast 1-8 + 7 WindDir Alarms (not-reversed values in hex) + 80 00 = NNW + 40 00 = NW + 20 00 = WNW + 10 00 = W + 08 00 = WSW + 04 00 = SW + 02 00 = SSW + 01 00 = S + 00 80 = SSE + 00 40 = SE + 00 20 = ESE + 00 10 = E + 00 08 = ENE + 00 04 = NE + 00 02 = NNE + 00 01 = N + 8 Other Alarms (not-reversed values in hex) + 80 00 = Hi Al Gust + 40 00 = Al WindDir + 20 00 = One or more WindDirs set + 10 00 = Hi Al Rain24H + 08 00 = Hi Al Outdoor Humidity + 04 00 = Lo Al Outdoor Humidity + 02 00 = Hi Al Indoor Humidity + 01 00 = Lo Al Indoor Humidity + 00 80 = Hi Al Outdoor Temp + 00 40 = Lo Al Outdoor Temp + 00 20 = Hi Al Indoor Temp + 00 10 = Lo Al Indoor Temp + 00 08 = Hi Al Pressure + 00 04 = Lo Al Pressure + 00 02 = not used + 00 01 = not used + 9 ResetMinMaxFlags (not-reversed values in hex) + "Output only; not included in checksum calc" + 80 00 00 = Reset DewpointMax + 40 00 00 = Reset DewpointMin + 20 00 00 = not used + 10 00 00 = Reset WindchillMin* + "*Reset dateTime only; Min._Value is preserved" + 08 00 00 = Reset TempOutMax + 04 00 00 = Reset TempOutMin + 02 00 00 = Reset TempInMax + 01 00 00 = Reset TempInMin + 00 80 00 = Reset Gust + 00 40 00 = not used + 00 20 00 = not used + 00 10 00 = not used + 00 08 00 = Reset HumOutMax + 00 04 00 = Reset HumOutMin + 00 02 00 = Reset HumInMax + 00 01 00 = Reset HumInMin + 00 00 80 = not used + 00 00 40 = Reset Rain Total + 00 00 20 = Reset last month? + 00 00 10 = Reset lastweek? + 00 00 08 = Reset Rain24H + 00 00 04 = Reset Rain1H + 00 00 02 = Reset PresRelMax + 00 00 01 = Reset PresRelMin + 10 Checksum = sum bytes (0-42) + 7 + +------------------------------------------------------------------------------- +16. Get Config Message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality +4 hi 1 1 _WindspeedFormat +4 lo 0,25 2 _RainFormat +4 lo 0,25 3 _PressureFormat +4 lo 0,25 4 _TemperatureFormat +4 lo 0,25 5 _ClockMode +5 hi 1 _WeatherThreshold +5 lo 1 _StormThreshold +6 hi 1 _LowBatFlags +6 lo 1 6 _LCDContrast +7 hi 4 7 _WindDirAlarmFlags +9 hi 4 8 _OtherAlarmFlags +11 hi 5 _TempIndoorMinMax._Min._Value +13 lo 5 _TempIndoorMinMax._Max._Value +16 hi 5 _TempOutdoorMinMax._Min._Value +18 lo 5 _TempOutdoorMinMax._Max._Value +21 hi 2 _HumidityIndoorMinMax._Max._Value +22 hi 2 _HumidityIndoorMinMax._Min._Value +23 hi 2 _HumidityOutdoorMinMax._Max._Value +24 hi 2 _HumidityOutdoorMinMax._Min._Value +25 hi 1 not used +25 lo 7 _Rain24HMax._Max._Value +29 hi 2 _HistoryInterval +30 hi 5 _GustMax._Max._Value +32 lo 1 not used +33 hi 5 _PressureRelative_hPaMinMax._Min._Value +35 lo 5 _PressureRelative_inHgMinMax._Min._Value +38 hi 5 _PressureRelative_hPaMinMax._Max._Value +40 lo 5 _PressureRelative_inHgMinMax._Max._Value +43 hi 6 9 _ResetMinMaxFlags +46 hi 4 10 _InBufCS + +47 lo 96 end + +Remarks + 1 0=m/s 1=knots 2=bft 3=km/h 4=mph + 2 0=mm 1=inch + 3 0=inHg 2=hPa + 4 0=F 1=C + 5 0=24h 1=12h + 6 values 0-7 => LCD contrast 1-8 + 7 WindDir Alarms (values in hex) + 80 00 = NNW + 40 00 = NW + 20 00 = WNW + 10 00 = W + 08 00 = WSW + 04 00 = SW + 02 00 = SSW + 01 00 = S + 00 80 = SSE + 00 40 = SE + 00 20 = ESE + 00 10 = E + 00 08 = ENE + 00 04 = NE + 00 02 = NNE + 00 01 = N + 8 Other Alarms (values in hex) + 80 00 = Hi Al Gust + 40 00 = Al WindDir + 20 00 = One or more WindDirs set + 10 00 = Hi Al Rain24H + 08 00 = Hi Al Outdoor Humidity + 04 00 = Lo Al Outdoor Humidity + 02 00 = Hi Al Indoor Humidity + 01 00 = Lo Al Indoor Humidity + 00 80 = Hi Al Outdoor Temp + 00 40 = Lo Al Outdoor Temp + 00 20 = Hi Al Indoor Temp + 00 10 = Lo Al Indoor Temp + 00 08 = Hi Al Pressure + 00 04 = Lo Al Pressure + 00 02 = not used + 00 01 = not used + 9 ResetMinMaxFlags (values in hex) + "Output only; input = 00 00 00" + 10 Checksum = sum bytes (0-42) + 7 + + +------------------------------------------------------------------------------- +Examples of messages + +readCurrentWeather +Cur 000: 01 2e 60 5f 05 1b 00 00 12 01 30 62 21 54 41 30 62 40 75 36 +Cur 020: 59 00 60 70 06 35 00 01 30 62 31 61 21 30 62 30 55 95 92 00 +Cur 040: 53 10 05 37 00 01 30 62 01 90 81 30 62 40 90 66 38 00 49 00 +Cur 060: 05 37 00 01 30 62 21 53 01 30 62 22 31 75 51 11 50 40 05 13 +Cur 080: 80 13 06 22 21 40 13 06 23 19 37 67 52 59 13 06 23 06 09 13 +Cur 100: 06 23 16 19 91 65 86 00 00 00 00 00 00 00 00 00 00 00 00 00 +Cur 120: 00 00 00 00 00 00 00 00 00 13 06 23 09 59 00 06 19 00 00 51 +Cur 140: 13 06 22 20 43 00 01 54 00 00 00 01 30 62 21 51 00 00 38 70 +Cur 160: a7 cc 7b 50 09 01 01 00 00 00 00 00 00 fc 00 a7 cc 7b 14 13 +Cur 180: 06 23 14 06 0e a0 00 01 b0 00 13 06 23 06 34 03 00 91 01 92 +Cur 200: 03 00 91 01 92 02 97 41 00 74 03 00 91 01 92 + +WeatherState: Sunny(Good) WeatherTendency: Rising(Up) AlarmRingingFlags: 0000 +TempIndoor 23.500 Min:20.700 2013-06-24 07:53 Max:25.900 2013-06-22 15:44 +HumidityIndoor 59.000 Min:52.000 2013-06-23 19:37 Max:67.000 2013-06-22 21:40 +TempOutdoor 13.700 Min:13.100 2013-06-23 05:59 Max:19.200 2013-06-23 16:12 +HumidityOutdoor 86.000 Min:65.000 2013-06-23 16:19 Max:91.000 2013-06-23 06:09 +Windchill 13.700 Min: 9.000 2013-06-24 09:06 Max:23.800 2013-06-20 19:08 +Dewpoint 11.380 Min:10.400 2013-06-22 23:17 Max:15.111 2013-06-22 15:30 +WindSpeed 2.520 +Gust 4.320 Max:37.440 2013-06-23 14:06 +WindDirection WSW GustDirection WSW +WindDirection1 SSE GustDirection1 SSE +WindDirection2 W GustDirection2 W +WindDirection3 W GustDirection3 W +WindDirection4 SSE GustDirection4 SSE +WindDirection5 SW GustDirection5 SW +RainLastMonth 0.000 Max: 0.000 1900-01-01 00:00 +RainLastWeek 0.000 Max: 0.000 1900-01-01 00:00 +Rain24H 0.510 Max: 6.190 2013-06-23 09:59 +Rain1H 0.000 Max: 1.540 2013-06-22 20:43 +RainTotal 3.870 LastRainReset 2013-06-22 15:10 +PresRelhPa 1019.200 Min:1007.400 2013-06-23 06:34 Max:1019.200 2013-06-23 06:34 +PresRel_inHg 30.090 Min: 29.740 2013-06-23 06:34 Max: 30.090 2013-06-23 06:34 +Bytes with unknown meaning at 157-165: 50 09 01 01 00 00 00 00 00 + +------------------------------------------------------------------------------- +readHistory +His 000: 01 2e 80 5f 05 1b 00 7b 32 00 7b 32 00 0c 70 0a 00 08 65 91 +His 020: 01 92 53 76 35 13 06 24 09 10 + +Time 2013-06-24 09:10:00 +TempIndoor= 23.5 +HumidityIndoor= 59 +TempOutdoor= 13.7 +HumidityOutdoor= 86 +PressureRelative= 1019.2 +RainCounterRaw= 0.0 +WindDirection= SSE +WindSpeed= 1.0 +Gust= 1.2 + +------------------------------------------------------------------------------- +readConfig +In 000: 01 2e 40 5f 36 53 02 00 00 00 00 81 00 04 10 00 82 00 04 20 +In 020: 00 71 41 72 42 00 05 00 00 00 27 10 00 02 83 60 96 01 03 07 +In 040: 21 04 01 00 00 00 05 1b + +------------------------------------------------------------------------------- +writeConfig +Out 000: 01 2e 40 64 36 53 02 00 00 00 00 00 10 04 00 81 00 20 04 00 +Out 020: 82 41 71 42 72 00 00 05 00 00 00 10 27 01 96 60 83 02 01 04 +Out 040: 21 07 03 10 00 00 05 1b + +OutBufCS= 051b +ClockMode= 0 +TemperatureFormat= 1 +PressureFormat= 1 +RainFormat= 0 +WindspeedFormat= 3 +WeatherThreshold= 3 +StormThreshold= 5 +LCDContrast= 2 +LowBatFlags= 0 +WindDirAlarmFlags= 0000 +OtherAlarmFlags= 0000 +HistoryInterval= 0 +TempIndoor_Min= 1.0 +TempIndoor_Max= 41.0 +TempOutdoor_Min= 2.0 +TempOutdoor_Max= 42.0 +HumidityIndoor_Min= 41 +HumidityIndoor_Max= 71 +HumidityOutdoor_Min= 42 +HumidityOutdoor_Max= 72 +Rain24HMax= 50.0 +GustMax= 100.0 +PressureRel_hPa_Min= 960.1 +PressureRel_inHg_Min= 28.36 +PressureRel_hPa_Max= 1040.1 +PressureRel_inHg_Max= 30.72 +ResetMinMaxFlags= 100000 (Output only; Input always 00 00 00) + +------------------------------------------------------------------------------- +class EHistoryInterval: +Constant Value Message received at +hi01Min = 0 00:00, 00:01, 00:02, 00:03 ... 23:59 +hi05Min = 1 00:00, 00:05, 00:10, 00:15 ... 23:55 +hi10Min = 2 00:00, 00:10, 00:20, 00:30 ... 23:50 +hi15Min = 3 00:00, 00:15, 00:30, 00:45 ... 23:45 +hi20Min = 4 00:00, 00:20, 00:40, 01:00 ... 23:40 +hi30Min = 5 00:00, 00:30, 01:00, 01:30 ... 23:30 +hi60Min = 6 00:00, 01:00, 02:00, 03:00 ... 23:00 +hi02Std = 7 00:00, 02:00, 04:00, 06:00 ... 22:00 +hi04Std = 8 00:00, 04:00, 08:00, 12:00 ... 20:00 +hi06Std = 9 00:00, 06:00, 12:00, 18:00 +hi08Std = 0xA 00:00, 08:00, 16:00 +hi12Std = 0xB 00:00, 12:00 +hi24Std = 0xC 00:00 + +------------------------------------------------------------------------------- +WS SetTime - Send time to WS +Time 000: 01 2e c0 05 1b 19 14 12 40 62 30 01 +time sent: 2013-06-24 12:14:19 + +------------------------------------------------------------------------------- +ReadConfigFlash data + +Ask for frequency correction +rcfo 000: dd 0a 01 f5 cc cc cc cc cc cc cc cc cc cc cc + +readConfigFlash frequency correction +rcfi 000: dc 0a 01 f5 00 01 78 a0 01 02 0a 0c 0c 01 2e ff ff ff ff ff +frequency correction: 96416 (0x178a0) +adjusted frequency: 910574957 (3646456d) + +Ask for transceiver data +rcfo 000: dd 0a 01 f9 cc cc cc cc cc cc cc cc cc cc cc + +readConfigFlash serial number and DevID +rcfi 000: dc 0a 01 f9 01 02 0a 0c 0c 01 2e ff ff ff ff ff ff ff ff ff +transceiver ID: 302 (0x012e) +transceiver serial: 01021012120146 + +Program Logic + +The RF communication thread uses the following logic to communicate with the +weather station console: + +Step 1. Perform in a while loop getState commands until state 0xde16 + is received. + +Step 2. Perform a getFrame command to read the message data. + +Step 3. Handle the contents of the message. The type of message depends on + the response type: + + Response type (hex): + 20: WS SetTime / SetConfig - Data written + confirmation the setTime/setConfig setFrame message has been received + by the console + 40: GetConfig + save the contents of the configuration for later use (i.e. a setConfig + message with one ore more parameters changed) + 60: Current Weather + handle the weather data of the current weather message + 80: Actual / Outstanding History + ignore the data of the actual history record when there is no data gap; + handle the data of a (one) requested history record (note: in step 4 we + can decide to request another history record). + a1: Request First-Time Config + prepare a setFrame first time message + a2: Request SetConfig + prepare a setFrame setConfig message + a3: Request SetTime + prepare a setFrame setTime message + +Step 4. When you didn't receive the message in step 3 you asked for (see + step 5 how to request a certain type of message), decide if you want + to ignore or handle the received message. Then go to step 5 to + request for a certain type of message unless the received message + has response type a1, a2 or a3, then prepare first the setFrame + message the wireless console asked for. + +Step 5. Decide what kind of message you want to receive next time. The + request is done via a setFrame message (see step 6). It is + not guaranteed that you will receive that kind of message the next + time but setting the proper timing parameters of firstSleep and + nextSleep increase the chance you will get the requested type of + message. + +Step 6. The action parameter in the setFrame message sets the type of the + next to receive message. + + Action (hex): + 00: rtGetHistory - Ask for History message + setSleep(0.300,0.010) + 01: rtSetTime - Ask for Send Time to weather station message + setSleep(0.085,0.005) + 02: rtSetConfig - Ask for Send Config to weather station message + setSleep(0.300,0.010) + 03: rtGetConfig - Ask for Config message + setSleep(0.400,0.400) + 05: rtGetCurrent - Ask for Current Weather message + setSleep(0.300,0.010) + c0: Send Time - Send Time to WS + setSleep(0.085,0.005) + 40: Send Config - Send Config to WS + setSleep(0.085,0.005) + + Note: after the Request First-Time Config message (response type = 0xa1) + perform a rtGetConfig with setSleep(0.085,0.005) + +Step 7. Perform a setTX command + +Step 8. Go to step 1 to wait for state 0xde16 again. + +""" + +# TODO: how often is currdat.lst modified with/without hi-speed mode? +# TODO: thread locking around observation data +# TODO: eliminate polling, make MainThread get data as soon as RFThread updates +# TODO: get rid of Length/Buffer construct, replace with a Buffer class or obj + +# FIXME: the history retrieval assumes a constant archive interval across all +# history records. this means anything that modifies the archive +# interval should clear the history. + +from __future__ import absolute_import +from __future__ import print_function + +import logging +import sys +import threading +import time +import usb +from datetime import datetime + +import weeutil.logger +import weeutil.weeutil +import weewx.drivers +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WS28xx' +DRIVER_VERSION = '0.51' + + +def loader(config_dict, engine): + return WS28xxDriver(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): + return WS28xxConfigurator() + +def confeditor_loader(): + return WS28xxConfEditor() + + +# flags for enabling/disabling debug verbosity +DEBUG_COMM = 0 +DEBUG_CONFIG_DATA = 0 +DEBUG_WEATHER_DATA = 0 +DEBUG_HISTORY_DATA = 0 +DEBUG_DUMP_FORMAT = 'auto' + +def log_frame(n, buf): + log.debug('frame length is %d' % n) + strbuf = '' + for i in range(n): + strbuf += str('%02x ' % buf[i]) + if (i + 1) % 16 == 0: + log.debug(strbuf) + strbuf = '' + if strbuf: + log.debug(strbuf) + +def get_datum_diff(v, np, ofl): + if abs(np - v) < 0.001 or abs(ofl - v) < 0.001: + return None + return v + +def get_datum_match(v, np, ofl): + if np == v or ofl == v: + return None + return v + +def calc_checksum(buf, start, end=None): + if end is None: + end = len(buf[0]) - start + cs = 0 + for i in range(end): + cs += buf[0][i+start] + return cs + +def get_next_index(idx): + return get_index(idx + 1) + +def get_index(idx): + if idx < 0: + return idx + WS28xxDriver.max_records + elif idx >= WS28xxDriver.max_records: + return idx - WS28xxDriver.max_records + return idx + +def tstr_to_ts(tstr): + try: + return int(time.mktime(time.strptime(tstr, "%Y-%m-%d %H:%M:%S"))) + except (OverflowError, ValueError, TypeError): + pass + return None + +def bytes_to_addr(a, b, c): + return ((((a & 0xF) << 8) | b) << 8) | c + +def addr_to_index(addr): + return (addr - 416) // 18 + +def index_to_addr(idx): + return 18 * idx + 416 + +def print_dict(data): + for x in sorted(data.keys()): + if x == 'dateTime': + print('%s: %s' % (x, weeutil.weeutil.timestamp_to_string(data[x]))) + else: + print('%s: %s' % (x, data[x])) + + +class WS28xxConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WS28xx] + # This section is for the La Crosse WS-2800 series of weather stations. + + # Radio frequency to use between USB transceiver and console: US or EU + # US uses 915 MHz, EU uses 868.3 MHz. Default is US. + transceiver_frequency = US + + # The station model, e.g., 'LaCrosse C86234' or 'TFA Primus' + model = LaCrosse WS28xx + + # The driver to use: + driver = weewx.drivers.ws28xx +""" + + def prompt_for_settings(self): + print("Specify the frequency used between the station and the") + print("transceiver, either 'US' (915 MHz) or 'EU' (868.3 MHz).") + freq = self._prompt('frequency', 'US', ['US', 'EU']) + return {'transceiver_frequency': freq} + + +class WS28xxConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super(WS28xxConfigurator, self).add_options(parser) + parser.add_option("--check-transceiver", dest="check", + action="store_true", + help="check USB transceiver") + parser.add_option("--pair", dest="pair", action="store_true", + help="pair the USB transceiver with station console") + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--set-interval", dest="interval", + type=int, metavar="N", + help="set logging interval to N minutes") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N history records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display history records since N minutes ago") + parser.add_option("--maxtries", dest="maxtries", type=int, + help="maximum number of retries, 0 indicates no max") + + def do_options(self, options, parser, config_dict, prompt): + maxtries = 3 if options.maxtries is None else int(options.maxtries) + self.station = WS28xxDriver(**config_dict[DRIVER_NAME]) + if options.check: + self.check_transceiver(maxtries) + elif options.pair: + self.pair(maxtries) + elif options.interval is not None: + self.set_interval(maxtries, options.interval, prompt) + elif options.current: + self.show_current(maxtries) + elif options.nrecords is not None: + self.show_history(maxtries, count=options.nrecords) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(maxtries, ts=ts) + else: + self.show_info(maxtries) + self.station.closePort() + + def check_transceiver(self, maxtries): + """See if the transceiver is installed and operational.""" + print('Checking for transceiver...') + ntries = 0 + while ntries < maxtries: + ntries += 1 + if self.station.transceiver_is_present(): + print('Transceiver is present') + sn = self.station.get_transceiver_serial() + print('serial: %s' % sn) + tid = self.station.get_transceiver_id() + print('id: %d (0x%04x)' % (tid, tid)) + break + print('Not found (attempt %d of %d) ...' % (ntries, maxtries)) + time.sleep(5) + else: + print('Transceiver not responding.') + + def pair(self, maxtries): + """Pair the transceiver with the station console.""" + print('Pairing transceiver with console...') + maxwait = 90 # how long to wait between button presses, in seconds + ntries = 0 + while ntries < maxtries or maxtries == 0: + if self.station.transceiver_is_paired(): + print('Transceiver is paired to console') + break + ntries += 1 + msg = 'Press and hold the [v] key until "PC" appears' + if maxtries > 0: + msg += ' (attempt %d of %d)' % (ntries, maxtries) + else: + msg += ' (attempt %d)' % ntries + print(msg) + now = start_ts = int(time.time()) + while (now - start_ts < maxwait and + not self.station.transceiver_is_paired()): + time.sleep(5) + now = int(time.time()) + else: + print('Transceiver not paired to console.') + + def get_interval(self, maxtries): + cfg = self.get_config(maxtries) + if cfg is None: + return None + return getHistoryInterval(cfg['history_interval']) + + def get_config(self, maxtries): + start_ts = None + ntries = 0 + while ntries < maxtries or maxtries == 0: + cfg = self.station.get_config() + if cfg is not None: + return cfg + ntries += 1 + if start_ts is None: + start_ts = int(time.time()) + else: + dur = int(time.time()) - start_ts + print('No data after %d seconds (press SET to sync)' % dur) + time.sleep(30) + return None + + def set_interval(self, maxtries, interval, prompt): + """Set the station archive interval""" + print("This feature is not yet implemented") + + def show_info(self, maxtries): + """Query the station then display the settings.""" + print('Querying the station for the configuration...') + cfg = self.get_config(maxtries) + if cfg is not None: + print_dict(cfg) + + def show_current(self, maxtries): + """Get current weather observation.""" + print('Querying the station for current weather data...') + start_ts = None + ntries = 0 + while ntries < maxtries or maxtries == 0: + packet = self.station.get_observation() + if packet is not None: + print_dict(packet) + break + ntries += 1 + if start_ts is None: + start_ts = int(time.time()) + else: + dur = int(time.time()) - start_ts + print('No data after %d seconds (press SET to sync)' % dur) + time.sleep(30) + + def show_history(self, maxtries, ts=0, count=0): + """Display the indicated number of records or the records since the + specified timestamp (local time, in seconds)""" + print("Querying the station for historical records...") + ntries = 0 + last_n = nrem = None + last_ts = int(time.time()) + self.station.start_caching_history(since_ts=ts, num_rec=count) + while nrem is None or nrem > 0: + if ntries >= maxtries: + print('Giving up after %d tries' % ntries) + break + time.sleep(30) + ntries += 1 + now = int(time.time()) + n = self.station.get_num_history_scanned() + if n == last_n: + dur = now - last_ts + print('No data after %d seconds (press SET to sync)' % dur) + else: + ntries = 0 + last_ts = now + last_n = n + nrem = self.station.get_uncached_history_count() + ni = self.station.get_next_history_index() + li = self.station.get_latest_history_index() + msg = " scanned %s records: current=%s latest=%s remaining=%s\r" % (n, ni, li, nrem) + sys.stdout.write(msg) + sys.stdout.flush() + self.station.stop_caching_history() + records = self.station.get_history_cache_records() + self.station.clear_history_cache() + print() + print('Found %d records' % len(records)) + for r in records: + print(r) + + +class WS28xxDriver(weewx.drivers.AbstractDevice): + """Driver for LaCrosse WS28xx stations.""" + + max_records = 1797 + + def __init__(self, **stn_dict) : + """Initialize the station object. + + model: Which station model is this? + [Optional. Default is 'LaCrosse WS28xx'] + + transceiver_frequency: Frequency for transceiver-to-console. Specify + either US or EU. + [Required. Default is US] + + polling_interval: How often to sample the USB interface for data. + [Optional. Default is 30 seconds] + + comm_interval: Communications mode interval + [Optional. Default is 3] + + device_id: The USB device ID for the transceiver. If there are + multiple devices with the same vendor and product IDs on the bus, + each will have a unique device identifier. Use this identifier + to indicate which device should be used. + [Optional. Default is None] + + serial: The transceiver serial number. If there are multiple + devices with the same vendor and product IDs on the bus, each will + have a unique serial number. Use the serial number to indicate which + transceiver should be used. + [Optional. Default is None] + """ + + self.model = stn_dict.get('model', 'LaCrosse WS28xx') + self.polling_interval = int(stn_dict.get('polling_interval', 30)) + self.comm_interval = int(stn_dict.get('comm_interval', 3)) + self.frequency = stn_dict.get('transceiver_frequency', 'US') + self.device_id = stn_dict.get('device_id', None) + self.serial = stn_dict.get('serial', None) + + self.vendor_id = 0x6666 + self.product_id = 0x5555 + + now = int(time.time()) + self._service = None + self._last_rain = None + self._last_obs_ts = None + self._last_nodata_log_ts = now + self._nodata_interval = 300 # how often to check for no data + self._last_contact_log_ts = now + self._nocontact_interval = 300 # how often to check for no contact + self._log_interval = 600 # how often to log + + global DEBUG_COMM + DEBUG_COMM = int(stn_dict.get('debug_comm', 0)) + global DEBUG_CONFIG_DATA + DEBUG_CONFIG_DATA = int(stn_dict.get('debug_config_data', 0)) + global DEBUG_WEATHER_DATA + DEBUG_WEATHER_DATA = int(stn_dict.get('debug_weather_data', 0)) + global DEBUG_HISTORY_DATA + DEBUG_HISTORY_DATA = int(stn_dict.get('debug_history_data', 0)) + global DEBUG_DUMP_FORMAT + DEBUG_DUMP_FORMAT = stn_dict.get('debug_dump_format', 'auto') + + log.info('driver version is %s' % DRIVER_VERSION) + log.info('frequency is %s' % self.frequency) + + self.startUp() + time.sleep(10) # give the rf thread time to start up + + @property + def hardware_name(self): + return self.model + + # this is invoked by StdEngine as it shuts down + def closePort(self): + self.shutDown() + + def genLoopPackets(self): + """Generator function that continuously returns decoded packets.""" + while self._service.isRunning(): + now = int(time.time()+0.5) + packet = self.get_observation() + if packet is not None: + ts = packet['dateTime'] + if self._last_obs_ts is None or self._last_obs_ts != ts: + self._last_obs_ts = ts + self._last_nodata_log_ts = now + self._last_contact_log_ts = now + else: + packet = None + + # if no new weather data, log it + if (packet is None + and (self._last_obs_ts is None + or now - self._last_obs_ts > self._nodata_interval) + and (now - self._last_nodata_log_ts > self._log_interval)): + msg = 'no new weather data' + if self._last_obs_ts is not None: + msg += ' after %d seconds' % (now - self._last_obs_ts) + log.info(msg) + self._last_nodata_log_ts = now + + # if no contact with console for awhile, log it + ts = self.get_last_contact() + if (ts is None or now - ts > self._nocontact_interval + and now - self._last_contact_log_ts > self._log_interval): + msg = 'no contact with console' + if ts is not None: + msg += ' after %d seconds' % (now - ts) + msg += ': press [SET] to sync' + log.info(msg) + self._last_contact_log_ts = now + + if packet is not None: + yield packet + time.sleep(self.polling_interval) + else: + raise weewx.WeeWxIOError('RF thread is not running') + + def genStartupRecords(self, ts): + log.info('Scanning historical records') + maxtries = 65 + ntries = 0 + last_n = n = nrem = None + last_ts = now = int(time.time()) + self.start_caching_history(since_ts=ts) + while nrem is None or nrem > 0: + if ntries >= maxtries: + log.error('No historical data after %d tries' % ntries) + return + time.sleep(60) + ntries += 1 + now = int(time.time()) + n = self.get_num_history_scanned() + if n == last_n: + dur = now - last_ts + if self._service.isRunning(): + log.info('No data after %d seconds (press SET to sync)' % dur) + else: + log.info('No data after %d seconds: RF thread is not running' % dur) + break + else: + ntries = 0 + last_ts = now + last_n = n + nrem = self.get_uncached_history_count() + ni = self.get_next_history_index() + li = self.get_latest_history_index() + log.info("Scanned %s records: current=%s latest=%s remaining=%s" + % (n, ni, li, nrem)) + self.stop_caching_history() + records = self.get_history_cache_records() + self.clear_history_cache() + log.info('Found %d historical records' % len(records)) + last_ts = None + for r in records: + if last_ts is not None and r['dateTime'] is not None: + r['usUnits'] = weewx.METRIC + # Calculate interval in minutes, rounding to the nearest minute + r['interval'] = int((r['dateTime'] - last_ts) / 60 + 0.5) + yield r + last_ts = r['dateTime'] + +# FIXME: do not implement hardware record generation until we figure +# out how to query the historical records faster. +# def genArchiveRecords(self, since_ts): +# pass + +# FIXME: implement retries for this so that rf thread has time to get +# configuration data from the station +# @property +# def archive_interval(self): +# cfg = self.get_config() +# return getHistoryInterval(cfg['history_interval']) * 60 + +# FIXME: implement set/get time +# def setTime(self): +# pass +# def getTime(self): +# pass + + def startUp(self): + if self._service is not None: + return + self._service = CCommunicationService() + self._service.setup(self.frequency, + self.vendor_id, self.product_id, self.device_id, + self.serial, comm_interval=self.comm_interval) + self._service.startRFThread() + + def shutDown(self): + self._service.stopRFThread() + self._service.teardown() + self._service = None + + def transceiver_is_present(self): + return self._service.DataStore.getTransceiverPresent() + + def transceiver_is_paired(self): + return self._service.DataStore.getDeviceRegistered() + + def get_transceiver_serial(self): + return self._service.DataStore.getTransceiverSerNo() + + def get_transceiver_id(self): + return self._service.DataStore.getDeviceID() + + def get_last_contact(self): + return self._service.getLastStat().last_seen_ts + + def get_observation(self): + data = self._service.getWeatherData() + ts = data._timestamp + if ts is None: + return None + + # add elements required for weewx LOOP packets + packet = {} + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = ts + + # data from the station sensors + packet['inTemp'] = get_datum_diff(data._TempIndoor, + CWeatherTraits.TemperatureNP(), + CWeatherTraits.TemperatureOFL()) + packet['inHumidity'] = get_datum_diff(data._HumidityIndoor, + CWeatherTraits.HumidityNP(), + CWeatherTraits.HumidityOFL()) + packet['outTemp'] = get_datum_diff(data._TempOutdoor, + CWeatherTraits.TemperatureNP(), + CWeatherTraits.TemperatureOFL()) + packet['outHumidity'] = get_datum_diff(data._HumidityOutdoor, + CWeatherTraits.HumidityNP(), + CWeatherTraits.HumidityOFL()) + packet['pressure'] = get_datum_diff(data._PressureRelative_hPa, + CWeatherTraits.PressureNP(), + CWeatherTraits.PressureOFL()) + packet['windSpeed'] = get_datum_diff(data._WindSpeed, + CWeatherTraits.WindNP(), + CWeatherTraits.WindOFL()) + packet['windGust'] = get_datum_diff(data._Gust, + CWeatherTraits.WindNP(), + CWeatherTraits.WindOFL()) + + packet['windDir'] = getWindDir(data._WindDirection, + packet['windSpeed']) + packet['windGustDir'] = getWindDir(data._GustDirection, + packet['windGust']) + + # calculated elements not directly reported by station + packet['rainRate'] = get_datum_match(data._Rain1H, + CWeatherTraits.RainNP(), + CWeatherTraits.RainOFL()) + if packet['rainRate'] is not None: + packet['rainRate'] /= 10.0 # weewx wants cm/hr + rain_total = get_datum_match(data._RainTotal, + CWeatherTraits.RainNP(), + CWeatherTraits.RainOFL()) + delta = weewx.wxformulas.calculate_rain(rain_total, self._last_rain) + self._last_rain = rain_total + packet['rain'] = delta + if packet['rain'] is not None: + packet['rain'] /= 10.0 # weewx wants cm + + # track the signal strength and battery levels + laststat = self._service.getLastStat() + packet['rxCheckPercent'] = laststat.LastLinkQuality + packet['windBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'wind') + packet['rainBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'rain') + packet['outTempBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'th') + packet['inTempBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'console') + + return packet + + def get_config(self): + log.debug('get station configuration') + cfg = self._service.getConfigData().asDict() + cs = cfg.get('checksum_out') + if cs is None or cs == 0: + return None + return cfg + + def start_caching_history(self, since_ts=0, num_rec=0): + self._service.startCachingHistory(since_ts, num_rec) + + def stop_caching_history(self): + self._service.stopCachingHistory() + + def get_uncached_history_count(self): + return self._service.getUncachedHistoryCount() + + def get_next_history_index(self): + return self._service.getNextHistoryIndex() + + def get_latest_history_index(self): + return self._service.getLatestHistoryIndex() + + def get_num_history_scanned(self): + return self._service.getNumHistoryScanned() + + def get_history_cache_records(self): + return self._service.getHistoryCacheRecords() + + def clear_history_cache(self): + self._service.clearHistoryCache() + + def set_interval(self, interval): + # FIXME: set the archive interval + pass + +# The following classes and methods are adapted from the implementation by +# eddie de pieri, which is in turn based on the HeavyWeather implementation. + +class BadResponse(Exception): + """raised when unexpected data found in frame buffer""" + pass + +class DataWritten(Exception): + """raised when message 'data written' in frame buffer""" + pass + +class BitHandling: + # return a nonzero result, 2**offset, if the bit at 'offset' is one. + @staticmethod + def testBit(int_type, offset): + mask = 1 << offset + return int_type & mask + + # return an integer with the bit at 'offset' set to 1. + @staticmethod + def setBit(int_type, offset): + mask = 1 << offset + return int_type | mask + + # return an integer with the bit at 'offset' set to 1. + @staticmethod + def setBitVal(int_type, offset, val): + mask = val << offset + return int_type | mask + + # return an integer with the bit at 'offset' cleared. + @staticmethod + def clearBit(int_type, offset): + mask = ~(1 << offset) + return int_type & mask + + # return an integer with the bit at 'offset' inverted, 0->1 and 1->0. + @staticmethod + def toggleBit(int_type, offset): + mask = 1 << offset + return int_type ^ mask + +class EHistoryInterval: + hi01Min = 0 + hi05Min = 1 + hi10Min = 2 + hi15Min = 3 + hi20Min = 4 + hi30Min = 5 + hi60Min = 6 + hi02Std = 7 + hi04Std = 8 + hi06Std = 9 + hi08Std = 0xA + hi12Std = 0xB + hi24Std = 0xC + +class EWindspeedFormat: + wfMs = 0 + wfKnots = 1 + wfBFT = 2 + wfKmh = 3 + wfMph = 4 + +class ERainFormat: + rfMm = 0 + rfInch = 1 + +class EPressureFormat: + pfinHg = 0 + pfHPa = 1 + +class ETemperatureFormat: + tfFahrenheit = 0 + tfCelsius = 1 + +class EClockMode: + ct24H = 0 + ctAmPm = 1 + +class EWeatherTendency: + TREND_NEUTRAL = 0 + TREND_UP = 1 + TREND_DOWN = 2 + TREND_ERR = 3 + +class EWeatherState: + WEATHER_BAD = 0 + WEATHER_NEUTRAL = 1 + WEATHER_GOOD = 2 + WEATHER_ERR = 3 + +class EWindDirection: + wdN = 0 + wdNNE = 1 + wdNE = 2 + wdENE = 3 + wdE = 4 + wdESE = 5 + wdSE = 6 + wdSSE = 7 + wdS = 8 + wdSSW = 9 + wdSW = 0x0A + wdWSW = 0x0B + wdW = 0x0C + wdWNW = 0x0D + wdNW = 0x0E + wdNNW = 0x0F + wdERR = 0x10 + wdInvalid = 0x11 + wdNone = 0x12 + +def getWindDir(wdir, wspeed): + if wspeed is None or wspeed == 0: + return None + if wdir < 0 or wdir >= 16: + return None + return wdir * 360.0 / 16 + +class EResetMinMaxFlags: + rmTempIndoorHi = 0 + rmTempIndoorLo = 1 + rmTempOutdoorHi = 2 + rmTempOutdoorLo = 3 + rmWindchillHi = 4 + rmWindchillLo = 5 + rmDewpointHi = 6 + rmDewpointLo = 7 + rmHumidityIndoorLo = 8 + rmHumidityIndoorHi = 9 + rmHumidityOutdoorLo = 0x0A + rmHumidityOutdoorHi = 0x0B + rmWindspeedHi = 0x0C + rmWindspeedLo = 0x0D + rmGustHi = 0x0E + rmGustLo = 0x0F + rmPressureLo = 0x10 + rmPressureHi = 0x11 + rmRain1hHi = 0x12 + rmRain24hHi = 0x13 + rmRainLastWeekHi = 0x14 + rmRainLastMonthHi = 0x15 + rmRainTotal = 0x16 + rmInvalid = 0x17 + +class ERequestType: + rtGetCurrent = 0 + rtGetHistory = 1 + rtGetConfig = 2 + rtSetConfig = 3 + rtSetTime = 4 + rtFirstConfig = 5 + rtINVALID = 6 + +class EAction: + aGetHistory = 0 + aReqSetTime = 1 + aReqSetConfig = 2 + aGetConfig = 3 + aGetCurrent = 5 + aSendTime = 0xc0 + aSendConfig = 0x40 + +class ERequestState: + rsQueued = 0 + rsRunning = 1 + rsFinished = 2 + rsPreamble = 3 + rsWaitDevice = 4 + rsWaitConfig = 5 + rsError = 6 + rsChanged = 7 + rsINVALID = 8 + +class EResponseType: + rtDataWritten = 0x20 + rtGetConfig = 0x40 + rtGetCurrentWeather = 0x60 + rtGetHistory = 0x80 + rtRequest = 0xa0 + rtReqFirstConfig = 0xa1 + rtReqSetConfig = 0xa2 + rtReqSetTime = 0xa3 + +# frequency standards and their associated transmission frequencies +class EFrequency: + fsUS = 'US' + tfUS = 905000000 + fsEU = 'EU' + tfEU = 868300000 + +def getFrequency(standard): + if standard == EFrequency.fsUS: + return EFrequency.tfUS + elif standard == EFrequency.fsEU: + return EFrequency.tfEU + log.error("unknown frequency standard '%s', using US" % standard) + return EFrequency.tfUS + +def getFrequencyStandard(frequency): + if frequency == EFrequency.tfUS: + return EFrequency.fsUS + elif frequency == EFrequency.tfEU: + return EFrequency.fsEU + log.error("unknown frequency '%s', using US" % frequency) + return EFrequency.fsUS + +# bit value battery_flag +# 0 1 thermo/hygro +# 1 2 rain +# 2 4 wind +# 3 8 console + +batterybits = {'th':0, 'rain':1, 'wind':2, 'console':3} + +def getBatteryStatus(status, flag): + """Return 1 if bit is set, 0 otherwise""" + bit = batterybits.get(flag) + if bit is None: + return None + if BitHandling.testBit(status, bit): + return 1 + return 0 + +history_intervals = { + EHistoryInterval.hi01Min: 1, + EHistoryInterval.hi05Min: 5, + EHistoryInterval.hi10Min: 10, + EHistoryInterval.hi20Min: 20, + EHistoryInterval.hi30Min: 30, + EHistoryInterval.hi60Min: 60, + EHistoryInterval.hi02Std: 120, + EHistoryInterval.hi04Std: 240, + EHistoryInterval.hi06Std: 360, + EHistoryInterval.hi08Std: 480, + EHistoryInterval.hi12Std: 720, + EHistoryInterval.hi24Std: 1440, + } + +def getHistoryInterval(i): + return history_intervals.get(i) + +# NP - not present +# OFL - outside factory limits +class CWeatherTraits(object): + windDirMap = { + 0: "N", 1: "NNE", 2: "NE", 3: "ENE", 4: "E", 5: "ESE", 6: "SE", + 7: "SSE", 8: "S", 9: "SSW", 10: "SW", 11: "WSW", 12: "W", + 13: "WNW", 14: "NW", 15: "NWN", 16: "err", 17: "inv", 18: "None" } + forecastMap = { + 0: "Rainy(Bad)", 1: "Cloudy(Neutral)", 2: "Sunny(Good)", 3: "Error" } + trendMap = { + 0: "Stable(Neutral)", 1: "Rising(Up)", 2: "Falling(Down)", 3: "Error" } + + @staticmethod + def TemperatureNP(): + return 81.099998 + + @staticmethod + def TemperatureOFL(): + return 136.0 + + @staticmethod + def PressureNP(): + return 10101010.0 + + @staticmethod + def PressureOFL(): + return 16666.5 + + @staticmethod + def HumidityNP(): + return 110.0 + + @staticmethod + def HumidityOFL(): + return 121.0 + + @staticmethod + def RainNP(): + return -0.2 + + @staticmethod + def RainOFL(): + return 16666.664 + + @staticmethod + def WindNP(): + return 183.6 # km/h = 51.0 m/s + + @staticmethod + def WindOFL(): + return 183.96 # km/h = 51.099998 m/s + + @staticmethod + def TemperatureOffset(): + return 40.0 + +class CMeasurement: + _Value = 0.0 + _ResetFlag = 23 + _IsError = 1 + _IsOverflow = 1 + _Time = None + + def Reset(self): + self._Value = 0.0 + self._ResetFlag = 23 + self._IsError = 1 + self._IsOverflow = 1 + +class CMinMaxMeasurement(object): + def __init__(self): + self._Min = CMeasurement() + self._Max = CMeasurement() + +# firmware XXX has bogus date values for these fields +_bad_labels = ['RainLastMonthMax','RainLastWeekMax','PressureRelativeMin'] + +class USBHardware(object): + @staticmethod + def isOFL2(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) == 15 \ + or (buf[0][start+0] & 0xF) == 15 + else: + result = (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 + return result + + @staticmethod + def isOFL3(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) == 15 \ + or (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 + else: + result = (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 \ + or (buf[0][start+1] & 0xF) == 15 + return result + + @staticmethod + def isOFL5(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) == 15 \ + or (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 \ + or (buf[0][start+1] & 0xF) == 15 \ + or (buf[0][start+2] >> 4) == 15 + else: + result = (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 \ + or (buf[0][start+1] & 0xF) == 15 \ + or (buf[0][start+2] >> 4) == 15 \ + or (buf[0][start+2] & 0xF) == 15 + return result + + @staticmethod + def isErr2(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) >= 10 \ + and (buf[0][start+0] >> 4) != 15 \ + or (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 + else: + result = (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 + return result + + @staticmethod + def isErr3(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) >= 10 \ + and (buf[0][start+0] >> 4) != 15 \ + or (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 + else: + result = (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 \ + or (buf[0][start+1] & 0xF) >= 10 \ + and (buf[0][start+1] & 0xF) != 15 + return result + + @staticmethod + def isErr5(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) >= 10 \ + and (buf[0][start+0] >> 4) != 15 \ + or (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 \ + or (buf[0][start+1] & 0xF) >= 10 \ + and (buf[0][start+1] & 0xF) != 15 \ + or (buf[0][start+2] >> 4) >= 10 \ + and (buf[0][start+2] >> 4) != 15 + else: + result = (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 \ + or (buf[0][start+1] & 0xF) >= 10 \ + and (buf[0][start+1] & 0xF) != 15 \ + or (buf[0][start+2] >> 4) >= 10 \ + and (buf[0][start+2] >> 4) != 15 \ + or (buf[0][start+2] & 0xF) >= 10 \ + and (buf[0][start+2] & 0xF) != 15 + return result + + @staticmethod + def reverseByteOrder(buf, start, Count): + nbuf=buf[0] + for i in range(Count >> 1): + tmp = nbuf[start + i] + nbuf[start + i] = nbuf[start + Count - i - 1] + nbuf[start + Count - i - 1 ] = tmp + buf[0]=nbuf + + @staticmethod + def readWindDirectionShared(buf, start): + return (buf[0][0+start] & 0xF, buf[0][start] >> 4) + + @staticmethod + def toInt_2(buf, start, StartOnHiNibble): + """read 2 nibbles""" + if StartOnHiNibble: + rawpre = (buf[0][start+0] >> 4)* 10 \ + + (buf[0][start+0] & 0xF)* 1 + else: + rawpre = (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 + return rawpre + + @staticmethod + def toRain_7_3(buf, start, StartOnHiNibble): + """read 7 nibbles, presentation with 3 decimals; units of mm""" + if (USBHardware.isErr2(buf, start+0, StartOnHiNibble) or + USBHardware.isErr5(buf, start+1, StartOnHiNibble)): + result = CWeatherTraits.RainNP() + elif (USBHardware.isOFL2(buf, start+0, StartOnHiNibble) or + USBHardware.isOFL5(buf, start+1, StartOnHiNibble)): + result = CWeatherTraits.RainOFL() + elif StartOnHiNibble: + result = (buf[0][start+0] >> 4)* 1000 \ + + (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 \ + + (buf[0][start+2] & 0xF)* 0.01 \ + + (buf[0][start+3] >> 4)* 0.001 + else: + result = (buf[0][start+0] & 0xF)* 1000 \ + + (buf[0][start+1] >> 4)* 100 \ + + (buf[0][start+1] & 0xF)* 10 \ + + (buf[0][start+2] >> 4)* 1 \ + + (buf[0][start+2] & 0xF)* 0.1 \ + + (buf[0][start+3] >> 4)* 0.01 \ + + (buf[0][start+3] & 0xF)* 0.001 + return result + + @staticmethod + def toRain_6_2(buf, start, StartOnHiNibble): + '''read 6 nibbles, presentation with 2 decimals; units of mm''' + if (USBHardware.isErr2(buf, start+0, StartOnHiNibble) or + USBHardware.isErr2(buf, start+1, StartOnHiNibble) or + USBHardware.isErr2(buf, start+2, StartOnHiNibble) ): + result = CWeatherTraits.RainNP() + elif (USBHardware.isOFL2(buf, start+0, StartOnHiNibble) or + USBHardware.isOFL2(buf, start+1, StartOnHiNibble) or + USBHardware.isOFL2(buf, start+2, StartOnHiNibble)): + result = CWeatherTraits.RainOFL() + elif StartOnHiNibble: + result = (buf[0][start+0] >> 4)* 1000 \ + + (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 \ + + (buf[0][start+2] & 0xF)* 0.01 + else: + result = (buf[0][start+0] & 0xF)* 1000 \ + + (buf[0][start+1] >> 4)* 100 \ + + (buf[0][start+1] & 0xF)* 10 \ + + (buf[0][start+2] >> 4)* 1 \ + + (buf[0][start+2] & 0xF)* 0.1 \ + + (buf[0][start+3] >> 4)* 0.01 + return result + + @staticmethod + def toRain_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal; units of 0.1 inch""" + if StartOnHiNibble: + hibyte = buf[0][start+0] + lobyte = (buf[0][start+1] >> 4) & 0xF + else: + hibyte = 16*(buf[0][start+0] & 0xF) + ((buf[0][start+1] >> 4) & 0xF) + lobyte = buf[0][start+1] & 0xF + if hibyte == 0xFF and lobyte == 0xE : + result = CWeatherTraits.RainNP() + elif hibyte == 0xFF and lobyte == 0xF : + result = CWeatherTraits.RainOFL() + else: + val = USBHardware.toFloat_3_1(buf, start, StartOnHiNibble) # 0.1 inch + result = val * 2.54 # mm + return result + + @staticmethod + def toFloat_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal""" + if StartOnHiNibble: + result = (buf[0][start+0] >> 4)*16**2 \ + + (buf[0][start+0] & 0xF)* 16**1 \ + + (buf[0][start+1] >> 4)* 16**0 + else: + result = (buf[0][start+0] & 0xF)*16**2 \ + + (buf[0][start+1] >> 4)* 16**1 \ + + (buf[0][start+1] & 0xF)* 16**0 + result = result / 10.0 + return result + + @staticmethod + def toDateTime(buf, start, StartOnHiNibble, label): + """read 10 nibbles, presentation as DateTime""" + result = None + if (USBHardware.isErr2(buf, start+0, StartOnHiNibble) + or USBHardware.isErr2(buf, start+1, StartOnHiNibble) + or USBHardware.isErr2(buf, start+2, StartOnHiNibble) + or USBHardware.isErr2(buf, start+3, StartOnHiNibble) + or USBHardware.isErr2(buf, start+4, StartOnHiNibble)): + log.error('ToDateTime: bogus date for %s: error status in buffer' + % label) + else: + year = USBHardware.toInt_2(buf, start+0, StartOnHiNibble) + 2000 + month = USBHardware.toInt_2(buf, start+1, StartOnHiNibble) + days = USBHardware.toInt_2(buf, start+2, StartOnHiNibble) + hours = USBHardware.toInt_2(buf, start+3, StartOnHiNibble) + minutes = USBHardware.toInt_2(buf, start+4, StartOnHiNibble) + try: + result = datetime(year, month, days, hours, minutes) + except ValueError: + if label not in _bad_labels: + log.error('ToDateTime: bogus date for %s:' + ' bad date conversion from' + ' %s %s %s %s %s' + % (label, minutes, hours, days, month, year)) + if result is None: + # FIXME: use None instead of a really old date to indicate invalid + result = datetime(1900, 0o1, 0o1, 00, 00) + return result + + @staticmethod + def toHumidity_2_0(buf, start, StartOnHiNibble): + """read 2 nibbles, presentation with 0 decimal""" + if USBHardware.isErr2(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.HumidityNP() + elif USBHardware.isOFL2(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.HumidityOFL() + else: + result = USBHardware.toInt_2(buf, start, StartOnHiNibble) + return result + + @staticmethod + def toTemperature_5_3(buf, start, StartOnHiNibble): + """read 5 nibbles, presentation with 3 decimals; units of degree C""" + if USBHardware.isErr5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureNP() + elif USBHardware.isOFL5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureOFL() + else: + if StartOnHiNibble: + rawtemp = (buf[0][start+0] >> 4)* 10 \ + + (buf[0][start+0] & 0xF)* 1 \ + + (buf[0][start+1] >> 4)* 0.1 \ + + (buf[0][start+1] & 0xF)* 0.01 \ + + (buf[0][start+2] >> 4)* 0.001 + else: + rawtemp = (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 \ + + (buf[0][start+1] & 0xF)* 0.1 \ + + (buf[0][start+2] >> 4)* 0.01 \ + + (buf[0][start+2] & 0xF)* 0.001 + result = rawtemp - CWeatherTraits.TemperatureOffset() + return result + + @staticmethod + def toTemperature_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal; units of degree C""" + if USBHardware.isErr3(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureNP() + elif USBHardware.isOFL3(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureOFL() + else: + if StartOnHiNibble : + rawtemp = (buf[0][start+0] >> 4)* 10 \ + + (buf[0][start+0] & 0xF)* 1 \ + + (buf[0][start+1] >> 4)* 0.1 + else: + rawtemp = (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 \ + + (buf[0][start+1] & 0xF)* 0.1 + result = rawtemp - CWeatherTraits.TemperatureOffset() + return result + + @staticmethod + def toWindspeed_6_2(buf, start): + """read 6 nibbles, presentation with 2 decimals; units of km/h""" + result = (buf[0][start+0] >> 4)* 16**5 \ + + (buf[0][start+0] & 0xF)* 16**4 \ + + (buf[0][start+1] >> 4)* 16**3 \ + + (buf[0][start+1] & 0xF)* 16**2 \ + + (buf[0][start+2] >> 4)* 16**1 \ + + (buf[0][start+2] & 0xF) + result /= 256.0 + result /= 100.0 # km/h + return result + + @staticmethod + def toWindspeed_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal; units of m/s""" + if StartOnHiNibble : + hibyte = buf[0][start+0] + lobyte = (buf[0][start+1] >> 4) & 0xF + else: + hibyte = 16*(buf[0][start+0] & 0xF) + ((buf[0][start+1] >> 4) & 0xF) + lobyte = buf[0][start+1] & 0xF + if hibyte == 0xFF and lobyte == 0xE: + result = CWeatherTraits.WindNP() + elif hibyte == 0xFF and lobyte == 0xF: + result = CWeatherTraits.WindOFL() + else: + result = USBHardware.toFloat_3_1(buf, start, StartOnHiNibble) # m/s + result *= 3.6 # km/h + return result + + @staticmethod + def readPressureShared(buf, start, StartOnHiNibble): + return (USBHardware.toPressure_hPa_5_1(buf,start+2,1-StartOnHiNibble), + USBHardware.toPressure_inHg_5_2(buf,start,StartOnHiNibble)) + + @staticmethod + def toPressure_hPa_5_1(buf, start, StartOnHiNibble): + """read 5 nibbles, presentation with 1 decimal; units of hPa (mbar)""" + if USBHardware.isErr5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureNP() + elif USBHardware.isOFL5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureOFL() + elif StartOnHiNibble : + result = (buf[0][start+0] >> 4)* 1000 \ + + (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 + else: + result = (buf[0][start+0] & 0xF)* 1000 \ + + (buf[0][start+1] >> 4)* 100 \ + + (buf[0][start+1] & 0xF)* 10 \ + + (buf[0][start+2] >> 4)* 1 \ + + (buf[0][start+2] & 0xF)* 0.1 + return result + + @staticmethod + def toPressure_inHg_5_2(buf, start, StartOnHiNibble): + """read 5 nibbles, presentation with 2 decimals; units of inHg""" + if USBHardware.isErr5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureNP() + elif USBHardware.isOFL5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureOFL() + elif StartOnHiNibble : + result = (buf[0][start+0] >> 4)* 100 \ + + (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 \ + + (buf[0][start+1] & 0xF)* 0.1 \ + + (buf[0][start+2] >> 4)* 0.01 + else: + result = (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 \ + + (buf[0][start+2] & 0xF)* 0.01 + return result + + +class CCurrentWeatherData(object): + + def __init__(self): + self._timestamp = None + self._checksum = None + self._PressureRelative_hPa = CWeatherTraits.PressureNP() + self._PressureRelative_hPaMinMax = CMinMaxMeasurement() + self._PressureRelative_inHg = CWeatherTraits.PressureNP() + self._PressureRelative_inHgMinMax = CMinMaxMeasurement() + self._WindSpeed = CWeatherTraits.WindNP() + self._WindDirection = EWindDirection.wdNone + self._WindDirection1 = EWindDirection.wdNone + self._WindDirection2 = EWindDirection.wdNone + self._WindDirection3 = EWindDirection.wdNone + self._WindDirection4 = EWindDirection.wdNone + self._WindDirection5 = EWindDirection.wdNone + self._Gust = CWeatherTraits.WindNP() + self._GustMax = CMinMaxMeasurement() + self._GustDirection = EWindDirection.wdNone + self._GustDirection1 = EWindDirection.wdNone + self._GustDirection2 = EWindDirection.wdNone + self._GustDirection3 = EWindDirection.wdNone + self._GustDirection4 = EWindDirection.wdNone + self._GustDirection5 = EWindDirection.wdNone + self._Rain1H = CWeatherTraits.RainNP() + self._Rain1HMax = CMinMaxMeasurement() + self._Rain24H = CWeatherTraits.RainNP() + self._Rain24HMax = CMinMaxMeasurement() + self._RainLastWeek = CWeatherTraits.RainNP() + self._RainLastWeekMax = CMinMaxMeasurement() + self._RainLastMonth = CWeatherTraits.RainNP() + self._RainLastMonthMax = CMinMaxMeasurement() + self._RainTotal = CWeatherTraits.RainNP() + self._LastRainReset = None + self._TempIndoor = CWeatherTraits.TemperatureNP() + self._TempIndoorMinMax = CMinMaxMeasurement() + self._TempOutdoor = CWeatherTraits.TemperatureNP() + self._TempOutdoorMinMax = CMinMaxMeasurement() + self._HumidityIndoor = CWeatherTraits.HumidityNP() + self._HumidityIndoorMinMax = CMinMaxMeasurement() + self._HumidityOutdoor = CWeatherTraits.HumidityNP() + self._HumidityOutdoorMinMax = CMinMaxMeasurement() + self._Dewpoint = CWeatherTraits.TemperatureNP() + self._DewpointMinMax = CMinMaxMeasurement() + self._Windchill = CWeatherTraits.TemperatureNP() + self._WindchillMinMax = CMinMaxMeasurement() + self._WeatherState = EWeatherState.WEATHER_ERR + self._WeatherTendency = EWeatherTendency.TREND_ERR + self._AlarmRingingFlags = 0 + self._AlarmMarkedFlags = 0 + self._PresRel_hPa_Max = 0.0 + self._PresRel_inHg_Max = 0.0 + + @staticmethod + def calcChecksum(buf): + return calc_checksum(buf, 6) + + def checksum(self): + return self._checksum + + def read(self, buf): + self._timestamp = int(time.time() + 0.5) + self._checksum = CCurrentWeatherData.calcChecksum(buf) + + nbuf = [0] + nbuf[0] = buf[0] + self._StartBytes = nbuf[0][6]*0xF + nbuf[0][7] # FIXME: what is this? + self._WeatherTendency = (nbuf[0][8] >> 4) & 0xF + if self._WeatherTendency > 3: + self._WeatherTendency = 3 + self._WeatherState = nbuf[0][8] & 0xF + if self._WeatherState > 3: + self._WeatherState = 3 + + self._TempIndoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 19, 0) + self._TempIndoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 22, 1) + self._TempIndoor = USBHardware.toTemperature_5_3(nbuf, 24, 0) + self._TempIndoorMinMax._Min._IsError = (self._TempIndoorMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._TempIndoorMinMax._Min._IsOverflow = (self._TempIndoorMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._TempIndoorMinMax._Max._IsError = (self._TempIndoorMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._TempIndoorMinMax._Max._IsOverflow = (self._TempIndoorMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._TempIndoorMinMax._Max._Time = None if self._TempIndoorMinMax._Max._IsError or self._TempIndoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 9, 0, 'TempIndoorMax') + self._TempIndoorMinMax._Min._Time = None if self._TempIndoorMinMax._Min._IsError or self._TempIndoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 14, 0, 'TempIndoorMin') + + self._TempOutdoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 37, 0) + self._TempOutdoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 40, 1) + self._TempOutdoor = USBHardware.toTemperature_5_3(nbuf, 42, 0) + self._TempOutdoorMinMax._Min._IsError = (self._TempOutdoorMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._TempOutdoorMinMax._Min._IsOverflow = (self._TempOutdoorMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._TempOutdoorMinMax._Max._IsError = (self._TempOutdoorMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._TempOutdoorMinMax._Max._IsOverflow = (self._TempOutdoorMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._TempOutdoorMinMax._Max._Time = None if self._TempOutdoorMinMax._Max._IsError or self._TempOutdoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 27, 0, 'TempOutdoorMax') + self._TempOutdoorMinMax._Min._Time = None if self._TempOutdoorMinMax._Min._IsError or self._TempOutdoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 32, 0, 'TempOutdoorMin') + + self._WindchillMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 55, 0) + self._WindchillMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 58, 1) + self._Windchill = USBHardware.toTemperature_5_3(nbuf, 60, 0) + self._WindchillMinMax._Min._IsError = (self._WindchillMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._WindchillMinMax._Min._IsOverflow = (self._WindchillMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._WindchillMinMax._Max._IsError = (self._WindchillMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._WindchillMinMax._Max._IsOverflow = (self._WindchillMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._WindchillMinMax._Max._Time = None if self._WindchillMinMax._Max._IsError or self._WindchillMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 45, 0, 'WindchillMax') + self._WindchillMinMax._Min._Time = None if self._WindchillMinMax._Min._IsError or self._WindchillMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 50, 0, 'WindchillMin') + + self._DewpointMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 73, 0) + self._DewpointMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 76, 1) + self._Dewpoint = USBHardware.toTemperature_5_3(nbuf, 78, 0) + self._DewpointMinMax._Min._IsError = (self._DewpointMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._DewpointMinMax._Min._IsOverflow = (self._DewpointMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._DewpointMinMax._Max._IsError = (self._DewpointMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._DewpointMinMax._Max._IsOverflow = (self._DewpointMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._DewpointMinMax._Min._Time = None if self._DewpointMinMax._Min._IsError or self._DewpointMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 68, 0, 'DewpointMin') + self._DewpointMinMax._Max._Time = None if self._DewpointMinMax._Max._IsError or self._DewpointMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 63, 0, 'DewpointMax') + + self._HumidityIndoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 91, 1) + self._HumidityIndoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 92, 1) + self._HumidityIndoor = USBHardware.toHumidity_2_0(nbuf, 93, 1) + self._HumidityIndoorMinMax._Min._IsError = (self._HumidityIndoorMinMax._Min._Value == CWeatherTraits.HumidityNP()) + self._HumidityIndoorMinMax._Min._IsOverflow = (self._HumidityIndoorMinMax._Min._Value == CWeatherTraits.HumidityOFL()) + self._HumidityIndoorMinMax._Max._IsError = (self._HumidityIndoorMinMax._Max._Value == CWeatherTraits.HumidityNP()) + self._HumidityIndoorMinMax._Max._IsOverflow = (self._HumidityIndoorMinMax._Max._Value == CWeatherTraits.HumidityOFL()) + self._HumidityIndoorMinMax._Max._Time = None if self._HumidityIndoorMinMax._Max._IsError or self._HumidityIndoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 81, 1, 'HumidityIndoorMax') + self._HumidityIndoorMinMax._Min._Time = None if self._HumidityIndoorMinMax._Min._IsError or self._HumidityIndoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 86, 1, 'HumidityIndoorMin') + + self._HumidityOutdoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 104, 1) + self._HumidityOutdoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 105, 1) + self._HumidityOutdoor = USBHardware.toHumidity_2_0(nbuf, 106, 1) + self._HumidityOutdoorMinMax._Min._IsError = (self._HumidityOutdoorMinMax._Min._Value == CWeatherTraits.HumidityNP()) + self._HumidityOutdoorMinMax._Min._IsOverflow = (self._HumidityOutdoorMinMax._Min._Value == CWeatherTraits.HumidityOFL()) + self._HumidityOutdoorMinMax._Max._IsError = (self._HumidityOutdoorMinMax._Max._Value == CWeatherTraits.HumidityNP()) + self._HumidityOutdoorMinMax._Max._IsOverflow = (self._HumidityOutdoorMinMax._Max._Value == CWeatherTraits.HumidityOFL()) + self._HumidityOutdoorMinMax._Max._Time = None if self._HumidityOutdoorMinMax._Max._IsError or self._HumidityOutdoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 94, 1, 'HumidityOutdoorMax') + self._HumidityOutdoorMinMax._Min._Time = None if self._HumidityOutdoorMinMax._Min._IsError or self._HumidityOutdoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 99, 1, 'HumidityOutdoorMin') + + self._RainLastMonthMax._Max._Time = USBHardware.toDateTime(nbuf, 107, 1, 'RainLastMonthMax') + self._RainLastMonthMax._Max._Value = USBHardware.toRain_6_2(nbuf, 112, 1) + self._RainLastMonth = USBHardware.toRain_6_2(nbuf, 115, 1) + + self._RainLastWeekMax._Max._Time = USBHardware.toDateTime(nbuf, 118, 1, 'RainLastWeekMax') + self._RainLastWeekMax._Max._Value = USBHardware.toRain_6_2(nbuf, 123, 1) + self._RainLastWeek = USBHardware.toRain_6_2(nbuf, 126, 1) + + self._Rain24HMax._Max._Time = USBHardware.toDateTime(nbuf, 129, 1, 'Rain24HMax') + self._Rain24HMax._Max._Value = USBHardware.toRain_6_2(nbuf, 134, 1) + self._Rain24H = USBHardware.toRain_6_2(nbuf, 137, 1) + + self._Rain1HMax._Max._Time = USBHardware.toDateTime(nbuf, 140, 1, 'Rain1HMax') + self._Rain1HMax._Max._Value = USBHardware.toRain_6_2(nbuf, 145, 1) + self._Rain1H = USBHardware.toRain_6_2(nbuf, 148, 1) + + self._LastRainReset = USBHardware.toDateTime(nbuf, 151, 0, 'LastRainReset') + self._RainTotal = USBHardware.toRain_7_3(nbuf, 156, 0) + + (w ,w1) = USBHardware.readWindDirectionShared(nbuf, 162) + (w2,w3) = USBHardware.readWindDirectionShared(nbuf, 161) + (w4,w5) = USBHardware.readWindDirectionShared(nbuf, 160) + self._WindDirection = w + self._WindDirection1 = w1 + self._WindDirection2 = w2 + self._WindDirection3 = w3 + self._WindDirection4 = w4 + self._WindDirection5 = w5 + + if DEBUG_WEATHER_DATA > 2: + unknownbuf = [0]*9 + for i in range(9): + unknownbuf[i] = nbuf[163+i] + strbuf = "" + for i in unknownbuf: + strbuf += str("%.2x " % i) + log.debug('Bytes with unknown meaning at 157-165: %s' % strbuf) + + self._WindSpeed = USBHardware.toWindspeed_6_2(nbuf, 172) + + # FIXME: read the WindErrFlags + (g ,g1) = USBHardware.readWindDirectionShared(nbuf, 177) + (g2,g3) = USBHardware.readWindDirectionShared(nbuf, 176) + (g4,g5) = USBHardware.readWindDirectionShared(nbuf, 175) + self._GustDirection = g + self._GustDirection1 = g1 + self._GustDirection2 = g2 + self._GustDirection3 = g3 + self._GustDirection4 = g4 + self._GustDirection5 = g5 + + self._GustMax._Max._Value = USBHardware.toWindspeed_6_2(nbuf, 184) + self._GustMax._Max._IsError = (self._GustMax._Max._Value == CWeatherTraits.WindNP()) + self._GustMax._Max._IsOverflow = (self._GustMax._Max._Value == CWeatherTraits.WindOFL()) + self._GustMax._Max._Time = None if self._GustMax._Max._IsError or self._GustMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 179, 1, 'GustMax') + self._Gust = USBHardware.toWindspeed_6_2(nbuf, 187) + + # Apparently the station returns only ONE date time for both hPa/inHg + # Min Time Reset and Max Time Reset + self._PressureRelative_hPaMinMax._Max._Time = USBHardware.toDateTime(nbuf, 190, 1, 'PressureRelative_hPaMax') + self._PressureRelative_inHgMinMax._Max._Time = self._PressureRelative_hPaMinMax._Max._Time + self._PressureRelative_hPaMinMax._Min._Time = self._PressureRelative_hPaMinMax._Max._Time # firmware bug, should be: USBHardware.toDateTime(nbuf, 195, 1) + self._PressureRelative_inHgMinMax._Min._Time = self._PressureRelative_hPaMinMax._Min._Time + + (self._PresRel_hPa_Max, self._PresRel_inHg_Max) = USBHardware.readPressureShared(nbuf, 195, 1) # firmware bug, should be: self._PressureRelative_hPaMinMax._Min._Time + (self._PressureRelative_hPaMinMax._Max._Value, self._PressureRelative_inHgMinMax._Max._Value) = USBHardware.readPressureShared(nbuf, 200, 1) + (self._PressureRelative_hPaMinMax._Min._Value, self._PressureRelative_inHgMinMax._Min._Value) = USBHardware.readPressureShared(nbuf, 205, 1) + (self._PressureRelative_hPa, self._PressureRelative_inHg) = USBHardware.readPressureShared(nbuf, 210, 1) + + def toLog(self): + log.debug("_WeatherState=%s _WeatherTendency=%s _AlarmRingingFlags %04x" % (CWeatherTraits.forecastMap[self._WeatherState], CWeatherTraits.trendMap[self._WeatherTendency], self._AlarmRingingFlags)) + log.debug("_TempIndoor= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._TempIndoor, self._TempIndoorMinMax._Min._Value, self._TempIndoorMinMax._Min._Time, self._TempIndoorMinMax._Max._Value, self._TempIndoorMinMax._Max._Time)) + log.debug("_HumidityIndoor= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._HumidityIndoor, self._HumidityIndoorMinMax._Min._Value, self._HumidityIndoorMinMax._Min._Time, self._HumidityIndoorMinMax._Max._Value, self._HumidityIndoorMinMax._Max._Time)) + log.debug("_TempOutdoor= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._TempOutdoor, self._TempOutdoorMinMax._Min._Value, self._TempOutdoorMinMax._Min._Time, self._TempOutdoorMinMax._Max._Value, self._TempOutdoorMinMax._Max._Time)) + log.debug("_HumidityOutdoor=%8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._HumidityOutdoor, self._HumidityOutdoorMinMax._Min._Value, self._HumidityOutdoorMinMax._Min._Time, self._HumidityOutdoorMinMax._Max._Value, self._HumidityOutdoorMinMax._Max._Time)) + log.debug("_Windchill= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._Windchill, self._WindchillMinMax._Min._Value, self._WindchillMinMax._Min._Time, self._WindchillMinMax._Max._Value, self._WindchillMinMax._Max._Time)) + log.debug("_Dewpoint= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._Dewpoint, self._DewpointMinMax._Min._Value, self._DewpointMinMax._Min._Time, self._DewpointMinMax._Max._Value, self._DewpointMinMax._Max._Time)) + log.debug("_WindSpeed= %8.3f" % self._WindSpeed) + log.debug("_Gust= %8.3f _Max=%8.3f (%s)" % (self._Gust, self._GustMax._Max._Value, self._GustMax._Max._Time)) + log.debug('_WindDirection= %3s _GustDirection= %3s' % (CWeatherTraits.windDirMap[self._WindDirection], CWeatherTraits.windDirMap[self._GustDirection])) + log.debug('_WindDirection1= %3s _GustDirection1= %3s' % (CWeatherTraits.windDirMap[self._WindDirection1], CWeatherTraits.windDirMap[self._GustDirection1])) + log.debug('_WindDirection2= %3s _GustDirection2= %3s' % (CWeatherTraits.windDirMap[self._WindDirection2], CWeatherTraits.windDirMap[self._GustDirection2])) + log.debug('_WindDirection3= %3s _GustDirection3= %3s' % (CWeatherTraits.windDirMap[self._WindDirection3], CWeatherTraits.windDirMap[self._GustDirection3])) + log.debug('_WindDirection4= %3s _GustDirection4= %3s' % (CWeatherTraits.windDirMap[self._WindDirection4], CWeatherTraits.windDirMap[self._GustDirection4])) + log.debug('_WindDirection5= %3s _GustDirection5= %3s' % (CWeatherTraits.windDirMap[self._WindDirection5], CWeatherTraits.windDirMap[self._GustDirection5])) + if (self._RainLastMonth > 0) or (self._RainLastWeek > 0): + log.debug("_RainLastMonth= %8.3f _Max=%8.3f (%s)" % (self._RainLastMonth, self._RainLastMonthMax._Max._Value, self._RainLastMonthMax._Max._Time)) + log.debug("_RainLastWeek= %8.3f _Max=%8.3f (%s)" % (self._RainLastWeek, self._RainLastWeekMax._Max._Value, self._RainLastWeekMax._Max._Time)) + log.debug("_Rain24H= %8.3f _Max=%8.3f (%s)" % (self._Rain24H, self._Rain24HMax._Max._Value, self._Rain24HMax._Max._Time)) + log.debug("_Rain1H= %8.3f _Max=%8.3f (%s)" % (self._Rain1H, self._Rain1HMax._Max._Value, self._Rain1HMax._Max._Time)) + log.debug("_RainTotal= %8.3f _LastRainReset= (%s)" % (self._RainTotal, self._LastRainReset)) + log.debug("PressureRel_hPa= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s) " % (self._PressureRelative_hPa, self._PressureRelative_hPaMinMax._Min._Value, self._PressureRelative_hPaMinMax._Min._Time, self._PressureRelative_hPaMinMax._Max._Value, self._PressureRelative_hPaMinMax._Max._Time)) + log.debug("PressureRel_inHg=%8.3f _Min=%8.3f (%s) _Max=%8.3f (%s) " % (self._PressureRelative_inHg, self._PressureRelative_inHgMinMax._Min._Value, self._PressureRelative_inHgMinMax._Min._Time, self._PressureRelative_inHgMinMax._Max._Value, self._PressureRelative_inHgMinMax._Max._Time)) + ###log.debug('(* Bug in Weather Station: PressureRelative._Min._Time is written to location of _PressureRelative._Max._Time') + ###log.debug('Instead of PressureRelative._Min._Time we get: _PresRel_hPa_Max= %8.3f, _PresRel_inHg_max =%8.3f;' % (self._PresRel_hPa_Max, self._PresRel_inHg_Max)) + + +class CWeatherStationConfig(object): + def __init__(self): + self._InBufCS = 0 # checksum of received config + self._OutBufCS = 0 # calculated config checksum from outbuf config + self._ClockMode = 0 + self._TemperatureFormat = 0 + self._PressureFormat = 0 + self._RainFormat = 0 + self._WindspeedFormat = 0 + self._WeatherThreshold = 0 + self._StormThreshold = 0 + self._LCDContrast = 0 + self._LowBatFlags = 0 + self._WindDirAlarmFlags = 0 + self._OtherAlarmFlags = 0 + self._ResetMinMaxFlags = 0 # output only + self._HistoryInterval = 0 + self._TempIndoorMinMax = CMinMaxMeasurement() + self._TempOutdoorMinMax = CMinMaxMeasurement() + self._HumidityIndoorMinMax = CMinMaxMeasurement() + self._HumidityOutdoorMinMax = CMinMaxMeasurement() + self._Rain24HMax = CMinMaxMeasurement() + self._GustMax = CMinMaxMeasurement() + self._PressureRelative_hPaMinMax = CMinMaxMeasurement() + self._PressureRelative_inHgMinMax = CMinMaxMeasurement() + + def setTemps(self,TempFormat,InTempLo,InTempHi,OutTempLo,OutTempHi): + f1 = TempFormat + t1 = InTempLo + t2 = InTempHi + t3 = OutTempLo + t4 = OutTempHi + if f1 not in [ETemperatureFormat.tfFahrenheit, + ETemperatureFormat.tfCelsius]: + log.error('setTemps: unknown temperature format %s' % TempFormat) + return 0 + if t1 < -40.0 or t1 > 59.9 or t2 < -40.0 or t2 > 59.9 or \ + t3 < -40.0 or t3 > 59.9 or t4 < -40.0 or t4 > 59.9: + log.error('setTemps: one or more values out of range') + return 0 + self._TemperatureFormat = f1 + self._TempIndoorMinMax._Min._Value = t1 + self._TempIndoorMinMax._Max._Value = t2 + self._TempOutdoorMinMax._Min._Value = t3 + self._TempOutdoorMinMax._Max._Value = t4 + return 1 + + def setHums(self,InHumLo,InHumHi,OutHumLo,OutHumHi): + h1 = InHumLo + h2 = InHumHi + h3 = OutHumLo + h4 = OutHumHi + if h1 < 1 or h1 > 99 or h2 < 1 or h2 > 99 or \ + h3 < 1 or h3 > 99 or h4 < 1 or h4 > 99: + log.error('setHums: one or more values out of range') + return 0 + self._HumidityIndoorMinMax._Min._Value = h1 + self._HumidityIndoorMinMax._Max._Value = h2 + self._HumidityOutdoorMinMax._Min._Value = h3 + self._HumidityOutdoorMinMax._Max._Value = h4 + return 1 + + def setRain24H(self,RainFormat,Rain24hHi): + f1 = RainFormat + r1 = Rain24hHi + if f1 not in [ERainFormat.rfMm, ERainFormat.rfInch]: + log.error('setRain24: unknown format %s' % RainFormat) + return 0 + if r1 < 0.0 or r1 > 9999.9: + log.error('setRain24: value outside range') + return 0 + self._RainFormat = f1 + self._Rain24HMax._Max._Value = r1 + return 1 + + def setGust(self,WindSpeedFormat,GustHi): + # When the units of a max gust alarm are changed in the weather + # station itself, automatically the value is converted to the new + # unit and rounded to a whole number. Weewx receives a value + # converted to km/h. + # + # It is too much trouble to sort out what exactly the internal + # conversion algoritms are for the other wind units. + # + # Setting a value in km/h units is tested and works, so this will + # be the only option available. + f1 = WindSpeedFormat + g1 = GustHi + if f1 < EWindspeedFormat.wfMs or f1 > EWindspeedFormat.wfMph: + log.error('setGust: unknown format %s' % WindSpeedFormat) + return 0 + if f1 != EWindspeedFormat.wfKmh: + log.error('setGust: only units of km/h are supported') + return 0 + if g1 < 0.0 or g1 > 180.0: + log.error('setGust: value outside range') + return 0 + self._WindSpeedFormat = f1 + self._GustMax._Max._Value = int(g1) # apparently gust value is always an integer + return 1 + + def setPresRels(self,PressureFormat,PresRelhPaLo,PresRelhPaHi,PresRelinHgLo,PresRelinHgHi): + f1 = PressureFormat + p1 = PresRelhPaLo + p2 = PresRelhPaHi + p3 = PresRelinHgLo + p4 = PresRelinHgHi + if f1 not in [EPressureFormat.pfinHg, EPressureFormat.pfHPa]: + log.error('setPresRel: unknown format %s' % PressureFormat) + return 0 + if p1 < 920.0 or p1 > 1080.0 or p2 < 920.0 or p2 > 1080.0 or \ + p3 < 27.10 or p3 > 31.90 or p4 < 27.10 or p4 > 31.90: + log.error('setPresRel: value outside range') + return 0 + self._RainFormat = f1 + self._PressureRelative_hPaMinMax._Min._Value = p1 + self._PressureRelative_hPaMinMax._Max._Value = p2 + self._PressureRelative_inHgMinMax._Min._Value = p3 + self._PressureRelative_inHgMinMax._Max._Value = p4 + return 1 + + def getOutBufCS(self): + return self._OutBufCS + + def getInBufCS(self): + return self._InBufCS + + def setResetMinMaxFlags(self, resetMinMaxFlags): + log.debug('setResetMinMaxFlags: %s' % resetMinMaxFlags) + self._ResetMinMaxFlags = resetMinMaxFlags + + def parseRain_3(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 7-digit number with 3 decimals''' + num = int(number*1000) + parsebuf=[0]*7 + for i in range(7-numbytes,7): + parsebuf[i] = num%10 + num = num//10 + if StartOnHiNibble: + buf[0][0+start] = parsebuf[6]*16 + parsebuf[5] + buf[0][1+start] = parsebuf[4]*16 + parsebuf[3] + buf[0][2+start] = parsebuf[2]*16 + parsebuf[1] + buf[0][3+start] = parsebuf[0]*16 + (buf[0][3+start] & 0xF) + else: + buf[0][0+start] = (buf[0][0+start] & 0xF0) + parsebuf[6] + buf[0][1+start] = parsebuf[5]*16 + parsebuf[4] + buf[0][2+start] = parsebuf[3]*16 + parsebuf[2] + buf[0][3+start] = parsebuf[1]*16 + parsebuf[0] + + def parseWind_6(self, number, buf, start): + '''Parse float number to 6 bytes''' + num = int(number*100*256) + parsebuf=[0]*6 + for i in range(6): + parsebuf[i] = num%16 + num = num//16 + buf[0][0+start] = parsebuf[5]*16 + parsebuf[4] + buf[0][1+start] = parsebuf[3]*16 + parsebuf[2] + buf[0][2+start] = parsebuf[1]*16 + parsebuf[0] + + def parse_0(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5-digit number with 0 decimals''' + num = int(number) + nbuf=[0]*5 + for i in range(5-numbytes,5): + nbuf[i] = num%10 + num = num//10 + if StartOnHiNibble: + buf[0][0+start] = nbuf[4]*16 + nbuf[3] + buf[0][1+start] = nbuf[2]*16 + nbuf[1] + buf[0][2+start] = nbuf[0]*16 + (buf[0][2+start] & 0x0F) + else: + buf[0][0+start] = (buf[0][0+start] & 0xF0) + nbuf[4] + buf[0][1+start] = nbuf[3]*16 + nbuf[2] + buf[0][2+start] = nbuf[1]*16 + nbuf[0] + + def parse_1(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5 digit number with 1 decimal''' + self.parse_0(number*10.0, buf, start, StartOnHiNibble, numbytes) + + def parse_2(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5 digit number with 2 decimals''' + self.parse_0(number*100.0, buf, start, StartOnHiNibble, numbytes) + + def parse_3(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5 digit number with 3 decimals''' + self.parse_0(number*1000.0, buf, start, StartOnHiNibble, numbytes) + + def read(self,buf): + nbuf=[0] + nbuf[0]=buf[0] + self._WindspeedFormat = (nbuf[0][4] >> 4) & 0xF + self._RainFormat = (nbuf[0][4] >> 3) & 1 + self._PressureFormat = (nbuf[0][4] >> 2) & 1 + self._TemperatureFormat = (nbuf[0][4] >> 1) & 1 + self._ClockMode = nbuf[0][4] & 1 + self._StormThreshold = (nbuf[0][5] >> 4) & 0xF + self._WeatherThreshold = nbuf[0][5] & 0xF + self._LowBatFlags = (nbuf[0][6] >> 4) & 0xF + self._LCDContrast = nbuf[0][6] & 0xF + self._WindDirAlarmFlags = (nbuf[0][7] << 8) | nbuf[0][8] + self._OtherAlarmFlags = (nbuf[0][9] << 8) | nbuf[0][10] + self._TempIndoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 11, 1) + self._TempIndoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 13, 0) + self._TempOutdoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 16, 1) + self._TempOutdoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 18, 0) + self._HumidityIndoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 21, 1) + self._HumidityIndoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 22, 1) + self._HumidityOutdoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 23, 1) + self._HumidityOutdoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 24, 1) + self._Rain24HMax._Max._Value = USBHardware.toRain_7_3(nbuf, 25, 0) + self._HistoryInterval = nbuf[0][29] + self._GustMax._Max._Value = USBHardware.toWindspeed_6_2(nbuf, 30) + (self._PressureRelative_hPaMinMax._Min._Value, self._PressureRelative_inHgMinMax._Min._Value) = USBHardware.readPressureShared(nbuf, 33, 1) + (self._PressureRelative_hPaMinMax._Max._Value, self._PressureRelative_inHgMinMax._Max._Value) = USBHardware.readPressureShared(nbuf, 38, 1) + self._ResetMinMaxFlags = (nbuf[0][43]) <<16 | (nbuf[0][44] << 8) | (nbuf[0][45]) + self._InBufCS = (nbuf[0][46] << 8) | nbuf[0][47] + self._OutBufCS = calc_checksum(buf, 4, end=39) + 7 + + """ + Reset DewpointMax 80 00 00 + Reset DewpointMin 40 00 00 + not used 20 00 00 + Reset WindchillMin* 10 00 00 *dateTime only; Min._Value is preserved + + Reset TempOutMax 08 00 00 + Reset TempOutMin 04 00 00 + Reset TempInMax 02 00 00 + Reset TempInMin 01 00 00 + + Reset Gust 00 80 00 + not used 00 40 00 + not used 00 20 00 + not used 00 10 00 + + Reset HumOutMax 00 08 00 + Reset HumOutMin 00 04 00 + Reset HumInMax 00 02 00 + Reset HumInMin 00 01 00 + + not used 00 00 80 + Reset Rain Total 00 00 40 + Reset last month? 00 00 20 + Reset last week? 00 00 10 + + Reset Rain24H 00 00 08 + Reset Rain1H 00 00 04 + Reset PresRelMax 00 00 02 + Reset PresRelMin 00 00 01 + """ + #self._ResetMinMaxFlags = 0x000000 + #log.debug('set _ResetMinMaxFlags to %06x' % self._ResetMinMaxFlags) + + """ + setTemps(self,TempFormat,InTempLo,InTempHi,OutTempLo,OutTempHi) + setHums(self,InHumLo,InHumHi,OutHumLo,OutHumHi) + setPresRels(self,PressureFormat,PresRelhPaLo,PresRelhPaHi,PresRelinHgLo,PresRelinHgHi) + setGust(self,WindSpeedFormat,GustHi) + setRain24H(self,RainFormat,Rain24hHi) + """ + # Examples: + #self.setTemps(ETemperatureFormat.tfCelsius,1.0,41.0,2.0,42.0) + #self.setHums(41,71,42,72) + #self.setPresRels(EPressureFormat.pfHPa,960.1,1040.1,28.36,30.72) + #self.setGust(EWindspeedFormat.wfKmh,040.0) + #self.setRain24H(ERainFormat.rfMm,50.0) + + # Set historyInterval to 5 minutes (default: 2 hours) + self._HistoryInterval = EHistoryInterval.hi05Min + # Clear all alarm flags, otherwise the datastream from the weather + # station will pause during an alarm and connection will be lost. + self._WindDirAlarmFlags = 0x0000 + self._OtherAlarmFlags = 0x0000 + + def testConfigChanged(self,buf): + nbuf = [0] + nbuf[0] = buf[0] + nbuf[0][0] = 16*(self._WindspeedFormat & 0xF) + 8*(self._RainFormat & 1) + 4*(self._PressureFormat & 1) + 2*(self._TemperatureFormat & 1) + (self._ClockMode & 1) + nbuf[0][1] = self._WeatherThreshold & 0xF | 16 * self._StormThreshold & 0xF0 + nbuf[0][2] = self._LCDContrast & 0xF | 16 * self._LowBatFlags & 0xF0 + nbuf[0][3] = (self._OtherAlarmFlags >> 0) & 0xFF + nbuf[0][4] = (self._OtherAlarmFlags >> 8) & 0xFF + nbuf[0][5] = (self._WindDirAlarmFlags >> 0) & 0xFF + nbuf[0][6] = (self._WindDirAlarmFlags >> 8) & 0xFF + # reverse buf from here + self.parse_2(self._PressureRelative_inHgMinMax._Max._Value, nbuf, 7, 1, 5) + self.parse_1(self._PressureRelative_hPaMinMax._Max._Value, nbuf, 9, 0, 5) + self.parse_2(self._PressureRelative_inHgMinMax._Min._Value, nbuf, 12, 1, 5) + self.parse_1(self._PressureRelative_hPaMinMax._Min._Value, nbuf, 14, 0, 5) + self.parseWind_6(self._GustMax._Max._Value, nbuf, 17) + nbuf[0][20] = self._HistoryInterval & 0xF + self.parseRain_3(self._Rain24HMax._Max._Value, nbuf, 21, 0, 7) + self.parse_0(self._HumidityOutdoorMinMax._Max._Value, nbuf, 25, 1, 2) + self.parse_0(self._HumidityOutdoorMinMax._Min._Value, nbuf, 26, 1, 2) + self.parse_0(self._HumidityIndoorMinMax._Max._Value, nbuf, 27, 1, 2) + self.parse_0(self._HumidityIndoorMinMax._Min._Value, nbuf, 28, 1, 2) + self.parse_3(self._TempOutdoorMinMax._Max._Value + CWeatherTraits.TemperatureOffset(), nbuf, 29, 1, 5) + self.parse_3(self._TempOutdoorMinMax._Min._Value + CWeatherTraits.TemperatureOffset(), nbuf, 31, 0, 5) + self.parse_3(self._TempIndoorMinMax._Max._Value + CWeatherTraits.TemperatureOffset(), nbuf, 34, 1, 5) + self.parse_3(self._TempIndoorMinMax._Min._Value + CWeatherTraits.TemperatureOffset(), nbuf, 36, 0, 5) + # reverse buf to here + USBHardware.reverseByteOrder(nbuf, 7, 32) + # do not include the ResetMinMaxFlags bytes when calculating checksum + nbuf[0][39] = (self._ResetMinMaxFlags >> 16) & 0xFF + nbuf[0][40] = (self._ResetMinMaxFlags >> 8) & 0xFF + nbuf[0][41] = (self._ResetMinMaxFlags >> 0) & 0xFF + self._OutBufCS = calc_checksum(nbuf, 0, end=39) + 7 + nbuf[0][42] = (self._OutBufCS >> 8) & 0xFF + nbuf[0][43] = (self._OutBufCS >> 0) & 0xFF + buf[0] = nbuf[0] + if self._OutBufCS == self._InBufCS and self._ResetMinMaxFlags == 0: + if DEBUG_CONFIG_DATA > 2: + log.debug('testConfigChanged: checksum not changed: OutBufCS=%04x' % self._OutBufCS) + changed = 0 + else: + if DEBUG_CONFIG_DATA > 0: + log.debug('testConfigChanged: checksum or resetMinMaxFlags changed: ' + 'OutBufCS=%04x InBufCS=%04x _ResetMinMaxFlags=%06x' + % (self._OutBufCS, self._InBufCS, self._ResetMinMaxFlags)) + if DEBUG_CONFIG_DATA > 1: + self.toLog() + changed = 1 + return changed + + def toLog(self): + log.debug('OutBufCS= %04x' % self._OutBufCS) + log.debug('InBufCS= %04x' % self._InBufCS) + log.debug('ClockMode= %s' % self._ClockMode) + log.debug('TemperatureFormat= %s' % self._TemperatureFormat) + log.debug('PressureFormat= %s' % self._PressureFormat) + log.debug('RainFormat= %s' % self._RainFormat) + log.debug('WindspeedFormat= %s' % self._WindspeedFormat) + log.debug('WeatherThreshold= %s' % self._WeatherThreshold) + log.debug('StormThreshold= %s' % self._StormThreshold) + log.debug('LCDContrast= %s' % self._LCDContrast) + log.debug('LowBatFlags= %01x' % self._LowBatFlags) + log.debug('WindDirAlarmFlags= %04x' % self._WindDirAlarmFlags) + log.debug('OtherAlarmFlags= %04x' % self._OtherAlarmFlags) + log.debug('HistoryInterval= %s' % self._HistoryInterval) + log.debug('TempIndoor_Min= %s' % self._TempIndoorMinMax._Min._Value) + log.debug('TempIndoor_Max= %s' % self._TempIndoorMinMax._Max._Value) + log.debug('TempOutdoor_Min= %s' % self._TempOutdoorMinMax._Min._Value) + log.debug('TempOutdoor_Max= %s' % self._TempOutdoorMinMax._Max._Value) + log.debug('HumidityIndoor_Min= %s' % self._HumidityIndoorMinMax._Min._Value) + log.debug('HumidityIndoor_Max= %s' % self._HumidityIndoorMinMax._Max._Value) + log.debug('HumidityOutdoor_Min= %s' % self._HumidityOutdoorMinMax._Min._Value) + log.debug('HumidityOutdoor_Max= %s' % self._HumidityOutdoorMinMax._Max._Value) + log.debug('Rain24HMax= %s' % self._Rain24HMax._Max._Value) + log.debug('GustMax= %s' % self._GustMax._Max._Value) + log.debug('PressureRel_hPa_Min= %s' % self._PressureRelative_hPaMinMax._Min._Value) + log.debug('PressureRel_inHg_Min= %s' % self._PressureRelative_inHgMinMax._Min._Value) + log.debug('PressureRel_hPa_Max= %s' % self._PressureRelative_hPaMinMax._Max._Value) + log.debug('PressureRel_inHg_Max= %s' % self._PressureRelative_inHgMinMax._Max._Value) + log.debug('ResetMinMaxFlags= %06x (Output only)' % self._ResetMinMaxFlags) + + def asDict(self): + return { + 'checksum_in': self._InBufCS, + 'checksum_out': self._OutBufCS, + 'format_clock': self._ClockMode, + 'format_temperature': self._TemperatureFormat, + 'format_pressure': self._PressureFormat, + 'format_rain': self._RainFormat, + 'format_windspeed': self._WindspeedFormat, + 'threshold_weather': self._WeatherThreshold, + 'threshold_storm': self._StormThreshold, + 'lcd_contrast': self._LCDContrast, + 'low_battery_flags': self._LowBatFlags, + 'alarm_flags_wind_dir': self._WindDirAlarmFlags, + 'alarm_flags_other': self._OtherAlarmFlags, +# 'reset_minmax_flags': self._ResetMinMaxFlags, + 'history_interval': self._HistoryInterval, + 'indoor_temp_min': self._TempIndoorMinMax._Min._Value, + 'indoor_temp_min_time': self._TempIndoorMinMax._Min._Time, + 'indoor_temp_max': self._TempIndoorMinMax._Max._Value, + 'indoor_temp_max_time': self._TempIndoorMinMax._Max._Time, + 'indoor_humidity_min': self._HumidityIndoorMinMax._Min._Value, + 'indoor_humidity_min_time': self._HumidityIndoorMinMax._Min._Time, + 'indoor_humidity_max': self._HumidityIndoorMinMax._Max._Value, + 'indoor_humidity_max_time': self._HumidityIndoorMinMax._Max._Time, + 'outdoor_temp_min': self._TempOutdoorMinMax._Min._Value, + 'outdoor_temp_min_time': self._TempOutdoorMinMax._Min._Time, + 'outdoor_temp_max': self._TempOutdoorMinMax._Max._Value, + 'outdoor_temp_max_time': self._TempOutdoorMinMax._Max._Time, + 'outdoor_humidity_min': self._HumidityOutdoorMinMax._Min._Value, + 'outdoor_humidity_min_time':self._HumidityOutdoorMinMax._Min._Time, + 'outdoor_humidity_max': self._HumidityOutdoorMinMax._Max._Value, + 'outdoor_humidity_max_time':self._HumidityOutdoorMinMax._Max._Time, + 'rain_24h_max': self._Rain24HMax._Max._Value, + 'rain_24h_max_time': self._Rain24HMax._Max._Time, + 'wind_gust_max': self._GustMax._Max._Value, + 'wind_gust_max_time': self._GustMax._Max._Time, + 'pressure_min': self._PressureRelative_hPaMinMax._Min._Value, + 'pressure_min_time': self._PressureRelative_hPaMinMax._Min._Time, + 'pressure_max': self._PressureRelative_hPaMinMax._Max._Value, + 'pressure_max_time': self._PressureRelative_hPaMinMax._Max._Time + # do not bother with pressure inHg + } + + +class CHistoryData(object): + + def __init__(self): + self.Time = None + self.TempIndoor = CWeatherTraits.TemperatureNP() + self.HumidityIndoor = CWeatherTraits.HumidityNP() + self.TempOutdoor = CWeatherTraits.TemperatureNP() + self.HumidityOutdoor = CWeatherTraits.HumidityNP() + self.PressureRelative = None + self.RainCounterRaw = 0 + self.WindSpeed = CWeatherTraits.WindNP() + self.WindDirection = EWindDirection.wdNone + self.Gust = CWeatherTraits.WindNP() + self.GustDirection = EWindDirection.wdNone + + def read(self, buf): + nbuf = [0] + nbuf[0] = buf[0] + self.Gust = USBHardware.toWindspeed_3_1(nbuf, 12, 0) + self.GustDirection = (nbuf[0][14] >> 4) & 0xF + self.WindSpeed = USBHardware.toWindspeed_3_1(nbuf, 14, 0) + self.WindDirection = (nbuf[0][14] >> 4) & 0xF + self.RainCounterRaw = USBHardware.toRain_3_1(nbuf, 16, 1) + self.HumidityOutdoor = USBHardware.toHumidity_2_0(nbuf, 17, 0) + self.HumidityIndoor = USBHardware.toHumidity_2_0(nbuf, 18, 0) + self.PressureRelative = USBHardware.toPressure_hPa_5_1(nbuf, 19, 0) + self.TempIndoor = USBHardware.toTemperature_3_1(nbuf, 23, 0) + self.TempOutdoor = USBHardware.toTemperature_3_1(nbuf, 22, 1) + self.Time = USBHardware.toDateTime(nbuf, 25, 1, 'HistoryData') + + def toLog(self): + """emit raw historical data""" + log.debug("Time %s" % self.Time) + log.debug("TempIndoor= %7.1f" % self.TempIndoor) + log.debug("HumidityIndoor= %7.0f" % self.HumidityIndoor) + log.debug("TempOutdoor= %7.1f" % self.TempOutdoor) + log.debug("HumidityOutdoor= %7.0f" % self.HumidityOutdoor) + log.debug("PressureRelative= %7.1f" % self.PressureRelative) + log.debug("RainCounterRaw= %7.3f" % self.RainCounterRaw) + log.debug("WindSpeed= %7.3f" % self.WindSpeed) + log.debug("WindDirection= % 3s" % CWeatherTraits.windDirMap[self.WindDirection]) + log.debug("Gust= %7.3f" % self.Gust) + log.debug("GustDirection= % 3s" % CWeatherTraits.windDirMap[self.GustDirection]) + + def asDict(self): + """emit historical data as a dict with weewx conventions""" + return { + 'dateTime': tstr_to_ts(str(self.Time)), + 'inTemp': self.TempIndoor, + 'inHumidity': self.HumidityIndoor, + 'outTemp': self.TempOutdoor, + 'outHumidity': self.HumidityOutdoor, + 'pressure': self.PressureRelative, + 'rain': self.RainCounterRaw / 10.0, # weewx wants cm + 'windSpeed': self.WindSpeed, + 'windDir': getWindDir(self.WindDirection, self.WindSpeed), + 'windGust': self.Gust, + 'windGustDir': getWindDir(self.GustDirection, self.Gust), + } + +class HistoryCache: + def __init__(self): + self.clear_records() + def clear_records(self): + self.since_ts = 0 + self.num_rec = 0 + self.start_index = None + self.next_index = None + self.records = [] + self.num_outstanding_records = None + self.num_scanned = 0 + self.last_ts = 0 + +class CDataStore(object): + + class TTransceiverSettings(object): + def __init__(self): + self.VendorId = 0x6666 + self.ProductId = 0x5555 + self.VersionNo = 1 + self.manufacturer = "LA CROSSE TECHNOLOGY" + self.product = "Weather Direct Light Wireless Device" + self.FrequencyStandard = EFrequency.fsUS + self.Frequency = getFrequency(self.FrequencyStandard) + self.SerialNumber = None + self.DeviceID = None + + class TLastStat(object): + def __init__(self): + self.LastBatteryStatus = None + self.LastLinkQuality = None + self.LastHistoryIndex = None + self.LatestHistoryIndex = None + self.last_seen_ts = None + self.last_weather_ts = 0 + self.last_history_ts = 0 + self.last_config_ts = 0 + + def __init__(self): + self.transceiverPresent = False + self.commModeInterval = 3 + self.registeredDeviceID = None + self.LastStat = CDataStore.TLastStat() + self.TransceiverSettings = CDataStore.TTransceiverSettings() + self.StationConfig = CWeatherStationConfig() + self.CurrentWeather = CCurrentWeatherData() + + def getFrequencyStandard(self): + return self.TransceiverSettings.FrequencyStandard + + def setFrequencyStandard(self, val): + log.debug('setFrequency: %s' % val) + self.TransceiverSettings.FrequencyStandard = val + self.TransceiverSettings.Frequency = getFrequency(val) + + def getDeviceID(self): + return self.TransceiverSettings.DeviceID + + def setDeviceID(self,val): + log.debug("setDeviceID: %04x" % val) + self.TransceiverSettings.DeviceID = val + + def getRegisteredDeviceID(self): + return self.registeredDeviceID + + def setRegisteredDeviceID(self, val): + if val != self.registeredDeviceID: + log.info("console is paired to device with ID %04x" % val) + self.registeredDeviceID = val + + def getTransceiverPresent(self): + return self.transceiverPresent + + def setTransceiverPresent(self, val): + self.transceiverPresent = val + + def setLastStatCache(self, seen_ts=None, + quality=None, battery=None, + weather_ts=None, + history_ts=None, + config_ts=None): + if DEBUG_COMM > 1: + log.debug('setLastStatCache: seen=%s quality=%s battery=%s weather=%s history=%s config=%s' + % (seen_ts, quality, battery, weather_ts, history_ts, config_ts)) + if seen_ts is not None: + self.LastStat.last_seen_ts = seen_ts + if quality is not None: + self.LastStat.LastLinkQuality = quality + if battery is not None: + self.LastStat.LastBatteryStatus = battery + if weather_ts is not None: + self.LastStat.last_weather_ts = weather_ts + if history_ts is not None: + self.LastStat.last_history_ts = history_ts + if config_ts is not None: + self.LastStat.last_config_ts = config_ts + + def setLastHistoryIndex(self,val): + self.LastStat.LastHistoryIndex = val + + def getLastHistoryIndex(self): + return self.LastStat.LastHistoryIndex + + def setLatestHistoryIndex(self,val): + self.LastStat.LatestHistoryIndex = val + + def getLatestHistoryIndex(self): + return self.LastStat.LatestHistoryIndex + + def setCurrentWeather(self, data): + self.CurrentWeather = data + + def getDeviceRegistered(self): + if ( self.registeredDeviceID is None + or self.TransceiverSettings.DeviceID is None + or self.registeredDeviceID != self.TransceiverSettings.DeviceID ): + return False + return True + + def getCommModeInterval(self): + return self.commModeInterval + + def setCommModeInterval(self,val): + log.debug("setCommModeInterval to %x" % val) + self.commModeInterval = val + + def setTransceiverSerNo(self,val): + log.debug("setTransceiverSerialNumber to %s" % val) + self.TransceiverSettings.SerialNumber = val + + def getTransceiverSerNo(self): + return self.TransceiverSettings.SerialNumber + + +class sHID(object): + """USB driver abstraction""" + + def __init__(self): + self.devh = None + self.timeout = 1000 + self.last_dump = None + + def open(self, vid, pid, did, serial): + device = self._find_device(vid, pid, did, serial) + if device is None: + log.critical('Cannot find USB device with ' + 'Vendor=0x%04x ProdID=0x%04x ' + 'Device=%s Serial=%s' + % (vid, pid, did, serial)) + raise weewx.WeeWxIOError('Unable to find transceiver on USB') + self._open_device(device) + + def close(self): + self._close_device() + + def _find_device(self, vid, pid, did, serial): + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vid and dev.idProduct == pid: + if did is None or dev.filename == did: + if serial is None: + log.info('found transceiver at bus=%s device=%s' + % (bus.dirname, dev.filename)) + return dev + else: + handle = dev.open() + try: + buf = self.readCfg(handle, 0x1F9, 7) + sn = str("%02d" % (buf[0])) + sn += str("%02d" % (buf[1])) + sn += str("%02d" % (buf[2])) + sn += str("%02d" % (buf[3])) + sn += str("%02d" % (buf[4])) + sn += str("%02d" % (buf[5])) + sn += str("%02d" % (buf[6])) + if str(serial) == sn: + log.info('found transceiver at bus=%s device=%s serial=%s' + % (bus.dirname, dev.filename, sn)) + return dev + else: + log.info('skipping transceiver with serial %s (looking for %s)' + % (sn, serial)) + finally: + del handle + return None + + def _open_device(self, dev, interface=0): + self.devh = dev.open() + if not self.devh: + raise weewx.WeeWxIOError('Open USB device failed') + + log.info('manufacturer: %s' % self.devh.getString(dev.iManufacturer,30)) + log.info('product: %s' % self.devh.getString(dev.iProduct,30)) + log.info('interface: %d' % interface) + + # be sure kernel does not claim the interface + try: + self.devh.detachKernelDriver(interface) + except Exception: + pass + + # attempt to claim the interface + try: + log.debug('claiming USB interface %d' % interface) + self.devh.claimInterface(interface) + self.devh.setAltInterface(interface) + except usb.USBError as e: + self._close_device() + log.critical('Unable to claim USB interface %s: %s' % (interface, e)) + raise weewx.WeeWxIOError(e) + + # FIXME: this seems to be specific to ws28xx? + # FIXME: check return values + usbWait = 0.05 + self.devh.getDescriptor(0x1, 0, 0x12) + time.sleep(usbWait) + self.devh.getDescriptor(0x2, 0, 0x9) + time.sleep(usbWait) + self.devh.getDescriptor(0x2, 0, 0x22) + time.sleep(usbWait) + self.devh.controlMsg( + usb.TYPE_CLASS + usb.RECIP_INTERFACE, 0xa, [], 0x0, 0x0, 1000) + time.sleep(usbWait) + self.devh.getDescriptor(0x22, 0, 0x2a9) + time.sleep(usbWait) + + def _close_device(self): + try: + log.debug('releasing USB interface') + self.devh.releaseInterface() + except Exception: + pass + self.devh = None + + def setTX(self): + buf = [0]*0x15 + buf[0] = 0xD1 + if DEBUG_COMM > 1: + self.dump('setTX', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d1, + index=0x0000000, + timeout=self.timeout) + + def setRX(self): + buf = [0]*0x15 + buf[0] = 0xD0 + if DEBUG_COMM > 1: + self.dump('setRX', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d0, + index=0x0000000, + timeout=self.timeout) + + def getState(self): + buf = self.devh.controlMsg( + requestType=usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x0a, + value=0x00003de, + index=0x0000000, + timeout=self.timeout) + if DEBUG_COMM > 1: + self.dump('getState', buf, fmt=DEBUG_DUMP_FORMAT) + return buf + + def readConfigFlash(self, addr, numBytes): + if numBytes > 512: + raise Exception('bad number of bytes (%s)' % numBytes) + + data = None + while numBytes: + buf=[0xcc]*0x0f #0x15 + buf[0] = 0xdd + buf[1] = 0x0a + buf[2] = (addr >>8) & 0xFF + buf[3] = (addr >>0) & 0xFF + if DEBUG_COMM > 1: + self.dump('readCfgFlash>', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003dd, + index=0x0000000, + timeout=self.timeout) + buf = self.devh.controlMsg( + usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x15, + value=0x00003dc, + index=0x0000000, + timeout=self.timeout) + data=[0]*0x15 + if numBytes < 16: + for i in range(numBytes): + data[i] = buf[i+4] + numBytes = 0 + else: + for i in range(16): + data[i] = buf[i+4] + numBytes -= 16 + addr += 16 + if DEBUG_COMM > 1: + self.dump('readCfgFlash<', buf, fmt=DEBUG_DUMP_FORMAT) + return data + + def setState(self,state): + buf = [0]*0x15 + buf[0] = 0xd7 + buf[1] = state + if DEBUG_COMM > 1: + self.dump('setState', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d7, + index=0x0000000, + timeout=self.timeout) + + def setFrame(self,data,numBytes): + buf = [0]*0x111 + buf[0] = 0xd5 + buf[1] = numBytes >> 8 + buf[2] = numBytes + for i in range(numBytes): + buf[i+3] = data[i] + if DEBUG_COMM == 1: + self.dump('setFrame', buf, 'short') + elif DEBUG_COMM > 1: + self.dump('setFrame', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d5, + index=0x0000000, + timeout=self.timeout) + + def getFrame(self,data,numBytes): + buf = self.devh.controlMsg( + requestType=usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x111, + value=0x00003d6, + index=0x0000000, + timeout=self.timeout) + new_data=[0]*0x131 + new_numBytes=(buf[1] << 8 | buf[2])& 0x1ff + for i in range(new_numBytes): + new_data[i] = buf[i+3] + if DEBUG_COMM == 1: + self.dump('getFrame', buf, 'short') + elif DEBUG_COMM > 1: + self.dump('getFrame', buf, fmt=DEBUG_DUMP_FORMAT) + data[0] = new_data + numBytes[0] = new_numBytes + + def writeReg(self,regAddr,data): + buf = [0]*0x05 + buf[0] = 0xf0 + buf[1] = regAddr & 0x7F + buf[2] = 0x01 + buf[3] = data + buf[4] = 0x00 + if DEBUG_COMM > 1: + self.dump('writeReg', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003f0, + index=0x0000000, + timeout=self.timeout) + + def execute(self, command): + buf = [0]*0x0f #*0x15 + buf[0] = 0xd9 + buf[1] = command + if DEBUG_COMM > 1: + self.dump('execute', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d9, + index=0x0000000, + timeout=self.timeout) + + def setPreamblePattern(self,pattern): + buf = [0]*0x15 + buf[0] = 0xd8 + buf[1] = pattern + if DEBUG_COMM > 1: + self.dump('setPreamble', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d8, + index=0x0000000, + timeout=self.timeout) + + # three formats, long, short, auto. short shows only the first 16 bytes. + # long shows the full length of the buffer. auto shows the message length + # as indicated by the length in the message itself for setFrame and + # getFrame, or the first 16 bytes for any other message. + def dump(self, cmd, buf, fmt='auto'): + strbuf = '' + msglen = None + if fmt == 'auto': + if buf[0] in [0xd5, 0x00]: + msglen = buf[2] + 3 # use msg length for set/get frame + else: + msglen = 16 # otherwise do same as short format + elif fmt == 'short': + msglen = 16 + for i,x in enumerate(buf): + strbuf += str('%02x ' % x) + if (i+1) % 16 == 0: + self.dumpstr(cmd, strbuf) + strbuf = '' + if msglen is not None and i+1 >= msglen: + break + if strbuf: + self.dumpstr(cmd, strbuf) + + # filter output that we do not care about, pad the command string. + def dumpstr(self, cmd, strbuf): + pad = ' ' * (15-len(cmd)) + # de15 is idle, de14 is intermediate + if strbuf in ['de 15 00 00 00 00 ','de 14 00 00 00 00 ']: + if strbuf != self.last_dump or DEBUG_COMM > 2: + log.debug('%s: %s%s' % (cmd, pad, strbuf)) + self.last_dump = strbuf + else: + log.debug('%s: %s%s' % (cmd, pad, strbuf)) + self.last_dump = None + + def readCfg(self, handle, addr, numBytes): + while numBytes: + buf=[0xcc]*0x0f #0x15 + buf[0] = 0xdd + buf[1] = 0x0a + buf[2] = (addr >>8) & 0xFF + buf[3] = (addr >>0) & 0xFF + handle.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003dd, + index=0x0000000, + timeout=1000) + buf = handle.controlMsg( + usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x15, + value=0x00003dc, + index=0x0000000, + timeout=1000) + new_data=[0]*0x15 + if numBytes < 16: + for i in range(numBytes): + new_data[i] = buf[i+4] + numBytes = 0 + else: + for i in range(16): + new_data[i] = buf[i+4] + numBytes -= 16 + addr += 16 + return new_data + +class CCommunicationService(object): + + reg_names = dict() + + class AX5051RegisterNames: + REVISION = 0x0 + SCRATCH = 0x1 + POWERMODE = 0x2 + XTALOSC = 0x3 + FIFOCTRL = 0x4 + FIFODATA = 0x5 + IRQMASK = 0x6 + IFMODE = 0x8 + PINCFG1 = 0x0C + PINCFG2 = 0x0D + MODULATION = 0x10 + ENCODING = 0x11 + FRAMING = 0x12 + CRCINIT3 = 0x14 + CRCINIT2 = 0x15 + CRCINIT1 = 0x16 + CRCINIT0 = 0x17 + FREQ3 = 0x20 + FREQ2 = 0x21 + FREQ1 = 0x22 + FREQ0 = 0x23 + FSKDEV2 = 0x25 + FSKDEV1 = 0x26 + FSKDEV0 = 0x27 + IFFREQHI = 0x28 + IFFREQLO = 0x29 + PLLLOOP = 0x2C + PLLRANGING = 0x2D + PLLRNGCLK = 0x2E + TXPWR = 0x30 + TXRATEHI = 0x31 + TXRATEMID = 0x32 + TXRATELO = 0x33 + MODMISC = 0x34 + FIFOCONTROL2 = 0x37 + ADCMISC = 0x38 + AGCTARGET = 0x39 + AGCATTACK = 0x3A + AGCDECAY = 0x3B + AGCCOUNTER = 0x3C + CICDEC = 0x3F + DATARATEHI = 0x40 + DATARATELO = 0x41 + TMGGAINHI = 0x42 + TMGGAINLO = 0x43 + PHASEGAIN = 0x44 + FREQGAIN = 0x45 + FREQGAIN2 = 0x46 + AMPLGAIN = 0x47 + TRKFREQHI = 0x4C + TRKFREQLO = 0x4D + XTALCAP = 0x4F + SPAREOUT = 0x60 + TESTOBS = 0x68 + APEOVER = 0x70 + TMMUX = 0x71 + PLLVCOI = 0x72 + PLLCPEN = 0x73 + PLLRNGMISC = 0x74 + AGCMANUAL = 0x78 + ADCDCLEVEL = 0x79 + RFMISC = 0x7A + TXDRIVER = 0x7B + REF = 0x7C + RXMISC = 0x7D + + def __init__(self): + log.debug('CCommunicationService.init') + + self.shid = sHID() + self.DataStore = CDataStore() + + self.firstSleep = 1 + self.nextSleep = 1 + self.pollCount = 0 + + self.running = False + self.child = None + self.thread_wait = 60.0 # seconds + + self.command = None + self.history_cache = HistoryCache() + # do not set time when offset to whole hour is <= _a3_offset + self._a3_offset = 3 + + def buildFirstConfigFrame(self, Buffer, cs): + log.debug('buildFirstConfigFrame: cs=%04x' % cs) + newBuffer = [0] + newBuffer[0] = [0]*9 + comInt = self.DataStore.getCommModeInterval() + historyAddress = 0xFFFFFF + newBuffer[0][0] = 0xf0 + newBuffer[0][1] = 0xf0 + newBuffer[0][2] = EAction.aGetConfig + newBuffer[0][3] = (cs >> 8) & 0xff + newBuffer[0][4] = (cs >> 0) & 0xff + newBuffer[0][5] = (comInt >> 4) & 0xff + newBuffer[0][6] = (historyAddress >> 16) & 0x0f | 16 * (comInt & 0xf) + newBuffer[0][7] = (historyAddress >> 8 ) & 0xff + newBuffer[0][8] = (historyAddress >> 0 ) & 0xff + Buffer[0] = newBuffer[0] + Length = 0x09 + return Length + + def buildConfigFrame(self, Buffer): + log.debug("buildConfigFrame") + newBuffer = [0] + newBuffer[0] = [0]*48 + cfgBuffer = [0] + cfgBuffer[0] = [0]*44 + changed = self.DataStore.StationConfig.testConfigChanged(cfgBuffer) + if changed: + self.shid.dump('OutBuf', cfgBuffer[0], fmt='long') + newBuffer[0][0] = Buffer[0][0] + newBuffer[0][1] = Buffer[0][1] + newBuffer[0][2] = EAction.aSendConfig # 0x40 # change this value if we won't store config + newBuffer[0][3] = Buffer[0][3] + for i in range(44): + newBuffer[0][i+4] = cfgBuffer[0][i] + Buffer[0] = newBuffer[0] + Length = 48 # 0x30 + else: # current config not up to date; do not write yet + Length = 0 + return Length + + def buildTimeFrame(self, Buffer, cs): + log.debug("buildTimeFrame: cs=%04x" % cs) + + now = time.time() + tm = time.localtime(now) + + newBuffer=[0] + newBuffer[0]=Buffer[0] + #00000000: d5 00 0c 00 32 c0 00 8f 45 25 15 91 31 20 01 00 + #00000000: d5 00 0c 00 32 c0 06 c1 47 25 15 91 31 20 01 00 + # 3 4 5 6 7 8 9 10 11 + newBuffer[0][2] = EAction.aSendTime # 0xc0 + newBuffer[0][3] = (cs >> 8) & 0xFF + newBuffer[0][4] = (cs >> 0) & 0xFF + newBuffer[0][5] = (tm[5] % 10) + 0x10 * (tm[5] // 10) #sec + newBuffer[0][6] = (tm[4] % 10) + 0x10 * (tm[4] // 10) #min + newBuffer[0][7] = (tm[3] % 10) + 0x10 * (tm[3] // 10) #hour + #DayOfWeek = tm[6] - 1; #ole from 1 - 7 - 1=Sun... 0-6 0=Sun + DayOfWeek = tm[6] #py from 0 - 6 - 0=Mon + newBuffer[0][8] = DayOfWeek % 10 + 0x10 * (tm[2] % 10) #DoW + Day + newBuffer[0][9] = (tm[2] // 10) + 0x10 * (tm[1] % 10) #day + month + newBuffer[0][10] = (tm[1] // 10) + 0x10 * ((tm[0] - 2000) % 10) #month + year + newBuffer[0][11] = (tm[0] - 2000) // 10 #year + Buffer[0]=newBuffer[0] + Length = 0x0c + return Length + + def buildACKFrame(self, Buffer, action, cs, hidx=None): + if DEBUG_COMM > 1: + log.debug("buildACKFrame: action=%x cs=%04x historyIndex=%s" + % (action, cs, hidx)) + newBuffer = [0] + newBuffer[0] = [0]*9 + for i in range(2): + newBuffer[0][i] = Buffer[0][i] + + comInt = self.DataStore.getCommModeInterval() + + # When last weather is stale, change action to get current weather + # This is only needed during long periods of history data catchup + if self.command == EAction.aGetHistory: + now = int(time.time()) + age = now - self.DataStore.LastStat.last_weather_ts + # Morphing action only with GetHistory requests, + # and stale data after a period of twice the CommModeInterval, + # but not with init GetHistory requests (0xF0) + if action == EAction.aGetHistory and age >= (comInt +1) * 2 and newBuffer[0][1] != 0xF0: + if DEBUG_COMM > 0: + log.debug('buildACKFrame: morphing action from %d to 5 (age=%s)' % (action, age)) + action = EAction.aGetCurrent + + if hidx is None: + if self.command == EAction.aGetHistory: + hidx = self.history_cache.next_index + elif self.DataStore.getLastHistoryIndex() is not None: + hidx = self.DataStore.getLastHistoryIndex() + if hidx is None or hidx < 0 or hidx >= WS28xxDriver.max_records: + haddr = 0xffffff + else: + haddr = index_to_addr(hidx) + if DEBUG_COMM > 1: + log.debug('buildACKFrame: idx: %s addr: 0x%04x' % (hidx, haddr)) + + newBuffer[0][2] = action & 0xF + newBuffer[0][3] = (cs >> 8) & 0xFF + newBuffer[0][4] = (cs >> 0) & 0xFF + newBuffer[0][5] = (comInt >> 4) & 0xFF + newBuffer[0][6] = (haddr >> 16) & 0x0F | 16 * (comInt & 0xF) + newBuffer[0][7] = (haddr >> 8 ) & 0xFF + newBuffer[0][8] = (haddr >> 0 ) & 0xFF + + #d5 00 09 f0 f0 03 00 32 00 3f ff ff + Buffer[0]=newBuffer[0] + return 9 + + def handleWsAck(self,Buffer,Length): + log.debug('handleWsAck') + self.DataStore.setLastStatCache(seen_ts=int(time.time()), + quality=(Buffer[0][3] & 0x7f), + battery=(Buffer[0][2] & 0xf)) + + def handleConfig(self,Buffer,Length): + log.debug('handleConfig: %s' % self.timing()) + if DEBUG_CONFIG_DATA > 2: + self.shid.dump('InBuf', Buffer[0], fmt='long') + newBuffer=[0] + newBuffer[0] = Buffer[0] + newLength = [0] + now = int(time.time()) + self.DataStore.StationConfig.read(newBuffer) + if DEBUG_CONFIG_DATA > 1: + self.DataStore.StationConfig.toLog() + self.DataStore.setLastStatCache(seen_ts=now, + quality=(Buffer[0][3] & 0x7f), + battery=(Buffer[0][2] & 0xf), + config_ts=now) + cs = newBuffer[0][47] | (newBuffer[0][46] << 8) + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + + Buffer[0] = newBuffer[0] + Length[0] = newLength[0] + + def handleCurrentData(self,Buffer,Length): + if DEBUG_WEATHER_DATA > 0: + log.debug('handleCurrentData: %s' % self.timing()) + + now = int(time.time()) + + # update the weather data cache if changed or stale + chksum = CCurrentWeatherData.calcChecksum(Buffer) + age = now - self.DataStore.LastStat.last_weather_ts + if age >= 10 or chksum != self.DataStore.CurrentWeather.checksum(): + if DEBUG_WEATHER_DATA > 2: + self.shid.dump('CurWea', Buffer[0], fmt='long') + data = CCurrentWeatherData() + data.read(Buffer) + self.DataStore.setCurrentWeather(data) + if DEBUG_WEATHER_DATA > 1: + data.toLog() + + # update the connection cache + self.DataStore.setLastStatCache(seen_ts=now, + quality=(Buffer[0][3] & 0x7f), + battery=(Buffer[0][2] & 0xf), + weather_ts=now) + + newBuffer = [0] + newBuffer[0] = Buffer[0] + newLength = [0] + + cs = newBuffer[0][5] | (newBuffer[0][4] << 8) + + cfgBuffer = [0] + cfgBuffer[0] = [0]*44 + changed = self.DataStore.StationConfig.testConfigChanged(cfgBuffer) + inBufCS = self.DataStore.StationConfig.getInBufCS() + if inBufCS == 0 or inBufCS != cs: + # request for a get config + log.debug('handleCurrentData: inBufCS of station does not match') + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetConfig, cs) + elif changed: + # Request for a set config + log.debug('handleCurrentData: outBufCS of station changed') + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aReqSetConfig, cs) + else: + # Request for either a history message or a current weather message + # In general we don't use EAction.aGetCurrent to ask for a current + # weather message; they also come when requested for + # EAction.aGetHistory. This we learned from the Heavy Weather Pro + # messages (via USB sniffer). + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + + Length[0] = newLength[0] + Buffer[0] = newBuffer[0] + + def handleHistoryData(self, buf, buflen): + if DEBUG_HISTORY_DATA > 0: + log.debug('handleHistoryData: %s' % self.timing()) + + now = int(time.time()) + self.DataStore.setLastStatCache(seen_ts=now, + quality=(buf[0][3] & 0x7f), + battery=(buf[0][2] & 0xf), + history_ts=now) + + newbuf = [0] + newbuf[0] = buf[0] + newlen = [0] + data = CHistoryData() + data.read(newbuf) + if DEBUG_HISTORY_DATA > 1: + data.toLog() + + cs = newbuf[0][5] | (newbuf[0][4] << 8) + latestAddr = bytes_to_addr(buf[0][6], buf[0][7], buf[0][8]) + thisAddr = bytes_to_addr(buf[0][9], buf[0][10], buf[0][11]) + latestIndex = addr_to_index(latestAddr) + thisIndex = addr_to_index(thisAddr) + ts = tstr_to_ts(str(data.Time)) + + nrec = get_index(latestIndex - thisIndex) + log.debug('handleHistoryData: time=%s' + ' this=%d (0x%04x) latest=%d (0x%04x) nrec=%d' + % (data.Time, thisIndex, thisAddr, latestIndex, latestAddr, nrec)) + + # track the latest history index + self.DataStore.setLastHistoryIndex(thisIndex) + self.DataStore.setLatestHistoryIndex(latestIndex) + + nextIndex = None + if self.command == EAction.aGetHistory: + if self.history_cache.start_index is None: + nreq = 0 + if self.history_cache.num_rec > 0: + log.info('handleHistoryData: request for %s records' + % self.history_cache.num_rec) + nreq = self.history_cache.num_rec + else: + log.info('handleHistoryData: request records since %s' + % weeutil.weeutil.timestamp_to_string(self.history_cache.since_ts)) + span = int(time.time()) - self.history_cache.since_ts + # FIXME: what if we do not have config data yet? + cfg = self.getConfigData().asDict() + arcint = 60 * getHistoryInterval(cfg['history_interval']) + # FIXME: this assumes a constant archive interval for all + # records in the station history + nreq = int(span / arcint) + 5 # FIXME: punt 5 + if nreq > nrec: + log.info('handleHistoryData: too many records requested (%d)' + ', clipping to number stored (%d)' % (nreq, nrec)) + nreq = nrec + idx = get_index(latestIndex - nreq) + self.history_cache.start_index = idx + self.history_cache.next_index = idx + self.DataStore.setLastHistoryIndex(idx) + self.history_cache.num_outstanding_records = nreq + log.debug('handleHistoryData: start_index=%s' + ' num_outstanding_records=%s' % (idx, nreq)) + nextIndex = idx + elif self.history_cache.next_index is not None: + # thisIndex should be the next record after next_index + thisIndexTst = get_next_index(self.history_cache.next_index) + if thisIndexTst == thisIndex: + self.history_cache.num_scanned += 1 + # get the next history record + if ts is not None and self.history_cache.since_ts <= ts: + # Check if two records in a row with the same ts + if self.history_cache.last_ts == ts: + log.debug('handleHistoryData: remove previous record' + ' with duplicate timestamp: %s' % + weeutil.weeutil.timestamp_to_string(ts)) + self.history_cache.records.pop() + self.history_cache.last_ts = ts + # append to the history + log.debug('handleHistoryData: appending history record' + ' %s: %s' % (thisIndex, data.asDict())) + self.history_cache.records.append(data.asDict()) + self.history_cache.num_outstanding_records = nrec + elif ts is None: + log.error('handleHistoryData: skip record: this_ts=None') + else: + log.debug('handleHistoryData: skip record: since_ts=%s this_ts=%s' + % (weeutil.weeutil.timestamp_to_string(self.history_cache.since_ts), + weeutil.weeutil.timestamp_to_string(ts))) + self.history_cache.next_index = thisIndex + else: + log.info('handleHistoryData: index mismatch: %s != %s' + % (thisIndexTst, thisIndex)) + nextIndex = self.history_cache.next_index + + log.debug('handleHistoryData: next=%s' % nextIndex) + self.setSleep(0.300,0.010) + newlen[0] = self.buildACKFrame(newbuf, EAction.aGetHistory, cs, nextIndex) + + buflen[0] = newlen[0] + buf[0] = newbuf[0] + + def handleNextAction(self,Buffer,Length): + newBuffer = [0] + newBuffer[0] = Buffer[0] + newLength = [0] + newLength[0] = Length[0] + self.DataStore.setLastStatCache(seen_ts=int(time.time()), + quality=(Buffer[0][3] & 0x7f)) + cs = newBuffer[0][5] | (newBuffer[0][4] << 8) + if (Buffer[0][2] & 0xEF) == EResponseType.rtReqFirstConfig: + log.debug('handleNextAction: a1 (first-time config)') + self.setSleep(0.085,0.005) + newLength[0] = self.buildFirstConfigFrame(newBuffer, cs) + elif (Buffer[0][2] & 0xEF) == EResponseType.rtReqSetConfig: + log.debug('handleNextAction: a2 (set config data)') + self.setSleep(0.085,0.005) + newLength[0] = self.buildConfigFrame(newBuffer) + elif (Buffer[0][2] & 0xEF) == EResponseType.rtReqSetTime: + log.debug('handleNextAction: a3 (set time data)') + now = int(time.time()) + age = now - self.DataStore.LastStat.last_weather_ts + if age >= (self.DataStore.getCommModeInterval() +1) * 2: + # always set time if init or stale communication + self.setSleep(0.085,0.005) + newLength[0] = self.buildTimeFrame(newBuffer, cs) + else: + # When time is set at the whole hour we may get an extra + # historical record with time stamp a history period ahead + # We will skip settime if offset to whole hour is too small + # (time difference between WS and server < self._a3_offset) + m, s = divmod(now, 60) + h, m = divmod(m, 60) + log.debug('Time: hh:%02d:%02d' % (m,s)) + if (m == 59 and s >= (60 - self._a3_offset)) or (m == 0 and s <= self._a3_offset): + log.debug('Skip settime; time difference <= %s s' % int(self._a3_offset)) + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + else: + # set time + self.setSleep(0.085,0.005) + newLength[0] = self.buildTimeFrame(newBuffer, cs) + else: + log.debug('handleNextAction: %02x' % (Buffer[0][2] & 0xEF)) + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + + Length[0] = newLength[0] + Buffer[0] = newBuffer[0] + + def generateResponse(self, Buffer, Length): + if DEBUG_COMM > 1: + log.debug('generateResponse: %s' % self.timing()) + newBuffer = [0] + newBuffer[0] = Buffer[0] + newLength = [0] + newLength[0] = Length[0] + if Length[0] == 0: + raise BadResponse('zero length buffer') + + bufferID = (Buffer[0][0] <<8) | Buffer[0][1] + respType = (Buffer[0][2] & 0xE0) + if DEBUG_COMM > 1: + log.debug("generateResponse: id=%04x resp=%x length=%x" + % (bufferID, respType, Length[0])) + deviceID = self.DataStore.getDeviceID() + if bufferID != 0xF0F0: + self.DataStore.setRegisteredDeviceID(bufferID) + + if bufferID == 0xF0F0: + log.info('generateResponse: console not paired, attempting to pair to 0x%04x' % deviceID) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetConfig, deviceID, 0xFFFF) + elif bufferID == deviceID: + if respType == EResponseType.rtDataWritten: + # 00000000: 00 00 06 00 32 20 + if Length[0] == 0x06: + self.DataStore.StationConfig.setResetMinMaxFlags(0) + self.shid.setRX() + raise DataWritten() + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtGetConfig: + # 00000000: 00 00 30 00 32 40 + if Length[0] == 0x30: + self.handleConfig(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtGetCurrentWeather: + # 00000000: 00 00 d7 00 32 60 + if Length[0] == 0xd7: #215 + self.handleCurrentData(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtGetHistory: + # 00000000: 00 00 1e 00 32 80 + if Length[0] == 0x1e: + self.handleHistoryData(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtRequest: + # 00000000: 00 00 06 f0 f0 a1 + # 00000000: 00 00 06 00 32 a3 + # 00000000: 00 00 06 00 32 a2 + if Length[0] == 0x06: + self.handleNextAction(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + else: + raise BadResponse('unexpected response type %x' % respType) + elif respType not in [0x20,0x40,0x60,0x80,0xa1,0xa2,0xa3]: + # message is probably corrupt + raise BadResponse('unknown response type %x' % respType) + else: + msg = 'message from console contains unknown device ID (id=%04x resp=%x)' % (bufferID, respType) + log.debug(msg) + log_frame(Length[0],Buffer[0]) + raise BadResponse(msg) + + Buffer[0] = newBuffer[0] + Length[0] = newLength[0] + + def configureRegisterNames(self): + self.reg_names[self.AX5051RegisterNames.IFMODE] =0x00 + self.reg_names[self.AX5051RegisterNames.MODULATION]=0x41 #fsk + self.reg_names[self.AX5051RegisterNames.ENCODING] =0x07 + self.reg_names[self.AX5051RegisterNames.FRAMING] =0x84 #1000:0100 ##?hdlc? |1000 010 0 + self.reg_names[self.AX5051RegisterNames.CRCINIT3] =0xff + self.reg_names[self.AX5051RegisterNames.CRCINIT2] =0xff + self.reg_names[self.AX5051RegisterNames.CRCINIT1] =0xff + self.reg_names[self.AX5051RegisterNames.CRCINIT0] =0xff + self.reg_names[self.AX5051RegisterNames.FREQ3] =0x38 + self.reg_names[self.AX5051RegisterNames.FREQ2] =0x90 + self.reg_names[self.AX5051RegisterNames.FREQ1] =0x00 + self.reg_names[self.AX5051RegisterNames.FREQ0] =0x01 + self.reg_names[self.AX5051RegisterNames.PLLLOOP] =0x1d + self.reg_names[self.AX5051RegisterNames.PLLRANGING]=0x08 + self.reg_names[self.AX5051RegisterNames.PLLRNGCLK] =0x03 + self.reg_names[self.AX5051RegisterNames.MODMISC] =0x03 + self.reg_names[self.AX5051RegisterNames.SPAREOUT] =0x00 + self.reg_names[self.AX5051RegisterNames.TESTOBS] =0x00 + self.reg_names[self.AX5051RegisterNames.APEOVER] =0x00 + self.reg_names[self.AX5051RegisterNames.TMMUX] =0x00 + self.reg_names[self.AX5051RegisterNames.PLLVCOI] =0x01 + self.reg_names[self.AX5051RegisterNames.PLLCPEN] =0x01 + self.reg_names[self.AX5051RegisterNames.RFMISC] =0xb0 + self.reg_names[self.AX5051RegisterNames.REF] =0x23 + self.reg_names[self.AX5051RegisterNames.IFFREQHI] =0x20 + self.reg_names[self.AX5051RegisterNames.IFFREQLO] =0x00 + self.reg_names[self.AX5051RegisterNames.ADCMISC] =0x01 + self.reg_names[self.AX5051RegisterNames.AGCTARGET] =0x0e + self.reg_names[self.AX5051RegisterNames.AGCATTACK] =0x11 + self.reg_names[self.AX5051RegisterNames.AGCDECAY] =0x0e + self.reg_names[self.AX5051RegisterNames.CICDEC] =0x3f + self.reg_names[self.AX5051RegisterNames.DATARATEHI]=0x19 + self.reg_names[self.AX5051RegisterNames.DATARATELO]=0x66 + self.reg_names[self.AX5051RegisterNames.TMGGAINHI] =0x01 + self.reg_names[self.AX5051RegisterNames.TMGGAINLO] =0x96 + self.reg_names[self.AX5051RegisterNames.PHASEGAIN] =0x03 + self.reg_names[self.AX5051RegisterNames.FREQGAIN] =0x04 + self.reg_names[self.AX5051RegisterNames.FREQGAIN2] =0x0a + self.reg_names[self.AX5051RegisterNames.AMPLGAIN] =0x06 + self.reg_names[self.AX5051RegisterNames.AGCMANUAL] =0x00 + self.reg_names[self.AX5051RegisterNames.ADCDCLEVEL]=0x10 + self.reg_names[self.AX5051RegisterNames.RXMISC] =0x35 + self.reg_names[self.AX5051RegisterNames.FSKDEV2] =0x00 + self.reg_names[self.AX5051RegisterNames.FSKDEV1] =0x31 + self.reg_names[self.AX5051RegisterNames.FSKDEV0] =0x27 + self.reg_names[self.AX5051RegisterNames.TXPWR] =0x03 + self.reg_names[self.AX5051RegisterNames.TXRATEHI] =0x00 + self.reg_names[self.AX5051RegisterNames.TXRATEMID] =0x51 + self.reg_names[self.AX5051RegisterNames.TXRATELO] =0xec + self.reg_names[self.AX5051RegisterNames.TXDRIVER] =0x88 + + def initTransceiver(self, frequency_standard): + log.debug('initTransceiver: frequency_standard=%s' % frequency_standard) + + self.DataStore.setFrequencyStandard(frequency_standard) + self.configureRegisterNames() + + # calculate the frequency then set frequency registers + freq = self.DataStore.TransceiverSettings.Frequency + log.info('base frequency: %d' % freq) + freqVal = int(freq / 16000000.0 * 16777216.0) + corVec = self.shid.readConfigFlash(0x1F5, 4) + corVal = corVec[0] << 8 + corVal |= corVec[1] + corVal <<= 8 + corVal |= corVec[2] + corVal <<= 8 + corVal |= corVec[3] + log.info('frequency correction: %d (0x%x)' % (corVal, corVal)) + freqVal += corVal + if not (freqVal % 2): + freqVal += 1 + log.info('adjusted frequency: %d (0x%x)' % (freqVal, freqVal)) + self.reg_names[self.AX5051RegisterNames.FREQ3] = (freqVal >>24) & 0xFF + self.reg_names[self.AX5051RegisterNames.FREQ2] = (freqVal >>16) & 0xFF + self.reg_names[self.AX5051RegisterNames.FREQ1] = (freqVal >>8) & 0xFF + self.reg_names[self.AX5051RegisterNames.FREQ0] = (freqVal >>0) & 0xFF + log.debug('frequency registers: %x %x %x %x' % ( + self.reg_names[self.AX5051RegisterNames.FREQ3], + self.reg_names[self.AX5051RegisterNames.FREQ2], + self.reg_names[self.AX5051RegisterNames.FREQ1], + self.reg_names[self.AX5051RegisterNames.FREQ0])) + + # figure out the transceiver id + buf = self.shid.readConfigFlash(0x1F9, 7) + tid = buf[5] << 8 + tid += buf[6] + log.info('transceiver identifier: %d (0x%04x)' % (tid, tid)) + self.DataStore.setDeviceID(tid) + + # figure out the transceiver serial number + sn = str("%02d"%(buf[0])) + sn += str("%02d"%(buf[1])) + sn += str("%02d"%(buf[2])) + sn += str("%02d"%(buf[3])) + sn += str("%02d"%(buf[4])) + sn += str("%02d"%(buf[5])) + sn += str("%02d"%(buf[6])) + log.info('transceiver serial: %s' % sn) + self.DataStore.setTransceiverSerNo(sn) + + for r in self.reg_names: + self.shid.writeReg(r, self.reg_names[r]) + + def setup(self, frequency_standard, + vendor_id, product_id, device_id, serial, + comm_interval=3): + self.DataStore.setCommModeInterval(comm_interval) + self.shid.open(vendor_id, product_id, device_id, serial) + self.initTransceiver(frequency_standard) + self.DataStore.setTransceiverPresent(True) + + def teardown(self): + self.shid.close() + + # FIXME: make this thread-safe + def getWeatherData(self): + return self.DataStore.CurrentWeather + + # FIXME: make this thread-safe + def getLastStat(self): + return self.DataStore.LastStat + + # FIXME: make this thread-safe + def getConfigData(self): + return self.DataStore.StationConfig + + def startCachingHistory(self, since_ts=0, num_rec=0): + self.history_cache.clear_records() + if since_ts is None: + since_ts = 0 + self.history_cache.since_ts = since_ts + if num_rec > WS28xxDriver.max_records - 2: + num_rec = WS28xxDriver.max_records - 2 + self.history_cache.num_rec = num_rec + self.command = EAction.aGetHistory + + def stopCachingHistory(self): + self.command = None + + def getUncachedHistoryCount(self): + return self.history_cache.num_outstanding_records + + def getNextHistoryIndex(self): + return self.history_cache.next_index + + def getNumHistoryScanned(self): + return self.history_cache.num_scanned + + def getLatestHistoryIndex(self): + return self.DataStore.LastStat.LatestHistoryIndex + + def getHistoryCacheRecords(self): + return self.history_cache.records + + def clearHistoryCache(self): + self.history_cache.clear_records() + + def startRFThread(self): + if self.child is not None: + return + log.debug('startRFThread: spawning RF thread') + self.running = True + self.child = threading.Thread(target=self.doRF) + self.child.name = 'RFComm' + self.child.daemon = True + self.child.start() + + def stopRFThread(self): + self.running = False + log.debug('stopRFThread: waiting for RF thread to terminate') + self.child.join(self.thread_wait) + if self.child.is_alive(): + log.error('unable to terminate RF thread after %d seconds' + % self.thread_wait) + else: + self.child = None + + def isRunning(self): + return self.running + + def doRF(self): + try: + log.debug('setting up rf communication') + self.doRFSetup() + log.debug('starting rf communication') + while self.running: + self.doRFCommunication() + except Exception as e: + log.error('exception in doRF: %s' % e) + weeutil.logger.log_traceback(log.error) + self.running = False +# raise + finally: + log.debug('stopping rf communication') + + # it is probably not necessary to have two setPreamblePattern invocations. + # however, HeavyWeatherPro seems to do it this way on a first time config. + # doing it this way makes configuration easier during a factory reset and + # when re-establishing communication with the station sensors. + def doRFSetup(self): + self.shid.execute(5) + self.shid.setPreamblePattern(0xaa) + self.shid.setState(0) + time.sleep(1) + self.shid.setRX() + + self.shid.setPreamblePattern(0xaa) + self.shid.setState(0x1e) + time.sleep(1) + self.shid.setRX() + self.setSleep(0.085,0.005) + + def doRFCommunication(self): + time.sleep(self.firstSleep) + self.pollCount = 0 + while self.running: + state_buffer = self.shid.getState() + self.pollCount += 1 + if state_buffer[1] == 0x16: + break + time.sleep(self.nextSleep) + else: + return + + DataLength = [0] + DataLength[0] = 0 + FrameBuffer=[0] + FrameBuffer[0]=[0]*0x03 + self.shid.getFrame(FrameBuffer, DataLength) + try: + self.generateResponse(FrameBuffer, DataLength) + self.shid.setFrame(FrameBuffer[0], DataLength[0]) + except BadResponse as e: + log.error('generateResponse failed: %s' % e) + except DataWritten as e: + log.debug('SetTime/SetConfig data written') + self.shid.setTX() + + # these are for diagnostics and debugging + def setSleep(self, firstsleep, nextsleep): + self.firstSleep = firstsleep + self.nextSleep = nextsleep + + def timing(self): + s = self.firstSleep + self.nextSleep * (self.pollCount - 1) + return 'sleep=%s first=%s next=%s count=%s' % ( + s, self.firstSleep, self.nextSleep, self.pollCount) diff --git a/dist/weewx-4.10.1/bin/weewx/engine.py b/dist/weewx-4.10.1/bin/weewx/engine.py new file mode 100644 index 0000000..a2172ea --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/engine.py @@ -0,0 +1,874 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Main engine for the weewx weather system.""" + +# Python imports +from __future__ import absolute_import +from __future__ import print_function + +import gc +import logging +import socket +import sys +import threading +import time + +import configobj + +# weewx imports: +import weeutil.config +import weeutil.logger +import weeutil.weeutil +import weewx.accum +import weewx.manager +import weewx.qc +import weewx.station +import weewx.units +from weeutil.weeutil import to_bool, to_int, to_sorted_string +from weewx import all_service_groups + +log = logging.getLogger(__name__) + + +class BreakLoop(Exception): + """Exception raised when it's time to break the main loop.""" + + +class InitializationError(weewx.WeeWxIOError): + """Exception raised when unable to initialize the console.""" + + +# ============================================================================== +# Class StdEngine +# ============================================================================== + +class StdEngine(object): + """The main engine responsible for the creating and dispatching of events + from the weather station. + + It loads a set of services, specified by an option in the configuration + file. + + When a service loads, it binds callbacks to events. When an event occurs, + the bound callback will be called.""" + + def __init__(self, config_dict): + """Initialize an instance of StdEngine. + + config_dict: The configuration dictionary. """ + + # Set a default socket time out, in case FTP or HTTP hang: + timeout = int(config_dict.get('socket_timeout', 20)) + socket.setdefaulttimeout(timeout) + + # Default garbage collection is every 3 hours: + self.gc_interval = int(config_dict.get('gc_interval', 3 * 3600)) + + # Whether to log events. This can be very verbose. + self.log_events = to_bool(config_dict.get('log_events', False)) + + # The callback dictionary: + self.callbacks = dict() + + # This will hold an instance of the device driver + self.console = None + + # Set up the device driver: + self.setupStation(config_dict) + + # Set up information about the station + self.stn_info = weewx.station.StationInfo(self.console, **config_dict['Station']) + + # Set up the database binder + self.db_binder = weewx.manager.DBBinder(config_dict) + + # The list of instantiated services + self.service_obj = [] + + # Load the services: + self.loadServices(config_dict) + + def setupStation(self, config_dict): + """Set up the weather station hardware.""" + + # Get the hardware type from the configuration dictionary. This will be + # a string such as "VantagePro" + station_type = config_dict['Station']['station_type'] + + # Find the driver name for this type of hardware + driver = config_dict[station_type]['driver'] + + log.info("Loading station type %s (%s)", station_type, driver) + + # Import the driver: + __import__(driver) + + # Open up the weather station, wrapping it in a try block in case + # of failure. + try: + # This is a bit of Python wizardry. First, find the driver module + # in sys.modules. + driver_module = sys.modules[driver] + # Find the function 'loader' within the module: + loader_function = getattr(driver_module, 'loader') + # Call it with the configuration dictionary as the only argument: + self.console = loader_function(config_dict, self) + except Exception as ex: + log.error("Import of driver failed: %s (%s)", ex, type(ex)) + weeutil.logger.log_traceback(log.critical, " **** ") + # Signal that we have an initialization error: + raise InitializationError(ex) + + def loadServices(self, config_dict): + """Set up the services to be run.""" + + # Make sure all service groups are lists (if there's just a single entry, ConfigObj + # will parse it as a string if it did not have a trailing comma). + for service_group in config_dict['Engine']['Services']: + if not isinstance(config_dict['Engine']['Services'][service_group], list): + config_dict['Engine']['Services'][service_group] \ + = [config_dict['Engine']['Services'][service_group]] + + # Versions before v4.2 did not have the service group 'xtype_services'. Set a default + # for them: + config_dict['Engine']['Services'].setdefault('xtype_services', + ['weewx.wxxtypes.StdWXXTypes', + 'weewx.wxxtypes.StdPressureCooker', + 'weewx.wxxtypes.StdRainRater', + 'weewx.wxxtypes.StdDelta']) + + # Wrap the instantiation of the services in a try block, so if an + # exception occurs, any service that may have started can be shut + # down in an orderly way. + try: + # Go through each of the service lists one by one: + for service_group in all_service_groups: + # For each service list, retrieve all the listed services. + # Provide a default, empty list in case the service list is + # missing completely: + svcs = config_dict['Engine']['Services'].get(service_group, []) + for svc in svcs: + if svc == '': + log.debug("No services in service group %s", service_group) + continue + log.debug("Loading service %s", svc) + # Get the class, then instantiate it with self and the config dictionary as + # arguments: + obj = weeutil.weeutil.get_object(svc)(self, config_dict) + # Append it to the list of open services. + self.service_obj.append(obj) + log.debug("Finished loading service %s", svc) + except Exception: + # An exception occurred. Shut down any running services, then + # reraise the exception. + self.shutDown() + raise + + def run(self): + """Main execution entry point.""" + + # Wrap the outer loop in a try block so we can do an orderly shutdown + # should an exception occur: + try: + # Send out a STARTUP event: + self.dispatchEvent(weewx.Event(weewx.STARTUP)) + + log.info("Starting main packet loop.") + + last_gc = time.time() + + # This is the outer loop. + while True: + + # See if garbage collection is scheduled: + if time.time() - last_gc > self.gc_interval: + gc_start = time.time() + ngc = gc.collect() + last_gc = time.time() + gc_time = last_gc - gc_start + log.info("Garbage collected %d objects in %.2f seconds", ngc, gc_time) + + # First, let any interested services know the packet LOOP is + # about to start + self.dispatchEvent(weewx.Event(weewx.PRE_LOOP)) + + # Get ready to enter the main packet loop. An exception of type + # BreakLoop will get thrown when a service wants to break the + # loop and interact with the console. + try: + + # And this is the main packet LOOP. It will continuously + # generate LOOP packets until some service breaks it by + # throwing an exception (usually when an archive period + # has passed). + for packet in self.console.genLoopPackets(): + # Package the packet as an event, then dispatch it. + self.dispatchEvent(weewx.Event(weewx.NEW_LOOP_PACKET, packet=packet)) + + # Allow services to break the loop by throwing + # an exception: + self.dispatchEvent(weewx.Event(weewx.CHECK_LOOP, packet=packet)) + + log.critical("Internal error. Packet loop has exited.") + + except BreakLoop: + + # Send out an event saying the packet LOOP is done: + self.dispatchEvent(weewx.Event(weewx.POST_LOOP)) + + finally: + # The main loop has exited. Shut the engine down. + log.info("Main loop exiting. Shutting engine down.") + self.shutDown() + + def bind(self, event_type, callback): + """Binds an event to a callback function.""" + + # Each event type has a list of callback functions to be called. + # If we have not seen the event type yet, then create an empty list, + # otherwise append to the existing list: + self.callbacks.setdefault(event_type, []).append(callback) + + def dispatchEvent(self, event): + """Call all registered callbacks for an event.""" + # See if any callbacks have been registered for this event type: + if event.event_type in self.callbacks: + if self.log_events: + log.debug(event) + # Yes, at least one has been registered. Call them in order: + for callback in self.callbacks[event.event_type]: + # Call the function with the event as an argument: + callback(event) + + def shutDown(self): + """Run when an engine shutdown is requested.""" + + # Shut down all the services + while self.service_obj: + # Wrap each individual service shutdown, in case of a problem. + try: + # Start from the end of the list and move forward + self.service_obj[-1].shutDown() + except: + pass + # Delete the actual service + del self.service_obj[-1] + + try: + # Close the console: + self.console.closePort() + except: + pass + + try: + self.db_binder.close() + except: + pass + + def _get_console_time(self): + try: + return self.console.getTime() + except NotImplementedError: + return int(time.time() + 0.5) + + +# ============================================================================== +# Class DummyEngine +# ============================================================================== + +class DummyEngine(StdEngine): + """A dummy engine, useful for loading services, but without actually running the engine.""" + + class DummyConsole(object): + """A dummy console, used to offer an archive_interval.""" + + def __init__(self, config_dict): + try: + self.archive_interval = to_int(config_dict['StdArchive']['archive_interval']) + except KeyError: + self.archive_interval = 300 + + def closePort(self): + pass + + def setupStation(self, config_dict): + self.console = DummyEngine.DummyConsole(config_dict) + + +# ============================================================================== +# Class StdService +# ============================================================================== + +class StdService(object): + """Abstract base class for all services.""" + + def __init__(self, engine, config_dict): + self.engine = engine + self.config_dict = config_dict + + def bind(self, event_type, callback): + """Bind the specified event to a callback.""" + # Just forward the request to the main engine: + self.engine.bind(event_type, callback) + + def shutDown(self): + pass + + +# ============================================================================== +# Class StdConvert +# ============================================================================== + +class StdConvert(StdService): + """Service for performing unit conversions. + + This service acts as a filter. Whatever packets and records come in are + converted to a target unit system. + + This service should be run before most of the others, so observations appear + in the correct unit.""" + + def __init__(self, engine, config_dict): + # Initialize my base class: + super(StdConvert, self).__init__(engine, config_dict) + + # Get the target unit nickname (something like 'US' or 'METRIC'). If there is no + # target, then do nothing + try: + target_unit_nickname = config_dict['StdConvert']['target_unit'] + except KeyError: + # Missing target unit. + return + # Get the target unit: weewx.US, weewx.METRIC, weewx.METRICWX + self.target_unit = weewx.units.unit_constants[target_unit_nickname.upper()] + # Bind self.converter to the appropriate standard converter + self.converter = weewx.units.StdUnitConverters[self.target_unit] + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + log.info("StdConvert target unit is 0x%x", self.target_unit) + + def new_loop_packet(self, event): + """Do unit conversions for a LOOP packet""" + # No need to do anything if the packet is already in the target + # unit system + if event.packet['usUnits'] == self.target_unit: + return + # Perform the conversion + converted_packet = self.converter.convertDict(event.packet) + # Add the new unit system + converted_packet['usUnits'] = self.target_unit + # Replace the old packet with the new, converted packet: + event.packet = converted_packet + + def new_archive_record(self, event): + """Do unit conversions for an archive record.""" + # No need to do anything if the record is already in the target + # unit system + if event.record['usUnits'] == self.target_unit: + return + # Perform the conversion + converted_record = self.converter.convertDict(event.record) + # Add the new unit system + converted_record['usUnits'] = self.target_unit + # Replace the old record with the new, converted record + event.record = converted_record + + +# ============================================================================== +# Class StdCalibrate +# ============================================================================== + +class StdCalibrate(StdService): + """Adjust data using calibration expressions. + + This service must be run before StdArchive, so the correction is applied + before the data is archived.""" + + def __init__(self, engine, config_dict): + # Initialize my base class: + super(StdCalibrate, self).__init__(engine, config_dict) + + # Get the list of calibration corrections to apply. If a section + # is missing, a KeyError exception will get thrown: + try: + correction_dict = config_dict['StdCalibrate']['Corrections'] + self.corrections = configobj.ConfigObj() + + # For each correction, compile it, then save in a dictionary of + # corrections to be applied: + for obs_type in correction_dict.scalars: + self.corrections[obs_type] = compile(correction_dict[obs_type], + 'StdCalibrate', 'eval') + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + except KeyError: + log.info("No calibration information in config file. Ignored.") + + def new_loop_packet(self, event): + """Apply a calibration correction to a LOOP packet""" + for obs_type in self.corrections: + if obs_type == 'foo': continue + try: + event.packet[obs_type] = eval(self.corrections[obs_type], None, event.packet) + except (TypeError, NameError): + pass + except ValueError as e: + log.error("StdCalibration loop error %s", e) + + def new_archive_record(self, event): + """Apply a calibration correction to an archive packet""" + # If the record was software generated, then any corrections have + # already been applied in the LOOP packet. + if event.origin != 'software': + for obs_type in self.corrections: + if obs_type == 'foo': continue + try: + event.record[obs_type] = eval(self.corrections[obs_type], None, event.record) + except (TypeError, NameError): + pass + except ValueError as e: + log.error("StdCalibration archive error %s", e) + + +# ============================================================================== +# Class StdQC +# ============================================================================== + +class StdQC(StdService): + """Service that performs quality check on incoming data. + + A StdService wrapper for a QC object so it may be called as a service. This + also allows the weewx.qc.QC class to be used elsewhere without the + overheads of running it as a weewx service. + """ + + def __init__(self, engine, config_dict): + super(StdQC, self).__init__(engine, config_dict) + + # If the 'StdQC' or 'MinMax' sections do not exist in the configuration + # dictionary, then an exception will get thrown and nothing will be + # done. + try: + mm_dict = config_dict['StdQC']['MinMax'] + except KeyError: + log.info("No QC information in config file.") + return + log_failure = to_bool(weeutil.config.search_up(config_dict['StdQC'], + 'log_failure', True)) + + self.qc = weewx.qc.QC(mm_dict, log_failure) + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + """Apply quality check to the data in a loop packet""" + + self.qc.apply_qc(event.packet, 'LOOP') + + def new_archive_record(self, event): + """Apply quality check to the data in an archive record""" + + self.qc.apply_qc(event.record, 'Archive') + + +# ============================================================================== +# Class StdArchive +# ============================================================================== + +class StdArchive(StdService): + """Service that archives LOOP and archive data in the SQL databases.""" + + # This service manages an "accumulator", which records high/lows and + # averages of LOOP packets over an archive period. At the end of the + # archive period it then emits an archive record. + + def __init__(self, engine, config_dict): + super(StdArchive, self).__init__(engine, config_dict) + + # Extract the various options from the config file. If it's missing, fill in with defaults: + archive_dict = config_dict.get('StdArchive', {}) + self.data_binding = archive_dict.get('data_binding', 'wx_binding') + self.record_generation = archive_dict.get('record_generation', 'hardware').lower() + self.no_catchup = to_bool(archive_dict.get('no_catchup', False)) + self.archive_delay = to_int(archive_dict.get('archive_delay', 15)) + software_interval = to_int(archive_dict.get('archive_interval', 300)) + self.loop_hilo = to_bool(archive_dict.get('loop_hilo', True)) + self.record_augmentation = to_bool(archive_dict.get('record_augmentation', True)) + self.log_success = to_bool(weeutil.config.search_up(archive_dict, 'log_success', True)) + self.log_failure = to_bool(weeutil.config.search_up(archive_dict, 'log_failure', True)) + + log.info("Archive will use data binding %s", self.data_binding) + log.info("Record generation will be attempted in '%s'", self.record_generation) + + # The timestamp that marks the end of the archive period + self.end_archive_period_ts = None + # The timestamp that marks the end of the archive period, plus a delay + self.end_archive_delay_ts = None + # The accumulator to be used for the current archive period + self.accumulator = None + # The accumulator that was used for the last archive period. Set to None after it has + # been processed. + self.old_accumulator = None + + if self.record_generation == 'software': + self.archive_interval = software_interval + ival_msg = "(software record generation)" + elif self.record_generation == 'hardware': + # If the station supports a hardware archive interval, use that. + # Warn if it is different than what is in config. + try: + if software_interval != self.engine.console.archive_interval: + log.info("The archive interval in the configuration file (%d) does not " + "match the station hardware interval (%d).", + software_interval, + self.engine.console.archive_interval) + self.archive_interval = self.engine.console.archive_interval + ival_msg = "(specified by hardware)" + except NotImplementedError: + self.archive_interval = software_interval + ival_msg = "(specified in weewx configuration)" + else: + log.error("Unknown type of record generation: %s", self.record_generation) + raise ValueError(self.record_generation) + + log.info("Using archive interval of %d seconds %s", self.archive_interval, ival_msg) + + if self.archive_delay <= 0: + raise weewx.ViolatedPrecondition("Archive delay (%.1f) must be greater than zero." + % (self.archive_delay,)) + if self.archive_delay >= self.archive_interval / 2: + log.warning("Archive delay (%d) is unusually long", self.archive_delay) + + log.debug("Use LOOP data in hi/low calculations: %d", self.loop_hilo) + + weewx.accum.initialize(config_dict) + + self.bind(weewx.STARTUP, self.startup) + self.bind(weewx.PRE_LOOP, self.pre_loop) + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.CHECK_LOOP, self.check_loop) + self.bind(weewx.POST_LOOP, self.post_loop) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def startup(self, _unused): + """Called when the engine is starting up. Main task is to set up the database, backfill it, + then perform a catch up if the hardware supports it. """ + + # This will create the database if it doesn't exist: + dbmanager = self.engine.db_binder.get_manager(self.data_binding, initialize=True) + log.info("Using binding '%s' to database '%s'", self.data_binding, dbmanager.database_name) + + # Make sure the daily summaries have not been partially updated + if dbmanager._read_metadata('lastWeightPatch'): + raise weewx.ViolatedPrecondition("Update of daily summary for database '%s' not" + " complete. Finish the update first." + % dbmanager.database_name) + + # Back fill the daily summaries. + _nrecs, _ndays = dbmanager.backfill_day_summary() + + # Do a catch up on any data still on the station, but not yet put in the database. + if self.no_catchup: + log.debug("No catchup specified.") + else: + # Not all consoles can do a hardware catchup, so be prepared to catch the exception: + try: + self._catchup(self.engine.console.genStartupRecords) + except NotImplementedError: + pass + + def pre_loop(self, _event): + """Called before the main packet loop is entered.""" + + # If this the the initial time through the loop, then the end of + # the archive and delay periods need to be primed: + if not self.end_archive_period_ts: + now = self.engine._get_console_time() + start_archive_period_ts = weeutil.weeutil.startOfInterval(now, self.archive_interval) + self.end_archive_period_ts = start_archive_period_ts + self.archive_interval + self.end_archive_delay_ts = self.end_archive_period_ts + self.archive_delay + self.old_accumulator = None + + def new_loop_packet(self, event): + """Called when A new LOOP record has arrived.""" + + # Do we have an accumulator at all? If not, create one: + if not self.accumulator: + self.accumulator = self._new_accumulator(event.packet['dateTime']) + + # Try adding the LOOP packet to the existing accumulator. If the + # timestamp is outside the timespan of the accumulator, an exception + # will be thrown: + try: + self.accumulator.addRecord(event.packet, add_hilo=self.loop_hilo) + except weewx.accum.OutOfSpan: + # Shuffle accumulators: + (self.old_accumulator, self.accumulator) = \ + (self.accumulator, self._new_accumulator(event.packet['dateTime'])) + # Try again: + self.accumulator.addRecord(event.packet, add_hilo=self.loop_hilo) + + def check_loop(self, event): + """Called after any loop packets have been processed. This is the opportunity + to break the main loop by throwing an exception.""" + # Is this the end of the archive period? If so, dispatch an + # END_ARCHIVE_PERIOD event + if event.packet['dateTime'] > self.end_archive_period_ts: + self.engine.dispatchEvent(weewx.Event(weewx.END_ARCHIVE_PERIOD, + packet=event.packet, + end=self.end_archive_period_ts)) + start_archive_period_ts = weeutil.weeutil.startOfInterval(event.packet['dateTime'], + self.archive_interval) + self.end_archive_period_ts = start_archive_period_ts + self.archive_interval + + # Has the end of the archive delay period ended? If so, break the loop. + if event.packet['dateTime'] >= self.end_archive_delay_ts: + raise BreakLoop + + def post_loop(self, _event): + """The main packet loop has ended, so process the old accumulator.""" + # If weewx happens to startup in the small time interval between the end of + # the archive interval and the end of the archive delay period, then + # there will be no old accumulator. Check for this. + if self.old_accumulator: + # If the user has requested software generation, then do that: + if self.record_generation == 'software': + self._software_catchup() + elif self.record_generation == 'hardware': + # Otherwise, try to honor hardware generation. An exception + # will be raised if the console does not support it. In that + # case, fall back to software generation. + try: + self._catchup(self.engine.console.genArchiveRecords) + except NotImplementedError: + self._software_catchup() + else: + raise ValueError("Unknown station record generation value %s" + % self.record_generation) + self.old_accumulator = None + + # Set the time of the next break loop: + self.end_archive_delay_ts = self.end_archive_period_ts + self.archive_delay + + def new_archive_record(self, event): + """Called when a new archive record has arrived. + Put it in the archive database.""" + + # If requested, extract any extra information we can out of the accumulator and put it in + # the record. Not necessary in the case of software record generation because it has + # already been done. + if self.record_augmentation \ + and self.old_accumulator \ + and event.record['dateTime'] == self.old_accumulator.timespan.stop \ + and event.origin != 'software': + self.old_accumulator.augmentRecord(event.record) + + dbmanager = self.engine.db_binder.get_manager(self.data_binding) + dbmanager.addRecord(event.record, + accumulator=self.old_accumulator, + log_success=self.log_success, + log_failure=self.log_failure) + + def _catchup(self, generator): + """Pull any unarchived records off the console and archive them. + + If the hardware does not support hardware archives, an exception of + type NotImplementedError will be thrown.""" + + dbmanager = self.engine.db_binder.get_manager(self.data_binding) + # Find out when the database was last updated. + lastgood_ts = dbmanager.lastGoodStamp() + + try: + # Now ask the console for any new records since then. Not all + # consoles support this feature. Note that for some consoles, + # notably the Vantage, when doing a long catchup the archive + # records may not be on the same boundaries as the archive + # interval. Reject any records that have a timestamp in the + # future, but provide some lenience for clock drift. + for record in generator(lastgood_ts): + ts = record.get('dateTime') + if ts and ts < time.time() + self.archive_delay: + self.engine.dispatchEvent(weewx.Event(weewx.NEW_ARCHIVE_RECORD, + record=record, + origin='hardware')) + else: + log.warning("Ignore historical record: %s" % record) + except weewx.HardwareError as e: + log.error("Internal error detected. Catchup abandoned") + log.error("**** %s" % e) + + def _software_catchup(self): + # Extract a record out of the old accumulator. + record = self.old_accumulator.getRecord() + # Add the archive interval + record['interval'] = self.archive_interval / 60 + # Send out an event with the new record: + self.engine.dispatchEvent(weewx.Event(weewx.NEW_ARCHIVE_RECORD, + record=record, + origin='software')) + + def _new_accumulator(self, timestamp): + start_ts = weeutil.weeutil.startOfInterval(timestamp, + self.archive_interval) + end_ts = start_ts + self.archive_interval + + # Instantiate a new accumulator + new_accumulator = weewx.accum.Accum(weeutil.weeutil.TimeSpan(start_ts, end_ts)) + return new_accumulator + + +# ============================================================================== +# Class StdTimeSynch +# ============================================================================== + +class StdTimeSynch(StdService): + """Regularly asks the station to synch up its clock.""" + + def __init__(self, engine, config_dict): + super(StdTimeSynch, self).__init__(engine, config_dict) + + # Zero out the time of last synch, and get the time between synchs. + self.last_synch_ts = 0 + self.clock_check = int(config_dict.get('StdTimeSynch', + {'clock_check': 14400}).get('clock_check', 14400)) + self.max_drift = int(config_dict.get('StdTimeSynch', + {'max_drift': 5}).get('max_drift', 5)) + + self.bind(weewx.STARTUP, self.startup) + self.bind(weewx.PRE_LOOP, self.pre_loop) + + def startup(self, _event): + """Called when the engine is starting up.""" + self.do_sync() + + def pre_loop(self, _event): + """Called before the main event loop is started.""" + self.do_sync() + + def do_sync(self): + """Ask the station to synch up if enough time has passed.""" + # Synch up the station's clock if it's been more than clock_check + # seconds since the last check: + now_ts = time.time() + if now_ts - self.last_synch_ts >= self.clock_check: + self.last_synch_ts = now_ts + try: + console_time = self.engine.console.getTime() + if console_time is None: + return + # getTime can take a long time to run, so we use the current + # system time + diff = console_time - time.time() + log.info("Clock error is %.2f seconds (positive is fast)", diff) + if abs(diff) > self.max_drift: + try: + self.engine.console.setTime() + except NotImplementedError: + log.debug("Station does not support setting the time") + except NotImplementedError: + log.debug("Station does not support reading the time") + except weewx.WeeWxIOError as e: + log.info("Error reading time: %s" % e) + + +# ============================================================================== +# Class StdPrint +# ============================================================================== + +class StdPrint(StdService): + """Service that prints diagnostic information when a LOOP + or archive packet is received.""" + + def __init__(self, engine, config_dict): + super(StdPrint, self).__init__(engine, config_dict) + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + """Print out the new LOOP packet""" + print("LOOP: ", + weeutil.weeutil.timestamp_to_string(event.packet['dateTime']), + to_sorted_string(event.packet)) + + def new_archive_record(self, event): + """Print out the new archive record.""" + print("REC: ", + weeutil.weeutil.timestamp_to_string(event.record['dateTime']), + to_sorted_string(event.record)) + + +# ============================================================================== +# Class StdReport +# ============================================================================== + +class StdReport(StdService): + """Launches a separate thread to do reporting.""" + + def __init__(self, engine, config_dict): + super(StdReport, self).__init__(engine, config_dict) + self.max_wait = int(config_dict['StdReport'].get('max_wait', 600)) + self.thread = None + self.launch_time = None + self.record = None + + # check if pyephem is installed and make a suitable log entry + try: + import ephem + log.info("'pyephem' detected, extended almanac data is available") + del ephem + except ImportError: + log.info("'pyephem' not detected, extended almanac data is not available") + + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + self.bind(weewx.POST_LOOP, self.launch_report_thread) + + def new_archive_record(self, event): + """Cache the archive record to pass to the report thread.""" + self.record = event.record + + def launch_report_thread(self, _event): + """Called after the packet LOOP. Processes any new data.""" + import weewx.reportengine + # Do not launch the reporting thread if an old one is still alive. + # To guard against a zombie thread (alive, but doing nothing) launch + # anyway if enough time has passed. + if self.thread and self.thread.is_alive(): + thread_age = time.time() - self.launch_time + if thread_age < self.max_wait: + log.info("Launch of report thread aborted: existing report thread still running") + return + else: + log.warning("Previous report thread has been running" + " %s seconds. Launching report thread anyway.", thread_age) + + try: + self.thread = weewx.reportengine.StdReportEngine(self.config_dict, + self.engine.stn_info, + self.record, + first_run=not self.launch_time) + self.thread.start() + self.launch_time = time.time() + except threading.ThreadError: + log.error("Unable to launch report thread.") + self.thread = None + + def shutDown(self): + if self.thread: + log.info("Shutting down StdReport thread") + self.thread.join(20.0) + if self.thread.is_alive(): + log.error("Unable to shut down StdReport thread") + else: + log.debug("StdReport thread has been terminated") + self.thread = None + self.launch_time = None diff --git a/dist/weewx-4.10.1/bin/weewx/filegenerator.py b/dist/weewx-4.10.1/bin/weewx/filegenerator.py new file mode 100644 index 0000000..2982f85 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/filegenerator.py @@ -0,0 +1,11 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +from __future__ import absolute_import +import weewx.cheetahgenerator + +# For backwards compatibility: +FileGenerator = weewx.cheetahgenerator.CheetahGenerator diff --git a/dist/weewx-4.10.1/bin/weewx/imagegenerator.py b/dist/weewx-4.10.1/bin/weewx/imagegenerator.py new file mode 100644 index 0000000..3689582 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/imagegenerator.py @@ -0,0 +1,433 @@ +# +# Copyright (c) 2009-2023 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Generate images for up to an effective date. +Should probably be refactored into smaller functions.""" + +from __future__ import absolute_import +from __future__ import with_statement + +import datetime +import logging +import os.path +import time + +from six.moves import zip + +import weeplot.genplot +import weeplot.utilities +import weeutil.logger +import weeutil.weeutil +import weewx.reportengine +import weewx.units +import weewx.xtypes +from weeutil.config import search_up, accumulateLeaves +from weeutil.weeutil import to_bool, to_int, to_float, TimeSpan +from weewx.units import ValueTuple + +log = logging.getLogger(__name__) + + +# ============================================================================= +# Class ImageGenerator +# ============================================================================= + +class ImageGenerator(weewx.reportengine.ReportGenerator): + """Class for managing the image generator.""" + + def run(self): + self.setup() + self.gen_images(self.gen_ts) + + def setup(self): + # generic_dict will contain "generic" labels, such as "Outside Temperature" + try: + self.generic_dict = self.skin_dict['Labels']['Generic'] + except KeyError: + self.generic_dict = {} + # text_dict contains translated text strings + self.text_dict = self.skin_dict.get('Texts', {}) + self.image_dict = self.skin_dict['ImageGenerator'] + self.formatter = weewx.units.Formatter.fromSkinDict(self.skin_dict) + self.converter = weewx.units.Converter.fromSkinDict(self.skin_dict) + # ensure that the skin_dir is in the image_dict + self.image_dict['skin_dir'] = os.path.join( + self.config_dict['WEEWX_ROOT'], + self.skin_dict['SKIN_ROOT'], + self.skin_dict['skin']) + # ensure that we are in a consistent right location + os.chdir(self.image_dict['skin_dir']) + + def gen_images(self, gen_ts): + """Generate the images. + + The time scales will be chosen to include the given timestamp, with nice beginning and + ending times. + + Args: + gen_ts (int): The time around which plots are to be generated. This will also be used + as the bottom label in the plots. [optional. Default is to use the time of the last + record in the database.] + """ + t1 = time.time() + ngen = 0 + + # determine how much logging is desired + log_success = to_bool(search_up(self.image_dict, 'log_success', True)) + + # Loop over each time span class (day, week, month, etc.): + for timespan in self.image_dict.sections: + + # Now, loop over all plot names in this time span class: + for plotname in self.image_dict[timespan].sections: + + # Accumulate all options from parent nodes: + plot_options = accumulateLeaves(self.image_dict[timespan][plotname]) + + plotgen_ts = gen_ts + if not plotgen_ts: + binding = plot_options['data_binding'] + db_manager = self.db_binder.get_manager(binding) + plotgen_ts = db_manager.lastGoodStamp() + if not plotgen_ts: + plotgen_ts = time.time() + + image_root = os.path.join(self.config_dict['WEEWX_ROOT'], + plot_options['HTML_ROOT']) + # Get the path that the image is going to be saved to: + img_file = os.path.join(image_root, '%s.png' % plotname) + + # Check whether this plot needs to be done at all: + if _skip_this_plot(plotgen_ts, plot_options, img_file): + continue + + # Generate the plot. + plot = self.gen_plot(plotgen_ts, + plot_options, + self.image_dict[timespan][plotname]) + + # 'plot' will be None if skip_if_empty was truthy, and the plot contains no data + if plot: + # We have a valid plot. Render it onto an image + image = plot.render() + + # Create the subdirectory that the image is to be put in. Wrap in a try block + # in case it already exists. + try: + os.makedirs(os.path.dirname(img_file)) + except OSError: + pass + + try: + # Now save the image + image.save(img_file) + ngen += 1 + except IOError as e: + log.error("Unable to save to file '%s' %s:", img_file, e) + + t2 = time.time() + + if log_success: + log.info("Generated %d images for report %s in %.2f seconds", + ngen, + self.skin_dict['REPORT_NAME'], t2 - t1) + + def gen_plot(self, plotgen_ts, plot_options, plot_dict): + """Generate a single plot image. + + Args: + plotgen_ts: A timestamp for which the plot will be valid. This is generally the last + datum to be plotted. + + plot_options: A dictionary of plot options. + + plot_dict: A section in a ConfigObj. Each subsection will contain data about plots + to be generated + + Returns: + An instance of weeplot.genplot.TimePlot or None. If the former, it will be ready + to render. If None, then skip_if_empty was truthy and no valid data were found. + """ + + # Create a new instance of a time plot and start adding to it + plot = weeplot.genplot.TimePlot(plot_options) + + # Calculate a suitable min, max time for the requested time. + minstamp, maxstamp, timeinc = weeplot.utilities.scaletime( + plotgen_ts - int(plot_options.get('time_length', 86400)), plotgen_ts) + x_domain = weeutil.weeutil.TimeSpan(minstamp, maxstamp) + + # Override the x interval if the user has given an explicit interval: + timeinc_user = to_int(plot_options.get('x_interval')) + if timeinc_user is not None: + timeinc = timeinc_user + plot.setXScaling((x_domain.start, x_domain.stop, timeinc)) + + # Set the y-scaling, using any user-supplied hints: + yscale = plot_options.get('yscale', ['None', 'None', 'None']) + plot.setYScaling(weeutil.weeutil.convertToFloat(yscale)) + + # Get a suitable bottom label: + bottom_label_format = plot_options.get('bottom_label_format', '%m/%d/%y %H:%M') + bottom_label = time.strftime(bottom_label_format, time.localtime(plotgen_ts)) + plot.setBottomLabel(bottom_label) + + # Set day/night display + plot.setLocation(self.stn_info.latitude_f, self.stn_info.longitude_f) + plot.setDayNight(to_bool(plot_options.get('show_daynight', False)), + weeplot.utilities.tobgr(plot_options.get('daynight_day_color', + '0xffffff')), + weeplot.utilities.tobgr(plot_options.get('daynight_night_color', + '0xf0f0f0')), + weeplot.utilities.tobgr(plot_options.get('daynight_edge_color', + '0xefefef'))) + + # Calculate the domain over which we should check for non-null data. It will be + # 'None' if we are not to do the check at all. + check_domain = _get_check_domain(plot_options.get('skip_if_empty', False), x_domain) + + # Set to True if we have _any_ data for the plot + have_data = False + + # Loop over each line to be added to the plot. + for line_name in plot_dict.sections: + + # Accumulate options from parent nodes. + line_options = accumulateLeaves(plot_dict[line_name]) + + # See what observation type to use for this line. By default, use the section + # name. + var_type = line_options.get('data_type', line_name) + + # Find the database + binding = line_options['data_binding'] + db_manager = self.db_binder.get_manager(binding) + + # If we were asked, see if there is any non-null data in the plot + skip = _skip_if_empty(db_manager, var_type, check_domain) + if skip: + # Nothing but null data. Skip this line and keep going + continue + # Either we found some non-null data, or skip_if_empty was false, and we don't care. + have_data = True + + # Look for aggregation type: + aggregate_type = line_options.get('aggregate_type') + if aggregate_type in (None, '', 'None', 'none'): + # No aggregation specified. + aggregate_type = aggregate_interval = None + else: + try: + # Aggregation specified. Get the interval. + aggregate_interval = weeutil.weeutil.nominal_spans( + line_options['aggregate_interval']) + except KeyError: + log.error("Aggregate interval required for aggregate type %s", + aggregate_type) + log.error("Line type %s skipped", var_type) + continue + + # we need to pass the line options and plotgen_ts to our xtype + # first get a copy of line_options + option_dict = dict(line_options) + # but we need to pop off aggregate_type and + # aggregate_interval as they are used as explicit arguments + # in our xtypes call + option_dict.pop('aggregate_type', None) + option_dict.pop('aggregate_interval', None) + # then add plotgen_ts + option_dict['plotgen_ts'] = plotgen_ts + try: + start_vec_t, stop_vec_t, data_vec_t = weewx.xtypes.get_series( + var_type, + x_domain, + db_manager, + aggregate_type=aggregate_type, + aggregate_interval=aggregate_interval, + **option_dict) + except weewx.UnknownType: + # If skip_if_empty is set, it's OK if a type is unknown. + if not skip: + raise + + # Get the type of plot ('bar', 'line', or 'vector') + plot_type = line_options.get('plot_type', 'line').lower() + + if plot_type not in {'line', 'bar', 'vector'}: + log.error(f"Unknown plot type {plot_type}. Ignored") + continue + + if aggregate_type and plot_type != 'bar': + # If aggregating, put the point in the middle of the interval + start_vec_t = ValueTuple( + [x - aggregate_interval / 2.0 for x in start_vec_t[0]], # Value + start_vec_t[1], # Unit + start_vec_t[2]) # Unit group + stop_vec_t = ValueTuple( + [x - aggregate_interval / 2.0 for x in stop_vec_t[0]], # Velue + stop_vec_t[1], # Unit + stop_vec_t[2]) # Unit group + + # Convert the data to the requested units + if plot_options.get('unit'): + # User has specified an override using option 'unit'. Convert to the explicit unit + new_data_vec_t = weewx.units.convert(data_vec_t, plot_options['unit']) + else: + # No override. Convert to whatever the unit group specified. + new_data_vec_t = self.converter.convert(data_vec_t) + + # Add a unit label. NB: all will get overwritten except the last. Get the label + # from the configuration dictionary. + unit_label = line_options.get( + 'y_label', self.formatter.get_label_string(new_data_vec_t[1])) + # Strip off any leading and trailing whitespace so it's easy to center + plot.setUnitLabel(unit_label.strip()) + + # See if a line label has been explicitly requested: + label = line_options.get('label') + if label: + # Yes. Get the text translation. Use the untranslated version if no translation + # is available. + label = self.text_dict.get(label, label) + else: + # No explicit label. Look up a generic one. Use the variable type itself if + # there is no generic label. + label = self.generic_dict.get(var_type, var_type) + + # See if a color has been explicitly requested. + color = line_options.get('color') + if color is not None: color = weeplot.utilities.tobgr(color) + fill_color = line_options.get('fill_color') + if fill_color is not None: fill_color = weeplot.utilities.tobgr(fill_color) + + # Get the line width, if explicitly requested. + width = to_int(line_options.get('width')) + + interval_vec = None + line_gap_fraction = None + vector_rotate = None + + # Some plot types require special treatments: + if plot_type == 'vector': + vector_rotate_str = line_options.get('vector_rotate') + vector_rotate = -float(vector_rotate_str) \ + if vector_rotate_str is not None else None + elif plot_type == 'bar': + interval_vec = [x[1] - x[0] for x in + zip(start_vec_t.value, stop_vec_t.value)] + if plot_type in ('line', 'bar'): + line_gap_fraction = to_float(line_options.get('line_gap_fraction')) + if line_gap_fraction and not 0 <= line_gap_fraction <= 1: + log.error("Gap fraction %5.3f outside range 0 to 1. Ignored.", + line_gap_fraction) + line_gap_fraction = None + + # Get the type of line (only 'solid' or 'none' for now) + line_type = line_options.get('line_type', 'solid') + if line_type.strip().lower() in ['', 'none']: + line_type = None + + marker_type = line_options.get('marker_type') + marker_size = to_int(line_options.get('marker_size', 8)) + + # Add the line to the emerging plot: + plot.addLine(weeplot.genplot.PlotLine( + stop_vec_t[0], new_data_vec_t[0], + label=label, + color=color, + fill_color=fill_color, + width=width, + plot_type=plot_type, + line_type=line_type, + marker_type=marker_type, + marker_size=marker_size, + bar_width=interval_vec, + vector_rotate=vector_rotate, + line_gap_fraction=line_gap_fraction)) + + # Return the constructed plot if it has any non-null data, otherwise return None + return plot if have_data else None + + +def _skip_this_plot(time_ts, plot_options, img_file): + """A plot can be skipped if it was generated recently and has not changed. This happens if the + time since the plot was generated is less than the aggregation interval. + + If a stale_age has been specified, then it can also be skipped if the file has been + freshly generated. + """ + + # Convert from possible string to an integer: + aggregate_interval = weeutil.weeutil.nominal_spans(plot_options.get('aggregate_interval')) + + # Images without an aggregation interval have to be plotted every time. Also, the image + # definitely has to be generated if it doesn't exist. + if aggregate_interval is None or not os.path.exists(img_file): + return False + + # If its a very old image, then it has to be regenerated + if time_ts - os.stat(img_file).st_mtime >= aggregate_interval: + return False + + # If we're on an aggregation boundary, regenerate. + time_dt = datetime.datetime.fromtimestamp(time_ts) + tdiff = time_dt - time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + if abs(tdiff.seconds % aggregate_interval) < 1: + return False + + # Check for stale plots, but only if 'stale_age' is defined + stale = to_int(plot_options.get('stale_age')) + if stale: + t_now = time.time() + try: + last_mod = os.path.getmtime(img_file) + if t_now - last_mod < stale: + log.debug("Skip '%s': last_mod=%s age=%s stale=%s", + img_file, last_mod, t_now - last_mod, stale) + return True + except os.error: + pass + return True + + +def _get_check_domain(skip_if_empty, x_domain): + # Convert to lower-case. It might not be a string, so be prepared for an AttributeError + try: + skip_if_empty = skip_if_empty.lower() + except AttributeError: + pass + # If it's something we recognize as False, return None + if skip_if_empty in ['false', False, None]: + return None + # If it's True, then return the existing time domain + elif skip_if_empty in ['true', True]: + return x_domain + # Otherwise, it's probably a string (such as 'day', 'month', etc.). Return the corresponding + # time domain + else: + return weeutil.weeutil.timespan_by_name(skip_if_empty, x_domain.stop) + + +def _skip_if_empty(db_manager, var_type, check_domain): + """ + + Args: + db_manager: An open instance of weewx.manager.Manager, or a subclass. + + var_type: An observation type to check (e.g., 'outTemp') + + check_domain: A two-way tuple of timestamps that contain the time domain to be checked + for non-null data. + + Returns: + True if there is no non-null data in the domain. False otherwise. + """ + if check_domain is None: + return False + try: + val = weewx.xtypes.get_aggregate(var_type, check_domain, 'not_null', db_manager) + except weewx.UnknownAggregation: + return True + return not val[0] diff --git a/dist/weewx-4.10.1/bin/weewx/manager.py b/dist/weewx-4.10.1/bin/weewx/manager.py new file mode 100644 index 0000000..4b0322d --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/manager.py @@ -0,0 +1,1668 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes and functions for interfacing with a weewx database archive. + +This module includes two classes for managing database connections: + + Manager: For managing a WeeWX database without a daily summary. + DaySummaryManager: For managing a WeeWX database with a daily summary. It inherits from Manager. + +While one could instantiate these classes directly, it's easier with these two class methods: + + cls.open(): For opening an existing database. + cls.open_with_create(): For opening a database that may or may not have been created. + +where: + cls is the class to be opened, either weewx.manager.Manager or weewx.manager.DaySummaryManager. + +Which manager to choose depends on whether or not a daily summary is desired for performance +reasons. Generally, it's a good idea to use one. The database binding section in weewx.conf +is responsible for choosing the type of manager. Here's a typical entry in the configuration file. +Note the entry 'manager': + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +To avoid making the user dig into the configuration dictionary to figure out which type of +database manager to open, there is a convenience function for doing so: + + open_manager_with_config(config_dict, data_binding, initialize, default_binding_dict) + +This will return a database manager of the proper type for the specified data binding. + +Because opening a database and creating a manager can be expensive, the module also provides +a caching utility, DBBinder. + +Example: + + db_binder = DBBinder(config_dict) + db_manager = db_binder.get_manager(data_binding='wx_binding') + for row in db_manager.genBatchRows(1664389800, 1664391600): + print(row) + +""" +from __future__ import absolute_import +from __future__ import print_function + +import datetime +import logging +import sys +import time + +from six.moves import zip + +import weedb +import weeutil.config +import weeutil.weeutil +import weewx.accum +import weewx.units +import weewx.xtypes +from weeutil.weeutil import timestamp_to_string, to_int, TimeSpan + +log = logging.getLogger(__name__) + + +class IntervalError(ValueError): + """Raised when a bad value of 'interval' is encountered.""" + + +# ============================================================================== +# class Manager +# ============================================================================== + +class Manager(object): + """Manages a database table. Offers a number of convenient member functions for querying and + inserting data into the table. These functions encapsulate whatever sql statements are needed. + + A limitation of this implementation is that it caches the timestamps of the first and last + record in the table. Normally, the caches get updated as data comes in. However, if one manager + is updating the table, while another is doing aggregate queries, the latter manager will be + unaware of later records in the database, and may choose the wrong query strategy. If this + might be the case, call member function _sync() before starting the query. + + Attributes: + connection (weedb.Connection): The underlying database connection. + table_name (str): The name of the main, archive table. + first_timestamp (int): The timestamp of the earliest record in the table. + last_timestamp (int): The timestamp of the last record in the table. + std_unit_system (int): The unit system used by the database table. + sqlkeys (list[str]): A list of the SQL keys that the database table supports. + """ + + def __init__(self, connection, table_name='archive', schema=None): + """Initialize an object of type Manager. + + Args: + connection (weedb.Connection): A weedb connection to the database to be managed. + table_name (str): The name of the table to be used in the database. + Default is 'archive'. + schema (dict): The schema to be used. Optional. + + Raises: + weedb.NoDatabaseError: If the database does not exist and no schema has been + supplied. + weedb.ProgrammingError: If the database exists, but has not been initialized and no + schema has been supplied. + """ + + self.connection = connection + self.table_name = table_name + self.first_timestamp = None + self.last_timestamp = None + self.std_unit_system = None + + # Now get the SQL types. + try: + self.sqlkeys = self.connection.columnsOf(self.table_name) + except weedb.ProgrammingError: + # Database exists, but is uninitialized. Did the caller supply + # a schema? + if schema is None: + # No. Nothing to be done. + log.error("Cannot get columns of table %s, and no schema specified", + self.table_name) + raise + # Database exists, but has not been initialized. Initialize it. + self._initialize_database(schema) + # Try again: + self.sqlkeys = self.connection.columnsOf(self.table_name) + + # Set up cached data. Make sure to call my version, not any subclass's version. This is + # because the subclass has not been initialized yet. + Manager._sync(self) + + @classmethod + def open(cls, database_dict, table_name='archive'): + """Open and return a Manager or a subclass of Manager. The database must exist. + + Args: + cls: The class object to be created. Typically, something + like weewx.manager.DaySummaryManager. + database_dict (dict): A database dictionary holding the information necessary to open + the database. + + For example, for sqlite, it looks something like this: + + { + 'SQLITE_ROOT' : '/home/weewx/archive', + 'database_name' : 'weewx.sdb', + 'driver' : 'weedb.sqlite' + } + + For MySQL: + { + 'host': 'localhost', + 'user': 'weewx', + 'password': 'weewx-password', + 'database_name' : 'weeewx', + 'driver' : 'weedb.mysql' + } + + table_name (str): The name of the table to be used in the database. Default + is 'archive'. + + Returns: + cls: An instantiated instance of class "cls". + + Raises: + weedb.NoDatabaseError: If the database does not exist. + weedb.ProgrammingError: If the database exists, but has not been initialized. + """ + + # This will raise a weedb.OperationalError if the database does not exist. The 'open' + # method we are implementing never attempts an initialization, so let it go by. + connection = weedb.connect(database_dict) + + # Create an instance of the right class and return it: + dbmanager = cls(connection, table_name) + return dbmanager + + @classmethod + def open_with_create(cls, database_dict, table_name='archive', schema=None): + """Open and return a Manager or a subclass of Manager, initializing if necessary. + + Args: + cls: The class object to be created. Typically, something + like weewx.manager.DaySummaryManager. + database_dict (dict): A database dictionary holding the information necessary to open + the database. + + For example, for sqlite, it looks something like this: + + { + 'SQLITE_ROOT' : '/home/weewx/archive', + 'database_name' : 'weewx.sdb', + 'driver' : 'weedb.sqlite' + } + + For MySQL: + { + 'host': 'localhost', + 'user': 'weewx', + 'password': 'weewx-password', + 'database_name' : 'weeewx', + 'driver' : 'weedb.mysql' + } + + table_name (str): The name of the table to be used in the database. Default + is 'archive'. + schema: The schema to be used. + Returns: + cls: An instantiated instance of class "cls". + Raises: + weedb.NoDatabaseError: Raised if the database does not exist and a schema has + not been supplied. + weedb.ProgrammingError: Raised if the database exists, but has not been initialized + and no schema has been supplied. + """ + + # This will raise a weedb.OperationalError if the database does not exist. + try: + connection = weedb.connect(database_dict) + except weedb.OperationalError: + # Database does not exist. Did the caller supply a schema? + if schema is None: + # No. Nothing to be done. + log.error("Cannot open database, and no schema specified") + raise + # Yes. Create the database: + weedb.create(database_dict) + # Now I can get a connection + connection = weedb.connect(database_dict) + + # Create an instance of the right class and return it: + dbmanager = cls(connection, table_name=table_name, schema=schema) + return dbmanager + + @property + def database_name(self): + """str: The name of the database the manager is bound to.""" + return self.connection.database_name + + @property + def obskeys(self): + """list[str]: The list of observation types""" + return [obs_type for obs_type in self.sqlkeys + if obs_type not in ['dateTime', 'usUnits', 'interval']] + + def close(self): + self.connection.close() + self.sqlkeys = None + self.first_timestamp = None + self.last_timestamp = None + self.std_unit_system = None + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.close() + + def _initialize_database(self, schema): + """Initialize the tables needed for the archive. + + schema: The schema to be used + """ + # If this is an old-style schema, this will raise an exception. Be prepared to catch it. + try: + table_schema = schema['table'] + except TypeError: + # Old style schema: + table_schema = schema + + # List comprehension of the types, joined together with commas. Put the SQL type in + # backquotes, because at least one of them ('interval') is a MySQL reserved word + sqltypestr = ', '.join(["`%s` %s" % _type for _type in table_schema]) + + try: + with weedb.Transaction(self.connection) as cursor: + cursor.execute("CREATE TABLE %s (%s);" % (self.table_name, sqltypestr)) + except weedb.DatabaseError as e: + log.error("Unable to create table '%s' in database '%s': %s", + self.table_name, self.database_name, e) + raise + + log.info("Created and initialized table '%s' in database '%s'", + self.table_name, self.database_name) + + def _create_sync(self): + """Create the internal caches.""" + + # Fetch the first row in the database to determine the unit system in use. If the database + # has never been used, then the unit system is still indeterminate --- set it to 'None'. + _row = self.getSql("SELECT usUnits FROM %s LIMIT 1;" % self.table_name) + self.std_unit_system = _row[0] if _row is not None else None + + # Cache the first and last timestamps + self.first_timestamp = self.firstGoodStamp() + self.last_timestamp = self.lastGoodStamp() + + def _sync(self): + Manager._create_sync(self) + + def lastGoodStamp(self): + """Retrieves the epoch time of the last good archive record. + + Returns: + int|None: Time of the last good archive record as an epoch time, + or None if there are no records. + """ + _row = self.getSql("SELECT MAX(dateTime) FROM %s" % self.table_name) + return _row[0] if _row else None + + def firstGoodStamp(self): + """Retrieves the earliest timestamp in the archive. + + Returns: + int|None: Time of the first good archive record as an epoch time, + or None if there are no records. + """ + _row = self.getSql("SELECT MIN(dateTime) FROM %s" % self.table_name) + return _row[0] if _row else None + + def exists(self, obs_type): + """Checks whether the observation type exists in the database. + + Args: + obs_type(str): The observation type to check for existence. + + Returns: + bool: True if the observation type is in the database schema. False otherwise. + """ + + # Check to see if this is a valid observation type: + return obs_type in self.obskeys + + def has_data(self, obs_type, timespan): + """Checks whether the observation type exists in the database and whether it has any + data. + + Args: + obs_type(str): The observation type to check for existence. + timespan (tuple): A 2-way tuple with the start and stop time to be checked for data. + + Returns: + bool: True if the type is in the schema, and has some data within the given timespan. + Otherwise, return False. + """ + return self.exists(obs_type) \ + and bool(weewx.xtypes.get_aggregate(obs_type, timespan, 'not_null', self)[0]) + + def addRecord(self, record_obj, + accumulator=None, + progress_fn=None, + log_success=True, + log_failure=True): + """ + Commit a single record or a collection of records to the archive. + + Args: + record_obj (iterable|dict): Either a data record, or an iterable that can return + data records. Each data record must look like a dictionary, where the keys are the + SQL types and the values are the values to be stored in the database. + accumulator (weewx.accum.Accum): An optional accumulator. If given, the record + will be added to the accumulator. + progress_fn (function): This function will be called every 1000 insertions. It should + have the signature fn(time, N) where time is the unix epoch time, and N is the + insertion count. + log_success (bool): Set to True to have successful insertions logged. + log_failure (bool): Set to True to have unsuccessful insertions logged + + Returns: + int: The number of successful insertions. + """ + + # Determine if record_obj is just a single dictionary instance (in which case it will have + # method 'keys'). If so, wrap it in something iterable (a list): + record_list = [record_obj] if hasattr(record_obj, 'keys') else record_obj + + min_ts = float('inf') # A "big number" + max_ts = 0 + N = 0 + with weedb.Transaction(self.connection) as cursor: + + for record in record_list: + try: + # If the accumulator time matches the record we are working with, + # use it to update the highs and lows. + if accumulator and record_obj['dateTime'] == accumulator.timespan.stop: + self._updateHiLo(accumulator, cursor) + + # Then add the record to the archives: + self._addSingleRecord(record, cursor, log_success, log_failure) + + N += 1 + if progress_fn and N % 1000 == 0: + progress_fn(record['dateTime'], N) + + min_ts = min(min_ts, record['dateTime']) + max_ts = max(max_ts, record['dateTime']) + except (weedb.IntegrityError, weedb.OperationalError) as e: + if log_failure: + log.error("Unable to add record %s to database '%s': %s", + timestamp_to_string(record['dateTime']), + self.database_name, e) + + # Update the cached timestamps. This has to sit outside the transaction context, + # in case an exception occurs. + if self.first_timestamp is not None: + self.first_timestamp = min(min_ts, self.first_timestamp) + if self.last_timestamp is not None: + self.last_timestamp = max(max_ts, self.last_timestamp) + + return N + + def _addSingleRecord(self, record, cursor, log_success=True, log_failure=True): + """Internal function for adding a single record to the main archive table.""" + + if record['dateTime'] is None: + if log_failure: + log.error("Archive record with null time encountered") + raise weewx.ViolatedPrecondition("Manager record with null time encountered.") + + # Check to make sure the incoming record is in the same unit system as the records already + # in the database: + self._check_unit_system(record['usUnits']) + + # Only data types that appear in the database schema can be inserted. To find them, form + # the intersection between the set of all record keys and the set of all sql keys + record_key_set = set(record.keys()) + insert_key_set = record_key_set.intersection(self.sqlkeys) + # Convert to an ordered list: + key_list = list(insert_key_set) + # Get the values in the same order: + value_list = [record[k] for k in key_list] + + # This will a string of sql types, separated by commas. Because some of the weewx sql keys + # (notably 'interval') are reserved words in MySQL, put them in backquotes. + k_str = ','.join(["`%s`" % k for k in key_list]) + # This will be a string with the correct number of placeholder + # question marks: + q_str = ','.join('?' * len(key_list)) + # Form the SQL insert statement: + sql_insert_stmt = "INSERT INTO %s (%s) VALUES (%s)" % (self.table_name, k_str, q_str) + cursor.execute(sql_insert_stmt, value_list) + if log_success: + log.info("Added record %s to database '%s'", + timestamp_to_string(record['dateTime']), + self.database_name) + + def _updateHiLo(self, accumulator, cursor): + pass + + def genBatchRows(self, startstamp=None, stopstamp=None): + """Generator function that yields raw rows from the archive database with timestamps within + an interval. + + Args: + startstamp (int|None): Exclusive start of the interval in epoch time. If 'None', + then start at earliest archive record. + stopstamp (int|None): Inclusive end of the interval in epoch time. If 'None', + then end at last archive record. + + Yields: + list: Each iteration yields a single data row. + """ + + with self.connection.cursor() as _cursor: + + if startstamp is None: + if stopstamp is None: + _gen = _cursor.execute( + "SELECT * FROM %s ORDER BY dateTime ASC" % self.table_name) + else: + _gen = _cursor.execute("SELECT * FROM %s WHERE " + "dateTime <= ? ORDER BY dateTime ASC" % self.table_name, + (stopstamp,)) + else: + if stopstamp is None: + _gen = _cursor.execute("SELECT * FROM %s WHERE " + "dateTime > ? ORDER BY dateTime ASC" % self.table_name, + (startstamp,)) + else: + _gen = _cursor.execute("SELECT * FROM %s WHERE " + "dateTime > ? AND dateTime <= ? ORDER BY dateTime ASC" + % self.table_name, (startstamp, stopstamp)) + + _last_time = 0 + for _row in _gen: + # The following is to get around a bug in sqlite when all the + # tables are in one file: + if _row[0] <= _last_time: + continue + _last_time = _row[0] + yield _row + + def genBatchRecords(self, startstamp=None, stopstamp=None): + """Generator function that yields records with timestamps within an interval. + + Args: + startstamp (int|None): Exclusive start of the interval in epoch time. If 'None', + then start at earliest archive record. + stopstamp (int|None): Inclusive end of the interval in epoch time. If 'None', + then end at last archive record. + + Yields: + dict|None: A dictionary where key is the observation type (eg, 'outTemp') and the + value is the observation value, or None if there are no rows in the time range. + """ + + for _row in self.genBatchRows(startstamp, stopstamp): + yield dict(list(zip(self.sqlkeys, _row))) if _row else None + + def getRecord(self, timestamp, max_delta=None): + """Get a single archive record with a given epoch time stamp. + + Args: + timestamp (int): The epoch time of the desired record. + max_delta (int|None): The largest difference in time that is acceptable. + [Optional. The default is no difference] + + Returns: + dict|None: a record dictionary or None if the record does not exist. + """ + + with self.connection.cursor() as _cursor: + + if max_delta: + time_start_ts = timestamp - max_delta + time_stop_ts = timestamp + max_delta + _cursor.execute("SELECT * FROM %s WHERE dateTime>=? AND dateTime<=? " + "ORDER BY ABS(dateTime-?) ASC LIMIT 1" % self.table_name, + (time_start_ts, time_stop_ts, timestamp)) + else: + _cursor.execute("SELECT * FROM %s WHERE dateTime=?" + % self.table_name, (timestamp,)) + _row = _cursor.fetchone() + return dict(list(zip(self.sqlkeys, _row))) if _row else None + + def updateValue(self, timestamp, obs_type, new_value): + """Update (replace) a single value in the database. + + Args: + timestamp (int): The timestamp of the record to be updated. + obs_type (str): The observation type to be updated. + new_value (float | str): The updated value + """ + + self.connection.execute("UPDATE %s SET %s=? WHERE dateTime=?" % + (self.table_name, obs_type), (new_value, timestamp)) + + def getSql(self, sql, sqlargs=(), cursor=None): + """Executes an arbitrary SQL statement on the database. The result will be a single row. + + Args: + sql (str): The SQL statement + sqlargs (tuple): A tuple containing the arguments for the SQL statement + cursor (cursor| None): An optional cursor to be used. If not given, then one will be + created and closed when finished. + + Returns: + tuple: a tuple containing a single result set. + """ + _cursor = cursor or self.connection.cursor() + try: + _cursor.execute(sql, sqlargs) + return _cursor.fetchone() + finally: + if cursor is None: + _cursor.close() + + def genSql(self, sql, sqlargs=()): + """Generator function that executes an arbitrary SQL statement on + the database, returning a result set. + + Args: + sql (str): The SQL statement + sqlargs (tuple): A tuple containing the arguments for the SQL statement. + + Yields: + list: A row in the result set. + """ + + with self.connection.cursor() as _cursor: + for _row in _cursor.execute(sql, sqlargs): + yield _row + + def getAggregate(self, timespan, obs_type, + aggregate_type, **option_dict): + """ OBSOLETE. Use weewx.xtypes.get_aggregate() instead. """ + + return weewx.xtypes.get_aggregate(obs_type, timespan, aggregate_type, self, **option_dict) + + def getSqlVectors(self, timespan, obs_type, + aggregate_type=None, + aggregate_interval=None): + """ OBSOLETE. Use weewx.xtypes.get_series() instead """ + + return weewx.xtypes.get_series(obs_type, timespan, self, + aggregate_type, aggregate_interval) + + def add_column(self, column_name, column_type="REAL"): + """Add a single new column to the database. + + Args: + column_name (str): The name of the new column. + column_type (str): The type ("REAL"|"INTEGER|) of the new column. Default is "REAL". + """ + with weedb.Transaction(self.connection) as cursor: + self._add_column(column_name, column_type, cursor) + + def _add_column(self, column_name, column_type, cursor): + """Add a column to the main archive table""" + cursor.execute("ALTER TABLE %s ADD COLUMN `%s` %s" + % (self.table_name, column_name, column_type)) + + def rename_column(self, old_column_name, new_column_name): + """Rename an existing column + + Args: + old_column_name (str): Tne old name of the column to be renamed. + new_column_name (str): Its new name + """ + with weedb.Transaction(self.connection) as cursor: + self._rename_column(old_column_name, new_column_name, cursor) + + def _rename_column(self, old_column_name, new_column_name, cursor): + """Rename a column in the main archive table.""" + cursor.execute("ALTER TABLE %s RENAME COLUMN %s TO %s" + % (self.table_name, old_column_name, new_column_name)) + + def drop_columns(self, column_names): + """Drop a list of columns from the database + + Args: + column_names (list[str]): A list containing the observation types to be dropped. + """ + with weedb.Transaction(self.connection) as cursor: + self._drop_columns(column_names, cursor) + + def _drop_columns(self, column_names, cursor): + """Drop a column in the main archive table""" + cursor.drop_columns(self.table_name, column_names) + + def _check_unit_system(self, unit_system): + """Check to make sure a unit system is the same as what's already in use in the database. + """ + + if self.std_unit_system is not None: + if unit_system != self.std_unit_system: + raise weewx.UnitError("Unit system of incoming record (0x%02x) " + "differs from '%s' table in '%s' database (0x%02x)" % + (unit_system, self.table_name, self.database_name, + self.std_unit_system)) + else: + # This is the first record. Remember the unit system to check against subsequent + # records: + self.std_unit_system = unit_system + + +def reconfig(old_db_dict, new_db_dict, new_unit_system=None, new_schema=None): + """Copy over an old archive to a new one, using an optionally new unit system and schema. + + Args: + old_db_dict (dict): The database dictionary for the old database. See + method Manager.open() for the definition of a database dictionary. + new_db_dict (dict): THe database dictionary for the new database. See + method Manager.open() for the definition of a database dictionary. + new_unit_system (int|None): The new unit system to be used, or None to keep the old one. + new_schema (dict): The new schema to use, or None to use the old one. + + """ + + with Manager.open(old_db_dict) as old_archive: + if new_schema is None: + import schemas.wview_extended + new_schema = schemas.wview_extended.schema + with Manager.open_with_create(new_db_dict, schema=new_schema) as new_archive: + # Wrap the input generator in a unit converter. + record_generator = weewx.units.GenWithConvert(old_archive.genBatchRecords(), + new_unit_system) + + # This is very fast because it is done in a single transaction context: + new_archive.addRecord(record_generator) + + +# =============================================================================== +# Class DBBinder +# =============================================================================== + +class DBBinder(object): + """Given a binding name, it returns the matching database as a managed object. Caches + results. + """ + + def __init__(self, config_dict): + """ Initialize a DBBinder object. + + Args: + config_dict (dict): The configuration dictionary. + """ + + self.config_dict = config_dict + self.default_binding_dict = {} + self.manager_cache = {} + + def close(self): + for data_binding in list(self.manager_cache.keys()): + self.manager_cache[data_binding].close() + del self.manager_cache[data_binding] + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.close() + + def set_binding_defaults(self, binding_name, default_binding_dict): + """Set the defaults for the binding binding_name.""" + self.default_binding_dict[binding_name] = default_binding_dict + + def get_manager(self, data_binding='wx_binding', initialize=False): + """Given a binding name, returns the managed object + + Args: + data_binding (str): The returned Manager object will be bound to this binding. + initialize (bool): True to initialize the database first. + + Returns: + weewx.manager.Manager: Or its subclass, weewx.manager.DaySummaryManager, depending + on the settings under the [DataBindings] section. + """ + global default_binding_dict + + if data_binding not in self.manager_cache: + # If this binding has a set of defaults, use them. Otherwise, use the generic + # defaults + defaults = self.default_binding_dict.get(data_binding, default_binding_dict) + manager_dict = get_manager_dict_from_config(self.config_dict, + data_binding, + default_binding_dict=defaults) + self.manager_cache[data_binding] = open_manager(manager_dict, initialize) + + return self.manager_cache[data_binding] + + # For backwards compatibility with early V3.1 alphas: + get_database = get_manager + + def bind_default(self, default_binding='wx_binding'): + """Returns a function that holds a default database binding.""" + + def db_lookup(data_binding=None): + if data_binding is None: + data_binding = default_binding + return self.get_manager(data_binding) + + return db_lookup + + +# =============================================================================== +# Utilities +# =============================================================================== + +# If the [DataBindings] section is missing or incomplete, this is the set +# of defaults that will be used. +default_binding_dict = {'database': 'archive_sqlite', + 'table_name': 'archive', + 'manager': 'weewx.manager.DaySummaryManager', + 'schema': 'schemas.wview_extended.schema'} + + +def get_database_dict_from_config(config_dict, database): + """Convenience function that given a configuration dictionary and a database name, + returns a database dictionary that can be used to open the database using Manager.open(). + + Args: + + config_dict (dict): The configuration dictionary. + database (str): The database whose database dict is to be retrieved + (example: 'archive_sqlite') + + Returns: + dict: Adatabase dictionary, with everything needed to pass on to a Manager or weedb in + order to open a database. + + Example: + Given a configuration file snippet that looks like: + + >>> import configobj + >>> from six.moves import StringIO + >>> config_snippet = ''' + ... WEEWX_ROOT = /home/weewx + ... [DatabaseTypes] + ... [[SQLite]] + ... driver = weedb.sqlite + ... SQLITE_ROOT = %(WEEWX_ROOT)s/archive + ... [Databases] + ... [[archive_sqlite]] + ... database_name = weewx.sdb + ... database_type = SQLite''' + >>> config_dict = configobj.ConfigObj(StringIO(config_snippet)) + >>> database_dict = get_database_dict_from_config(config_dict, 'archive_sqlite') + >>> keys = sorted(database_dict.keys()) + >>> for k in keys: + ... print("%15s: %12s" % (k, database_dict[k])) + SQLITE_ROOT: /home/weewx/archive + database_name: weewx.sdb + driver: weedb.sqlite + """ + try: + database_dict = dict(config_dict['Databases'][database]) + except KeyError as e: + raise weewx.UnknownDatabase("Unknown database '%s'" % e) + + # See if a 'database_type' is specified. This is something + # like 'SQLite' or 'MySQL'. If it is, use it to augment any + # missing information in the database_dict: + if 'database_type' in database_dict: + database_type = database_dict.pop('database_type') + + # Augment any missing information in the database dictionary with + # the top-level stanza + if database_type in config_dict['DatabaseTypes']: + weeutil.config.conditional_merge(database_dict, + config_dict['DatabaseTypes'][database_type]) + else: + raise weewx.UnknownDatabaseType('database_type') + + return database_dict + + +# +# A "manager dict" includes keys: +# +# manager: The manager class +# table_name: The name of the internal table +# schema: The schema to be used in case of initialization +# database_dict: The database dictionary. This will be passed on to weedb. +# +def get_manager_dict_from_config(config_dict, data_binding, + default_binding_dict=default_binding_dict): + # Start with a copy of the bindings in the config dictionary (we will be adding to it): + try: + manager_dict = dict(config_dict['DataBindings'][data_binding]) + except KeyError as e: + raise weewx.UnknownBinding("Unknown data binding '%s'" % e) + + # If anything is missing, substitute from the default dictionary: + weeutil.config.conditional_merge(manager_dict, default_binding_dict) + + # Now get the database dictionary if it's missing: + if 'database_dict' not in manager_dict: + try: + database = manager_dict.pop('database') + manager_dict['database_dict'] = get_database_dict_from_config(config_dict, + database) + except KeyError as e: + raise weewx.UnknownDatabase("Unknown database '%s'" % e) + + # The schema may be specified as a string, in which case we resolve the python object to which + # it refers. Or it may be specified as a dict with field_name=sql_type pairs. + schema_name = manager_dict.get('schema') + if schema_name is None: + manager_dict['schema'] = None + elif isinstance(schema_name, dict): + # Schema is a ConfigObj section (that is, a dictionary). Retrieve the + # elements of the schema in order: + manager_dict['schema'] = [(col_name, manager_dict['schema'][col_name]) for col_name in + manager_dict['schema']] + else: + # Schema is a string, with the name of the schema object + manager_dict['schema'] = weeutil.weeutil.get_object(schema_name) + + return manager_dict + + +# The following is for backwards compatibility: +def get_manager_dict(bindings_dict, databases_dict, data_binding, + default_binding_dict=default_binding_dict): + if bindings_dict.parent != databases_dict.parent: + raise weewx.UnsupportedFeature("Database and binding dictionaries" + " require common parent") + return get_manager_dict_from_config(bindings_dict.parent, data_binding, + default_binding_dict) + + +def open_manager(manager_dict, initialize=False): + manager_cls = weeutil.weeutil.get_object(manager_dict['manager']) + if initialize: + return manager_cls.open_with_create(manager_dict['database_dict'], + manager_dict['table_name'], + manager_dict['schema']) + else: + return manager_cls.open(manager_dict['database_dict'], + manager_dict['table_name']) + + +def open_manager_with_config(config_dict, data_binding, + initialize=False, default_binding_dict=default_binding_dict): + """Given a binding name, returns an open manager object.""" + manager_dict = get_manager_dict_from_config(config_dict, + data_binding=data_binding, + default_binding_dict=default_binding_dict) + return open_manager(manager_dict, initialize) + + +def drop_database(manager_dict): + """Drop (delete) a database, given a manager dict""" + + weedb.drop(manager_dict['database_dict']) + + +def drop_database_with_config(config_dict, data_binding, + default_binding_dict=default_binding_dict): + """Drop (delete) the database associated with a binding name""" + + manager_dict = get_manager_dict_from_config(config_dict, + data_binding=data_binding, + default_binding_dict=default_binding_dict) + drop_database(manager_dict) + + +def show_progress(last_time, nrec=None): + """Utility function to show our progress""" + if nrec: + msg = "Records processed: %d; time: %s\r" \ + % (nrec, timestamp_to_string(last_time)) + else: + msg = "Processed through: %s\r" % timestamp_to_string(last_time) + print(msg, end='', file=sys.stdout) + sys.stdout.flush() + + +# =============================================================================== +# Class DaySummaryManager +# +# Adds daily summaries to the database. +# +# This class specializes method _addSingleRecord so that it adds the data to a daily summary, +# as well as the regular archive table. +# +# Note that a date does not include midnight --- that belongs to the previous day. That is +# because a data record archives the *previous* interval. So, for the date 5-Oct-2008 with a +# five minute archive interval, the statistics would include the following records (local +# time): +# 5-Oct-2008 00:05:00 +# 5-Oct-2008 00:10:00 +# 5-Oct-2008 00:15:00 +# . +# . +# . +# 5-Oct-2008 23:55:00 +# 6-Oct-2008 00:00:00 +# +# =============================================================================== + +class DaySummaryManager(Manager): + """Manage a daily statistical summary. + + The daily summary consists of a separate table for each type. The columns of each table are + things like min, max, the timestamps for min and max, sum and sumtime. The values sum and + sumtime are kept to make it easy to calculate averages for different time periods. + + For example, for type 'outTemp' (outside temperature), there is a table of name + 'archive_day_outTemp' with the following column names: + + dateTime, min, mintime, max, maxtime, sum, count, wsum, sumtime + + wsum is the "Weighted sum," that is, the sum weighted by the archive interval. sumtime is the + sum of the archive intervals. + + In addition to all the tables for each type, there is one additional table called + 'archive_day__metadata', which currently holds the version number and the time of the last + update. + """ + + version = "4.0" + + # Schemas used by the daily summaries: + day_schemas = { + 'scalar': [ + ('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('min', 'REAL'), + ('mintime', 'INTEGER'), + ('max', 'REAL'), + ('maxtime', 'INTEGER'), + ('sum', 'REAL'), + ('count', 'INTEGER'), + ('wsum', 'REAL'), + ('sumtime', 'INTEGER') + ], + 'vector': [ + ('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('min', 'REAL'), + ('mintime', 'INTEGER'), + ('max', 'REAL'), + ('maxtime', 'INTEGER'), + ('sum', 'REAL'), + ('count', 'INTEGER'), + ('wsum', 'REAL'), + ('sumtime', 'INTEGER'), + ('max_dir', 'REAL'), + ('xsum', 'REAL'), + ('ysum', 'REAL'), + ('dirsumtime', 'INTEGER'), + ('squaresum', 'REAL'), + ('wsquaresum', 'REAL'), + ] + } + + # SQL statements used by the meta data in the daily summaries. + meta_create_str = "CREATE TABLE %s_day__metadata (name CHAR(20) NOT NULL " \ + "UNIQUE PRIMARY KEY, value TEXT);" + meta_replace_str = "REPLACE INTO %s_day__metadata VALUES(?, ?)" + meta_select_str = "SELECT value FROM %s_day__metadata WHERE name=?" + + def __init__(self, connection, table_name='archive', schema=None): + """Initialize an instance of DaySummaryManager + + connection: A weedb connection to the database to be managed. + + table_name: The name of the table to be used in the database. Default is 'archive'. + + schema: The schema to be used. Optional. If not supplied, then an exception of type + weedb.OperationalError will be raised if the database does not exist, and of type + weedb.Uninitialized if it exists, but has not been initialized. + """ + # Initialize my superclass: + super(DaySummaryManager, self).__init__(connection, table_name, schema) + + # Has the database been initialized with the daily summaries? + if '%s_day__metadata' % self.table_name not in self.connection.tables(): + # Database has not been initialized. Initialize it: + self._initialize_day_tables(schema) + + self.version = None + self.daykeys = None + DaySummaryManager._create_sync(self) + self.patch_sums() + + def exists(self, obs_type): + """Checks whether the observation type exists in the database.""" + + # Check both with the superclass, and my own set of daily summaries + return super(DaySummaryManager, self).exists(obs_type) or obs_type in self.daykeys + + def close(self): + self.version = None + self.daykeys = None + super(DaySummaryManager, self).close() + + def _create_sync(self): + # Get a list of all the observation types which have daily summaries + all_tables = self.connection.tables() + prefix = "%s_day_" % self.table_name + n_prefix = len(prefix) + meta_name = '%s_day__metadata' % self.table_name + # Create a set of types that are in the daily summaries: + self.daykeys = {x[n_prefix:] for x in all_tables + if (x.startswith(prefix) and x != meta_name)} + + self.version = self._read_metadata('Version') + if self.version is None: + self.version = '1.0' + log.debug('Daily summary version is %s', self.version) + + def _sync(self): + super(DaySummaryManager, self)._sync() + self._create_sync() + + def _initialize_day_tables(self, schema): + """Initialize the tables needed for the daily summary.""" + + if schema is None: + # Uninitialized, but no schema was supplied. Raise an exception + raise weedb.OperationalError("No day summary schema for table '%s' in database '%s'" + % (self.table_name, self.connection.database_name)) + # See if we have new-style daily summaries, or old-style. Old-style will raise an + # exception. Be prepared to catch it. + try: + day_summaries_schemas = schema['day_summaries'] + except TypeError: + # Old-style schema. Include a daily summary for each observation type in the archive + # table. + day_summaries_schemas = [(e, 'scalar') for e in self.sqlkeys if + e not in ('dateTime', 'usUnits', 'interval')] + import weewx.wxmanager + if type(self) == weewx.wxmanager.WXDaySummaryManager or 'windSpeed' in self.sqlkeys: + # For backwards compatibility, include 'wind' + day_summaries_schemas += [('wind', 'vector')] + + # Create the tables needed for the daily summaries in one transaction: + with weedb.Transaction(self.connection) as cursor: + # obs will be a 2-way tuple (obs_type, ('scalar'|'vector')) + for obs in day_summaries_schemas: + self._initialize_day_table(obs[0], obs[1].lower(), cursor) + + # Now create the meta table... + cursor.execute(DaySummaryManager.meta_create_str % self.table_name) + # ... then put the version number in it: + self._write_metadata('Version', DaySummaryManager.version, cursor) + + log.info("Created daily summary tables") + + def _initialize_day_table(self, obs_type, day_schema_type, cursor): + """Initialize a single daily summary. + + obs_type: An observation type, such as 'outTemp' + day_schema: The schema to be used. Either 'scalar', or 'vector' + cursor: An open cursor + """ + s = ', '.join( + ["%s %s" % column_type + for column_type in DaySummaryManager.day_schemas[day_schema_type]]) + + sql_create_str = "CREATE TABLE %s_day_%s (%s);" % (self.table_name, obs_type, s) + cursor.execute(sql_create_str) + + def _add_column(self, column_name, column_type, cursor): + # First call my superclass's version... + Manager._add_column(self, column_name, column_type, cursor) + # ... then do mine + self._initialize_day_table(column_name, 'scalar', cursor) + + def _rename_column(self, old_column_name, new_column_name, cursor): + # First call my superclass's version... + Manager._rename_column(self, old_column_name, new_column_name, cursor) + # ... then do mine + cursor.execute("ALTER TABLE %s_day_%s RENAME TO %s_day_%s;" + % (self.table_name, old_column_name, self.table_name, new_column_name)) + + def _drop_columns(self, column_names, cursor): + # First call my superclass's version... + Manager._drop_columns(self, column_names, cursor) + # ... then do mine + for column_name in column_names: + cursor.execute("DROP TABLE IF EXISTS %s_day_%s;" % (self.table_name, column_name)) + + def _addSingleRecord(self, record, cursor, log_success=True, log_failure=True): + """Specialized version that updates the daily summaries, as well as the main archive + table. + """ + + # First let my superclass handle adding the record to the main archive table: + super(DaySummaryManager, self)._addSingleRecord(record, cursor, log_success, log_failure) + + # Get the start of day for the record: + _sod_ts = weeutil.weeutil.startOfArchiveDay(record['dateTime']) + + # Get the weight. If the value for 'interval' is bad, an exception will be raised. + try: + _weight = self._calc_weight(record) + except IntervalError as e: + # Bad value for interval. Ignore this record + if log_failure: + log.info(e) + log.info('*** record ignored') + return + + # Now add to the daily summary for the appropriate day: + _day_summary = self._get_day_summary(_sod_ts, cursor) + _day_summary.addRecord(record, weight=_weight) + self._set_day_summary(_day_summary, record['dateTime'], cursor) + if log_success: + log.info("Added record %s to daily summary in '%s'", + timestamp_to_string(record['dateTime']), + self.database_name) + + def _updateHiLo(self, accumulator, cursor): + """Use the contents of an accumulator to update the daily hi/lows.""" + + # Get the start-of-day for the timespan in the accumulator + _sod_ts = weeutil.weeutil.startOfArchiveDay(accumulator.timespan.stop) + + # Retrieve the daily summaries seen so far: + _stats_dict = self._get_day_summary(_sod_ts, cursor) + # Update them with the contents of the accumulator: + _stats_dict.updateHiLo(accumulator) + # Then save the results: + self._set_day_summary(_stats_dict, accumulator.timespan.stop, cursor) + + def backfill_day_summary(self, start_d=None, stop_d=None, + progress_fn=show_progress, trans_days=5): + + """Fill the daily summaries from an archive database. + + Normally, the daily summaries get filled by LOOP packets (to get maximum time resolution), + but if the database gets corrupted, or if a new user is starting up with imported wview + data, it's necessary to recreate it from straight archive data. The Hi/Lows will all be + there, but the times won't be any more accurate than the archive period. + + To help prevent database errors for large archives, database transactions are limited to + trans_days days of archive data. This is a trade-off between speed and memory usage. + + Args: + + start_d (datetime.date|None): The first day to be included, specified as a datetime.date + object [Optional. Default is to start with the first datum in the archive.] + stop_d (datetime.date|None): The last day to be included, specified as a datetime.date + object [Optional. Default is to include the date of the last archive record.] + progress_fn (function): This function will be called after processing every 1000 records. + trans_day (int): Number of days of archive data to be used for each daily summaries database + transaction. [Optional. Default is 5.] + + Returns: + tuple[int,int]: A 2-way tuple (nrecs, ndays) where + nrecs is the number of records backfilled; + ndays is the number of days + """ + # Definition: + # last_daily_ts: Timestamp of the last record that was incorporated into the + # daily summary. Usually it is equal to last_record, but it can be less + # if a backfill was aborted. + + log.info("Starting backfill of daily summaries") + + if self.first_timestamp is None: + # Nothing in the archive database, so there's nothing to do. + log.info("Empty database") + return 0, 0 + + # Convert tranch size to a timedelta object, so we can perform arithmetic with it. + tranche_days = datetime.timedelta(days=trans_days) + + t1 = time.time() + + last_daily_ts = to_int(self._read_metadata('lastUpdate')) + + # The goal here is to figure out: + # first_d: A datetime.date object, representing the first date to be rebuilt. + # last_d: A datetime.date object, representing the date after the last date + # to be rebuilt. + + # Check preconditions. Cannot specify start_d or stop_d unless the summaries are complete. + if last_daily_ts != self.last_timestamp and (start_d or stop_d): + raise weewx.ViolatedPrecondition("Daily summaries are not complete. " + "Try again without from/to dates.") + + # If we were doing a complete rebuild, these would be the first and + # last dates to be processed: + first_d = datetime.date.fromtimestamp(weeutil.weeutil.startOfArchiveDay( + self.first_timestamp)) + last_d = datetime.date.fromtimestamp(weeutil.weeutil.startOfArchiveDay( + self.last_timestamp)) + + # Are there existing daily summaries? + if last_daily_ts: + # Yes. Is it an aborted rebuild? + if last_daily_ts < self.last_timestamp: + # We are restarting from an aborted build. Pick up from where we left off. + # Because last_daily_ts always sits on the boundary of a day, this will include the + # following day to be included, but not the actual record with + # timestamp last_daily_ts. + first_d = datetime.date.fromtimestamp(last_daily_ts) + else: + # Daily summaries exist, and they are complete. + if not start_d and not stop_d: + # The daily summaries are complete, yet the user has not specified anything. + # Guess we're done. + log.info("Daily summaries up to date") + return 0, 0 + # Trim what we rebuild to what the user has specified + if start_d: + first_d = max(first_d, start_d) + if stop_d: + last_d = min(last_d, stop_d) + + # For what follows, last_d needs to point to the day *after* the last desired day + last_d += datetime.timedelta(days=1) + + nrecs = 0 + ndays = 0 + + mark_d = first_d + + while mark_d < last_d: + # Calculate the last date included in this transaction + stop_transaction = min(mark_d + tranche_days, last_d) + day_accum = None + + with weedb.Transaction(self.connection) as cursor: + # Go through all the archive records in the time span, adding them to the + # daily summaries + start_batch_ts = time.mktime(mark_d.timetuple()) + stop_batch_ts = time.mktime(stop_transaction.timetuple()) + for rec in self.genBatchRecords(start_batch_ts, stop_batch_ts): + # If this is the very first record, fetch a new accumulator + if not day_accum: + # Get a TimeSpan that include's the record's timestamp: + timespan = weeutil.weeutil.archiveDaySpan(rec['dateTime']) + # Get an empty day accumulator: + day_accum = weewx.accum.Accum(timespan) + try: + weight = self._calc_weight(rec) + except IntervalError as e: + # Ignore records with bad values for 'interval' + log.info(e) + log.info('*** ignored.') + continue + # Try updating. If the time is out of the accumulator's time span, an + # exception will get raised. + try: + day_accum.addRecord(rec, weight=weight) + except weewx.accum.OutOfSpan: + # The record is out of the time span. + # Save the old accumulator: + self._set_day_summary(day_accum, None, cursor) + ndays += 1 + # Get a new accumulator: + timespan = weeutil.weeutil.archiveDaySpan(rec['dateTime']) + day_accum = weewx.accum.Accum(timespan) + # try again + day_accum.addRecord(rec, weight=weight) + + if last_daily_ts is None: + last_daily_ts = rec['dateTime'] + else: + last_daily_ts = max(last_daily_ts, rec['dateTime']) + nrecs += 1 + if progress_fn and nrecs % 1000 == 0: + progress_fn(rec['dateTime'], nrecs) + + # We're done with this transaction. Unless it is empty, save the daily summary for + # the last day + if day_accum and not day_accum.isEmpty: + self._set_day_summary(day_accum, None, cursor) + ndays += 1 + # Patch lastUpdate: + if last_daily_ts: + self._write_metadata('lastUpdate', str(int(last_daily_ts)), cursor) + + # Advance to the next tranche + mark_d += tranche_days + + tdiff = time.time() - t1 + log.info("Processed %d records to backfill %d day summaries in %.2f seconds", + nrecs, ndays, tdiff) + + return nrecs, ndays + + def drop_daily(self): + """Drop the daily summaries.""" + + log.info("Dropping daily summary tables from '%s' ...", self.connection.database_name) + try: + _all_tables = self.connection.tables() + with weedb.Transaction(self.connection) as _cursor: + for _table_name in _all_tables: + if _table_name.startswith('%s_day_' % self.table_name): + _cursor.execute("DROP TABLE %s" % _table_name) + + self.daykeys = None + except weedb.OperationalError as e: + log.error("Drop daily summary tables failed for database '%s': %s", + self.connection.database_name, e) + raise + else: + log.info("Dropped daily summary tables from database '%s'", + self.connection.database_name) + + def recalculate_weights(self, start_d=None, stop_d=None, + tranche_size=100, weight_fn=None, progress_fn=show_progress): + """Recalculate just the daily summary weights. + + Rather than backfill all the daily summaries, this function simply recalculates the + weighted sums. + + start_d: The first day to be included, specified as a datetime.date object [Optional. + Default is to start with the first record in the daily summaries.] + + stop_d: The last day to be included, specified as a datetime.date object [Optional. + Default is to end with the last record in the daily summaries.] + + tranche_size: How many days to do in a single transaction. + + weight_fn: A function used to calculate the weights for a record. Default + is _calc_weight(). + + progress_fn: This function will be called after every tranche with the timestamp of the + last record processed. + """ + + log.info("recalculate_weights: Using database '%s'" % self.database_name) + log.debug("recalculate_weights: Tranche size %d" % tranche_size) + + # Convert tranch size to a timedelta object, so we can perform arithmetic with it. + tranche_days = datetime.timedelta(days=tranche_size) + + # Get the first and last timestamps for all the tables in the daily summaries. + first_ts, last_ts = self.get_first_last() + if first_ts is None or last_ts is None: + log.info("recalculate_weights: Empty daily summaries. Nothing done.") + return + + # Convert to date objects + first_d = datetime.date.fromtimestamp(first_ts) + last_d = datetime.date.fromtimestamp(last_ts) + + # Trim according to the requested dates + if start_d: + first_d = max(first_d, start_d) + if stop_d: + last_d = min(last_d, stop_d) + + # For what follows, last_date needs to point to the day *after* the last desired day. + last_d += datetime.timedelta(days=1) + + mark_d = first_d + + # March forward, tranche by tranche + while mark_d < last_d: + end_of_tranche_d = min(mark_d + tranche_days, last_d) + self._do_tranche(mark_d, end_of_tranche_d, weight_fn, progress_fn) + mark_d = end_of_tranche_d + + def _do_tranche(self, start_d, last_d, weight_fn=None, progress_fn=None): + """Reweight a tranche of daily summaries. + + start_d: A datetime.date object with the first date in the tranche to be reweighted. + + last_d: A datetime.date object with the day after the last date in the + tranche to be reweighted. + + weight_fn: A function used to calculate the weights for a record. Default is + _calc_weight(). + + progress_fn: A function to call to show progress. It will be called after every update. + """ + + if weight_fn is None: + weight_fn = DaySummaryManager._calc_weight + + # Do all the dates in the tranche as a single transaction + with weedb.Transaction(self.connection) as cursor: + + # March down the tranche, day by day + mark_d = start_d + while mark_d < last_d: + next_d = mark_d + datetime.timedelta(days=1) + day_span = TimeSpan(time.mktime(mark_d.timetuple()), + time.mktime(next_d.timetuple())) + # Get an accumulator for the day + day_accum = weewx.accum.Accum(day_span) + # Now populate it with a day's worth of records + for rec in self.genBatchRecords(day_span.start, day_span.stop): + try: + weight = weight_fn(self, rec) + except IntervalError as e: + log.info("%s: %s", timestamp_to_string(rec['dateTime']), e) + log.info('*** ignored.') + else: + day_accum.addRecord(rec, weight=weight) + # Write out the results of the accumulator + self._set_day_sums(day_accum, cursor) + if progress_fn: + # Update our progress + progress_fn(day_accum.timespan.stop) + # On to the next day + mark_d += datetime.timedelta(days=1) + + def _set_day_sums(self, day_accum, cursor): + """Replace the weighted sums for all types for a day. Don't touch the mins and maxes.""" + for obs_type in day_accum: + # Skip any types that are not in the daily summary schema + if obs_type not in self.daykeys: + continue + # This will be list that looks like ['sum=2345.65', 'count=123', ... etc] + # It will only include attributes that are in the accumulator for this type. + set_list = ['%s=%s' % (k, getattr(day_accum[obs_type], k)) + for k in ['sum', 'count', 'wsum', 'sumtime', + 'xsum', 'ysum', 'dirsumtime', + 'squaresum', 'wsquaresum'] + if hasattr(day_accum[obs_type], k)] + update_sql = "UPDATE {archive_table}_day_{obs_type} SET {set_stmt} " \ + "WHERE dateTime = ?;".format(archive_table=self.table_name, + obs_type=obs_type, + set_stmt=', '.join(set_list)) + # Update this observation type's weighted sums: + cursor.execute(update_sql, (day_accum.timespan.start,)) + + def patch_sums(self): + """Version 4.2.0 accidentally interpreted V2.0 daily sums as V1.0, so the weighted sums + were all given a weight of 1.0, instead of the interval length. Version 4.3.0 attempted + to fix this bug but introduced its own bug by failing to weight 'dirsumtime'. This fixes + both bugs.""" + if '1.0' < self.version < '4.0': + msg = "Daily summaries at V%s. Patching to V%s" \ + % (self.version, DaySummaryManager.version) + print(msg) + log.info(msg) + # We need to upgrade from V2.0 or V3.0 to V4.0. The only difference is + # that the patch has been supplied to V4.0 daily summaries. The patch + # need only be done from a date well before the V4.2 release. + # We pick 1-Jun-2020. + self.recalculate_weights(start_d=datetime.date(2020, 6, 1)) + self._write_metadata('Version', DaySummaryManager.version) + self.version = DaySummaryManager.version + log.info("Patch finished.") + + def update(self): + """Update the database to V4.0. + + - all V1.0 daily sums need to be upgraded + - V2.0 daily sums need to be upgraded but only those after a date well before the + V4.2.0 release (we pick 1 June 2020) + - V3.0 daily sums need to be upgraded due to a bug in the V4.2.0 and V4.3.0 releases + but only those after 1 June 2020 + """ + if self.version == '1.0': + self.recalculate_weights(weight_fn=DaySummaryManager._get_weight) + self._write_metadata('Version', DaySummaryManager.version) + self.version = DaySummaryManager.version + elif self.version == '2.0' or self.version == '3.0': + self.patch_sums() + + # --------------------------- UTILITY FUNCTIONS ----------------------------------- + + def get_first_last(self): + """Obtain the first and last timestamp of all the daily summaries. + + Returns: + tuple[int,int]|None: A two-way tuple (first_ts, last_ts) with the first timestamp and + the last timestamp. Returns None if there is nothing in the daily summaries. + """ + + big_select = ["SELECT MIN(dateTime) AS mtime FROM %s_day_%s" + % (self.table_name, key) for key in self.daykeys] + big_sql = " UNION ".join(big_select) + " ORDER BY mtime ASC LIMIT 1" + first_ts = self.getSql(big_sql) + + big_select = ["SELECT MAX(dateTime) AS mtime FROM %s_day_%s" + % (self.table_name, key) for key in self.daykeys] + big_sql = " UNION ".join(big_select) + " ORDER BY mtime DESC LIMIT 1" + last_ts = self.getSql(big_sql) + + return first_ts[0], last_ts[0] + + def _get_day_summary(self, sod_ts, cursor=None): + """Return an instance of an appropriate accumulator, initialized to a given day's + statistics. + + sod_ts: The timestamp of the start-of-day of the desired day. + """ + + # Get the TimeSpan for the day starting with sod_ts: + _timespan = weeutil.weeutil.daySpan(sod_ts) + + # Get an empty day accumulator: + _day_accum = weewx.accum.Accum(_timespan, self.std_unit_system) + + _cursor = cursor or self.connection.cursor() + + try: + # For each observation type, execute the SQL query and hand the results on to the + # accumulator. + for _day_key in self.daykeys: + _cursor.execute( + "SELECT * FROM %s_day_%s WHERE dateTime = ?" % (self.table_name, _day_key), + (_day_accum.timespan.start,)) + _row = _cursor.fetchone() + # If the date does not exist in the database yet then _row will be None. + _stats_tuple = _row[1:] if _row is not None else None + _day_accum.set_stats(_day_key, _stats_tuple) + + return _day_accum + finally: + if not cursor: + _cursor.close() + + def _set_day_summary(self, day_accum, lastUpdate, cursor): + """Write all statistics for a day to the database in a single transaction. + + day_accum: an accumulator with the daily summary. See weewx.accum + + lastUpdate: the time of the last update will be set to this unless it is None. + Normally, this is the timestamp of the last archive record added to the instance + day_accum. """ + + # Make sure the new data uses the same unit system as the database. + self._check_unit_system(day_accum.unit_system) + + _sod = day_accum.timespan.start + + # For each daily summary type... + for _summary_type in day_accum: + # Don't try an update for types not in the database: + if _summary_type not in self.daykeys: + continue + # ... get the stats tuple to be written to the database... + _write_tuple = (_sod,) + day_accum[_summary_type].getStatsTuple() + # ... and an appropriate SQL command with the correct number of question marks ... + _qmarks = ','.join(len(_write_tuple) * '?') + _sql_replace_str = "REPLACE INTO %s_day_%s VALUES(%s)" % ( + self.table_name, _summary_type, _qmarks) + # ... and write to the database. In case the type doesn't appear in the database, + # be prepared to catch an exception: + try: + cursor.execute(_sql_replace_str, _write_tuple) + except weedb.OperationalError as e: + log.error("Replace failed for database %s: %s", self.database_name, e) + + # If requested, update the time of the last daily summary update: + if lastUpdate is not None: + self._write_metadata('lastUpdate', str(int(lastUpdate)), cursor) + + def _calc_weight(self, record): + """Returns the weighting to be used, depending on the version of the daily summaries.""" + if 'interval' not in record: + raise ValueError("Missing value for record field 'interval'") + elif record['interval'] <= 0: + raise IntervalError( + "Non-positive value for record field 'interval': %s" % (record['interval'],)) + weight = 60.0 * record['interval'] if self.version >= '2.0' else 1.0 + return weight + + def _get_weight(self, record): + """Always returns a weight based on the field 'interval'.""" + if 'interval' not in record: + raise ValueError("Missing value for record field 'interval'") + elif record['interval'] <= 0: + raise IntervalError( + "Non-positive value for record field 'interval': %s" % (record['interval'],)) + return 60.0 * record['interval'] + + def _read_metadata(self, key, cursor=None): + """Obtain a value from the daily summary metadata table. + + Returns: + Value of the metadata field. Returns None if no value was found. + """ + _row = self.getSql(DaySummaryManager.meta_select_str % self.table_name, (key,), cursor) + return _row[0] if _row else None + + def _write_metadata(self, key, value, cursor=None): + """Write a value to the daily summary metadata table. + + Input parameters: + key: The name of the metadata field to be written to. + value: The value to be written to the metadata field. + """ + _cursor = cursor or self.connection.cursor() + + try: + _cursor.execute(DaySummaryManager.meta_replace_str % self.table_name, + (key, value)) + finally: + if cursor is None: + _cursor.close() + + +if __name__ == '__main__': + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weewx/qc.py b/dist/weewx-4.10.1/bin/weewx/qc.py new file mode 100644 index 0000000..cd87cf2 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/qc.py @@ -0,0 +1,73 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes and functions related to Quality Control of incoming data.""" + +# Python imports +from __future__ import absolute_import +import logging + +# weewx imports +import weeutil.weeutil +import weewx.units +from weeutil.weeutil import to_float + +log = logging.getLogger(__name__) + + +# ============================================================================== +# Class QC +# ============================================================================== + +class QC(object): + """Class to apply quality checks to a record.""" + + def __init__(self, mm_dict, log_failure=True): + """ + Initialize + Args: + mm_dict: A dictionary containing the limits. The key is an observation type, the value + is a 2- or 3-way tuple. If a 2-way tuple, then the values are (min, max) acceptable + value in a record for that observation type. If a 3-way tuple, then the values are + (min, max, unit), where min and max are as before, but the value 'unit' is the unit the + min and max values are in. If 'unit' is not specified, then the values must be in the + same unit as the incoming record (a risky supposition!). + + log_failure: True to log values outside of their limits. False otherwise. + """ + + self.mm_dict = {} + for obs_type in mm_dict: + self.mm_dict[obs_type] = list(mm_dict[obs_type]) + # The incoming min, max values may be from a ConfigObj, which are typically strings. + # Convert to floats. + self.mm_dict[obs_type][0] = to_float(self.mm_dict[obs_type][0]) + self.mm_dict[obs_type][1] = to_float(self.mm_dict[obs_type][1]) + + self.log_failure = log_failure + + def apply_qc(self, data_dict, data_type=''): + """Apply quality checks to the data in a record""" + + converter = weewx.units.StdUnitConverters[data_dict['usUnits']] + + for obs_type in self.mm_dict: + if obs_type in data_dict and data_dict[obs_type] is not None: + # Extract the minimum and maximum acceptable values + min_v, max_v = self.mm_dict[obs_type][0:2] + # If a unit has been specified, convert the min, max acceptable value to the same + # unit system as the incoming record: + if len(self.mm_dict[obs_type]) == 3: + min_max_unit = self.mm_dict[obs_type][2] + group = weewx.units.getUnitGroup(obs_type) + min_v = converter.convert((min_v, min_max_unit, group))[0] + max_v = converter.convert((max_v, min_max_unit, group))[0] + + if not min_v <= data_dict[obs_type] <= max_v: + if self.log_failure: + log.warning("%s %s value '%s' %s outside limits (%s, %s)", + weeutil.weeutil.timestamp_to_string(data_dict['dateTime']), + data_type, obs_type, data_dict[obs_type], min_v, max_v) + data_dict[obs_type] = None diff --git a/dist/weewx-4.10.1/bin/weewx/reportengine.py b/dist/weewx-4.10.1/bin/weewx/reportengine.py new file mode 100644 index 0000000..5dfbbb6 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/reportengine.py @@ -0,0 +1,843 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Engine for generating reports""" + +from __future__ import absolute_import + +# System imports: +import datetime +import ftplib +import glob +import logging +import os.path +import threading +import time +import traceback + +# 3rd party imports +import configobj +from six.moves import zip + +# WeeWX imports: +import weeutil.config +import weeutil.logger +import weeutil.weeutil +import weewx.defaults +import weewx.manager +import weewx.units +from weeutil.weeutil import to_bool, to_int + +log = logging.getLogger(__name__) + +# spans of valid values for each CRON like field +MINUTES = (0, 59) +HOURS = (0, 23) +DOM = (1, 31) +MONTHS = (1, 12) +DOW = (0, 6) +# valid day names for DOW field +DAY_NAMES = ('sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat') +# valid month names for month field +MONTH_NAMES = ('jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec') +# map month names to month number +MONTH_NAME_MAP = list(zip(('jan', 'feb', 'mar', 'apr', + 'may', 'jun', 'jul', 'aug', + 'sep', 'oct', 'nov', 'dec'), list(range(1, 13)))) +# map day names to day number +DAY_NAME_MAP = list(zip(('sun', 'mon', 'tue', 'wed', + 'thu', 'fri', 'sat'), list(range(7)))) +# map CRON like nicknames to equivalent CRON like line +NICKNAME_MAP = { + "@yearly": "0 0 1 1 *", + "@anually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@hourly": "0 * * * *" +} +# list of valid spans for CRON like fields +SPANS = (MINUTES, HOURS, DOM, MONTHS, DOW) +# list of valid names for CRON lik efields +NAMES = ((), (), (), MONTH_NAMES, DAY_NAMES) +# list of name maps for CRON like fields +MAPS = ((), (), (), MONTH_NAME_MAP, DAY_NAME_MAP) + + +# ============================================================================= +# Class StdReportEngine +# ============================================================================= + +class StdReportEngine(threading.Thread): + """Reporting engine for weewx. + + This engine runs zero or more reports. Each report uses a skin. A skin + has its own configuration file specifying things such as which 'generators' + should be run, which templates are to be used, what units are to be used, + etc.. + A 'generator' is a class inheriting from class ReportGenerator, that + produces the parts of the report, such as image plots, HTML files. + + StdReportEngine inherits from threading.Thread, so it will be run in a + separate thread. + + See below for examples of generators. + """ + + def __init__(self, config_dict, stn_info, record=None, gen_ts=None, first_run=True): + """Initializer for the report engine. + + config_dict: The configuration dictionary. + + stn_info: An instance of weewx.station.StationInfo, with static + station information. + + record: The current archive record [Optional; default is None] + + gen_ts: The timestamp for which the output is to be current + [Optional; default is the last time in the database] + + first_run: True if this is the first time the report engine has been + run. If this is the case, then any 'one time' events should be done. + """ + threading.Thread.__init__(self, name="ReportThread") + + self.config_dict = config_dict + self.stn_info = stn_info + self.record = record + self.gen_ts = gen_ts + self.first_run = first_run + + def run(self): + """This is where the actual work gets done. + + Runs through the list of reports. """ + + if self.gen_ts: + log.debug("Running reports for time %s", + weeutil.weeutil.timestamp_to_string(self.gen_ts)) + else: + log.debug("Running reports for latest time in the database.") + + # Iterate over each requested report + for report in self.config_dict['StdReport'].sections: + + # Ignore the [[Defaults]] section + if report == 'Defaults': + continue + + # See if this report is disabled + enabled = to_bool(self.config_dict['StdReport'][report].get('enable', True)) + if not enabled: + log.debug("Report '%s' not enabled. Skipping.", report) + continue + + log.debug("Running report '%s'", report) + + # Fetch and build the skin_dict: + try: + skin_dict = _build_skin_dict(self.config_dict, report) + except SyntaxError as e: + log.error("Syntax error: %s", e) + log.error(" **** Report ignored") + continue + + # Default action is to run the report. Only reason to not run it is + # if we have a valid report report_timing and it did not trigger. + if self.record: + # StdReport called us not wee_reports so look for a report_timing + # entry if we have one. + timing_line = skin_dict.get('report_timing') + if timing_line: + # Get a ReportTiming object. + timing = ReportTiming(timing_line) + if timing.is_valid: + # Get timestamp and interval so we can check if the + # report timing is triggered. + _ts = self.record['dateTime'] + _interval = self.record['interval'] * 60 + # Is our report timing triggered? timing.is_triggered + # returns True if triggered, False if not triggered + # and None if an invalid report timing line. + if timing.is_triggered(_ts, _ts - _interval) is False: + # report timing was valid but not triggered so do + # not run the report. + log.debug("Report '%s' skipped due to report_timing setting", report) + continue + else: + log.debug("Invalid report_timing setting for report '%s', " + "running report anyway", report) + log.debug(" **** %s", timing.validation_error) + + if 'Generators' in skin_dict and 'generator_list' in skin_dict['Generators']: + for generator in weeutil.weeutil.option_as_list(skin_dict['Generators']['generator_list']): + + try: + # Instantiate an instance of the class. + obj = weeutil.weeutil.get_object(generator)( + self.config_dict, + skin_dict, + self.gen_ts, + self.first_run, + self.stn_info, + self.record) + except Exception as e: + log.error("Unable to instantiate generator '%s'", generator) + log.error(" **** %s", e) + weeutil.logger.log_traceback(log.error, " **** ") + log.error(" **** Generator ignored") + traceback.print_exc() + continue + + try: + # Call its start() method + obj.start() + + except Exception as e: + # Caught unrecoverable error. Log it, continue on to the + # next generator. + log.error("Caught unrecoverable exception in generator '%s'", generator) + log.error(" **** %s", e) + weeutil.logger.log_traceback(log.error, " **** ") + log.error(" **** Generator terminated") + traceback.print_exc() + continue + + finally: + obj.finalize() + else: + log.debug("No generators specified for report '%s'", report) + + +def _build_skin_dict(config_dict, report): + """Find and build the skin_dict for the given report""" + + ####################################################################### + # Start with the defaults in the defaults module. Because we will be modifying it, we need + # to make a deep copy. + skin_dict = weeutil.config.deep_copy(weewx.defaults.defaults) + + # Turn off interpolation for the copy. It will interfere with interpretation of delta + # time fields + skin_dict.interpolation = False + # Add the report name: + skin_dict['REPORT_NAME'] = report + + ####################################################################### + # Add in the global values for log_success and log_failure: + if 'log_success' in config_dict: + skin_dict['log_success'] = to_bool(config_dict['log_success']) + if 'log_failure' in config_dict: + skin_dict['log_failure'] = to_bool(config_dict['log_failure']) + + ####################################################################### + # Now add the options in the report's skin.conf file. + # Start by figuring out where it is located. + skin_config_path = os.path.join( + config_dict['WEEWX_ROOT'], + config_dict['StdReport']['SKIN_ROOT'], + config_dict['StdReport'][report].get('skin', ''), + 'skin.conf') + + # Retrieve the configuration dictionary for the skin. Wrap it in a try block in case we + # fail. It is ok if there is no file - everything for a skin might be defined in the weewx + # configuration. + try: + merge_dict = configobj.ConfigObj(skin_config_path, + encoding='utf-8', + interpolation=False, + file_error=True) + except IOError as e: + log.debug("Cannot read skin configuration file %s for report '%s': %s", + skin_config_path, report, e) + except SyntaxError as e: + log.error("Failed to read skin configuration file %s for report '%s': %s", + skin_config_path, report, e) + raise + else: + log.debug("Found configuration file %s for report '%s'", skin_config_path, report) + # If a language is specified, honor it. + if 'lang' in merge_dict: + merge_lang(merge_dict['lang'], config_dict, report, skin_dict) + # If the file has a unit_system specified, honor it. + if 'unit_system' in merge_dict: + merge_unit_system(merge_dict['unit_system'], skin_dict) + # Merge the rest of the config file in: + weeutil.config.merge_config(skin_dict, merge_dict) + + ####################################################################### + # Merge in the [[Defaults]] section + if 'Defaults' in config_dict['StdReport']: + # Because we will be modifying the results, make a deep copy of the section. + merge_dict = weeutil.config.deep_copy(config_dict)['StdReport']['Defaults'] + # If a language is specified, honor it + if 'lang' in merge_dict: + merge_lang(merge_dict['lang'], config_dict, report, skin_dict) + # If a unit_system is specified, honor it + if 'unit_system' in merge_dict: + merge_unit_system(merge_dict['unit_system'], skin_dict) + weeutil.config.merge_config(skin_dict, merge_dict) + + # Any scalar overrides have lower-precedence than report-specific options, so do them now. + for scalar in config_dict['StdReport'].scalars: + skin_dict[scalar] = config_dict['StdReport'][scalar] + + # Finally the report-specific section. + if report in config_dict['StdReport']: + # Because we will be modifying the results, make a deep copy of the section. + merge_dict = weeutil.config.deep_copy(config_dict)['StdReport'][report] + # If a language is specified, honor it + if 'lang' in merge_dict: + merge_lang(merge_dict['lang'], config_dict, report, skin_dict) + # If a unit_system is specified, honor it + if 'unit_system' in merge_dict: + merge_unit_system(merge_dict['unit_system'], skin_dict) + weeutil.config.merge_config(skin_dict, merge_dict) + + return skin_dict + + +def merge_unit_system(report_units_base, skin_dict): + """ + Given a unit system, merge its unit groups into a configuration dictionary + Args: + report_units_base (str): A unit base (such as 'us', or 'metricwx') + skin_dict (dict): A configuration dictionary + + Returns: + None + """ + report_units_base = report_units_base.upper() + # Get the chosen unit system out of units.py, then merge it into skin_dict. + units_dict = weewx.units.std_groups[ + weewx.units.unit_constants[report_units_base]] + skin_dict['Units']['Groups'].update(units_dict) + + +def get_lang_dict(lang_spec, config_dict, report): + """Given a language specification, return its corresponding locale dictionary. """ + + # The language's corresponding locale file will be found in subdirectory 'lang', with + # a suffix '.conf'. Find the path to it:. + lang_config_path = os.path.join( + config_dict['WEEWX_ROOT'], + config_dict['StdReport']['SKIN_ROOT'], + config_dict['StdReport'][report].get('skin', ''), + 'lang', + lang_spec+'.conf') + + # Retrieve the language dictionary for the skin and requested language. Wrap it in a + # try block in case we fail. It is ok if there is no file - everything for a skin + # might be defined in the weewx configuration. + try: + lang_dict = configobj.ConfigObj(lang_config_path, + encoding='utf-8', + interpolation=False, + file_error=True) + except IOError as e: + log.debug("Cannot read localization file %s for report '%s': %s", + lang_config_path, report, e) + log.debug("**** Using defaults instead.") + lang_dict = configobj.ConfigObj({}, + encoding='utf-8', + interpolation=False) + except SyntaxError as e: + log.error("Syntax error while reading localization file %s for report '%s': %s", + lang_config_path, report, e) + raise + + if 'Texts' not in lang_dict: + lang_dict['Texts'] = {} + + return lang_dict + + +def merge_lang(lang_spec, config_dict, report, skin_dict): + + lang_dict = get_lang_dict(lang_spec, config_dict, report) + # There may or may not be a unit system specified. If so, honor it. + if 'unit_system' in lang_dict: + merge_unit_system(lang_dict['unit_system'], skin_dict) + weeutil.config.merge_config(skin_dict, lang_dict) + return skin_dict + + +# ============================================================================= +# Class ReportGenerator +# ============================================================================= + +class ReportGenerator(object): + """Base class for all report generators.""" + + def __init__(self, config_dict, skin_dict, gen_ts, first_run, stn_info, record=None): + self.config_dict = config_dict + self.skin_dict = skin_dict + self.gen_ts = gen_ts + self.first_run = first_run + self.stn_info = stn_info + self.record = record + self.db_binder = weewx.manager.DBBinder(self.config_dict) + + def start(self): + self.run() + + def run(self): + pass + + def finalize(self): + self.db_binder.close() + + +# ============================================================================= +# Class FtpGenerator +# ============================================================================= + +class FtpGenerator(ReportGenerator): + """Class for managing the "FTP generator". + + This will ftp everything in the public_html subdirectory to a webserver.""" + + def run(self): + import weeutil.ftpupload + + # determine how much logging is desired + log_success = to_bool(weeutil.config.search_up(self.skin_dict, 'log_success', True)) + log_failure = to_bool(weeutil.config.search_up(self.skin_dict, 'log_failure', True)) + + t1 = time.time() + try: + local_root = os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict.get('HTML_ROOT', self.config_dict['StdReport']['HTML_ROOT'])) + ftp_data = weeutil.ftpupload.FtpUpload( + server=self.skin_dict['server'], + user=self.skin_dict['user'], + password=self.skin_dict['password'], + local_root=local_root, + remote_root=self.skin_dict['path'], + port=int(self.skin_dict.get('port', 21)), + name=self.skin_dict['REPORT_NAME'], + passive=to_bool(self.skin_dict.get('passive', True)), + secure=to_bool(self.skin_dict.get('secure_ftp', False)), + debug=weewx.debug, + secure_data=to_bool(self.skin_dict.get('secure_data', True)), + reuse_ssl=to_bool(self.skin_dict.get('reuse_ssl', False)), + encoding=self.skin_dict.get('ftp_encoding', 'utf-8'), + ciphers=self.skin_dict.get('ciphers') + ) + except KeyError: + log.debug("ftpgenerator: FTP upload not requested. Skipped.") + return + + max_tries = int(self.skin_dict.get('max_tries', 3)) + for count in range(max_tries): + try: + n = ftp_data.run() + except ftplib.all_errors as e: + log.error("ftpgenerator: (%d): caught exception '%s': %s", count, type(e), e) + weeutil.logger.log_traceback(log.error, " **** ") + else: + if log_success: + t2 = time.time() + log.info("ftpgenerator: Ftp'd %d files in %0.2f seconds", n, (t2 - t1)) + break + else: + # The loop completed normally, meaning the upload failed. + if log_failure: + log.error("ftpgenerator: Upload failed") + + +# ============================================================================= +# Class RsyncGenerator +# ============================================================================= + +class RsyncGenerator(ReportGenerator): + """Class for managing the "rsync generator". + + This will rsync everything in the public_html subdirectory to a server.""" + + def run(self): + import weeutil.rsyncupload + log_success = to_bool(weeutil.config.search_up(self.skin_dict, 'log_success', True)) + log_failure = to_bool(weeutil.config.search_up(self.skin_dict, 'log_failure', True)) + + # We don't try to collect performance statistics about rsync, because + # rsync will report them for us. Check the debug log messages. + try: + local_root = os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict.get('HTML_ROOT', self.config_dict['StdReport']['HTML_ROOT'])) + rsync_data = weeutil.rsyncupload.RsyncUpload( + local_root=local_root, + remote_root=self.skin_dict['path'], + server=self.skin_dict['server'], + user=self.skin_dict.get('user'), + port=to_int(self.skin_dict.get('port')), + ssh_options=self.skin_dict.get('ssh_options'), + compress=to_bool(self.skin_dict.get('compress', False)), + delete=to_bool(self.skin_dict.get('delete', False)), + log_success=log_success, + log_failure=log_failure + ) + except KeyError: + log.debug("rsyncgenerator: Rsync upload not requested. Skipped.") + return + + try: + rsync_data.run() + except IOError as e: + log.error("rsyncgenerator: Caught exception '%s': %s", type(e), e) + + +# ============================================================================= +# Class CopyGenerator +# ============================================================================= + +class CopyGenerator(ReportGenerator): + """Class for managing the 'copy generator.' + + This will copy files from the skin subdirectory to the public_html + subdirectory.""" + + def run(self): + copy_dict = self.skin_dict['CopyGenerator'] + # determine how much logging is desired + log_success = to_bool(weeutil.config.search_up(copy_dict, 'log_success', True)) + + copy_list = [] + + if self.first_run: + # Get the list of files to be copied only once, at the first + # invocation of the generator. Wrap in a try block in case the + # list does not exist. + try: + copy_list += weeutil.weeutil.option_as_list(copy_dict['copy_once']) + except KeyError: + pass + + # Get the list of files to be copied everytime. Again, wrap in a + # try block. + try: + copy_list += weeutil.weeutil.option_as_list(copy_dict['copy_always']) + except KeyError: + pass + + # Change directory to the skin subdirectory: + os.chdir(os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict['SKIN_ROOT'], + self.skin_dict['skin'])) + # Figure out the destination of the files + html_dest_dir = os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict['HTML_ROOT']) + + # The copy list can contain wildcard characters. Go through the + # list globbing any character expansions + ncopy = 0 + for pattern in copy_list: + # Glob this pattern; then go through each resultant path: + for path in glob.glob(pattern): + ncopy += weeutil.weeutil.deep_copy_path(path, html_dest_dir) + if log_success: + log.info("Copied %d files to %s", ncopy, html_dest_dir) + + +# =============================================================================== +# Class ReportTiming +# =============================================================================== + +class ReportTiming(object): + """Class for processing a CRON like line and determining whether it should + be fired for a given time. + + The following CRON like capabilities are supported: + - There are two ways to specify the day the line is fired, DOM and DOW. A + match on either all other fields and either DOM or DOW will casue the + line to be fired. + - first-last, *. Matches all possible values for the field concerned. + - step, /x. Matches every xth minute/hour/day etc. May be bounded by a list + or range. + - range, lo-hi. Matches all values from lo to hi inclusive. Ranges using + month and day names are not supported. + - lists, x,y,z. Matches those items in the list. List items may be a range. + Lists using month and day names are not supported. + - month names. Months may be specified by number 1..12 or first 3 (case + insensitive) letters of the English month name jan..dec. + - weekday names. Weekday names may be specified by number 0..7 + (0,7 = Sunday) or first 3 (case insensitive) letters of the English + weekday names sun..sat. + - nicknames. Following nicknames are supported: + @yearly : Run once a year, ie "0 0 1 1 *" + @annually : Run once a year, ie "0 0 1 1 *" + @monthly : Run once a month, ie "0 0 1 * *" + @weekly : Run once a week, ie "0 0 * * 0" + @daily : Run once a day, ie "0 0 * * *" + @hourly : Run once an hour, ie "0 * * * *" + + Useful ReportTiming class attributes: + + is_valid: Whether passed line is a valid line or not. + validation_error: Error message if passed line is an invalid line. + raw_line: Raw line data passed to ReportTiming. + line: 5 item list representing the 5 date/time fields after the + raw line has been processed and dom/dow named parameters + replaced with numeric equivalents. + """ + + def __init__(self, raw_line): + """Initialises a ReportTiming object. + + Processes raw line to produce 5 field line suitable for further + processing. + + raw_line: The raw line to be processed. + """ + + # initialise some properties + self.is_valid = None + self.validation_error = None + # To simplify error reporting keep a copy of the raw line passed to us + # as a string. The raw line could be a list if it included any commas. + # Assume a string but catch the error if it is a list and join the list + # elements to make a string + try: + line_str = raw_line.strip() + except AttributeError: + line_str = ','.join(raw_line).strip() + self.raw_line = line_str + # do some basic checking of the line for unsupported characters + for unsupported_char in ('%', '#', 'L', 'W'): + if unsupported_char in line_str: + self.is_valid = False + self.validation_error = "Unsupported character '%s' in '%s'." % (unsupported_char, + self.raw_line) + return + # Six special time definition 'nicknames' are supported which replace + # the line elements with pre-determined values. These nicknames start + # with the @ character. Check for any of these nicknames and substitute + # the corresponding line. + for nickname, nn_line in NICKNAME_MAP.items(): + if line_str == nickname: + line_str = nn_line + break + fields = line_str.split(None, 5) + if len(fields) < 5: + # Not enough fields + self.is_valid = False + self.validation_error = "Insufficient fields found in '%s'" % self.raw_line + return + elif len(fields) == 5: + fields.append(None) + # extract individual line elements + minutes, hours, dom, months, dow, _extra = fields + # save individual fields + self.line = [minutes, hours, dom, months, dow] + # is DOM restricted ie is DOM not '*' + self.dom_restrict = self.line[2] != '*' + # is DOW restricted ie is DOW not '*' + self.dow_restrict = self.line[4] != '*' + # decode the line and generate a set of possible values for each field + (self.is_valid, self.validation_error) = self.decode_fields() + + def decode_fields(self): + """Decode each field and store the sets of valid values. + + Set of valid values is stored in self.decode. Self.decode can only be + considered valid if self.is_valid is True. Returns a 2-way tuple + (True|False, ERROR MESSAGE). First item is True is the line is valid + otherwise False. ERROR MESSAGE is None if the line is valid otherwise a + string containing a short error message. + """ + + # set a list to hold our decoded ranges + self.decode = [] + try: + # step through each field and its associated range, names and maps + for field, span, names, mapp in zip(self.line, SPANS, NAMES, MAPS): + field_set = self.parse_field(field, span, names, mapp) + self.decode.append(field_set) + # if we are this far then our line is valid so return True and no + # error message + return (True, None) + except ValueError as e: + # we picked up a ValueError in self.parse_field() so return False + # and the error message + return (False, e) + + def parse_field(self, field, span, names, mapp, is_rorl=False): + """Return the set of valid values for a field. + + Parses and validates a field and if the field is valid returns a set + containing all of the possible field values. Called recursively to + parse sub-fields (eg lists of ranges). If a field is invalid a + ValueError is raised. + + field: String containing the raw field to be parsed. + span: Tuple representing the lower and upper numeric values the + field may take. Format is (lower, upper). + names: Tuple containing all valid named values for the field. For + numeric only fields the tuple is empty. + mapp: Tuple of 2 way tuples mapping named values to numeric + equivalents. Format is ((name1, numeric1), .. + (namex, numericx)). For numeric only fields the tuple is empty. + is_rorl: Is field part of a range or list. Either True or False. + """ + + field = field.strip() + if field == '*': # first-last + # simply return a set of all poss values + return set(range(span[0], span[1] + 1)) + elif field.isdigit(): # just a number + # If its a DOW then replace any 7s with 0 + _field = field.replace('7', '0') if span == DOW else field + # its valid if its within our span + if span[0] <= int(_field) <= span[1]: + # it's valid so return the field itself as a set + return set((int(_field),)) + else: + # invalid field value so raise ValueError + raise ValueError("Invalid field value '%s' in '%s'" % (field, + self.raw_line)) + elif field.lower() in names: # an abbreviated name + # abbreviated names are only valid if not used in a range or list + if not is_rorl: + # replace all named values with numbers + _field = field + for _name, _ord in mapp: + _field = _field.replace(_name, str(_ord)) + # its valid if its within our span + if span[0] <= int(_field) <= span[1]: + # it's valid so return the field itself as a set + return set((int(_field),)) + else: + # invalid field value so raise ValueError + raise ValueError("Invalid field value '%s' in '%s'" % (field, + self.raw_line)) + else: + # invalid use of abbreviated name so raise ValueError + raise ValueError("Invalid use of abbreviated name '%s' in '%s'" % (field, + self.raw_line)) + elif ',' in field: # we have a list + # get the first list item and the rest of the list + _first, _rest = field.split(',', 1) + # get _first as a set using a recursive call + _first_set = self.parse_field(_first, span, names, mapp, True) + # get _rest as a set using a recursive call + _rest_set = self.parse_field(_rest, span, names, mapp, True) + # return the union of the _first and _rest sets + return _first_set | _rest_set + elif '/' in field: # a step + # get the value and the step + _val, _step = field.split('/', 1) + # step is valid if it is numeric + if _step.isdigit(): + # get _val as a set using a recursive call + _val_set = self.parse_field(_val, span, names, mapp, True) + # get the set of all possible values using _step + _lowest = min(_val_set) + _step_set = set([x for x in _val_set if ((x - _lowest) % int(_step) == 0)]) + # return the intersection of the _val and _step sets + return _val_set & _step_set + else: + # invalid step so raise ValueError + raise ValueError("Invalid step value '%s' in '%s'" % (field, + self.raw_line)) + elif '-' in field: # we have a range + # get the lo and hi values of the range + lo, hi = field.split('-', 1) + # if lo is numeric and in the span range then the range is valid if + # hi is valid + if lo.isdigit() and span[0] <= int(lo) <= span[1]: + # if hi is numeric and in the span range and greater than or + # equal to lo then the range is valid + if hi.isdigit() and int(hi) >= int(lo) and span[0] <= int(hi) <= span[1]: + # valid range so return a set of the range + return set(range(int(lo), int(hi) + 1)) + else: + # something is wrong, we have an invalid field + raise ValueError("Invalid range specification '%s' in '%s'" % (field, + self.raw_line)) + else: + # something is wrong with lo, we have an invalid field + raise ValueError("Invalid range specification '%s' in '%s'" % (field, + self.raw_line)) + else: + # we have something I don't know how to parse so raise a ValueError + raise ValueError("Invalid field '%s' in '%s'" % (field, + self.raw_line)) + + def is_triggered(self, ts_hi, ts_lo=None): + """Determine if CRON like line is to be triggered. + + Return True if line is triggered between timestamps ts_lo and ts_hi + (exclusive on ts_lo inclusive on ts_hi), False if it is not + triggered or None if the line is invalid or ts_hi is not valid. + If ts_lo is not specified check for triggering on ts_hi only. + + ts_hi: Timestamp of latest time to be checked for triggering. + ts_lo: Timestamp used for earliest time in range of times to be + checked for triggering. May be omitted in which case only + ts_hi is checked. + """ + + if self.is_valid and ts_hi is not None: + # setup ts range to iterate over + if ts_lo is None: + _range = [int(ts_hi)] + else: + # CRON like line has a 1 min resolution so step backwards every + # 60 sec. + _range = list(range(int(ts_hi), int(ts_lo), -60)) + # Iterate through each ts in our range. All we need is one ts that + # triggers the line. + for _ts in _range: + # convert ts to timetuple and extract required data + trigger_dt = datetime.datetime.fromtimestamp(_ts) + trigger_tt = trigger_dt.timetuple() + month, dow, day, hour, minute = (trigger_tt.tm_mon, + (trigger_tt.tm_wday + 1) % 7, + trigger_tt.tm_mday, + trigger_tt.tm_hour, + trigger_tt.tm_min) + # construct a tuple so we can iterate over and process each + # field + element_tuple = list(zip((minute, hour, day, month, dow), + self.line, + SPANS, + self.decode)) + # Iterate over each field and check if it will prevent + # triggering. Remember, we only need a match on either DOM or + # DOW but all other fields must match. + dom_match = False + dom_restricted_match = False + for period, _field, field_span, decode in element_tuple: + if period in decode: + # we have a match + if field_span == DOM: + # we have a match on DOM but we need to know if it + # was a match on a restricted DOM field + dom_match = True + dom_restricted_match = self.dom_restrict + elif field_span == DOW and not (dom_restricted_match or self.dow_restrict or dom_match): + break + continue + elif field_span == DOW and dom_restricted_match or field_span == DOM: + # No match but consider it a match if this field is DOW + # and we already have a DOM match. Also, if we didn't + # match on DOM then continue as we might match on DOW. + continue + else: + # The field will prevent the line from triggerring for + # this ts so we break and move to the next ts. + break + else: + # If we arrived here then all fields match and the line + # would be triggered on this ts so return True. + return True + # If we are here it is because we broke out of all inner for loops + # and the line was not triggered so return False. + return False + else: + # Our line is not valid or we do not have a timestamp to use, + # return None + return None diff --git a/dist/weewx-4.10.1/bin/weewx/restx.py b/dist/weewx-4.10.1/bin/weewx/restx.py new file mode 100644 index 0000000..343aabc --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/restx.py @@ -0,0 +1,1909 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Publish weather data to RESTful sites such as the Weather Underground. + + GENERAL ARCHITECTURE + +Each protocol uses two classes: + + o A weewx service, that runs in the main thread. Call this the + "controlling object" + o A separate "threading" class that runs in its own thread. Call this the + "posting object". + +Communication between the two is via an instance of queue.Queue. New loop +packets or archive records are put into the queue by the controlling object +and received by the posting object. Details below. + +The controlling object should inherit from StdRESTful. The controlling object +is responsible for unpacking any configuration information from weewx.conf, and +supplying any defaults. It sets up the queue. It arranges for any new LOOP or +archive records to be put in the queue. It then launches the thread for the +posting object. + +When a new LOOP or record arrives, the controlling object puts it in the queue, +to be received by the posting object. The controlling object can tell the +posting object to terminate by putting a 'None' in the queue. + +The posting object should inherit from class RESTThread. It monitors the queue +and blocks until a new record arrives. + +The base class RESTThread has a lot of functionality, so specializing classes +should only have to implement a few functions. In particular, + + - format_url(self, record). This function takes a record dictionary as an + argument. It is responsible for formatting it as an appropriate URL. + For example, the station registry's version emits strings such as + http://weewx.com/register/register.cgi?weewx_info=2.6.0a5&python_info= ... + + - skip_this_post(self, time_ts). If this function returns True, then the + post will be skipped. Otherwise, it is done. The default version does two + checks. First, it sees how old the record is. If it is older than the value + 'stale', then the post is skipped. Second, it will not allow posts more + often than 'post_interval'. Both of these can be set in the constructor of + RESTThread. + + - post_request(self, request, data). This function takes a urllib.request.Request object + and is responsible for performing the HTTP GET or POST. The default version + simply uses urllib.request.urlopen(request) and returns the result. If the + post could raise an unusual exception, override this function and catch the + exception. See the WOWThread implementation for an example. + + - check_response(self, response). After an HTTP request gets posted, the + webserver sends back a "response." This response may contain clues as to + whether the post worked. For example, a request might succeed, but the + actual posting of data might fail, with the reason indicated in the + response. The uploader can then take appropriate action, such as raising + a FailedPost exception, which results in logging the failure but not + retrying the post. See the StationRegistry uploader as an example. + + +In some cases, you might also have to implement the following: + + - get_request(self, url). The default version of this function creates + an urllib.request.Request object from the url, adds a 'User-Agent' header, + then returns it. You may need to override this function if you need to add + other headers, such as "Authorization" header. + + - get_post_body(self, record). Override this function if you want to do an + HTTP POST (instead of GET). It should return a tuple. First element is the + body of the POST, the second element is the type of the body. An example + would be (json.dumps({'city' : 'Sacramento'}), 'application/json'). + + - process_record(self, record, dbmanager). The default version is designed + to handle HTTP GET and POST. However, if your uploader uses some other + protocol, you may need to override this function. See the CWOP version, + CWOPThread.process_record(), for an example that uses sockets. + +See the file restful.md in the "tests" subdirectory for known behaviors +of various RESTful services. + +""" + +from __future__ import absolute_import + +import datetime +import logging +import platform +import re +import socket +import ssl +import sys +import threading +import time + +# Python 2/3 compatiblity shims +import six +from six.moves import http_client +from six.moves import queue +from six.moves import urllib + +import weedb +import weeutil.logger +import weeutil.weeutil +import weewx.engine +import weewx.manager +import weewx.units +from weeutil.config import search_up, accumulateLeaves +from weeutil.weeutil import to_int, to_float, to_bool, timestamp_to_string, to_sorted_string + +log = logging.getLogger(__name__) + + +class FailedPost(IOError): + """Raised when a post fails, and is unlikely to succeed if retried.""" + + +class AbortedPost(Exception): + """Raised when a post is aborted by the client.""" + + +class BadLogin(Exception): + """Raised when login information is bad or missing.""" + + +class ConnectError(IOError): + """Raised when unable to get a socket connection.""" + + +class SendError(IOError): + """Raised when unable to send through a socket.""" + + +# ============================================================================== +# Abstract base classes +# ============================================================================== + +class StdRESTful(weewx.engine.StdService): + """Abstract base class for RESTful weewx services. + + Offers a few common bits of functionality.""" + + def shutDown(self): + """Shut down any threads""" + if hasattr(self, 'loop_queue') and hasattr(self, 'loop_thread'): + StdRESTful.shutDown_thread(self.loop_queue, self.loop_thread) + if hasattr(self, 'archive_queue') and hasattr(self, 'archive_thread'): + StdRESTful.shutDown_thread(self.archive_queue, self.archive_thread) + + @staticmethod + def shutDown_thread(q, t): + """Function to shut down a thread.""" + if q and t.is_alive(): + # Put a None in the queue to signal the thread to shutdown + q.put(None) + # Wait up to 20 seconds for the thread to exit: + t.join(20.0) + if t.is_alive(): + log.error("Unable to shut down %s thread", t.name) + else: + log.debug("Shut down %s thread.", t.name) + + +# For backwards compatibility with early v2.6 alphas. In particular, the WeatherCloud uploader depends on it. +StdRESTbase = StdRESTful + + +class RESTThread(threading.Thread): + """Abstract base class for RESTful protocol threads. + + Offers a few bits of common functionality.""" + + def __init__(self, + q, + protocol_name, + essentials={}, + manager_dict=None, + post_interval=None, + max_backlog=six.MAXSIZE, + stale=None, + log_success=True, + log_failure=True, + timeout=10, + max_tries=3, + retry_wait=5, + retry_login=3600, + retry_ssl=3600, + softwaretype="weewx-%s" % weewx.__version__, + skip_upload=False): + """Initializer for the class RESTThread + + Args: + + q (queue.Queue): An instance of queue.Queue where the records will appear. + protocol_name (str): A string holding the name of the protocol. + essentials (dict): An optional dictionary that holds observation types that must + not be None for the post to go ahead. + manager_dict (dict|None): A database manager dictionary, to be used to open up a + database manager. Default is None. + post_interval (int|None): How long to wait between posts in seconds. + Default is None (post every record). + max_backlog (int): How many records are allowed to accumulate in the queue + before the queue is trimmed. Default is six.MAXSIZE (essentially, allow any number). + stale (int|None): How old a record can be and still considered useful. + Default is None (never becomes too old). + log_success (bool): If True, log a successful post in the system log. + Default is True. + log_failure (bool): If True, log an unsuccessful post in the system log. + Default is True. + timeout (int): How long to wait for the server to respond before giving up. + Default is 10 seconds. + max_tries (int): How many times to try the post before giving up. + Default is 3 + retry_wait (int): How long to wait between retries when failures. + Default is 5 seconds. + retry_login (int): How long to wait before retrying a login. Default + is 3600 seconds (one hour). + retry_ssl (int): How long to wait before retrying after an SSL error. Default + is 3600 seconds (one hour). + softwaretype (str): Sent as field "softwaretype" in the Ambient post. + Default is "weewx-x.y.z where x.y.z is the weewx version. + skip_upload (bool): Do all record processing, but do not upload the result. + Useful for diagnostic purposes when local debugging should not + interfere with the downstream data service. Default is False. + """ + # Initialize my superclass: + threading.Thread.__init__(self, name=protocol_name) + self.daemon = True + + self.queue = q + self.protocol_name = protocol_name + self.essentials = essentials + self.manager_dict = manager_dict + self.log_success = to_bool(log_success) + self.log_failure = to_bool(log_failure) + self.max_backlog = to_int(max_backlog) + self.max_tries = to_int(max_tries) + self.stale = to_int(stale) + self.post_interval = to_int(post_interval) + self.timeout = to_int(timeout) + self.retry_wait = to_int(retry_wait) + self.retry_login = to_int(retry_login) + self.retry_ssl = to_int(retry_ssl) + self.softwaretype = softwaretype + self.lastpost = 0 + self.skip_upload = to_bool(skip_upload) + + def get_record(self, record, dbmanager): + """Augment record data with additional data from the archive. + Should return results in the same units as the record and the database. + + This is a general version that for each of types 'hourRain', 'rain24', and 'dayRain', + it checks for existence. If not there, then the database is used to add it. This works for: + - WeatherUnderground + - PWSweather + - WOW + - CWOP + + It can be overridden and specialized for additional protocols. + + Args: + record (dict): An incoming record that will be augmented. It will not be touched. + dbmanager (weewx.manager.Manager|None): An instance of a database manager. If set + to None, then the record will not be augmented. + + Returns: + dict: A dictionary of augmented weather values + """ + + if dbmanager is None: + # If we don't have a database, we can't do anything + if self.log_failure and weewx.debug >= 2: + log.debug("No database specified. Augmentation from database skipped.") + return record + + _time_ts = record['dateTime'] + _sod_ts = weeutil.weeutil.startOfDay(_time_ts) + + # Make a copy of the record, then start adding to it: + _datadict = dict(record) + + # If the type 'rain' does not appear in the archive schema, + # or the database is locked, an exception will be raised. Be prepared + # to catch it. + try: + if 'hourRain' not in _datadict: + # CWOP says rain should be "rain that fell in the past hour". + # WU says it should be "the accumulated rainfall in the past + # 60 min". Presumably, this is exclusive of the archive record + # 60 minutes before, so the SQL statement is exclusive on the + # left, inclusive on the right. + _result = dbmanager.getSql( + "SELECT SUM(rain), MIN(usUnits), MAX(usUnits) FROM %s " + "WHERE dateTime>? AND dateTime<=?" + % dbmanager.table_name, (_time_ts - 3600.0, _time_ts)) + if _result is not None and _result[0] is not None: + if not _result[1] == _result[2] == record['usUnits']: + raise ValueError( + "Inconsistent units (%s vs %s vs %s) when querying for hourRain" + % (_result[1], _result[2], record['usUnits'])) + _datadict['hourRain'] = _result[0] + else: + _datadict['hourRain'] = None + + if 'rain24' not in _datadict: + # Similar issue, except for last 24 hours: + _result = dbmanager.getSql( + "SELECT SUM(rain), MIN(usUnits), MAX(usUnits) FROM %s " + "WHERE dateTime>? AND dateTime<=?" + % dbmanager.table_name, (_time_ts - 24 * 3600.0, _time_ts)) + if _result is not None and _result[0] is not None: + if not _result[1] == _result[2] == record['usUnits']: + raise ValueError( + "Inconsistent units (%s vs %s vs %s) when querying for rain24" + % (_result[1], _result[2], record['usUnits'])) + _datadict['rain24'] = _result[0] + else: + _datadict['rain24'] = None + + if 'dayRain' not in _datadict: + # NB: The WU considers the archive with time stamp 00:00 + # (midnight) as (wrongly) belonging to the current day + # (instead of the previous day). But, it's their site, + # so we'll do it their way. That means the SELECT statement + # is inclusive on both time ends: + _result = dbmanager.getSql( + "SELECT SUM(rain), MIN(usUnits), MAX(usUnits) FROM %s " + "WHERE dateTime>=? AND dateTime<=?" + % dbmanager.table_name, (_sod_ts, _time_ts)) + if _result is not None and _result[0] is not None: + if not _result[1] == _result[2] == record['usUnits']: + raise ValueError( + "Inconsistent units (%s vs %s vs %s) when querying for dayRain" + % (_result[1], _result[2], record['usUnits'])) + _datadict['dayRain'] = _result[0] + else: + _datadict['dayRain'] = None + + except weedb.OperationalError as e: + log.debug("%s: Database OperationalError '%s'", self.protocol_name, e) + + return _datadict + + def run(self): + """If there is a database specified, open the database, then call + run_loop() with the database. If no database is specified, simply + call run_loop().""" + + # Open up the archive. Use a 'with' statement. This will automatically + # close the archive in the case of an exception: + if self.manager_dict is not None: + with weewx.manager.open_manager(self.manager_dict) as _manager: + self.run_loop(_manager) + else: + self.run_loop() + + def run_loop(self, dbmanager=None): + """Runs a continuous loop, waiting for records to appear in the queue, + then processing them. + """ + + while True: + while True: + # This will block until something appears in the queue: + _record = self.queue.get() + # A None record is our signal to exit: + if _record is None: + return + # If packets have backed up in the queue, trim it until it's + # no bigger than the max allowed backlog: + if self.queue.qsize() <= self.max_backlog: + break + + if self.skip_this_post(_record['dateTime']): + continue + + try: + # Process the record, using whatever method the specializing + # class provides + self.process_record(_record, dbmanager) + except AbortedPost as e: + if self.log_success: + _time_str = timestamp_to_string(_record['dateTime']) + log.info("%s: Skipped record %s: %s", self.protocol_name, _time_str, e) + except BadLogin: + if self.retry_login: + log.error("%s: Bad login; waiting %s minutes then retrying", + self.protocol_name, self.retry_login / 60.0) + time.sleep(self.retry_login) + else: + log.error("%s: Bad login; no retry specified. Terminating", self.protocol_name) + raise + except FailedPost as e: + if self.log_failure: + _time_str = timestamp_to_string(_record['dateTime']) + log.error("%s: Failed to publish record %s: %s" + % (self.protocol_name, _time_str, e)) + except ssl.SSLError as e: + if self.retry_ssl: + log.error("%s: SSL error (%s); waiting %s minutes then retrying", + self.protocol_name, e, self.retry_ssl / 60.0) + time.sleep(self.retry_ssl) + else: + log.error("%s: SSL error (%s); no retry specified. Terminating", + self.protocol_name, e) + raise + except Exception as e: + # Some unknown exception occurred. This is probably a serious + # problem. Exit. + log.error("%s: Unexpected exception of type %s", self.protocol_name, type(e)) + weeutil.logger.log_traceback(log.error, '*** ') + log.critical("%s: Thread terminating. Reason: %s", self.protocol_name, e) + raise + else: + if self.log_success: + _time_str = timestamp_to_string(_record['dateTime']) + log.info("%s: Published record %s" % (self.protocol_name, _time_str)) + + def process_record(self, record, dbmanager): + """Default version of process_record. + + This version uses HTTP GETs to do the post, which should work for many + protocols, but it can always be replaced by a specializing class.""" + + # Get the full record by querying the database ... + _full_record = self.get_record(record, dbmanager) + # ... check it ... + self.check_this_record(_full_record) + # ... format the URL, using the relevant protocol ... + _url = self.format_url(_full_record) + # ... get the Request to go with it... + _request = self.get_request(_url) + # ... get any POST payload... + _payload = self.get_post_body(_full_record) + # ... add a proper Content-Type if needed... + if _payload: + _request.add_header('Content-Type', _payload[1]) + data = _payload[0] + else: + data = None + # ... check to see if this is just a drill... + if self.skip_upload: + raise AbortedPost("Skip post") + + # ... then, finally, post it + self.post_with_retries(_request, data) + + def get_request(self, url): + """Get a request object. This can be overridden to add any special headers.""" + _request = urllib.request.Request(url) + _request.add_header("User-Agent", "weewx/%s" % weewx.__version__) + return _request + + def post_with_retries(self, request, data=None): + """Post a request, retrying if necessary + + Attempts to post the request object up to max_tries times. + Catches a set of generic exceptions. + + request: An instance of urllib.request.Request + + data: The body of the POST. If not given, the request will be done as a GET. + """ + + # Retry up to max_tries times: + for _count in range(self.max_tries): + try: + if _count: + # If this is not the first time through, sleep a bit before retrying + time.sleep(self.retry_wait) + + # Do a single post. The function post_request() can be + # specialized by a RESTful service to catch any unusual + # exceptions. + _response = self.post_request(request, data) + + if 200 <= _response.code <= 299: + # No exception thrown and we got a good response code, but + # we're still not done. Some protocols encode a bad + # station ID or password in the return message. + # Give any interested protocols a chance to examine it. + # This must also be inside the try block because some + # implementations defer hitting the socket until the + # response is used. + self.check_response(_response) + # Does not seem to be an error. We're done. + return + # We got a bad response code. By default, log it and try again. + # Provide method for derived classes to behave otherwise if + # necessary. + self.handle_code(_response.code, _count + 1) + except (urllib.error.URLError, socket.error, http_client.HTTPException) as e: + # An exception was thrown. By default, log it and try again. + # Provide method for derived classes to behave otherwise if + # necessary. + self.handle_exception(e, _count + 1) + else: + # This is executed only if the loop terminates normally, meaning + # the upload failed max_tries times. Raise an exception. Caller + # can decide what to do with it. + raise FailedPost("Failed upload after %d tries" % self.max_tries) + + def check_this_record(self, record): + """Raises exception AbortedPost if the record should not be posted. + Otherwise, does nothing""" + for obs_type in self.essentials: + if to_bool(self.essentials[obs_type]) and record.get(obs_type) is None: + raise AbortedPost("Observation type %s missing" % obs_type) + + def check_response(self, response): + """Check the response from a HTTP post. This version does nothing.""" + pass + + def handle_code(self, code, count): + """Check code from HTTP post. This simply logs the response.""" + log.debug("%s: Failed upload attempt %d: Code %s" + % (self.protocol_name, count, code)) + + def handle_exception(self, e, count): + """Check exception from HTTP post. This simply logs the exception.""" + log.debug("%s: Failed upload attempt %d: %s" % (self.protocol_name, count, e)) + + def post_request(self, request, data=None): + """Post a request object. This version does not catch any HTTP + exceptions. + + Specializing versions can can catch any unusual exceptions that might + get raised by their protocol. + + request: An instance of urllib.request.Request + + data: If given, the request will be done as a POST. Otherwise, + as a GET. [optional] + """ + # Data might be a unicode string. Encode it first. + data_bytes = six.ensure_binary(data) if data is not None else None + if weewx.debug >= 2: + log.debug("%s url: '%s'", self.protocol_name, request.get_full_url()) + _response = urllib.request.urlopen(request, data=data_bytes, timeout=self.timeout) + return _response + + def skip_this_post(self, time_ts): + """Check whether the post is current""" + # Don't post if this record is too old + if self.stale is not None: + _how_old = time.time() - time_ts + if _how_old > self.stale: + log.debug("%s: record %s is stale (%d > %d).", + self.protocol_name, timestamp_to_string(time_ts), _how_old, self.stale) + return True + + if self.post_interval is not None: + # We don't want to post more often than the post interval + _how_long = time_ts - self.lastpost + if _how_long < self.post_interval: + log.debug("%s: wait interval (%d < %d) has not passed for record %s", + self.protocol_name, _how_long, + self.post_interval, timestamp_to_string(time_ts)) + return True + + self.lastpost = time_ts + return False + + def get_post_body(self, record): # @UnusedVariable + """Return any POST payload. + + The returned value should be a 2-way tuple. First element is the Python + object to be included as the payload. Second element is the MIME type it + is in (such as "application/json"). + + Return a simple 'None' if there is no POST payload. This is the default. + """ + # Maintain backwards compatibility with the old format_data() function. + body = self.format_data(record) + if body: + return body, 'application/x-www-form-urlencoded' + return None + + def format_data(self, _): + """Return a POST payload as an urlencoded object. + + DEPRECATED. Use get_post_body() instead. + """ + return None + + def format_url(self, _): + raise NotImplementedError + + +# ============================================================================== +# Ambient protocols +# ============================================================================== + +class StdWunderground(StdRESTful): + """Specialized version of the Ambient protocol for the Weather Underground. + """ + + # the rapidfire URL: + rf_url = "https://rtupdate.wunderground.com/weatherstation/updateweatherstation.php" + # the personal weather station URL: + pws_url = "https://weatherstation.wunderground.com/weatherstation/updateweatherstation.php" + + def __init__(self, engine, config_dict): + + super(StdWunderground, self).__init__(engine, config_dict) + + _ambient_dict = get_site_dict( + config_dict, 'Wunderground', 'station', 'password') + if _ambient_dict is None: + return + + _essentials_dict = search_up(config_dict['StdRESTful']['Wunderground'], 'Essentials', {}) + + log.debug("WU essentials: %s", _essentials_dict) + + # Get the manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + # The default is to not do an archive post if a rapidfire post + # has been specified, but this can be overridden + do_rapidfire_post = to_bool(_ambient_dict.pop('rapidfire', False)) + do_archive_post = to_bool(_ambient_dict.pop('archive_post', + not do_rapidfire_post)) + + if do_archive_post: + _ambient_dict.setdefault('server_url', StdWunderground.pws_url) + self.archive_queue = queue.Queue() + self.archive_thread = AmbientThread( + self.archive_queue, + _manager_dict, + protocol_name="Wunderground-PWS", + essentials=_essentials_dict, + **_ambient_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("Wunderground-PWS: Data for station %s will be posted", + _ambient_dict['station']) + + if do_rapidfire_post: + _ambient_dict.setdefault('server_url', StdWunderground.rf_url) + _ambient_dict.setdefault('log_success', False) + _ambient_dict.setdefault('log_failure', False) + _ambient_dict.setdefault('max_backlog', 0) + _ambient_dict.setdefault('max_tries', 1) + _ambient_dict.setdefault('rtfreq', 2.5) + self.cached_values = CachedValues() + self.loop_queue = queue.Queue() + self.loop_thread = AmbientLoopThread( + self.loop_queue, + _manager_dict, + protocol_name="Wunderground-RF", + essentials=_essentials_dict, + **_ambient_dict) + self.loop_thread.start() + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + log.info("Wunderground-RF: Data for station %s will be posted", + _ambient_dict['station']) + + def new_loop_packet(self, event): + """Puts new LOOP packets in the loop queue""" + if weewx.debug >= 3: + log.debug("Raw packet: %s", to_sorted_string(event.packet)) + self.cached_values.update(event.packet, event.packet['dateTime']) + if weewx.debug >= 3: + log.debug("Cached packet: %s", + to_sorted_string(self.cached_values.get_packet(event.packet['dateTime']))) + self.loop_queue.put( + self.cached_values.get_packet(event.packet['dateTime'])) + + def new_archive_record(self, event): + """Puts new archive records in the archive queue""" + self.archive_queue.put(event.record) + + +class CachedValues(object): + """Dictionary of value-timestamp pairs. Each timestamp indicates when the + corresponding value was last updated.""" + + def __init__(self): + self.unit_system = None + self.values = dict() + + def update(self, packet, ts): + # update the cache with values from the specified packet, using the + # specified timestamp. + for k in packet: + if k is None: + # well-formed packets do not have None as key, but just in case + continue + elif k == 'dateTime': + # do not cache the timestamp + continue + elif k == 'usUnits': + # assume unit system of first packet, then enforce consistency + if self.unit_system is None: + self.unit_system = packet['usUnits'] + elif packet['usUnits'] != self.unit_system: + raise ValueError("Mixed units encountered in cache. %s vs %s" + % (self.unit_system, packet['usUnits'])) + else: + # cache each value, associating it with the it was cached + self.values[k] = {'value': packet[k], 'ts': ts} + + def get_value(self, k, ts, stale_age): + # get the value for the specified key. if the value is older than + # stale_age (seconds) then return None. + if k in self.values and ts - self.values[k]['ts'] < stale_age: + return self.values[k]['value'] + return None + + def get_packet(self, ts=None, stale_age=960): + if ts is None: + ts = int(time.time() + 0.5) + pkt = {'dateTime': ts, 'usUnits': self.unit_system} + for k in self.values: + pkt[k] = self.get_value(k, ts, stale_age) + return pkt + + +class StdPWSWeather(StdRESTful): + """Specialized version of the Ambient protocol for PWSWeather""" + + # The URL used by PWSWeather: + archive_url = "https://www.pwsweather.com/pwsupdate/pwsupdate.php" + + def __init__(self, engine, config_dict): + super(StdPWSWeather, self).__init__(engine, config_dict) + + _ambient_dict = get_site_dict( + config_dict, 'PWSweather', 'station', 'password') + if _ambient_dict is None: + return + + # Get the manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + _ambient_dict.setdefault('server_url', StdPWSWeather.archive_url) + self.archive_queue = queue.Queue() + self.archive_thread = AmbientThread(self.archive_queue, _manager_dict, + protocol_name="PWSWeather", + **_ambient_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("PWSWeather: Data for station %s will be posted", _ambient_dict['station']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +# For backwards compatibility with early alpha versions: +StdPWSweather = StdPWSWeather + + +class StdWOW(StdRESTful): + """Upload using the UK Met Office's WOW protocol. + + For details of the WOW upload protocol, see + http://wow.metoffice.gov.uk/support/dataformats#dataFileUpload + """ + + # The URL used by WOW: + archive_url = "https://wow.metoffice.gov.uk/automaticreading" + + def __init__(self, engine, config_dict): + super(StdWOW, self).__init__(engine, config_dict) + + _ambient_dict = get_site_dict( + config_dict, 'WOW', 'station', 'password') + if _ambient_dict is None: + return + + # Get the manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + _ambient_dict.setdefault('server_url', StdWOW.archive_url) + self.archive_queue = queue.Queue() + self.archive_thread = WOWThread(self.archive_queue, _manager_dict, + protocol_name="WOW", + **_ambient_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("WOW: Data for station %s will be posted", _ambient_dict['station']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +class AmbientThread(RESTThread): + """Concrete class for threads posting from the archive queue, + using the Ambient PWS protocol. + """ + + def __init__(self, + q, + manager_dict, + station, + password, + server_url, + post_indoor_observations=False, + api_key=None, # Not used. + protocol_name="Unknown-Ambient", + essentials={}, + post_interval=None, + max_backlog=six.MAXSIZE, + stale=None, + log_success=True, + log_failure=True, + timeout=10, + max_tries=3, + retry_wait=5, + retry_login=3600, + retry_ssl=3600, + softwaretype="weewx-%s" % weewx.__version__, + skip_upload=False, + force_direction=False): + + """ + Initializer for the AmbientThread class. + + Parameters specific to this class: + + station: The name of the station. For example, for the WU, this + would be something like "KORHOODR3". + + password: Password used for the station. + + server_url: An url where the server for this protocol can be found. + """ + super(AmbientThread, self).__init__(q, + protocol_name, + essentials=essentials, + manager_dict=manager_dict, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + retry_login=retry_login, + retry_ssl=retry_ssl, + softwaretype=softwaretype, + skip_upload=skip_upload) + self.station = station + self.password = password + self.server_url = server_url + self.formats = dict(AmbientThread._FORMATS) + if to_bool(post_indoor_observations): + self.formats.update(AmbientThread._INDOOR_FORMATS) + self.force_direction = to_bool(force_direction) + self.last_direction = 0 + + # Types and formats of the data to be published. + # See https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US + # for definitions. + _FORMATS = { + 'barometer': 'baromin=%.3f', + 'co': 'AqCO=%f', + 'dateTime': 'dateutc=%s', + 'dayRain': 'dailyrainin=%.2f', + 'dewpoint': 'dewptf=%.1f', + 'hourRain': 'rainin=%.2f', + 'leafWet1': "leafwetness=%03.0f", + 'leafWet2': "leafwetness2=%03.0f", + 'no2': 'AqNO2=%f', + 'o3': 'AqOZONE=%f', + 'outHumidity': 'humidity=%03.0f', + 'outTemp': 'tempf=%.1f', + 'pm10_0': 'AqPM10=%.1f', + 'pm2_5': 'AqPM2.5=%.1f', + 'radiation': 'solarradiation=%.2f', + 'realtime': 'realtime=%d', + 'rtfreq': 'rtfreq=%.1f', + 'so2': 'AqSO2=%f', + 'soilMoist1': "soilmoisture=%03.0f", + 'soilMoist2': "soilmoisture2=%03.0f", + 'soilMoist3': "soilmoisture3=%03.0f", + 'soilMoist4': "soilmoisture4=%03.0f", + 'soilTemp1': "soiltempf=%.1f", + 'soilTemp2': "soiltemp2f=%.1f", + 'soilTemp3': "soiltemp3f=%.1f", + 'soilTemp4': "soiltemp4f=%.1f", + 'UV': 'UV=%.2f', + 'windDir': 'winddir=%03.0f', + 'windGust': 'windgustmph=%03.1f', + 'windGust10': 'windgustmph_10m=%03.1f', + 'windGustDir10': 'windgustdir_10m=%03.0f', + 'windSpeed': 'windspeedmph=%03.1f', + 'windSpeed2': 'windspdmph_avg2m=%03.1f', + # The following four formats have been commented out until the WU + # fixes the bug that causes them to be displayed as soil moisture. + # 'extraTemp1' : "temp2f=%.1f", + # 'extraTemp2' : "temp3f=%.1f", + # 'extraTemp3' : "temp4f=%.1f", + # 'extraTemp4' : "temp5f=%.1f", + } + + _INDOOR_FORMATS = { + 'inTemp': 'indoortempf=%.1f', + 'inHumidity': 'indoorhumidity=%.0f'} + + def format_url(self, incoming_record): + """Return an URL for posting using the Ambient protocol.""" + + record = weewx.units.to_US(incoming_record) + + _liststr = ["action=updateraw", + "ID=%s" % self.station, + "PASSWORD=%s" % urllib.parse.quote(self.password), + "softwaretype=%s" % self.softwaretype] + + # Go through each of the supported types, formatting it, then adding + # to _liststr: + for _key in self.formats: + _v = record.get(_key) + # WU claims a station is "offline" if it sends a null wind direction, even when wind + # speed is zero. If option 'force_direction' is set, cache the last non-null wind + # direction and use it instead. + if _key == 'windDir' and self.force_direction: + if _v is None: + _v = self.last_direction + else: + self.last_direction = _v + # Check to make sure the type is not null + if _v is not None: + if _key == 'dateTime': + # Convert from timestamp to string. The results will look something + # like '2020-10-19%2021%3A43%3A18' + _v = urllib.parse.quote(str(datetime.datetime.utcfromtimestamp(_v))) + # Format the value, and accumulate in _liststr: + _liststr.append(self.formats[_key] % _v) + # Now stick all the pieces together with an ampersand between them: + _urlquery = '&'.join(_liststr) + # This will be the complete URL for the HTTP GET: + _url = "%s?%s" % (self.server_url, _urlquery) + # show the url in the logs for debug, but mask any password + if weewx.debug >= 2: + log.debug("Ambient: url: %s", re.sub(r"PASSWORD=[^\&]*", "PASSWORD=XXX", _url)) + return _url + + def check_response(self, response): + """Check the HTTP response for an Ambient related error.""" + for line in response: + # PWSweather signals a bad login with 'ERROR' + if line.startswith(b'ERROR'): + # Bad login. No reason to retry. Raise an exception. + raise BadLogin(line) + # PWS signals something garbled with a line that includes 'invalid'. + elif line.find(b'invalid') != -1: + # Again, no reason to retry. Raise an exception. + raise FailedPost(line) + + +class AmbientLoopThread(AmbientThread): + """Version used for the Rapidfire protocol.""" + + def __init__(self, + q, + manager_dict, + station, + password, + server_url, + post_indoor_observations=False, + api_key=None, # Not used + protocol_name="Unknown-Ambient", + essentials={}, + post_interval=None, + max_backlog=six.MAXSIZE, + stale=None, + log_success=True, + log_failure=True, + timeout=10, + max_tries=3, + retry_wait=5, + retry_login=3600, + retry_ssl=3600, + softwaretype="weewx-%s" % weewx.__version__, + skip_upload=False, + force_direction=False, + rtfreq=2.5 # This is the only one added by AmbientLoopThread + ): + """ + Initializer for the AmbientLoopThread class. + + Parameters specific to this class: + + rtfreq: Frequency of update in seconds for RapidFire + """ + super(AmbientLoopThread, self).__init__(q, + manager_dict=manager_dict, + station=station, + password=password, + server_url=server_url, + post_indoor_observations=post_indoor_observations, + api_key=api_key, + protocol_name=protocol_name, + essentials=essentials, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + retry_login=retry_login, + retry_ssl=retry_ssl, + softwaretype=softwaretype, + skip_upload=skip_upload, + force_direction=force_direction) + + self.rtfreq = float(rtfreq) + self.formats.update(AmbientLoopThread.WUONLY_FORMATS) + + # may also be used by non-rapidfire; this is the least invasive way to just fix rapidfire, + # which i know supports windGustDir, while the Ambient class is used elsewhere + WUONLY_FORMATS = { + 'windGustDir': 'windgustdir=%03.0f' + } + + def get_record(self, record, dbmanager): + """Prepare a record for the Rapidfire protocol.""" + + # Call the regular Ambient PWS version + _record = AmbientThread.get_record(self, record, dbmanager) + # Add the Rapidfire-specific keywords: + _record['realtime'] = 1 + _record['rtfreq'] = self.rtfreq + + return _record + + +class WOWThread(AmbientThread): + """Class for posting to the WOW variant of the Ambient protocol.""" + + # Types and formats of the data to be published: + _FORMATS = {'dateTime': 'dateutc=%s', + 'barometer': 'baromin=%.3f', + 'outTemp': 'tempf=%.1f', + 'outHumidity': 'humidity=%.0f', + 'windSpeed': 'windspeedmph=%.0f', + 'windDir': 'winddir=%.0f', + 'windGust': 'windgustmph=%.0f', + 'windGustDir': 'windgustdir=%.0f', + 'dewpoint': 'dewptf=%.1f', + 'hourRain': 'rainin=%.2f', + 'dayRain': 'dailyrainin=%.3f'} + + def format_url(self, incoming_record): + """Return an URL for posting using WOW's version of the Ambient + protocol.""" + + record = weewx.units.to_US(incoming_record) + + _liststr = ["action=updateraw", + "siteid=%s" % self.station, + "siteAuthenticationKey=%s" % self.password, + "softwaretype=weewx-%s" % weewx.__version__] + + # Go through each of the supported types, formatting it, then adding + # to _liststr: + for _key in WOWThread._FORMATS: + _v = record.get(_key) + # Check to make sure the type is not null + if _v is not None: + if _key == 'dateTime': + _v = urllib.parse.quote_plus( + datetime.datetime.utcfromtimestamp(_v).isoformat(' ')) + # Format the value, and accumulate in _liststr: + _liststr.append(WOWThread._FORMATS[_key] % _v) + # Now stick all the pieces together with an ampersand between them: + _urlquery = '&'.join(_liststr) + # This will be the complete URL for the HTTP GET: + _url = "%s?%s" % (self.server_url, _urlquery) + # show the url in the logs for debug, but mask any password + if weewx.debug >= 2: + log.debug("WOW: url: %s", re.sub(r"siteAuthenticationKey=[^\&]*", + "siteAuthenticationKey=XXX", _url)) + return _url + + def post_request(self, request, data=None): # @UnusedVariable + """Version of post_request() for the WOW protocol, which + uses a response error code to signal a bad login.""" + try: + _response = urllib.request.urlopen(request, timeout=self.timeout) + except urllib.error.HTTPError as e: + # WOW signals a bad login with a HTML Error 403 code: + if e.code == 403: + raise BadLogin(e) + elif e.code >= 400: + raise FailedPost(e) + else: + raise + else: + return _response + + +# ============================================================================== +# CWOP +# ============================================================================== + +class StdCWOP(StdRESTful): + """Weewx service for posting using the CWOP protocol. + + Manages a separate thread CWOPThread""" + + # Default list of CWOP servers to try: + default_servers = ['cwop.aprs.net:14580', 'cwop.aprs.net:23'] + + def __init__(self, engine, config_dict): + super(StdCWOP, self).__init__(engine, config_dict) + + _cwop_dict = get_site_dict(config_dict, 'CWOP', 'station') + if _cwop_dict is None: + return + + if 'passcode' not in _cwop_dict or _cwop_dict['passcode'] == 'replace_me': + _cwop_dict['passcode'] = '-1' + _cwop_dict['station'] = _cwop_dict['station'].upper() + _cwop_dict.setdefault('latitude', self.engine.stn_info.latitude_f) + _cwop_dict.setdefault('longitude', self.engine.stn_info.longitude_f) + _cwop_dict.setdefault('station_type', config_dict['Station'].get( + 'station_type', 'Unknown')) + + # Get the database manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + self.archive_queue = queue.Queue() + self.archive_thread = CWOPThread(self.archive_queue, _manager_dict, + **_cwop_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("CWOP: Data for station %s will be posted", _cwop_dict['station']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +class CWOPThread(RESTThread): + """Concrete class for threads posting from the archive queue, using the CWOP protocol. For + details on the protocol, see http://www.wxqa.com/faq.html.""" + + def __init__(self, q, manager_dict, + station, passcode, latitude, longitude, station_type, + server_list=StdCWOP.default_servers, + post_interval=600, max_backlog=six.MAXSIZE, stale=600, + log_success=True, log_failure=True, + timeout=10, max_tries=3, retry_wait=5, skip_upload=False): + + """ + Initializer for the CWOPThread class. + + Parameters specific to this class: + + station: The name of the station. Something like "DW1234". + + passcode: Some stations require a passcode. + + latitude: Latitude of the station in decimal degrees. + + longitude: Longitude of the station in decimal degrees. + + station_type: The type of station. Generally, this is the driver + symbolic name, such as "Vantage". + + server_list: A list of strings holding the CWOP server name and + port. Default is ['cwop.aprs.net:14580', 'cwop.aprs.net:23'] + + Parameters customized for this class: + + post_interval: How long to wait between posts. + Default is 600 (every 10 minutes). + + stale: How old a record can be and still considered useful. + Default is 60 (one minute). + """ + # Initialize my superclass + super(CWOPThread, self).__init__(q, + protocol_name="CWOP", + manager_dict=manager_dict, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + skip_upload=skip_upload) + self.station = station + self.passcode = passcode + # In case we have a single server that would likely appear as a string + # not a list + self.server_list = weeutil.weeutil.option_as_list(server_list) + self.latitude = to_float(latitude) + self.longitude = to_float(longitude) + self.station_type = station_type + + def process_record(self, record, dbmanager): + """Process a record in accordance with the CWOP protocol.""" + + # Get the full record by querying the database ... + _full_record = self.get_record(record, dbmanager) + # ... convert to US if necessary ... + _us_record = weewx.units.to_US(_full_record) + # ... get the login and packet strings... + _login = self.get_login_string() + _tnc_packet = self.get_tnc_packet(_us_record) + if self.skip_upload: + raise AbortedPost("Skip post") + # ... then post them: + self.send_packet(_login, _tnc_packet) + + def get_login_string(self): + _login = "user %s pass %s vers weewx %s\r\n" % ( + self.station, self.passcode, weewx.__version__) + return _login + + def get_tnc_packet(self, record): + """Form the TNC2 packet used by CWOP.""" + + # Preamble to the TNC packet: + _prefix = "%s>APRS,TCPIP*:" % self.station + + # Time: + _time_tt = time.gmtime(record['dateTime']) + _time_str = time.strftime("@%d%H%Mz", _time_tt) + + # Position: + _lat_str = weeutil.weeutil.latlon_string(self.latitude, + ('N', 'S'), 'lat') + _lon_str = weeutil.weeutil.latlon_string(self.longitude, + ('E', 'W'), 'lon') + # noinspection PyStringFormat + _latlon_str = '%s%s%s/%s%s%s' % (_lat_str + _lon_str) + + # Wind and temperature + _wt_list = [] + for _obs_type in ['windDir', 'windSpeed', 'windGust', 'outTemp']: + _v = record.get(_obs_type) + _wt_list.append("%03d" % int(_v + 0.5) if _v is not None else '...') + _wt_str = "_%s/%sg%st%s" % tuple(_wt_list) + + # Rain + _rain_list = [] + for _obs_type in ['hourRain', 'rain24', 'dayRain']: + _v = record.get(_obs_type) + _rain_list.append("%03d" % int(_v * 100.0 + 0.5) if _v is not None else '...') + _rain_str = "r%sp%sP%s" % tuple(_rain_list) + + # Barometer: + _baro = record.get('altimeter') + if _baro is None: + _baro_str = "b....." + else: + # While everything else in the CWOP protocol is in US Customary, + # they want the barometer in millibars. + _baro_vt = weewx.units.convert((_baro, 'inHg', 'group_pressure'), + 'mbar') + _baro_str = "b%05d" % int(_baro_vt[0] * 10.0 + 0.5) + + # Humidity: + _humidity = record.get('outHumidity') + if _humidity is None: + _humid_str = "h.." + else: + _humid_str = ("h%02d" % int(_humidity + 0.5)) if _humidity < 99.5 else "h00" + + # Radiation: + _radiation = record.get('radiation') + if _radiation is None: + _radiation_str = "" + elif _radiation < 999.5: + _radiation_str = "L%03d" % int(_radiation + 0.5) + elif _radiation < 1999.5: + _radiation_str = "l%03d" % int(_radiation - 1000 + 0.5) + else: + _radiation_str = "" + + # Station equipment + _equipment_str = ".weewx-%s-%s" % (weewx.__version__, self.station_type) + + _tnc_packet = ''.join([_prefix, _time_str, _latlon_str, _wt_str, + _rain_str, _baro_str, _humid_str, + _radiation_str, _equipment_str, "\r\n"]) + + # show the packet in the logs for debug + if weewx.debug >= 2: + log.debug("CWOP: packet: '%s'", _tnc_packet.rstrip('\r\n')) + + return _tnc_packet + + def send_packet(self, login, tnc_packet): + + # Go through the list of known server:ports, looking for + # a connection that works: + for _serv_addr_str in self.server_list: + + try: + _server, _port_str = _serv_addr_str.split(":") + _port = int(_port_str) + except ValueError: + log.error("%s: Bad server address: '%s'; ignored", self.protocol_name, + _serv_addr_str) + continue + + # Try each combination up to max_tries times: + for _count in range(self.max_tries): + try: + # Get a socket connection: + _sock = self._get_connect(_server, _port) + log.debug("%s: Connected to server %s:%d", self.protocol_name, _server, _port) + try: + # Send the login ... + self._send(_sock, login, dbg_msg='login') + # ... and then the packet + response = self._send(_sock, tnc_packet, dbg_msg='tnc') + if weewx.debug >= 2: + log.debug("%s: Response to packet: '%s'", self.protocol_name, response) + return + finally: + _sock.close() + except ConnectError as e: + log.debug("%s: Attempt %d to %s:%d. Connection error: %s", + self.protocol_name, _count + 1, _server, _port, e) + except SendError as e: + log.debug("%s: Attempt %d to %s:%d. Socket send error: %s", + self.protocol_name, _count + 1, _server, _port, e) + + # If we get here, the loop terminated normally, meaning we failed + # all tries + raise FailedPost("Tried %d servers %d times each" + % (len(self.server_list), self.max_tries)) + + def _get_connect(self, server, port): + """Get a socket connection to a specific server and port.""" + + _sock = None + try: + _sock = socket.socket() + _sock.connect((server, port)) + except IOError as e: + # Unsuccessful. Close it in case it was open: + try: + _sock.close() + except (AttributeError, socket.error): + pass + raise ConnectError(e) + + return _sock + + def _send(self, sock, msg, dbg_msg): + """Send a message to a specific socket.""" + + # Convert from string to byte string + msg_bytes = msg.encode('ascii') + try: + sock.send(msg_bytes) + except IOError as e: + # Unsuccessful. Log it and go around again for another try + raise SendError("Packet %s; Error %s" % (dbg_msg, e)) + else: + # Success. Look for response from the server. + try: + _resp = sock.recv(1024).decode('ascii') + return _resp + except IOError as e: + log.debug("%s: Exception %s (%s) when looking for response to %s packet", + self.protocol_name, type(e), e, dbg_msg) + return + + +# ============================================================================== +# Station Registry +# ============================================================================== + +class StdStationRegistry(StdRESTful): + """Class for phoning home to register a weewx station. + + To enable this module, add the following to weewx.conf: + + [StdRESTful] + [[StationRegistry]] + register_this_station = True + + This will periodically do a http GET with the following information: + + station_url Should be world-accessible. Used as key. + description Brief synopsis of the station + latitude Station latitude in decimal + longitude Station longitude in decimal + station_type The driver name, for example Vantage, FineOffsetUSB + station_model The hardware_name property from the driver + weewx_info weewx version + python_info + platform_info + config_path Where the configuration file is located. + entry_path The path to the top-level module (usually where weewxd is located) + + The station_url is the unique key by which a station is identified. + """ + + archive_url = 'http://weewx.com/register/register.cgi' + + def __init__(self, engine, config_dict): + + super(StdStationRegistry, self).__init__(engine, config_dict) + + _registry_dict = get_site_dict(config_dict, 'StationRegistry', 'register_this_station') + if _registry_dict is None: + return + + # Should the service be run? + if not to_bool(_registry_dict.pop('register_this_station', False)): + log.info("StationRegistry: Registration not requested.") + return + + # Registry requires a valid station url + _registry_dict.setdefault('station_url', + self.engine.stn_info.station_url) + if _registry_dict['station_url'] is None: + log.info("StationRegistry: Station will not be registered: no station_url specified.") + return + + _registry_dict.setdefault('station_type', + config_dict['Station'].get('station_type', 'Unknown')) + _registry_dict.setdefault('description', self.engine.stn_info.location) + _registry_dict.setdefault('latitude', self.engine.stn_info.latitude_f) + _registry_dict.setdefault('longitude', self.engine.stn_info.longitude_f) + _registry_dict.setdefault('station_model', self.engine.stn_info.hardware) + _registry_dict.setdefault('config_path', config_dict.get('config_path', 'Unknown')) + # Find the top-level module. This is where the entry point will be. + _registry_dict.setdefault('entry_path', getattr(sys.modules['__main__'], '__file__', + 'Unknown') +) + + self.archive_queue = queue.Queue() + self.archive_thread = StationRegistryThread(self.archive_queue, + **_registry_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("StationRegistry: Station will be registered.") + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +class StationRegistryThread(RESTThread): + """Concrete threaded class for posting to the weewx station registry.""" + + def __init__(self, + q, + station_url, + latitude, + longitude, + server_url=StdStationRegistry.archive_url, + description="Unknown", + station_type="Unknown", + station_model="Unknown", + config_path="Unknown", + entry_path="Unknown", + post_interval=86400, + timeout=60, + **kwargs): + """Initialize an instance of StationRegistryThread. + + Args: + + q (queue.Queue): An instance of queue.Queue where the records will appear. + station_url (str): An URL used to identify the station. This will be + used as the unique key in the registry to identify each station. + latitude (float): Latitude of the staion + longitude (float): Longitude of the station + server_url (str): The URL of the registry server. + Default is 'http://weewx.com/register/register.cgi' + description (str): A brief description of the station. + Default is 'Unknown' + station_type (str): The type of station. Generally, this is the name of + the driver used by the station. Default is 'Unknown' + config_path (str): location of the configuration file, used in system + registration to determine how weewx might have been installed. + Default is 'Unknown'. + entry_path (str): location of the top-level module that was executed. Usually this is + where 'weewxd' is located. Default is "Unknown". + station_model (str): The hardware model, typically the hardware_name property provided + by the driver. Default is 'Unknown'. + post_interval (int): How long to wait between posts. + Default is 86400 seconds (1 day). + timeout (int): How long to wait for the server to respond before giving up. + Default is 60 seconds. + """ + + super(StationRegistryThread, self).__init__( + q, + protocol_name='StationRegistry', + post_interval=post_interval, + timeout=timeout, + **kwargs) + self.station_url = station_url + self.latitude = to_float(latitude) + self.longitude = to_float(longitude) + self.server_url = server_url + self.description = weeutil.weeutil.list_as_string(description) + self.station_type = station_type + self.station_model = station_model + self.config_path = config_path + self.entry_path = entry_path + + def get_record(self, dummy_record, dummy_archive): + _record = { + 'station_url': self.station_url, + 'description': self.description, + 'latitude': self.latitude, + 'longitude': self.longitude, + 'station_type': self.station_type, + 'station_model': self.station_model, + 'python_info': platform.python_version(), + 'platform_info': platform.platform(), + 'weewx_info': weewx.__version__, + 'config_path': self.config_path, + 'entry_path' : self.entry_path, + 'usUnits': weewx.US, + } + return _record + + _FORMATS = {'station_url': 'station_url=%s', + 'description': 'description=%s', + 'latitude': 'latitude=%.4f', + 'longitude': 'longitude=%.4f', + 'station_type': 'station_type=%s', + 'station_model': 'station_model=%s', + 'python_info': 'python_info=%s', + 'platform_info': 'platform_info=%s', + 'config_path': 'config_path=%s', + 'entry_path': 'entry_path=%s', + 'weewx_info': 'weewx_info=%s'} + + def format_url(self, record): + """Return an URL for posting using the StationRegistry protocol.""" + + _liststr = [] + for _key in StationRegistryThread._FORMATS: + v = record[_key] + if v is not None: + # Under Python 2, quote_plus() can only accept strings (no unicode). + # If necessary, convert. + if isinstance(v, six.string_types): + v = six.ensure_str(v) + _liststr.append(urllib.parse.quote_plus(StationRegistryThread._FORMATS[_key] % v, + '=')) + _urlquery = '&'.join(_liststr) + _url = "%s?%s" % (self.server_url, _urlquery) + return _url + + def check_response(self, response): + """ + Check the response from a Station Registry post. The server will + reply with a single line that starts with OK or FAIL. If a post fails + at this point, it is probably due to a configuration problem, not + communications, so retrying probably not help. So raise a FailedPost + exception, which will result in logging the failure without retrying. + """ + for line in response: + if line.startswith(b'FAIL'): + raise FailedPost(line) + + +# ============================================================================== +# AWEKAS +# ============================================================================== + +class StdAWEKAS(StdRESTful): + """Upload data to AWEKAS - Automatisches WEtterKArten System + http://www.awekas.at + + To enable this module, add the following to weewx.conf: + + [StdRESTful] + [[AWEKAS]] + enable = True + username = AWEKAS_USERNAME + password = AWEKAS_PASSWORD + + The AWEKAS server expects a single string of values delimited by + semicolons. The position of each value matters, for example position 1 + is the awekas username and position 2 is the awekas password. + + Positions 1-25 are defined for the basic API: + + Pos1: user (awekas username) + Pos2: password (awekas password MD5 Hash) + Pos3: date (dd.mm.yyyy) (varchar) + Pos4: time (hh:mm) (varchar) + Pos5: temperature (C) (float) + Pos6: humidity (%) (int) + Pos7: air pressure (hPa) (float) [22dec15. This should be SLP. -tk personal communications] + Pos8: precipitation (rain at this day) (float) + Pos9: wind speed (km/h) float) + Pos10: wind direction (degree) (int) + Pos11: weather condition (int) + 0=clear warning + 1=clear + 2=sunny sky + 3=partly cloudy + 4=cloudy + 5=heavy cloundy + 6=overcast sky + 7=fog + 8=rain showers + 9=heavy rain showers + 10=light rain + 11=rain + 12=heavy rain + 13=light snow + 14=snow + 15=light snow showers + 16=snow showers + 17=sleet + 18=hail + 19=thunderstorm + 20=storm + 21=freezing rain + 22=warning + 23=drizzle + 24=heavy snow + 25=heavy snow showers + Pos12: warning text (varchar) + Pos13: snow high (cm) (int) if no snow leave blank + Pos14: language (varchar) + de=german; en=english; it=italian; fr=french; nl=dutch + Pos15: tendency (int) + -2 = high falling + -1 = falling + 0 = steady + 1 = rising + 2 = high rising + Pos16. wind gust (km/h) (float) + Pos17: solar radiation (W/m^2) (float) + Pos18: UV Index (float) + Pos19: brightness (LUX) (int) + Pos20: sunshine hours today (float) + Pos21: soil temperature (degree C) (float) + Pos22: rain rate (mm/h) (float) + Pos23: software flag NNNN_X.Y, for example, WLIP_2.15 + Pos24: longitude (float) + Pos25: latitude (float) + + positions 26-111 are defined for API2 + """ + + def __init__(self, engine, config_dict): + super(StdAWEKAS, self).__init__(engine, config_dict) + + site_dict = get_site_dict(config_dict, 'AWEKAS', 'username', 'password') + if site_dict is None: + return + + site_dict.setdefault('latitude', engine.stn_info.latitude_f) + site_dict.setdefault('longitude', engine.stn_info.longitude_f) + site_dict.setdefault('language', 'de') + + site_dict['manager_dict'] = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + self.archive_queue = queue.Queue() + self.archive_thread = AWEKASThread(self.archive_queue, **site_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("AWEKAS: Data will be uploaded for user %s", site_dict['username']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +# For compatibility with some early alpha versions: +AWEKAS = StdAWEKAS + + +class AWEKASThread(RESTThread): + _SERVER_URL = 'http://data.awekas.at/eingabe_pruefung.php' + _FORMATS = {'barometer': '%.3f', + 'outTemp': '%.1f', + 'outHumidity': '%.0f', + 'windSpeed': '%.1f', + 'windDir': '%.0f', + 'windGust': '%.1f', + 'dewpoint': '%.1f', + 'hourRain': '%.2f', + 'dayRain': '%.2f', + 'radiation': '%.2f', + 'UV': '%.2f', + 'rainRate': '%.2f'} + + def __init__(self, q, username, password, latitude, longitude, + manager_dict, + language='de', server_url=_SERVER_URL, + post_interval=300, max_backlog=six.MAXSIZE, stale=None, + log_success=True, log_failure=True, + timeout=10, max_tries=3, retry_wait=5, + retry_login=3600, retry_ssl=3600, skip_upload=False): + """Initialize an instances of AWEKASThread. + + Parameters specific to this class: + + username: AWEKAS user name + + password: AWEKAS password + + language: Possible values include de, en, it, fr, nl + Default is de + + latitude: Station latitude in decimal degrees + Default is station latitude + + longitude: Station longitude in decimal degrees + Default is station longitude + + manager_dict: A dictionary holding the database manager + information. It will be used to open a connection to the archive + database. + + server_url: URL of the server + Default is the AWEKAS site + + Parameters customized for this class: + + post_interval: The interval in seconds between posts. AWEKAS requests + that uploads happen no more often than 5 minutes, so this should be + set to no less than 300. Default is 300 + """ + import hashlib + super(AWEKASThread, self).__init__(q, + protocol_name='AWEKAS', + manager_dict=manager_dict, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + retry_login=retry_login, + retry_ssl=retry_ssl, + skip_upload=skip_upload) + self.username = username + # Calculate and save the password hash + m = hashlib.md5() + m.update(password.encode('utf-8')) + self.password_hash = m.hexdigest() + self.latitude = float(latitude) + self.longitude = float(longitude) + self.language = language + self.server_url = server_url + + def get_record(self, record, dbmanager): + """Ensure that rainRate is in the record.""" + # Have my superclass process the record first. + record = super(AWEKASThread, self).get_record(record, dbmanager) + + # No need to do anything if rainRate is already in the record + if 'rainRate' in record: + return record + + # If we don't have a database, we can't do anything + if dbmanager is None: + if self.log_failure: + log.debug("AWEKAS: No database specified. Augmentation from database skipped.") + return record + + # If the database does not have rainRate in its schema, an exception will be raised. + # Be prepare to catch it. + try: + rr = dbmanager.getSql('select rainRate from %s where dateTime=?' + % dbmanager.table_name, (record['dateTime'],)) + except weedb.OperationalError: + pass + else: + # If there is no record with the timestamp, None will be returned. + # In theory, this shouldn't happen, but check just in case: + if rr: + record['rainRate'] = rr[0] + + return record + + def format_url(self, in_record): + """Specialized version of format_url() for the AWEKAS protocol.""" + + # Convert to units required by awekas + record = weewx.units.to_METRIC(in_record) + if 'dayRain' in record and record['dayRain'] is not None: + record['dayRain'] *= 10 + if 'rainRate' in record and record['rainRate'] is not None: + record['rainRate'] *= 10 + + time_tt = time.gmtime(record['dateTime']) + # assemble an array of values in the proper order + values = [ + self.username, + self.password_hash, + time.strftime("%d.%m.%Y", time_tt), + time.strftime("%H:%M", time_tt), + self._format(record, 'outTemp'), # C + self._format(record, 'outHumidity'), # % + self._format(record, 'barometer'), # mbar + self._format(record, 'dayRain'), # mm + self._format(record, 'windSpeed'), # km/h + self._format(record, 'windDir'), + '', # weather condition + '', # warning text + '', # snow high + self.language, + '', # tendency + self._format(record, 'windGust'), # km/h + self._format(record, 'radiation'), # W/m^2 + self._format(record, 'UV'), # uv index + '', # brightness in lux + '', # sunshine hours + '', # soil temperature + self._format(record, 'rainRate'), # mm/h + 'weewx_%s' % weewx.__version__, + str(self.longitude), + str(self.latitude), + ] + + valstr = ';'.join(values) + url = self.server_url + '?val=' + valstr + + if weewx.debug >= 2: + # show the url in the logs for debug, but mask any credentials + log.debug('AWEKAS: url: %s', url.replace(self.password_hash, 'XXX')) + + return url + + def _format(self, record, label): + if label in record and record[label] is not None: + if label in self._FORMATS: + return self._FORMATS[label] % record[label] + return str(record[label]) + return '' + + def check_response(self, response): + """Specialized version of check_response().""" + for line in response: + # Skip blank lines: + if not line.strip(): + continue + if line.startswith(b'OK'): + return + elif line.startswith(b"Benutzer/Passwort Fehler"): + raise BadLogin(line) + else: + raise FailedPost("Server returned '%s'" % six.ensure_text(line)) + + +############################################################################### + +def get_site_dict(config_dict, service, *args): + """Obtain the site options, with defaults from the StdRESTful section. + If the service is not enabled, or if one or more required parameters is + not specified, then return None.""" + + try: + site_dict = accumulateLeaves(config_dict['StdRESTful'][service], + max_level=1) + except KeyError: + log.info("%s: No config info. Skipped.", service) + return None + + # If site_dict has the key 'enable' and it is False, then + # the service is not enabled. + try: + if not to_bool(site_dict['enable']): + log.info("%s: Posting not enabled.", service) + return None + except KeyError: + pass + + # At this point, either the key 'enable' does not exist, or + # it is set to True. Check to see whether all the needed + # options exist, and none of them have been set to 'replace_me': + try: + for option in args: + if site_dict[option] == 'replace_me': + raise KeyError(option) + except KeyError as e: + log.debug("%s: Data will not be posted: Missing option %s", service, e) + return None + + # If the site dictionary does not have a log_success or log_failure, get + # them from the root dictionary + site_dict.setdefault('log_success', to_bool(config_dict.get('log_success', True))) + site_dict.setdefault('log_failure', to_bool(config_dict.get('log_failure', True))) + + # Get rid of the no longer needed key 'enable': + site_dict.pop('enable', None) + + return site_dict + + +# For backward compatibility pre 3.6.0 +check_enable = get_site_dict diff --git a/dist/weewx-4.10.1/bin/weewx/station.py b/dist/weewx-4.10.1/bin/weewx/station.py new file mode 100644 index 0000000..d2f3915 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/station.py @@ -0,0 +1,181 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Defines (mostly static) information about a station.""" +from __future__ import absolute_import +import sys +import time + +import weeutil.weeutil +import weewx.units + +class StationInfo(object): + """Readonly class with static station information. It has no formatting information. Just a POS. + + Attributes: + + altitude_vt: Station altitude as a ValueTuple + hardware: A string holding a hardware description + rain_year_start: The start of the rain year (1=January) + latitude_f: Floating point latitude + longitude_f: Floating point longitude + location: String holding a description of the station location + week_start: The start of the week (0=Monday) + station_url: An URL with an informative website (if any) about the station + """ + + def __init__(self, console=None, **stn_dict): + """Extracts info from the console and stn_dict and stores it in self.""" + + if console and hasattr(console, "altitude_vt"): + self.altitude_vt = console.altitude_vt + else: + altitude_t = weeutil.weeutil.option_as_list(stn_dict.get('altitude', (None, None))) + try: + self.altitude_vt = weewx.units.ValueTuple(float(altitude_t[0]), altitude_t[1], "group_altitude") + except KeyError as e: + raise weewx.ViolatedPrecondition("Value 'altitude' needs a unit (%s)" % e) + + if console and hasattr(console, 'hardware_name'): + self.hardware = console.hardware_name + else: + self.hardware = stn_dict.get('station_type', 'Unknown') + + if console and hasattr(console, 'rain_year_start'): + self.rain_year_start = getattr(console, 'rain_year_start') + else: + self.rain_year_start = int(stn_dict.get('rain_year_start', 1)) + + self.latitude_f = float(stn_dict['latitude']) + self.longitude_f = float(stn_dict['longitude']) + # Locations frequently have commas in them. Guard against ConfigObj turning it into a list: + self.location = weeutil.weeutil.list_as_string(stn_dict.get('location', 'Unknown')) + self.week_start = int(stn_dict.get('week_start', 6)) + self.station_url = stn_dict.get('station_url') + # For backwards compatibility: + self.webpath = self.station_url + +class Station(object): + """Formatted version of StationInfo.""" + + def __init__(self, stn_info, formatter, converter, skin_dict): + + # Store away my instance of StationInfo + self.stn_info = stn_info + self.formatter = formatter + self.converter = converter + + # Add a bunch of formatted attributes: + label_dict = skin_dict.get('Labels', {}) + hemispheres = label_dict.get('hemispheres', ('N','S','E','W')) + latlon_formats = label_dict.get('latlon_formats') + self.latitude = weeutil.weeutil.latlon_string(stn_info.latitude_f, + hemispheres[0:2], + 'lat', latlon_formats) + self.longitude = weeutil.weeutil.latlon_string(stn_info.longitude_f, + hemispheres[2:4], + 'lon', latlon_formats) + self.altitude = weewx.units.ValueHelper(value_t=stn_info.altitude_vt, + formatter=formatter, + converter=converter) + self.rain_year_str = time.strftime("%b", (0, self.rain_year_start, 1, 0, 0, 0, 0, 0, -1)) + + self.version = weewx.__version__ + + self.python_version = "%d.%d.%d" % sys.version_info[:3] + + @property + def uptime(self): + """Lazy evaluation of weewx uptime.""" + delta_time = time.time() - weewx.launchtime_ts if weewx.launchtime_ts else None + + return weewx.units.ValueHelper(value_t=(delta_time, "second", "group_deltatime"), + context="month", + formatter=self.formatter, + converter=self.converter) + + @property + def os_uptime(self): + """Lazy evaluation of the server uptime.""" + os_uptime_secs = _os_uptime() + return weewx.units.ValueHelper(value_t=(os_uptime_secs, "second", "group_deltatime"), + context="month", + formatter=self.formatter, + converter=self.converter) + + def __getattr__(self, name): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if name in ['__call__', 'has_key']: + raise AttributeError + # For anything that is not an explicit attribute of me, try + # my instance of StationInfo. + return getattr(self.stn_info, name) + + +def _os_uptime(): + """ Get the OS uptime. Because this is highly operating system dependent, several different + strategies may have to be tried:""" + + try: + # For Python 3.7 and later, most systems + return time.clock_gettime(time.CLOCK_UPTIME) + except AttributeError: + pass + + try: + # For Python 3.3 and later, most systems + return time.clock_gettime(time.CLOCK_MONOTONIC) + except AttributeError: + pass + + try: + # For Linux, Python 2 and 3: + return float(open("/proc/uptime").read().split()[0]) + except (IOError, KeyError, OSError): + pass + + try: + # For MacOS, Python 2: + from Quartz.QuartzCore import CACurrentMediaTime + return CACurrentMediaTime() + except ImportError: + pass + + try: + # for FreeBSD, Python 2 + import ctypes + from ctypes.util import find_library + + libc = ctypes.CDLL(find_library('c')) + size = ctypes.c_size_t() + buf = ctypes.c_int() + size.value = ctypes.sizeof(buf) + libc.sysctlbyname("kern.boottime", ctypes.byref(buf), ctypes.byref(size), None, 0) + os_uptime_secs = time.time() - float(buf.value) + return os_uptime_secs + except (ImportError, AttributeError, IOError, NameError): + pass + + try: + # For OpenBSD, Python 2. See issue #428. + import subprocess + from datetime import datetime + cmd = ['sysctl', 'kern.boottime'] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + o, e = proc.communicate() + # Check for errors + if e: + raise IOError + time_t = o.decode('ascii').split() + time_as_string = time_t[1] + " " + time_t[2] + " " + time_t[4][:4] + " " + time_t[3] + os_time = datetime.strptime(time_as_string, "%b %d %Y %H:%M:%S") + epoch_time = (os_time - datetime(1970, 1, 1)).total_seconds() + os_uptime_secs = time.time() - epoch_time + return os_uptime_secs + except (IOError, IndexError, ValueError): + pass + + # Nothing seems to be working. Return None + return None diff --git a/dist/weewx-4.10.1/bin/weewx/tags.py b/dist/weewx-4.10.1/bin/weewx/tags.py new file mode 100644 index 0000000..cdc8385 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/tags.py @@ -0,0 +1,678 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes for implementing the weewx tag 'code' codes.""" + +from __future__ import absolute_import + +import weeutil.weeutil +import weewx.units +import weewx.xtypes +from weeutil.weeutil import to_int +from weewx.units import ValueTuple + +# Attributes we are to ignore. Cheetah calls these housekeeping functions. +IGNORE_ATTR = {'mro', 'im_func', 'func_code', '__func__', '__code__', '__init__', '__self__'} + + +# =============================================================================== +# Class TimeBinder +# =============================================================================== + +class TimeBinder(object): + """Binds to a specific time. Can be queried for time attributes, such as month. + + When a time period is given as an attribute to it, such as obj.month, the next item in the + chain is returned, in this case an instance of TimespanBinder, which binds things to a + timespan. + """ + + def __init__(self, db_lookup, report_time, + formatter=None, + converter=None, + **option_dict): + """Initialize an instance of DatabaseBinder. + + db_lookup: A function with call signature db_lookup(data_binding), which returns a database + manager and where data_binding is an optional binding name. If not given, then a default + binding will be used. + + report_time: The time for which the report should be run. + + formatter: An instance of weewx.units.Formatter() holding the formatting information to be + used. [Optional. If not given, the default Formatter will be used.] + + converter: An instance of weewx.units.Converter() holding the target unit information to be + used. [Optional. If not given, the default Converter will be used.] + + option_dict: Other options which can be used to customize calculations. [Optional.] + """ + self.db_lookup = db_lookup + self.report_time = report_time + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + # What follows is the list of time period attributes: + + def trend(self, time_delta=None, time_grace=None, data_binding=None): + """Returns a TrendObj that is bound to the trend parameters.""" + if time_delta is None: + time_delta = to_int(self.option_dict['trend'].get('time_delta', 10800)) + if time_grace is None: + time_grace = to_int(self.option_dict['trend'].get('time_grace', 300)) + return TrendObj(time_delta, time_grace, self.db_lookup, data_binding, self.report_time, + self.formatter, self.converter, **self.option_dict) + + def hour(self, data_binding=None, hours_ago=0): + return TimespanBinder( + weeutil.weeutil.archiveHoursAgoSpan(self.report_time, hours_ago=hours_ago), + self.db_lookup, data_binding=data_binding, + context='day', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def day(self, data_binding=None, days_ago=0): + return TimespanBinder(weeutil.weeutil.archiveDaySpan(self.report_time, days_ago=days_ago), + self.db_lookup, data_binding=data_binding, + context='day', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def yesterday(self, data_binding=None): + return self.day(data_binding, days_ago=1) + + def week(self, data_binding=None, weeks_ago=0): + week_start = to_int(self.option_dict.get('week_start', 6)) + return TimespanBinder( + weeutil.weeutil.archiveWeekSpan(self.report_time, startOfWeek=week_start, weeks_ago=weeks_ago), + self.db_lookup, data_binding=data_binding, + context='week', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def month(self, data_binding=None, months_ago=0): + return TimespanBinder( + weeutil.weeutil.archiveMonthSpan(self.report_time, months_ago=months_ago), + self.db_lookup, data_binding=data_binding, + context='month', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def year(self, data_binding=None, years_ago=0): + return TimespanBinder( + weeutil.weeutil.archiveYearSpan(self.report_time, years_ago=years_ago), + self.db_lookup, data_binding=data_binding, + context='year', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def alltime(self, data_binding=None): + manager = self.db_lookup(data_binding) + # We do not need to worry about 'first' being None, because CheetahGenerator would not + # start the generation if this was the case. + first = manager.firstGoodStamp() + return TimespanBinder( + weeutil.weeutil.TimeSpan(first, self.report_time), + self.db_lookup, data_binding=data_binding, + context='year', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def rainyear(self, data_binding=None): + rain_year_start = to_int(self.option_dict.get('rain_year_start', 1)) + return TimespanBinder( + weeutil.weeutil.archiveRainYearSpan(self.report_time, rain_year_start), + self.db_lookup, data_binding=data_binding, + context='rainyear', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def span(self, data_binding=None, time_delta=0, hour_delta=0, day_delta=0, week_delta=0, + month_delta=0, year_delta=0, boundary=None): + return TimespanBinder( + weeutil.weeutil.archiveSpanSpan(self.report_time, time_delta=time_delta, + hour_delta=hour_delta, day_delta=day_delta, + week_delta=week_delta, month_delta=month_delta, + year_delta=year_delta, boundary=boundary), + self.db_lookup, data_binding=data_binding, + context='day', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + # For backwards compatiblity + hours_ago = hour + days_ago = day + + +# =============================================================================== +# Class TimespanBinder +# =============================================================================== + +class TimespanBinder(object): + """Holds a binding between a database and a timespan. + + This class is the next class in the chain of helper classes. + + When an observation type is given as an attribute to it (such as 'obj.outTemp'), the next item + in the chain is returned, in this case an instance of ObservationBinder, which binds the + database, the time period, and the statistical type all together. + + It also includes a few "special attributes" that allow iteration over certain time periods. + Example: + + # Iterate by month: + for monthStats in yearStats.months: + # Print maximum temperature for each month in the year: + print(monthStats.outTemp.max) + """ + + def __init__(self, timespan, db_lookup, data_binding=None, context='current', + formatter=None, + converter=None, + **option_dict): + """Initialize an instance of TimespanBinder. + + timespan: An instance of weeutil.Timespan with the time span over which the statistics are + to be calculated. + + db_lookup: A function with call signature db_lookup(data_binding), which returns a database + manager and where data_binding is an optional binding name. If not given, then a default + binding will be used. + + data_binding: If non-None, then use this data binding. + + context: A tag name for the timespan. This is something like 'current', 'day', 'week', etc. + This is used to pick an appropriate time label. + + formatter: An instance of weewx.units.Formatter() holding the formatting information to be + used. [Optional. If not given, the default Formatter will be used.] + + converter: An instance of weewx.units.Converter() holding the target unit information to be + used. [Optional. If not given, the default Converter will be used.] + + option_dict: Other options which can be used to customize calculations. [Optional.] + """ + + self.timespan = timespan + self.db_lookup = db_lookup + self.data_binding = data_binding + self.context = context + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + # Iterate over all records in the time period: + def records(self): + manager = self.db_lookup(self.data_binding) + for record in manager.genBatchRecords(self.timespan.start, self.timespan.stop): + yield CurrentObj(self.db_lookup, self.data_binding, record['dateTime'], self.formatter, + self.converter, record=record) + + # Iterate over custom span + def spans(self, context='day', interval=10800): + for span in weeutil.weeutil.intervalgen(self.timespan.start, self.timespan.stop, interval): + yield TimespanBinder(span, self.db_lookup, self.data_binding, + context, self.formatter, self.converter, **self.option_dict) + + # Iterate over hours in the time period: + def hours(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genHourSpans, self.timespan, + self.db_lookup, self.data_binding, + 'hour', self.formatter, self.converter, + **self.option_dict) + + # Iterate over days in the time period: + def days(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genDaySpans, self.timespan, + self.db_lookup, self.data_binding, + 'day', self.formatter, self.converter, + **self.option_dict) + + # Iterate over months in the time period: + def months(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genMonthSpans, self.timespan, + self.db_lookup, self.data_binding, + 'month', self.formatter, self.converter, + **self.option_dict) + + # Iterate over years in the time period: + def years(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genYearSpans, self.timespan, + self.db_lookup, self.data_binding, + 'year', self.formatter, self.converter, + **self.option_dict) + + # Static method used to implement the iteration: + @staticmethod + def _seqGenerator(genSpanFunc, timespan, *args, **option_dict): + """Generator function that returns TimespanBinder for the appropriate timespans""" + for span in genSpanFunc(timespan.start, timespan.stop): + yield TimespanBinder(span, *args, **option_dict) + + # Return the start time of the time period as a ValueHelper + @property + def start(self): + val = weewx.units.ValueTuple(self.timespan.start, 'unix_epoch', 'group_time') + return weewx.units.ValueHelper(val, self.context, self.formatter, self.converter) + + # Return the ending time: + @property + def end(self): + val = weewx.units.ValueTuple(self.timespan.stop, 'unix_epoch', 'group_time') + return weewx.units.ValueHelper(val, self.context, self.formatter, self.converter) + + # Return the length of the timespan + @property + def length(self): + val = weewx.units.ValueTuple(self.timespan.stop-self.timespan.start, 'second', 'group_deltatime') + return weewx.units.ValueHelper(val, self.context, self.formatter, self.converter) + + # Alias for the start time: + dateTime = start + + def check_for_data(self, sql_expr): + """Check whether the given sql expression returns any data""" + db_manager = self.db_lookup(self.data_binding) + try: + val = weewx.xtypes.get_aggregate(sql_expr, self.timespan, 'not_null', db_manager) + return bool(val[0]) + except weewx.UnknownAggregation: + return False + + def __call__(self, data_binding=None): + """The iterators return an instance of TimespanBinder. Allow them to override + data_binding""" + return TimespanBinder(self.timespan, self.db_lookup, data_binding, self.context, + self.formatter, self.converter, **self.option_dict) + + def __getattr__(self, obs_type): + """Return a helper object that binds the database, a time period, and the given observation + type. + + obs_type: An observation type, such as 'outTemp', or 'heatDeg' + + returns: An instance of class ObservationBinder.""" + + if obs_type in IGNORE_ATTR: + raise AttributeError(obs_type) + + # Return an ObservationBinder: if an attribute is + # requested from it, an aggregation value will be returned. + return ObservationBinder(obs_type, self.timespan, self.db_lookup, self.data_binding, + self.context, + self.formatter, self.converter, **self.option_dict) + + +# =============================================================================== +# Class ObservationBinder +# =============================================================================== + +class ObservationBinder(object): + """This is the next class in the chain of helper classes. It binds the + database, a time period, and an observation type all together. + + When an aggregation type (eg, 'max') is given as an attribute to it, it binds it to + an instance of AggTypeBinder and returns it. + """ + + def __init__(self, obs_type, timespan, db_lookup, data_binding, context, + formatter=None, + converter=None, + **option_dict): + """ Initialize an instance of ObservationBinder + + obs_type: A string with the stats type (e.g., 'outTemp') for which the query is to be done. + + timespan: An instance of TimeSpan holding the time period over which the query is to be run + + db_lookup: A function with call signature db_lookup(data_binding), which returns a database + manager and where data_binding is an optional binding name. If not given, then a default + binding will be used. + + data_binding: If non-None, then use this data binding. + + context: A tag name for the timespan. This is something like 'current', 'day', 'week', etc. + This is used to find an appropriate label, if necessary. + + formatter: An instance of weewx.units.Formatter() holding the formatting information to be + used. [Optional. If not given, the default Formatter will be used.] + + converter: An instance of weewx.units.Converter() holding the target unit information to be + used. [Optional. If not given, the default Converter will be used.] + + option_dict: Other options which can be used to customize calculations. [Optional.] + """ + + self.obs_type = obs_type + self.timespan = timespan + self.db_lookup = db_lookup + self.data_binding = data_binding + self.context = context + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + def __getattr__(self, aggregate_type): + """Use the specified aggregation type + + aggregate_type: The type of aggregation over which the summary is to be done. This is + normally something like 'sum', 'min', 'mintime', 'count', etc. However, there are two + special aggregation types that can be used to determine the existence of data: + 'exists': Return True if the observation type exists in the database. + 'has_data': Return True if the type exists and there is a non-zero number of entries over + the aggregation period. + + returns: An instance of AggTypeBinder, which is bound to the aggregation type. + """ + if aggregate_type in IGNORE_ATTR: + raise AttributeError(aggregate_type) + return AggTypeBinder(aggregate_type=aggregate_type, + obs_type=self.obs_type, + timespan=self.timespan, + db_lookup=self.db_lookup, + data_binding=self.data_binding, + context=self.context, + formatter=self.formatter, converter=self.converter, + **self.option_dict) + + @property + def exists(self): + return self.db_lookup(self.data_binding).exists(self.obs_type) + + @property + def has_data(self): + return self.db_lookup(self.data_binding).has_data(self.obs_type, self.timespan) + + def series(self, aggregate_type=None, + aggregate_interval=None, + time_series='both', + time_unit='unix_epoch'): + """Return a series with the given aggregation type and interval. + + Args: + aggregate_type (str or None): The type of aggregation to use, if any. Default is None + (no aggregation). + aggregate_interval (str or None): The aggregation interval in seconds. Default is + None (no aggregation). + time_series (str): What to include for the time series. Either 'start', 'stop', or + 'both'. + time_unit (str): Which unit to use for time. Choices are 'unix_epoch', 'unix_epoch_ms', + or 'unix_epoch_ns'. Default is 'unix_epoch'. + + Returns: + SeriesHelper. + """ + time_series = time_series.lower() + if time_series not in ['both', 'start', 'stop']: + raise ValueError("Unknown option '%s' for parameter 'time_series'" % time_series) + + db_manager = self.db_lookup(self.data_binding) + + # If we cannot calculate the series, we will get an UnknownType or UnknownAggregation + # error. Be prepared to catch it. + try: + # The returned values start_vt, stop_vt, and data_vt, will be ValueTuples. + start_vt, stop_vt, data_vt = weewx.xtypes.get_series( + self.obs_type, self.timespan, db_manager, + aggregate_type, aggregate_interval) + except (weewx.UnknownType, weewx.UnknownAggregation): + # Cannot calculate the series. Convert to AttributeError, which will signal to Cheetah + # that this type of series is unknown. + raise AttributeError(self.obs_type) + + # Figure out which time series are desired, and convert them to the desired time unit. + # If the conversion cannot be done, a KeyError will be raised. + # When done, start_vh and stop_vh will be ValueHelpers. + if time_series in ['start', 'both']: + start_vt = weewx.units.convert(start_vt, time_unit) + start_vh = weewx.units.ValueHelper(start_vt, self.context, self.formatter) + else: + start_vh = None + if time_series in ['stop', 'both']: + stop_vt = weewx.units.convert(stop_vt, time_unit) + stop_vh = weewx.units.ValueHelper(stop_vt, self.context, self.formatter) + else: + stop_vh = None + + # Form a SeriesHelper, using our existing context and formatter. For the data series, + # use the existing converter. + sh = weewx.units.SeriesHelper( + start_vh, + stop_vh, + weewx.units.ValueHelper(data_vt, self.context, self.formatter, self.converter)) + return sh + + +# =============================================================================== +# Class AggTypeBinder +# =============================================================================== + +class AggTypeBinder(object): + """This is the final class in the chain of helper classes. It binds everything needed + for a query.""" + + def __init__(self, aggregate_type, obs_type, timespan, db_lookup, data_binding, context, + formatter=None, converter=None, + **option_dict): + self.aggregate_type = aggregate_type + self.obs_type = obs_type + self.timespan = timespan + self.db_lookup = db_lookup + self.data_binding = data_binding + self.context = context + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + def __call__(self, *args, **kwargs): + """Offer a call option for expressions such as $month.outTemp.max_ge((90.0, 'degree_F')). + + In this example, self.aggregate_type would be 'max_ge', and val would be the tuple + (90.0, 'degree_F'). + """ + if len(args): + self.option_dict['val'] = args[0] + self.option_dict.update(kwargs) + return self + + def __str__(self): + """Need a string representation. Force the query, return as string.""" + vh = self._do_query() + return str(vh) + + def __unicode__(self): + """Used only Python 2. Force the query, return as a unicode string.""" + vh = self._do_query() + return unicode(vh) + + def _do_query(self): + """Run a query against the databases, using the given aggregation type.""" + db_manager = self.db_lookup(self.data_binding) + try: + # If we cannot perform the aggregation, we will get an UnknownType or + # UnknownAggregation error. Be prepared to catch it. + result = weewx.xtypes.get_aggregate(self.obs_type, self.timespan, + self.aggregate_type, + db_manager, **self.option_dict) + except (weewx.UnknownType, weewx.UnknownAggregation): + # Signal Cheetah that we don't know how to do this by raising an AttributeError. + raise AttributeError(self.obs_type) + return weewx.units.ValueHelper(result, self.context, self.formatter, self.converter) + + def __getattr__(self, attr): + # The following is an optimization, so we avoid doing an SQL query for these kinds of + # housekeeping attribute queries done by Cheetah's NameMapper + if attr in IGNORE_ATTR: + raise AttributeError(attr) + # Do the query, getting a ValueHelper back + vh = self._do_query() + # Now seek the desired attribute of the ValueHelper and return + return getattr(vh, attr) + + +# =============================================================================== +# Class RecordBinder +# =============================================================================== + +class RecordBinder(object): + + def __init__(self, db_lookup, report_time, + formatter=None, converter=None, + record=None): + self.db_lookup = db_lookup + self.report_time = report_time + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.record = record + + def current(self, timestamp=None, max_delta=None, data_binding=None): + """Return a CurrentObj""" + if timestamp is None: + timestamp = self.report_time + return CurrentObj(self.db_lookup, data_binding, current_time=timestamp, + max_delta=max_delta, + formatter=self.formatter, converter=self.converter, record=self.record) + + def latest(self, data_binding=None): + """Return a CurrentObj, using the last available timestamp.""" + manager = self.db_lookup(data_binding) + timestamp = manager.lastGoodStamp() + return self.current(timestamp, data_binding=data_binding) + + +# =============================================================================== +# Class CurrentObj +# =============================================================================== + +class CurrentObj(object): + """Helper class for the "Current" record. Hits the database lazily. + + This class allows tags such as: + $current.barometer + """ + + def __init__(self, db_lookup, data_binding, current_time, + formatter, converter, max_delta=None, record=None): + self.db_lookup = db_lookup + self.data_binding = data_binding + self.current_time = current_time + self.formatter = formatter + self.converter = converter + self.max_delta = max_delta + self.record = record + + def __getattr__(self, obs_type): + """Return the given observation type.""" + + if obs_type in IGNORE_ATTR: + raise AttributeError(obs_type) + + # TODO: Refactor the following to be a separate function. + + # If no data binding has been specified, and we have a current record with the right + # timestamp at hand, we don't have to hit the database. + if not self.data_binding and self.record and obs_type in self.record \ + and self.record['dateTime'] == self.current_time: + # Use the record given to us to form a ValueTuple + vt = weewx.units.as_value_tuple(self.record, obs_type) + else: + # A binding has been specified, or we don't have a record, or the observation type + # is not in the record, or the timestamp is wrong. + try: + # Get the appropriate database manager + db_manager = self.db_lookup(self.data_binding) + except weewx.UnknownBinding: + # Don't recognize the binding. + raise AttributeError(self.data_binding) + else: + # Get the record for this timestamp from the database + record = db_manager.getRecord(self.current_time, max_delta=self.max_delta) + # If there was no record at that timestamp, it will be None. If there was a record, + # check to see if the type is in it. + if not record or obs_type in record: + # If there was no record, then the value of the ValueTuple will be None. + # Otherwise, it will be value stored in the database. + vt = weewx.units.as_value_tuple(record, obs_type) + else: + # Couldn't get the value out of the record. Try the XTypes system. + try: + vt = weewx.xtypes.get_scalar(obs_type, self.record, db_manager) + except (weewx.UnknownType, weewx.CannotCalculate): + # Nothing seems to be working. It's an unknown type. + vt = weewx.units.UnknownType(obs_type) + + # Finally, return a ValueHelper + return weewx.units.ValueHelper(vt, 'current', self.formatter, self.converter) + + +# =============================================================================== +# Class TrendObj +# =============================================================================== + +class TrendObj(object): + """Helper class that calculates trends. + + This class allows tags such as: + $trend.barometer + """ + + def __init__(self, time_delta, time_grace, db_lookup, data_binding, + nowtime, formatter, converter, **option_dict): # @UnusedVariable + """Initialize a Trend object + + time_delta: The time difference over which the trend is to be calculated + + time_grace: A time within this amount is accepted. + """ + self.time_delta_val = time_delta + self.time_grace_val = time_grace + self.db_lookup = db_lookup + self.data_binding = data_binding + self.nowtime = nowtime + self.formatter = formatter + self.converter = converter + self.time_delta = weewx.units.ValueHelper((time_delta, 'second', 'group_elapsed'), + 'current', + self.formatter, + self.converter) + self.time_grace = weewx.units.ValueHelper((time_grace, 'second', 'group_elapsed'), + 'current', + self.formatter, + self.converter) + + def __getattr__(self, obs_type): + """Return the trend for the given observation type.""" + if obs_type in IGNORE_ATTR: + raise AttributeError(obs_type) + + db_manager = self.db_lookup(self.data_binding) + # Get the current record, and one "time_delta" ago: + now_record = db_manager.getRecord(self.nowtime, self.time_grace_val) + then_record = db_manager.getRecord(self.nowtime - self.time_delta_val, self.time_grace_val) + + # Do both records exist? + if now_record is None or then_record is None: + # No. One is missing. + trend = ValueTuple(None, None, None) + else: + # Both records exist. Check to see if the observation type is known + if obs_type not in now_record or obs_type not in then_record: + # obs_type is unknown. Signal it + raise AttributeError(obs_type) + else: + # Both records exist, both types are known. We can proceed. + now_vt = weewx.units.as_value_tuple(now_record, obs_type) + then_vt = weewx.units.as_value_tuple(then_record, obs_type) + # Do the unit conversion now, rather than lazily. This is because the temperature + # conversion functions are not distributive. That is, + # F_to_C(68F - 50F) + # is not equal to + # F_to_C(68F) - F_to_C(50F) + # We want the latter, not the former, so we perform the conversion immediately. + now_vtc = self.converter.convert(now_vt) + then_vtc = self.converter.convert(then_vt) + if now_vtc.value is None or then_vtc.value is None: + # One of the values is None, so the trend will be None. + trend = ValueTuple(None, now_vtc.unit, now_vtc.group) + else: + # All good. Calculate the trend. + trend = now_vtc - then_vtc + + # Return the results as a ValueHelper. Use the formatting and labeling options from the + # current time record. The user can always override these. + return weewx.units.ValueHelper(trend, 'current', self.formatter, self.converter) diff --git a/dist/weewx-4.10.1/bin/weewx/units.py b/dist/weewx-4.10.1/bin/weewx/units.py new file mode 100644 index 0000000..83bf4c3 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/units.py @@ -0,0 +1,1677 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Data structures and functions for dealing with units.""" + +# +# The doctest examples work only under Python 3!! +# + +from __future__ import absolute_import +from __future__ import print_function +import json +import locale +import logging +import time + +import six + +import weewx +import weeutil.weeutil +from weeutil.weeutil import ListOfDicts, Polar, is_iterable + +log = logging.getLogger(__name__) + +# Handy conversion constants and functions: +INHG_PER_MBAR = 0.0295299875 +MM_PER_INCH = 25.4 +CM_PER_INCH = MM_PER_INCH / 10.0 +METER_PER_MILE = 1609.34 +METER_PER_FOOT = METER_PER_MILE / 5280.0 +MILE_PER_KM = 1000.0 / METER_PER_MILE +SECS_PER_DAY = 86400 + +def CtoK(x): + return x + 273.15 + +def KtoC(x): + return x - 273.15 + +def KtoF(x): + return CtoF(KtoC(x)) + +def FtoK(x): + return CtoK(FtoC(x)) + +def CtoF(x): + return x * 1.8 + 32.0 + +def FtoC(x): + return (x - 32.0) / 1.8 + +# Conversions to and from Felsius. +# For the definition of Felsius, see https://xkcd.com/1923/ +def FtoE(x): + return (7.0 * x - 80.0) / 9.0 + +def EtoF(x): + return (9.0 * x + 80.0) / 7.0 + +def CtoE(x): + return (7.0 / 5.0) * x + 16.0 + +def EtoC(x): + return (x - 16.0) * 5.0 / 7.0 + +def mps_to_mph(x): + return x * 3600.0 / METER_PER_MILE + +def kph_to_mph(x): + return x * 1000.0 / METER_PER_MILE + +def mph_to_knot(x): + return x * 0.868976242 + +def kph_to_knot(x): + return x * 0.539956803 + +def mps_to_knot(x): + return x * 1.94384449 + +class UnknownType(object): + """Indicates that the observation type is unknown.""" + def __init__(self, obs_type): + self.obs_type = obs_type + +unit_constants = { + 'US' : weewx.US, + 'METRIC' : weewx.METRIC, + 'METRICWX' : weewx.METRICWX +} + +unit_nicknames = { + weewx.US : 'US', + weewx.METRIC : 'METRIC', + weewx.METRICWX : 'METRICWX' +} + +# This data structure maps observation types to a "unit group" +# We start with a standard object group dictionary, but users are +# free to extend it: +obs_group_dict = ListOfDicts({ + "altimeter" : "group_pressure", + "altimeterRate" : "group_pressurerate", + "altitude" : "group_altitude", + "appTemp" : "group_temperature", + "appTemp1" : "group_temperature", + "barometer" : "group_pressure", + "barometerRate" : "group_pressurerate", + "beaufort" : "group_count", # DEPRECATED + "cloudbase" : "group_altitude", + "cloudcover" : "group_percent", + "co" : "group_fraction", + "co2" : "group_fraction", + "consBatteryVoltage" : "group_volt", + "cooldeg" : "group_degree_day", + "dateTime" : "group_time", + "dayRain" : "group_rain", + "daySunshineDur" : "group_deltatime", + "dewpoint" : "group_temperature", + "dewpoint1" : "group_temperature", + "ET" : "group_rain", + "extraHumid1" : "group_percent", + "extraHumid2" : "group_percent", + "extraHumid3" : "group_percent", + "extraHumid4" : "group_percent", + "extraHumid5" : "group_percent", + "extraHumid6" : "group_percent", + "extraHumid7" : "group_percent", + "extraHumid8" : "group_percent", + "extraTemp1" : "group_temperature", + "extraTemp2" : "group_temperature", + "extraTemp3" : "group_temperature", + "extraTemp4" : "group_temperature", + "extraTemp5" : "group_temperature", + "extraTemp6" : "group_temperature", + "extraTemp7" : "group_temperature", + "extraTemp8" : "group_temperature", + "growdeg" : "group_degree_day", + "gustdir" : "group_direction", + "hail" : "group_rain", + "hailRate" : "group_rainrate", + "heatdeg" : "group_degree_day", + "heatindex" : "group_temperature", + "heatindex1" : "group_temperature", + "heatingTemp" : "group_temperature", + "heatingVoltage" : "group_volt", + "highOutTemp" : "group_temperature", + "hourRain" : "group_rain", + "humidex" : "group_temperature", + "humidex1" : "group_temperature", + "illuminance" : "group_illuminance", + "inDewpoint" : "group_temperature", + "inHumidity" : "group_percent", + "inTemp" : "group_temperature", + "interval" : "group_interval", + "leafTemp1" : "group_temperature", + "leafTemp2" : "group_temperature", + "leafTemp3" : "group_temperature", + "leafTemp4" : "group_temperature", + "leafWet1" : "group_count", + "leafWet2" : "group_count", + "lightning_distance" : "group_distance", + "lightning_disturber_count" : "group_count", + "lightning_noise_count" : "group_count", + "lightning_strike_count" : "group_count", + "lowOutTemp" : "group_temperature", + "maxSolarRad" : "group_radiation", + "monthRain" : "group_rain", + "nh3" : "group_fraction", + "no2" : "group_concentration", + "noise" : "group_db", + "o3" : "group_fraction", + "outHumidity" : "group_percent", + "outTemp" : "group_temperature", + "outWetbulb" : "group_temperature", + "pb" : "group_fraction", + "pm1_0" : "group_concentration", + "pm2_5" : "group_concentration", + "pm10_0" : "group_concentration", + "pop" : "group_percent", + "pressure" : "group_pressure", + "pressureRate" : "group_pressurerate", + "radiation" : "group_radiation", + "rain" : "group_rain", + "rain24" : "group_rain", + "rainDur" : "group_deltatime", + "rainRate" : "group_rainrate", + "referenceVoltage" : "group_volt", + "rms" : "group_speed2", + "rxCheckPercent" : "group_percent", + "snow" : "group_rain", + "snowDepth" : "group_rain", + "snowMoisture" : "group_percent", + "snowRate" : "group_rainrate", + "so2" : "group_fraction", + "soilMoist1" : "group_moisture", + "soilMoist2" : "group_moisture", + "soilMoist3" : "group_moisture", + "soilMoist4" : "group_moisture", + "soilTemp1" : "group_temperature", + "soilTemp2" : "group_temperature", + "soilTemp3" : "group_temperature", + "soilTemp4" : "group_temperature", + "stormRain" : "group_rain", + "stormStart" : "group_time", + "sunshineDur" : "group_deltatime", + "supplyVoltage" : "group_volt", + "THSW" : "group_temperature", + "totalRain" : "group_rain", + "UV" : "group_uv", + "vecavg" : "group_speed2", + "vecdir" : "group_direction", + "wind" : "group_speed", + "windchill" : "group_temperature", + "windDir" : "group_direction", + "windDir10" : "group_direction", + "windGust" : "group_speed", + "windGustDir" : "group_direction", + "windgustvec" : "group_speed", + "windrun" : "group_distance", + "windSpeed" : "group_speed", + "windSpeed10" : "group_speed", + "windvec" : "group_speed", + "yearRain" : "group_rain", +}) + +# Some aggregations when applied to a type result in a different unit +# group. This data structure maps aggregation type to the group: +agg_group = { + "firsttime" : "group_time", + "lasttime" : "group_time", + "maxsumtime" : "group_time", + "minsumtime" : "group_time", + 'count' : "group_count", + 'gustdir' : "group_direction", + 'max_ge' : "group_count", + 'max_le' : "group_count", + 'maxmintime' : "group_time", + 'maxtime' : "group_time", + 'min_ge' : "group_count", + 'min_le' : "group_count", + 'minmaxtime' : "group_time", + 'mintime' : "group_time", + 'not_null' : "group_boolean", + 'sum_ge' : "group_count", + 'sum_le' : "group_count", + 'vecdir' : "group_direction", + 'avg_ge' : "group_count", + 'avg_le' : "group_count", +} + +# This dictionary maps unit groups to a standard unit type in the +# US customary unit system: +USUnits = ListOfDicts({ + "group_altitude" : "foot", + "group_amp" : "amp", + "group_boolean" : "boolean", + "group_concentration": "microgram_per_meter_cubed", + "group_count" : "count", + "group_data" : "byte", + "group_db" : "dB", + "group_degree_day" : "degree_F_day", + "group_deltatime" : "second", + "group_direction" : "degree_compass", + "group_distance" : "mile", + "group_elapsed" : "second", + "group_energy" : "watt_hour", + "group_energy2" : "watt_second", + "group_fraction" : "ppm", + "group_frequency" : "hertz", + "group_illuminance" : "lux", + "group_interval" : "minute", + "group_length" : "inch", + "group_moisture" : "centibar", + "group_percent" : "percent", + "group_power" : "watt", + "group_pressure" : "inHg", + "group_pressurerate": "inHg_per_hour", + "group_radiation" : "watt_per_meter_squared", + "group_rain" : "inch", + "group_rainrate" : "inch_per_hour", + "group_speed" : "mile_per_hour", + "group_speed2" : "mile_per_hour2", + "group_temperature" : "degree_F", + "group_time" : "unix_epoch", + "group_uv" : "uv_index", + "group_volt" : "volt", + "group_volume" : "gallon" +}) + +# This dictionary maps unit groups to a standard unit type in the +# metric unit system: +MetricUnits = ListOfDicts({ + "group_altitude" : "meter", + "group_amp" : "amp", + "group_boolean" : "boolean", + "group_concentration": "microgram_per_meter_cubed", + "group_count" : "count", + "group_data" : "byte", + "group_db" : "dB", + "group_degree_day" : "degree_C_day", + "group_deltatime" : "second", + "group_direction" : "degree_compass", + "group_distance" : "km", + "group_elapsed" : "second", + "group_energy" : "watt_hour", + "group_energy2" : "watt_second", + "group_fraction" : "ppm", + "group_frequency" : "hertz", + "group_illuminance" : "lux", + "group_interval" : "minute", + "group_length" : "cm", + "group_moisture" : "centibar", + "group_percent" : "percent", + "group_power" : "watt", + "group_pressure" : "mbar", + "group_pressurerate": "mbar_per_hour", + "group_radiation" : "watt_per_meter_squared", + "group_rain" : "cm", + "group_rainrate" : "cm_per_hour", + "group_speed" : "km_per_hour", + "group_speed2" : "km_per_hour2", + "group_temperature" : "degree_C", + "group_time" : "unix_epoch", + "group_uv" : "uv_index", + "group_volt" : "volt", + "group_volume" : "liter" +}) + +# This dictionary maps unit groups to a standard unit type in the +# "Metric WX" unit system. It's the same as the "Metric" system, +# except for rain and speed: +MetricWXUnits = ListOfDicts(*MetricUnits.maps) +MetricWXUnits.prepend({ + 'group_rain': 'mm', + 'group_rainrate' : 'mm_per_hour', + 'group_speed': 'meter_per_second', + 'group_speed2': 'meter_per_second2', +}) + +std_groups = { + weewx.US: USUnits, + weewx.METRIC: MetricUnits, + weewx.METRICWX: MetricWXUnits +} + +# Conversion functions to go from one unit type to another. +conversionDict = { + 'bit' : {'byte' : lambda x : x / 8}, + 'byte' : {'bit' : lambda x : x * 8}, + 'cm' : {'inch' : lambda x : x / CM_PER_INCH, + 'mm' : lambda x : x * 10.0}, + 'cm_per_hour' : {'inch_per_hour' : lambda x : x * 0.393700787, + 'mm_per_hour' : lambda x : x * 10.0}, + 'cubic_foot' : {'gallon' : lambda x : x * 7.48052, + 'litre' : lambda x : x * 28.3168, + 'liter' : lambda x : x * 28.3168}, + 'day' : {'second' : lambda x : x * SECS_PER_DAY, + 'minute' : lambda x : x*1440.0, + 'hour' : lambda x : x*24.0}, + 'degree_C' : {'degree_F' : CtoF, + 'degree_E' : CtoE, + 'degree_K' : CtoK}, + 'degree_C_day' : {'degree_F_day' : lambda x : x * (9.0/5.0)}, + 'degree_E' : {'degree_C' : EtoC, + 'degree_F' : EtoF}, + 'degree_F' : {'degree_C' : FtoC, + 'degree_E' : FtoE, + 'degree_K' : FtoK}, + 'degree_F_day' : {'degree_C_day' : lambda x : x * (5.0/9.0)}, + 'degree_K' : {'degree_C' : KtoC, + 'degreeF' : KtoF}, + 'dublin_jd' : {'unix_epoch' : lambda x : (x-25567.5) * SECS_PER_DAY, + 'unix_epoch_ms' : lambda x : (x-25567.5) * SECS_PER_DAY * 1000, + 'unix_epoch_ns' : lambda x : (x-25567.5) * SECS_PER_DAY * 1e06}, + 'foot' : {'meter' : lambda x : x * METER_PER_FOOT}, + 'gallon' : {'liter' : lambda x : x * 3.78541, + 'litre' : lambda x : x * 3.78541, + 'cubic_foot' : lambda x : x * 0.133681}, + 'hour' : {'second' : lambda x : x*3600.0, + 'minute' : lambda x : x*60.0, + 'day' : lambda x : x/24.0}, + 'hPa' : {'inHg' : lambda x : x * INHG_PER_MBAR, + 'mmHg' : lambda x : x * 0.75006168, + 'mbar' : lambda x : x, + 'kPa' : lambda x : x / 10.0}, + 'hPa_per_hour' : {'inHg_per_hour' : lambda x : x * INHG_PER_MBAR, + 'mmHg_per_hour' : lambda x : x * 0.75006168, + 'mbar_per_hour' : lambda x : x, + 'kPa_per_hour' : lambda x : x / 10.0}, + 'inch' : {'cm' : lambda x : x * CM_PER_INCH, + 'mm' : lambda x : x * MM_PER_INCH}, + 'inch_per_hour' : {'cm_per_hour' : lambda x : x * 2.54, + 'mm_per_hour' : lambda x : x * 25.4}, + 'inHg' : {'mbar' : lambda x : x / INHG_PER_MBAR, + 'hPa' : lambda x : x / INHG_PER_MBAR, + 'kPa' : lambda x : x / INHG_PER_MBAR / 10.0, + 'mmHg' : lambda x : x * 25.4}, + 'inHg_per_hour' : {'mbar_per_hour' : lambda x : x / INHG_PER_MBAR, + 'hPa_per_hour' : lambda x : x / INHG_PER_MBAR, + 'kPa_per_hour' : lambda x : x / INHG_PER_MBAR / 10.0, + 'mmHg_per_hour' : lambda x : x * 25.4}, + 'kilowatt' : {'watt' : lambda x : x * 1000.0}, + 'kilowatt_hour' : {'mega_joule' : lambda x : x * 3.6, + 'watt_second' : lambda x : x * 3.6e6, + 'watt_hour' : lambda x : x * 1000.0}, + 'km' : {'meter' : lambda x : x * 1000.0, + 'mile' : lambda x : x * 0.621371192}, + 'km_per_hour' : {'mile_per_hour' : kph_to_mph, + 'knot' : kph_to_knot, + 'meter_per_second' : lambda x : x * 0.277777778}, + 'knot' : {'mile_per_hour' : lambda x : x * 1.15077945, + 'km_per_hour' : lambda x : x * 1.85200, + 'meter_per_second' : lambda x : x * 0.514444444}, + 'knot2' : {'mile_per_hour2' : lambda x : x * 1.15077945, + 'km_per_hour2' : lambda x : x * 1.85200, + 'meter_per_second2': lambda x : x * 0.514444444}, + 'kPa' : {'inHg' : lambda x: x * INHG_PER_MBAR * 10.0, + 'mmHg' : lambda x: x * 7.5006168, + 'mbar' : lambda x: x * 10.0, + 'hPa' : lambda x: x * 10.0}, + 'kPa_per_hour' : {'inHg_per_hour' : lambda x: x * INHG_PER_MBAR * 10.0, + 'mmHg_per_hour' : lambda x: x * 7.5006168, + 'mbar_per_hour' : lambda x: x * 10.0, + 'hPa_per_hour' : lambda x: x * 10.0}, + 'liter' : {'gallon' : lambda x : x * 0.264172, + 'cubic_foot' : lambda x : x * 0.0353147}, + 'mbar' : {'inHg' : lambda x : x * INHG_PER_MBAR, + 'mmHg' : lambda x : x * 0.75006168, + 'hPa' : lambda x : x, + 'kPa' : lambda x : x / 10.0}, + 'mbar_per_hour' : {'inHg_per_hour' : lambda x : x * INHG_PER_MBAR, + 'mmHg_per_hour' : lambda x : x * 0.75006168, + 'hPa_per_hour' : lambda x : x, + 'kPa_per_hour' : lambda x : x / 10.0}, + 'mega_joule' : {'kilowatt_hour' : lambda x : x / 3.6, + 'watt_hour' : lambda x : x * 1000000 / 3600, + 'watt_second' : lambda x : x * 1000000}, + 'meter' : {'foot' : lambda x : x / METER_PER_FOOT, + 'km' : lambda x : x / 1000.0}, + 'meter_per_second' : {'mile_per_hour' : mps_to_mph, + 'knot' : mps_to_knot, + 'km_per_hour' : lambda x : x * 3.6}, + 'meter_per_second2': {'mile_per_hour2' : lambda x : x * 2.23693629, + 'knot2' : lambda x : x * 1.94384449, + 'km_per_hour2' : lambda x : x * 3.6}, + 'mile' : {'km' : lambda x : x * 1.609344}, + 'mile_per_hour' : {'km_per_hour' : lambda x : x * 1.609344, + 'knot' : mph_to_knot, + 'meter_per_second' : lambda x : x * 0.44704}, + 'mile_per_hour2' : {'km_per_hour2' : lambda x : x * 1.609344, + 'knot2' : lambda x : x * 0.868976242, + 'meter_per_second2': lambda x : x * 0.44704}, + 'minute' : {'second' : lambda x : x * 60.0, + 'hour' : lambda x : x / 60.0, + 'day' : lambda x : x / 1440.0}, + 'mm' : {'inch' : lambda x : x / MM_PER_INCH, + 'cm' : lambda x : x * 0.10}, + 'mm_per_hour' : {'inch_per_hour' : lambda x : x * .0393700787, + 'cm_per_hour' : lambda x : x * 0.10}, + 'mmHg' : {'inHg' : lambda x : x / MM_PER_INCH, + 'mbar' : lambda x : x / 0.75006168, + 'hPa' : lambda x : x / 0.75006168, + 'kPa' : lambda x : x / 7.5006168}, + 'mmHg_per_hour' : {'inHg_per_hour' : lambda x : x / MM_PER_INCH, + 'mbar_per_hour' : lambda x : x / 0.75006168, + 'hPa_per_hour' : lambda x : x / 0.75006168, + 'kPa_per_hour' : lambda x : x / 7.5006168}, + 'second' : {'hour' : lambda x : x/3600.0, + 'minute' : lambda x : x/60.0, + 'day' : lambda x : x / SECS_PER_DAY}, + 'unix_epoch' : {'dublin_jd' : lambda x: x / SECS_PER_DAY + 25567.5, + 'unix_epoch_ms' : lambda x : x * 1000, + 'unix_epoch_ns' : lambda x : x * 1000000}, + 'unix_epoch_ms' : {'dublin_jd' : lambda x: x / (SECS_PER_DAY * 1000) + 25567.5, + 'unix_epoch' : lambda x : x / 1000, + 'unix_epoch_ns' : lambda x : x * 1000}, + 'unix_epoch_ns' : {'dublin_jd' : lambda x: x / (SECS_PER_DAY * 1e06) + 25567.5, + 'unix_epoch' : lambda x : x / 1e06, + 'unix_epoch_ms' : lambda x : x / 1000}, + 'watt' : {'kilowatt' : lambda x : x / 1000.0}, + 'watt_hour' : {'kilowatt_hour' : lambda x : x / 1000.0, + 'mega_joule' : lambda x : x * 0.0036, + 'watt_second' : lambda x : x * 3600.0}, + 'watt_second' : {'kilowatt_hour' : lambda x : x / 3.6e6, + 'mega_joule' : lambda x : x / 1000000, + 'watt_hour' : lambda x : x / 3600.0}, +} + + +# These used to hold default values for formats and labels, but that has since been moved +# to units.defaults. However, they are still used by modules that extend the unit system +# programmatically. +default_unit_format_dict = {} +default_unit_label_dict = {} + +DEFAULT_DELTATIME_FORMAT = "%(day)d%(day_label)s, " \ + "%(hour)d%(hour_label)s, " \ + "%(minute)d%(minute_label)s" + +# Default mapping from compass degrees to ordinals +DEFAULT_ORDINATE_NAMES = [ + 'N', 'NNE','NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW','SW', 'WSW', 'W', 'WNW', 'NW', 'NNW', + 'N/A' +] + +complex_conversions = { + 'x': lambda c: c.real if c is not None else None, + 'y': lambda c: c.imag if c is not None else None, + 'magnitude': lambda c: abs(c) if c is not None else None, + 'direction': weeutil.weeutil.dirN, + 'polar': lambda c: weeutil.weeutil.Polar.from_complex(c) if c is not None else None, +} + +class ValueTuple(tuple): + """ + A value, along with the unit it is in, can be represented by a 3-way tuple called a value + tuple. All weewx routines can accept a simple unadorned 3-way tuple as a value tuple, but they + return the type ValueTuple. It is useful because its contents can be accessed using named + attributes. + + Item attribute Meaning + 0 value The data value(s). Can be a series (eg, [20.2, 23.2, ...]) + or a scalar (eg, 20.2). + 1 unit The unit it is in ("degree_C") + 2 group The unit group ("group_temperature") + + It is valid to have a datum value of None. + + It is also valid to have a unit type of None (meaning there is no information about the unit + the value is in). In this case, you won't be able to convert it to another unit. + """ + def __new__(cls, *args): + return tuple.__new__(cls, args) + + @property + def value(self): + return self[0] + + @property + def unit(self): + return self[1] + + @property + def group(self): + return self[2] + + # ValueTuples have some modest math abilities: subtraction and addition. + def __sub__(self, other): + if self[1] != other[1] or self[2] != other[2]: + raise TypeError("Unsupported operand error for subtraction: %s and %s" + % (self[1], other[1])) + return ValueTuple(self[0] - other[0], self[1], self[2]) + + def __add__(self, other): + if self[1] != other[1] or self[2] != other[2]: + raise TypeError("Unsupported operand error for addition: %s and %s" + % (self[1], other[1])) + return ValueTuple(self[0] + other[0], self[1], self[2]) + + +#============================================================================== +# class Formatter +#============================================================================== + +class Formatter(object): + """Holds formatting information for the various unit types. """ + + def __init__(self, unit_format_dict = None, + unit_label_dict = None, + time_format_dict = None, + ordinate_names = None, + deltatime_format_dict = None): + """ + + Args: + unit_format_dict (dict): Key is unit type (e.g., 'inHg'), value is a + string format (e.g., "%.1f") + unit_label_dict (dict): Key is unit type (e.g., 'inHg'), value is a + label (e.g., " inHg") + time_format_dict (dict): Key is a context (e.g., 'week'), value is a + strftime format (e.g., "%d-%b-%Y %H:%M"). + ordinate_names(list): A list containing ordinal compass names (e.g., ['N', 'NNE', etc.] + deltatime_format_dict (dict): Key is a context (e.g., 'week'), value is a deltatime + format string (e.g., "%(minute)d%(minute_label)s, %(second)d%(second_label)s") + """ + + self.unit_format_dict = unit_format_dict or {} + self.unit_label_dict = unit_label_dict or {} + self.time_format_dict = time_format_dict or {} + self.ordinate_names = ordinate_names or DEFAULT_ORDINATE_NAMES + self.deltatime_format_dict = deltatime_format_dict or {} + + @staticmethod + def fromSkinDict(skin_dict): + """Factory static method to initialize from a skin dictionary.""" + try: + unit_format_dict = skin_dict['Units']['StringFormats'] + except KeyError: + unit_format_dict = {} + + try: + unit_label_dict = skin_dict['Units']['Labels'] + except KeyError: + unit_label_dict = {} + + try: + time_format_dict = skin_dict['Units']['TimeFormats'] + except KeyError: + time_format_dict = {} + + try: + ordinate_names = weeutil.weeutil.option_as_list( + skin_dict['Units']['Ordinates']['directions']) + except KeyError: + ordinate_names = {} + + try: + deltatime_format_dict = skin_dict['Units']['DeltaTimeFormats'] + except KeyError: + deltatime_format_dict = {} + + return Formatter(unit_format_dict, + unit_label_dict, + time_format_dict, + ordinate_names, + deltatime_format_dict) + + def get_format_string(self, unit): + """Return a suitable format string.""" + + # First, try the (misnamed) custom unit format dictionary + if unit in default_unit_format_dict: + return default_unit_format_dict[unit] + # If that didn't work, try my internal format dictionary + elif unit in self.unit_format_dict: + return self.unit_format_dict[unit] + else: + # Can't find one. Return a generic formatter: + return '%f' + + def get_label_string(self, unit, plural=True): + """Return a suitable label. + + This function looks up a suitable label in the unit_label_dict. If the + associated value is a string, it returns it. If it is a tuple or a list, + then it is assumed the first value is a singular version of the label + (e.g., "foot"), the second a plural version ("feet"). If the parameter + plural=False, then the singular version is returned. Otherwise, the + plural version. + """ + + # First, try the (misnamed) custom dictionary + if unit in default_unit_label_dict: + label = default_unit_label_dict[unit] + # Then try my internal label dictionary: + elif unit in self.unit_label_dict: + label = self.unit_label_dict[unit] + else: + # Can't find a label. Just return an empty string: + return u'' + + # Is the label a tuple or list? + if isinstance(label, (tuple, list)): + # Yes. Return the singular or plural version as requested + return label[1] if plural and len(label) > 1 else label[0] + else: + # No singular/plural version. It's just a string. Return it. + return label + + def toString(self, val_t, context='current', addLabel=True, + useThisFormat=None, None_string=None, + localize=True): + """Format the value as a unicode string. + + Args: + val_t (ValueTuple): A ValueTuple holding the value to be formatted. The value can be an iterable. + context (str): A time context (eg, 'day'). + [Optional. If not given, context 'current' will be used.] + addLabel (bool): True to add a unit label (eg, 'mbar'), False to not. + [Optional. If not given, a label will be added.] + useThisFormat (str): An optional string or strftime format to be used. + [Optional. If not given, the format given in the initializer will be used.] + None_string (str): A string to be used if the value val is None. + [Optional. If not given, the string given by unit_format_dict['NONE'] + will be used.] + localize (bool): True to localize the results. False otherwise. + + Returns: + str. The localized, formatted, and labeled value. + """ + + # Check to see if the ValueTuple holds an iterable: + if is_iterable(val_t[0]): + # Yes. Format each element individually, then stick them all together. + s_list = [self._to_string((v, val_t[1], val_t[2]), + context, addLabel, useThisFormat, None_string, localize) + for v in val_t[0]] + s = ", ".join(s_list) + else: + # The value is a simple scalar. + s = self._to_string(val_t, context, addLabel, useThisFormat, None_string, localize) + + return s + + def _to_string(self, val_t, context='current', addLabel=True, + useThisFormat=None, None_string=None, + localize=True): + """Similar to the function toString(), except that the value in val_t must be a + simple scalar.""" + + if val_t is None or val_t[0] is None: + if None_string is None: + val_str = self.unit_format_dict.get('NONE', u'N/A') + else: + # Make sure the "None_string" is, in fact, a string + if isinstance(None_string, six.string_types): + val_str = None_string + else: + # Coerce to a string. + val_str = str(None_string) + addLabel = False + elif type(val_t[0]) is complex: + # The type is complex. Break it up into real and imaginary, then format + # them separately. No label --- it will get added later + r = ValueTuple(val_t[0].real, val_t[1], val_t[2]) + i = ValueTuple(val_t[0].imag, val_t[1], val_t[2]) + val_str = "(%s, %s)" % (self._to_string(r, context, False, + useThisFormat, None_string, localize), + self._to_string(i, context, False, + useThisFormat, None_string, localize)) + elif type(val_t[0]) is Polar: + # The type is a Polar number. Break it up into magnitude and direction, then format + # them separately. + mag = ValueTuple(val_t[0].mag, val_t[1], val_t[2]) + dir = ValueTuple(val_t[0].dir, "degree_compass", "group_direction") + val_str = "(%s, %s)" % (self._to_string(mag, context, addLabel, + useThisFormat, None_string, localize), + self._to_string(dir, context, addLabel, + None, None_string, localize)) + addLabel = False + elif val_t[1] in {"unix_epoch", "unix_epoch_ms", "unix_epoch_ns"}: + # Different formatting routines are used if the value is a time. + t = val_t[0] + if val_t[1] == "unix_epoch_ms": + t /= 1000.0 + elif val_t[1] == "unix_epoch_ns": + t /= 1000000.0 + if useThisFormat is None: + val_str = time.strftime(self.time_format_dict.get(context, "%d-%b-%Y %H:%M"), + time.localtime(t)) + else: + val_str = time.strftime(useThisFormat, time.localtime(t)) + addLabel = False + else: + # It's not a time. It's a regular value. Get a suitable format string: + if useThisFormat is None: + # No user-specified format string. Go get one: + format_string = self.get_format_string(val_t[1]) + else: + # User has specified a string. Use it. + format_string = useThisFormat + if localize: + # Localization requested. Use locale with the supplied format: + val_str = locale.format_string(format_string, val_t[0]) + else: + # No localization. Just format the string. + val_str = format_string % val_t[0] + + # Make sure the results are in unicode: + val_ustr = six.ensure_text(val_str) + + # Add a label, if requested: + if addLabel: + # Make sure the label is in unicode before tacking it on to the end + label = self.get_label_string(val_t[1], plural=(not val_t[0]==1)) + val_ustr += six.ensure_text(label) + + return val_ustr + + def to_ordinal_compass(self, val_t): + if val_t[0] is None: + return self.ordinate_names[-1] + _sector_size = 360.0 / (len(self.ordinate_names)-1) + _degree = (val_t[0] + _sector_size/2.0) % 360.0 + _sector = int(_degree / _sector_size) + return self.ordinate_names[_sector] + + def long_form(self, val_t, context, format_string=None): + """Format a delta time using the long-form. + + Args: + val_t (ValueTuple): a ValueTuple holding the delta time. + context (str): The time context. Something like 'day', 'current', etc. + format_string (str|None): An optional custom format string. Otherwise, an appropriate + string will be looked up in deltatime_format_dict. + Returns + str: The results formatted in a "long-form" time. This is something like + "2 hours, 14 minutes, 21 seconds". + """ + # Get a delta-time format string. Use a default if the user did not supply one: + if not format_string: + format_string = self.deltatime_format_dict.get(context, DEFAULT_DELTATIME_FORMAT) + # Now format the delta time, using the function delta_time_to_string: + val_str = self.delta_time_to_string(val_t, format_string) + return val_str + + def delta_time_to_string(self, val_t, label_format): + """Format elapsed time as a string + + Args: + val_t (ValueTuple): A ValueTuple containing the elapsed time. + label_format (str): The formatting string. + + Returns: + str: The formatted time as a string. + """ + secs = convert(val_t, 'second')[0] + etime_dict = {} + secs = abs(secs) + for (label, interval) in (('day', 86400), ('hour', 3600), ('minute', 60), ('second', 1)): + amt = int(secs // interval) + etime_dict[label] = amt + etime_dict[label + '_label'] = self.get_label_string(label, not amt == 1) + secs %= interval + if 'day' not in label_format: + # If 'day' does not appear in the formatting string, add its time to hours + etime_dict['hour'] += 24 * etime_dict['day'] + ans = locale.format_string(label_format, etime_dict) + return ans + +#============================================================================== +# class Converter +#============================================================================== + +class Converter(object): + """Holds everything necessary to do conversions to a target unit system.""" + + def __init__(self, group_unit_dict=USUnits): + """Initialize an instance of Converter + + group_unit_dict: A dictionary holding the conversion information. + Key is a unit_group (eg, 'group_pressure'), value is the target + unit type ('mbar')""" + + self.group_unit_dict = group_unit_dict + + @staticmethod + def fromSkinDict(skin_dict): + """Factory static method to initialize from a skin dictionary.""" + try: + group_unit_dict = skin_dict['Units']['Groups'] + except KeyError: + group_unit_dict = USUnits + return Converter(group_unit_dict) + + def convert(self, val_t): + """Convert a value from a given unit type to the target type. + + val_t: A value tuple with the datum, a unit type, and a unit group + + returns: A value tuple in the new, target unit type. If the input + value tuple contains an unknown unit type an exception of type KeyError + will be thrown. If the input value tuple has either a unit + type of None, or a group type of None (but not both), then an + exception of type KeyError will be thrown. If both the + unit and group are None, then the original val_t will be + returned (i.e., no conversion is done). + + Examples: + >>> p_m = (1016.5, 'mbar', 'group_pressure') + >>> c = Converter() + >>> print("%.3f %s %s" % c.convert(p_m)) + 30.017 inHg group_pressure + + Try an unspecified unit type: + >>> p2 = (1016.5, None, None) + >>> print(c.convert(p2)) + (1016.5, None, None) + + Try a bad unit type: + >>> p3 = (1016.5, 'foo', 'group_pressure') + >>> try: + ... print(c.convert(p3)) + ... except KeyError: + ... print("Exception thrown") + Exception thrown + + Try a bad group type: + >>> p4 = (1016.5, 'mbar', 'group_foo') + >>> try: + ... print(c.convert(p4)) + ... except KeyError: + ... print("Exception thrown") + Exception thrown + """ + if val_t[1] is None and val_t[2] is None: + return val_t + # Determine which units (eg, "mbar") this group should be in. + # If the user has not specified anything, then fall back to US Units. + new_unit_type = self.group_unit_dict.get(val_t[2], USUnits[val_t[2]]) + # Now convert to this new unit type: + new_val_t = convert(val_t, new_unit_type) + return new_val_t + + def convertDict(self, obs_dict): + """Convert an observation dictionary into the target unit system. + + The source dictionary must include the key 'usUnits' in order for the + converter to figure out what unit system it is in. + + The output dictionary will contain no information about the unit + system (that is, it will not contain a 'usUnits' entry). This is + because the conversion is general: it may not result in a standard + unit system. + + Example: convert a dictionary which is in the metric unit system + into US units + + >>> # Construct a default converter, which will be to US units + >>> c = Converter() + >>> # Source dictionary is in metric units + >>> source_dict = {'dateTime': 194758100, 'outTemp': 20.0,\ + 'usUnits': weewx.METRIC, 'barometer':1015.9166, 'interval':15} + >>> target_dict = c.convertDict(source_dict) + >>> print("dateTime: %d, interval: %d, barometer: %.3f, outTemp: %.3f" %\ + (target_dict['dateTime'], target_dict['interval'], \ + target_dict['barometer'], target_dict['outTemp'])) + dateTime: 194758100, interval: 15, barometer: 30.000, outTemp: 68.000 + """ + target_dict = {} + for obs_type in obs_dict: + if obs_type == 'usUnits': continue + # Do the conversion, but keep only the first value in + # the ValueTuple: + target_dict[obs_type] = self.convert(as_value_tuple(obs_dict, obs_type))[0] + return target_dict + + + def getTargetUnit(self, obs_type, agg_type=None): + """Given an observation type and an aggregation type, return the + target unit type and group, or (None, None) if they cannot be determined. + + obs_type: An observation type ('outTemp', 'rain', etc.) + + agg_type: Type of aggregation ('mintime', 'count', etc.) + [Optional. default is no aggregation) + + returns: A 2-way tuple holding the unit type and the unit group + or (None, None) if they cannot be determined. + """ + unit_group = _getUnitGroup(obs_type, agg_type) + if unit_group in self.group_unit_dict: + unit_type = self.group_unit_dict[unit_group] + else: + unit_type = USUnits.get(unit_group) + return (unit_type, unit_group) + +#============================================================================== +# Standard Converters +#============================================================================== + +# This dictionary holds converters for the standard unit conversion systems. +StdUnitConverters = {weewx.US : Converter(USUnits), + weewx.METRIC : Converter(MetricUnits), + weewx.METRICWX : Converter(MetricWXUnits)} + + +#============================================================================== +# class ValueHelper +#============================================================================== + +class ValueHelper(object): + """A helper class that binds a value tuple together with everything needed to do a + context sensitive formatting """ + def __init__(self, value_t, context='current', formatter=Formatter(), converter=None): + """Initialize a ValueHelper + + Args: + value_t (ValueTuple or UnknownType): This parameter can be either a ValueTuple, + or an instance of UnknownType. If a ValueTuple, the "value" part can be either a + scalar, or a series. If a converter is given, it will be used to convert the + ValueTuple before storing. If the parameter is 'UnknownType', it is an error ot + perform any operation on the resultant ValueHelper, except ask it to be formatted + as a string. In this case, the name of the unknown type will be included in the + resultant string. + context (str): The time context. Something like 'current', 'day', 'week'. + [Optional. If not given, context 'current' will be used.] + formatter (Formatter): An instance of class Formatter. + [Optional. If not given, then the default Formatter() will be used] + converter (Converter): An instance of class Converter. + [Optional.] + """ + # If there's a converter, then perform the conversion: + if converter and not isinstance(value_t, UnknownType): + self.value_t = converter.convert(value_t) + else: + self.value_t = value_t + self.context = context + self.formatter = formatter + + def toString(self, + addLabel=True, + useThisFormat=None, + None_string=None, + localize=True, + NONE_string=None): + """Convert my internally held ValueTuple to a unicode string, using the supplied + converter and formatter. + + Args: + addLabel (bool): If True, add a unit label + useThisFormat (str): String with a format to be used when formatting the value. + If None, then a format will be supplied. Default is None. + None_string (str): A string to be used if the value is None. If None, then a default + string from skin.conf will be used. Default is None. + localize (bool): If True, localize the results. Default is True + NONE_string (str): Supplied for backwards compatibility. Identical semantics to + None_string. + + Returns: + str. The formatted and labeled string + """ + # If the type is unknown, then just return an error string: + if isinstance(self.value_t, UnknownType): + return u"?'%s'?" % self.value_t.obs_type + # Check NONE_string for backwards compatibility: + if None_string is None and NONE_string is not None: + None_string = NONE_string + # Then do the format conversion: + s = self.formatter.toString(self.value_t, self.context, addLabel=addLabel, + useThisFormat=useThisFormat, None_string=None_string, + localize=localize) + return s + + def __str__(self): + """Return as the native string type for the version of Python being run.""" + s = self.toString() + return six.ensure_str(s) + + def __unicode__(self): + """Return as unicode. This function is called only under Python 2.""" + return self.toString() + + def format(self, format_string=None, None_string=None, add_label=True, localize=True): + """Returns a formatted version of the datum, using user-supplied customizations.""" + return self.toString(useThisFormat=format_string, None_string=None_string, + addLabel=add_label, localize=localize) + + def ordinal_compass(self): + """Returns an ordinal compass direction (eg, 'NNW')""" + # Get the raw value tuple, then ask the formatter to look up an + # appropriate ordinate: + return self.formatter.to_ordinal_compass(self.value_t) + + def long_form(self, format_string=None): + """Format a delta time""" + return self.formatter.long_form(self.value_t, + context=self.context, + format_string=format_string) + + def json(self, **kwargs): + return json.dumps(self.raw, cls=ComplexEncoder, **kwargs) + + def round(self, ndigits=None): + """Round the data part to ndigits decimal digits.""" + # Create a new ValueTuple with the rounded data + vt = ValueTuple(weeutil.weeutil.rounder(self.value_t[0], ndigits), + self.value_t[1], + self.value_t[2]) + # Use it to create a new ValueHelper + return ValueHelper(vt, self.context, self.formatter) + + @property + def raw(self): + """Returns just the data part, without any formatting.""" + return self.value_t[0] + + def convert(self, target_unit): + """Return a ValueHelper in a new target unit. + + Args: + target_unit (str): The unit (eg, 'degree_C') to which the data will be converted + + Returns: + ValueHelper. + """ + value_t = convert(self.value_t, target_unit) + return ValueHelper(value_t, self.context, self.formatter) + + def __getattr__(self, target_unit): + """Convert to a new unit type. + + Args: + target_unit (str): The new target unit + + Returns: + ValueHelper. The data in the new ValueHelper will be in the desired units. + """ + + # This is to get around bugs in the Python version of Cheetah's namemapper: + if target_unit in ['__call__', 'has_key']: + raise AttributeError + + # Convert any illegal conversions to an AttributeError: + try: + converted = self.convert(target_unit) + except KeyError: + raise AttributeError("Illegal conversion from '%s' to '%s'" + % (self.value_t[1], target_unit)) + return converted + + def __iter__(self): + """Return an iterator that can iterate over the elements of self.value_t.""" + for row in self.value_t[0]: + # Form a ValueTuple using the value, plus the unit and unit group + vt = ValueTuple(row, self.value_t[1], self.value_t[2]) + # Form a ValueHelper out of that + vh = ValueHelper(vt, self.context, self.formatter) + yield vh + + def exists(self): + return not isinstance(self.value_t, UnknownType) + + def has_data(self): + return self.exists() and self.value_t[0] is not None + + # Backwards compatibility + def string(self, None_string=None): + """Return as string with an optional user specified string to be used if None. + DEPRECATED.""" + return self.toString(None_string=None_string) + + # Backwards compatibility + def nolabel(self, format_string, None_string=None): + """Returns a formatted version of the datum, using a user-supplied format. No label. + DEPRECATED.""" + return self.toString(addLabel=False, useThisFormat=format_string, None_string=None_string) + + # Backwards compatibility + @property + def formatted(self): + """Return a formatted version of the datum. No label. + DEPRECATED.""" + return self.toString(addLabel=False) + + +#============================================================================== +# SeriesHelper +#============================================================================== + +class SeriesHelper(object): + """Convenience class that binds the series data, along with start and stop times.""" + + def __init__(self, start, stop, data): + """Initializer + + Args: + start (ValueHelper): A ValueHelper holding the start times of the data. None if + there is no start series. + stop (ValueHelper): A ValueHelper holding the stop times of the data. None if + there is no stop series + data (ValueHelper): A ValueHelper holding the data. + """ + self.start = start + self.stop = stop + self.data = data + + def json(self, order_by='row', **kwargs): + """Return the data in this series as JSON. + + Args: + order_by (str): A string that determines whether the generated string is ordered by + row or column. Either 'row' or 'column'. + **kwargs (Any): Any extra arguments are passed on to json.loads() + + Returns: + str. A string with the encoded JSON. + """ + + if order_by == 'row': + if self.start and self.stop: + json_data = list(zip(self.start.raw, self.stop.raw, self.data.raw)) + elif self.start and not self.stop: + json_data = list(zip(self.start.raw, self.data.raw)) + else: + json_data = list(zip(self.stop.raw, self.data.raw)) + elif order_by == 'column': + if self.start and self.stop: + json_data = [self.start.raw, self.stop.raw, self.data.raw] + elif self.start and not self.stop: + json_data = [self.start.raw, self.data.raw] + else: + json_data = [self.stop.raw, self.data.raw] + else: + raise ValueError("Unknown option '%s' for parameter 'order_by'" % order_by) + + return json.dumps(json_data, cls=ComplexEncoder, **kwargs) + + def round(self, ndigits=None): + """ + Round the data part to ndigits number of decimal digits. + + Args: + ndigits (int): The number of decimal digits to include in the data. Default is None, + which means keep all digits. + + Returns: + SeriesHelper: A new SeriesHelper, with the data part rounded to the requested number of + decimal digits. + """ + return SeriesHelper(self.start, self.stop, self.data.round(ndigits)) + + def __str__(self): + """Return as the native string type for the version of Python being run.""" + s = self.format() + return six.ensure_str(s) + + def __unicode__(self): + """Return as unicode. This function is called only under Python 2.""" + return self.format() + + def __len__(self): + return len(self.start) + + def format(self, format_string=None, None_string=None, add_label=True, + localize=True, order_by='row'): + """Format a series as a string. + + Args: + format_string (str): String with a format to be used when formatting the values. + If None, then a format will be supplied. Default is None. + None_string (str): A string to be used if a value is None. If None, + then a default string from skin.conf will be used. Default is None. + add_label (bool): If True, add a unit label to each value. + localize (bool): If True, localize the results. Default is True + order_by (str): A string that determines whether the generated string is ordered by + row or column. Either 'row' or 'column'. + + Returns: + str. The formatted and labeled string + """ + + if order_by == 'row': + rows = [] + if self.start and self.stop: + for start_, stop_, data_ in self: + rows += ["%s, %s, %s" + % (str(start_), + str(stop_), + data_.format(format_string, None_string, add_label, localize)) + ] + elif self.start and not self.stop: + for start_, data_ in zip(self.start, self.data): + rows += ["%s, %s" + % (str(start_), + data_.format(format_string, None_string, add_label, localize)) + ] + else: + for stop_, data_ in zip(self.stop, self.data): + rows += ["%s, %s" + % (str(stop_), + data_.format(format_string, None_string, add_label, localize)) + ] + return "\n".join(rows) + + elif order_by == 'column': + if self.start and self.stop: + return "%s\n%s\n%s" \ + % (str(self.start), + str(self.stop), + self.data.format(format_string, None_string, add_label, localize)) + elif self.start and not self.stop: + return "%s\n%s" \ + % (str(self.start), + self.data.format(format_string, None_string, add_label, localize)) + else: + return "%s\n%s" \ + % (str(self.stop), + self.data.format(format_string, None_string, add_label, localize)) + else: + raise ValueError("Unknown option '%s' for parameter 'order_by'" % order_by) + + def __getattr__(self, target_unit): + """Return a new SeriesHelper, with the data part converted to a new unit + + Args: + target_unit (str): The data part of the returned SeriesHelper will be in this unit. + + Returns: + SeriesHelper. The data in the new SeriesHelper will be in the target unit. + """ + + # This is to get around bugs in the Python version of Cheetah's namemapper: + if target_unit in ['__call__', 'has_key']: + raise AttributeError + + # This will be a ValueHelper. + converted_data = self.data.convert(target_unit) + + return SeriesHelper(self.start, self.stop, converted_data) + + def __iter__(self): + """Iterate over myself by row.""" + for start, stop, data in zip(self.start, self.stop, self.data): + yield start, stop, data + +#============================================================================== +# class UnitInfoHelper and friends +#============================================================================== + +class UnitHelper(object): + def __init__(self, converter): + self.converter = converter + def __getattr__(self, obs_type): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if obs_type in ['__call__', 'has_key']: + raise AttributeError + return self.converter.getTargetUnit(obs_type)[0] + +class FormatHelper(object): + def __init__(self, formatter, converter): + self.formatter = formatter + self.converter = converter + def __getattr__(self, obs_type): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if obs_type in ['__call__', 'has_key']: + raise AttributeError + return get_format_string(self.formatter, self.converter, obs_type) + +class LabelHelper(object): + def __init__(self, formatter, converter): + self.formatter = formatter + self.converter = converter + def __getattr__(self, obs_type): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if obs_type in ['__call__', 'has_key']: + raise AttributeError + return get_label_string(self.formatter, self.converter, obs_type) + +class UnitInfoHelper(object): + """Helper class used for for the $unit template tag.""" + def __init__(self, formatter, converter): + """ + formatter: an instance of Formatter + converter: an instance of Converter + """ + self.unit_type = UnitHelper(converter) + self.format = FormatHelper(formatter, converter) + self.label = LabelHelper(formatter, converter) + self.group_unit_dict = converter.group_unit_dict + + # This is here for backwards compatibility: + @property + def unit_type_dict(self): + return self.group_unit_dict + + +class ObsInfoHelper(object): + """Helper class to implement the $obs template tag.""" + def __init__(self, skin_dict): + try: + d = skin_dict['Labels']['Generic'] + except KeyError: + d = {} + self.label = weeutil.weeutil.KeyDict(d) + + +#============================================================================== +# Helper functions +#============================================================================== +def getUnitGroup(obs_type, agg_type=None): + """Given an observation type and an aggregation type, what unit group + does it belong to? + + Examples: + +-------------+-----------+---------------------+ + | obs_type | agg_type | Returns | + +=============+===========+=====================+ + | 'outTemp' | None | 'group_temperature' | + +-------------+-----------+---------------------+ + | 'outTemp' | 'min' | 'group_temperature' | + +-------------+-----------+---------------------+ + | 'outTemp' | 'mintime' | 'group_time' | + +-------------+-----------+---------------------+ + | 'wind' | 'avg' | 'group_speed' | + +-------------+-----------+---------------------+ + | 'wind' | 'vecdir' | 'group_direction' | + +-------------+-----------+---------------------+ + + Args: + obs_type (str): An observation type (eg, 'barometer') + agg_type (str): An aggregation (eg, 'mintime', or 'avg'.) + + Returns: + str or None. The unit group or None if it cannot be determined. + """ + if agg_type and agg_type in agg_group: + return agg_group[agg_type] + else: + return obs_group_dict.get(obs_type) + + +# For backwards compatibility: +_getUnitGroup = getUnitGroup + + +def convert(val_t, target_unit): + """Convert a ValueTuple to a new unit + + Args: + val_t (ValueTuple): A ValueTuple containing the value to be converted. The first element + can be either a scalar or an iterable. + target_unit (str): The unit type (e.g., "meter", or "mbar") to which the value is to be + converted. If the ValueTuple holds a complex number, target_unit can be a complex + conversion nickname, such as 'polar'. + + Returns: + ValueTuple. An instance of ValueTuple, where the desired conversion has been performed. + """ + + # Is the "target_unit" really a conversion for complex numbers? + if target_unit in complex_conversions: + # Yes. Get the conversion function. Also, note that these operations do not change the + # unit the ValueTuple is in. + conversion_func = complex_conversions[target_unit] + target_unit = val_t[1] + else: + # We are converting between units. If the value is already in the target unit type, then + # just return it: + if val_t[1] == target_unit: + return val_t + + # Retrieve the conversion function. An exception of type KeyError + # will occur if the target or source units are invalid + try: + conversion_func = conversionDict[val_t[1]][target_unit] + except KeyError: + log.debug("Unable to convert from %s to %s", val_t[1], target_unit) + raise + # Are we converting a list, or a simple scalar? + if isinstance(val_t[0], (list, tuple)): + # A list + new_val = [conversion_func(x) if x is not None else None for x in val_t[0]] + else: + # A scalar + new_val = conversion_func(val_t[0]) if val_t[0] is not None else None + + # Add on the unit type and the group type and return the results: + return ValueTuple(new_val, target_unit, val_t[2]) + + +def convertStd(val_t, target_std_unit_system): + """Convert a value tuple to an appropriate unit in a target standardized + unit system + + Example: + >>> value_t = (30.02, 'inHg', 'group_pressure') + >>> print("(%.2f, %s, %s)" % convertStd(value_t, weewx.METRIC)) + (1016.59, mbar, group_pressure) + >>> value_t = (1.2, 'inch', 'group_rain') + >>> print("(%.2f, %s, %s)" % convertStd(value_t, weewx.METRICWX)) + (30.48, mm, group_rain) + Args: + val_t (ValueTuple): The ValueTuple to be converted. + target_std_unit_system (int): A standardized WeeWX unit system (weewx.US, weewx.METRIC, + or weewx.METRICWX) + + Returns: + ValueTuple. A value tuple in the given standardized unit system. + """ + return StdUnitConverters[target_std_unit_system].convert(val_t) + +def convertStdName(val_t, target_nickname): + """Convert to a target standard unit system, using the unit system's nickname""" + return convertStd(val_t, unit_constants[target_nickname.upper()]) + +def getStandardUnitType(target_std_unit_system, obs_type, agg_type=None): + """Given a standard unit system (weewx.US, weewx.METRIC, weewx.METRICWX), + an observation type, and an aggregation type, what units would it be in? + + Examples: + >>> print(getStandardUnitType(weewx.US, 'barometer')) + ('inHg', 'group_pressure') + >>> print(getStandardUnitType(weewx.METRIC, 'barometer')) + ('mbar', 'group_pressure') + >>> print(getStandardUnitType(weewx.US, 'barometer', 'mintime')) + ('unix_epoch', 'group_time') + >>> print(getStandardUnitType(weewx.METRIC, 'barometer', 'avg')) + ('mbar', 'group_pressure') + >>> print(getStandardUnitType(weewx.METRIC, 'wind', 'rms')) + ('km_per_hour', 'group_speed') + >>> print(getStandardUnitType(None, 'barometer', 'avg')) + (None, None) + + Args: + target_std_unit_system (int): A standardized unit system. If None, then + the the output units are indeterminate, so (None, None) is returned. + + obs_type (str): An observation type, e.g., 'outTemp' + + agg_type (str): An aggregation type, e.g., 'mintime', or 'avg'. + + Returns: + tuple. A 2-way tuple containing the target units, and the target group. + """ + + if target_std_unit_system is not None: + return StdUnitConverters[target_std_unit_system].getTargetUnit(obs_type, agg_type) + else: + return None, None + + +def get_format_string(formatter, converter, obs_type): + # First convert to the target unit type: + u = converter.getTargetUnit(obs_type)[0] + # Then look up the format string for that unit type: + return formatter.get_format_string(u) + + +def get_label_string(formatter, converter, obs_type, plural=True): + # First convert to the target unit type: + u = converter.getTargetUnit(obs_type)[0] + # Then look up the label for that unit type: + return formatter.get_label_string(u, plural) + + +class GenWithConvert(object): + """Generator wrapper. Converts the output of the wrapped generator to a + target unit system. + + Example: + >>> def genfunc(): + ... for i in range(3): + ... _rec = {'dateTime' : 194758100 + i*300, + ... 'outTemp' : 68.0 + i * 9.0/5.0, + ... 'usUnits' : weewx.US} + ... yield _rec + >>> # First, try the raw generator function. Output should be in US + >>> for _out in genfunc(): + ... print("Timestamp: %d; Temperature: %.2f; Unit system: %d" + ... % (_out['dateTime'], _out['outTemp'], _out['usUnits'])) + Timestamp: 194758100; Temperature: 68.00; Unit system: 1 + Timestamp: 194758400; Temperature: 69.80; Unit system: 1 + Timestamp: 194758700; Temperature: 71.60; Unit system: 1 + >>> # Now do it again, but with the generator function wrapped by GenWithConvert: + >>> for _out in GenWithConvert(genfunc(), weewx.METRIC): + ... print("Timestamp: %d; Temperature: %.2f; Unit system: %d" + ... % (_out['dateTime'], _out['outTemp'], _out['usUnits'])) + Timestamp: 194758100; Temperature: 20.00; Unit system: 16 + Timestamp: 194758400; Temperature: 21.00; Unit system: 16 + Timestamp: 194758700; Temperature: 22.00; Unit system: 16 + """ + + def __init__(self, input_generator, target_unit_system=weewx.METRIC): + """Initialize an instance of GenWithConvert + + input_generator: An iterator which will return dictionary records. + + target_unit_system: The unit system the output of the generator should + use, or 'None' if it should leave the output unchanged.""" + self.input_generator = input_generator + self.target_unit_system = target_unit_system + + def __iter__(self): + return self + + def __next__(self): + _record = next(self.input_generator) + if self.target_unit_system is None: + return _record + else: + return to_std_system(_record, self.target_unit_system) + + # For Python 2: + next = __next__ + + +def to_US(datadict): + """Convert the units used in a dictionary to US Customary.""" + return to_std_system(datadict, weewx.US) + +def to_METRIC(datadict): + """Convert the units used in a dictionary to Metric.""" + return to_std_system(datadict, weewx.METRIC) + +def to_METRICWX(datadict): + """Convert the units used in a dictionary to MetricWX.""" + return to_std_system(datadict, weewx.METRICWX) + +def to_std_system(datadict, unit_system): + """Convert the units used in a dictionary to a target unit system.""" + if datadict['usUnits'] == unit_system: + # It's already in the unit system. + return datadict + else: + # It's in something else. Perform the conversion + _datadict_target = StdUnitConverters[unit_system].convertDict(datadict) + # Add the new unit system + _datadict_target['usUnits'] = unit_system + return _datadict_target + + +def as_value_tuple(record_dict, obs_type): + """Look up an observation type in a record, returning the result as a ValueTuple. + + Args: + record_dict (dict): A record. May be None. If it is not None, then it must contain an + entry for `usUnits`. + obs_type (str): The observation type to be returned + + Returns: + ValueTuple. + + Raises: + KeyIndex, If the observation type cannot be found in the record, a KeyIndex error is + raised. + """ + + # Is the record None? + if record_dict is None: + # Yes. Signal a value of None and, arbitrarily, pick the US unit system: + val = None + std_unit_system = weewx.US + else: + # There is a record. Get the value, and the unit system. + val = record_dict[obs_type] + std_unit_system = record_dict['usUnits'] + + # Given this standard unit system, what is the unit type of this + # particular observation type? If the observation type is not recognized, + # a unit_type of None will be returned + (unit_type, unit_group) = StdUnitConverters[std_unit_system].getTargetUnit(obs_type) + + # Form the value-tuple and return it: + return ValueTuple(val, unit_type, unit_group) + + +class ComplexEncoder(json.JSONEncoder): + """Custom encoder that knows how to encode complex and polar objects""" + def default(self, obj): + if isinstance(obj, complex): + # Return as tuple + return obj.real, obj.imag + elif isinstance(obj, Polar): + # Return as tuple: + return obj.mag, obj.dir + # Otherwise, let the base class handle it + return json.JSONEncoder.default(self, obj) + + +def get_default_formatter(): + """Get a default formatter. Useful for the test suites.""" + import weewx.defaults + weewx.defaults.defaults.interpolation = False + formatter = Formatter( + unit_format_dict=weewx.defaults.defaults['Units']['StringFormats'], + unit_label_dict=weewx.defaults.defaults['Units']['Labels'], + time_format_dict=weewx.defaults.defaults['Units']['TimeFormats'], + ordinate_names=weewx.defaults.defaults['Units']['Ordinates']['directions'], + deltatime_format_dict=weewx.defaults.defaults['Units']['DeltaTimeFormats'] + ) + return formatter + + +if __name__ == "__main__": + if not six.PY3: + exit("units.py doctest must be run under Python 3") + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weewx/uwxutils.py b/dist/weewx-4.10.1/bin/weewx/uwxutils.py new file mode 100644 index 0000000..8ae5cb4 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/uwxutils.py @@ -0,0 +1,530 @@ +# Adapted for use with weewx +# +# This source code may be freely used, including for commercial purposes +# Steve Hatchett info@softwx.com +# http:#www.softwx.org/weather + +""" +Functions for performing various weather related calculations. + +Notes about pressure + Sensor Pressure raw pressure indicated by the barometer instrument + Station Pressure Sensor Pressure adjusted for any difference between + sensor elevation and official station elevation + Field Pressure (QFE) Usually the same as Station Pressure + Altimeter Setting (QNH) Station Pressure adjusted for elevation (assumes + standard atmosphere) + Sea Level Pressure (QFF) Station Pressure adjusted for elevation, + temperature and humidity + +Notes about input parameters: + currentTemp - current instantaneous station temperature + meanTemp - average of current temp and the temperature 12 hours in + the past. If the 12 hour temp is not known, simply pass + the same value as currentTemp for the mean temp. + humidity - Value should be 0 to 100. For the pressure conversion + functions, pass a value of zero if you do not want to + the algorithm to include the humidity correction factor + in the calculation. If you provide a humidity value + > 0, then humidity effect will be included in the + calculation. + elevation - This should be the geometric altitude of the station + (this is the elevation provided by surveys and normally + used by people when they speak of elevation). Some + algorithms will convert the elevation internally into + a geopotential altitude. + sensorElevation - This should be the geometric altitude of the actual + barometric sensor (which could be different than the + official station elevation). + +Notes about Sensor Pressure vs. Station Pressure: + SensorToStationPressure and StationToSensorPressure functions are based + on an ASOS algorithm. It corrects for a difference in elevation between + the official station location and the location of the barometetric sensor. + It turns out that if the elevation difference is under 30 ft, then the + algorithm will give the same result (a 0 to .01 inHg adjustment) regardless + of temperature. In that case, the difference can be covered using a simple + fixed offset. If the difference is 30 ft or greater, there is some effect + from temperature, though it is small. For example, at a 100ft difference, + the adjustment will be .13 inHg at -30F and .10 at 100F. The bottom line + is that while ASOS stations may do this calculation, it is likely unneeded + for home weather stations, and the station pressure and the sensor pressure + can be treated as equivalent.""" + +from __future__ import absolute_import +from __future__ import print_function +import math + +def FToC(value): + return (value - 32.0) * (5.0 / 9.0) + +def CToF(value): + return (9.0/5.0)*value + 32.0 + +def CToK(value): + return value + 273.15 + +def KToC(value): + return value - 273.15 + +def FToR(value): + return value + 459.67 + +def RToF(value): + return value - 459.67 + +def InToHPa(value): + return value / 0.02953 + +def HPaToIn(value): + return value * 0.02953 + +def FtToM(value): + return value * 0.3048 + +def MToFt(value): + return value / 0.3048 + +def InToMm(value): + return value * 25.4 + +def MmToIn(value): + return value / 25.4 + +def MToKm(value): # NB: This is *miles* to Km. + return value * 1.609344 + +def KmToM(value): # NB: This is Km to *miles* + return value / 1.609344 + +def msToKmh(value): + return value * 3.6 + +def Power10(y): + return pow(10.0, y) + +# This maps various Pascal functions to Python functions. +Power = pow +Exp = math.exp +Round = round + +class TWxUtils(object): + + gravity = 9.80665 # g at sea level at lat 45.5 degrees in m/sec^2 + uGC = 8.31432 # universal gas constant in J/mole-K + moleAir = 0.0289644 # mean molecular mass of air in kg/mole + moleWater = 0.01801528 # molecular weight of water in kg/mole + gasConstantAir = uGC/moleAir # (287.053) gas constant for air in J/kgK + standardSLP = 1013.25 # standard sea level pressure in hPa + standardSlpInHg = 29.921 # standard sea level pressure in inHg + standardTempK = 288.15 # standard sea level temperature in Kelvin + earthRadius45 = 6356.766 # radius of the earth at lat 45.5 degrees in km + + # standard lapse rate (6.5C/1000m i.e. 6.5K/1000m) + standardLapseRate = 0.0065 + # (0.0019812) standard lapse rate per foot (1.98C/1000ft) + standardLapseRateFt = standardLapseRate * 0.3048 + vpLapseRateUS = 0.00275 # lapse rate used by VantagePro (2.75F/1000ft) + manBarLapseRate = 0.0117 # lapse rate from Manual of Barometry (11.7F/1000m, which = 6.5C/1000m) + + @staticmethod + def StationToSensorPressure(pressureHPa, sensorElevationM, stationElevationM, currentTempC): + # from ASOS formula specified in US units + Result = InToHPa(HPaToIn(pressureHPa) / Power10(0.00813 * MToFt(sensorElevationM - stationElevationM) / FToR(CToF(currentTempC)))) + return Result + + @staticmethod + def StationToAltimeter(pressureHPa, elevationM, algorithm='aaMADIS'): + if algorithm == 'aaASOS': + # see ASOS training at http://www.nwstc.noaa.gov + # see also http://wahiduddin.net/calc/density_altitude.htm + Result = InToHPa(Power(Power(HPaToIn(pressureHPa), 0.1903) + (1.313E-5 * MToFt(elevationM)), 5.255)) + + elif algorithm == 'aaASOS2': + geopEl = TWxUtils.GeopotentialAltitude(elevationM) + k1 = TWxUtils.standardLapseRate * TWxUtils.gasConstantAir / TWxUtils.gravity # approx. 0.190263 + k2 = 8.41728638E-5 # (stdLapseRate / stdTempK) * (Power(stdSLP, k1) + Result = Power(Power(pressureHPa, k1) + (k2 * geopEl), 1/k1) + + elif algorithm == 'aaMADIS': + # from MADIS API by NOAA Forecast Systems Lab + # http://madis.noaa.gov/madis_api.html + k1 = 0.190284 # discrepency with calculated k1 probably + # because Smithsonian used less precise gas + # constant and gravity values + k2 = 8.4184960528E-5 # (stdLapseRate / stdTempK) * (Power(stdSLP, k1) + Result = Power(Power(pressureHPa - 0.3, k1) + (k2 * elevationM), 1/k1) + + elif algorithm == 'aaNOAA': + # http://www.srh.noaa.gov/elp/wxclc/formulas/altimeterSetting.html + k1 = 0.190284 # discrepency with k1 probably because + # Smithsonian used less precise gas constant + # and gravity values + k2 = 8.42288069E-5 # (stdLapseRate / 288) * (Power(stdSLP, k1SMT) + Result = (pressureHPa - 0.3) * Power(1 + (k2 * (elevationM / Power(pressureHPa - 0.3, k1))), 1/k1) + + elif algorithm == 'aaWOB': + # see http://www.wxqa.com/archive/obsman.pdf + k1 = TWxUtils.standardLapseRate * TWxUtils.gasConstantAir / TWxUtils.gravity # approx. 0.190263 + k2 = 1.312603E-5 # (stdLapseRateFt / stdTempK) * Power(stdSlpInHg, k1) + Result = InToHPa(Power(Power(HPaToIn(pressureHPa), k1) + (k2 * MToFt(elevationM)), 1/k1)) + + elif algorithm == 'aaSMT': + # WMO Instruments and Observing Methods Report No.19 + # http://www.wmo.int/pages/prog/www/IMOP/publications/IOM-19-Synoptic-AWS.pdf + k1 = 0.190284 # discrepency with calculated value probably + # because Smithsonian used less precise gas + # constant and gravity values + k2 = 4.30899E-5 # (stdLapseRate / 288) * (Power(stdSlpInHg, k1SMT)) + geopEl = TWxUtils.GeopotentialAltitude(elevationM) + Result = InToHPa((HPaToIn(pressureHPa) - 0.01) * Power(1 + (k2 * (geopEl / Power(HPaToIn(pressureHPa) - 0.01, k1))), 1/k1)) + + else: + raise ValueError("Unknown StationToAltimeter algorithm '%s'" % + algorithm) + return Result + + @staticmethod + def StationToSeaLevelPressure(pressureHPa, elevationM, + currentTempC, meanTempC, humidity, + algorithm = 'paManBar'): + Result = pressureHPa * TWxUtils.PressureReductionRatio(pressureHPa, + elevationM, + currentTempC, + meanTempC, + humidity, + algorithm) + return Result + + @staticmethod + def SensorToStationPressure(pressureHPa, sensorElevationM, + stationElevationM, currentTempC): + # see ASOS training at http://www.nwstc.noaa.gov + # from US units ASOS formula + Result = InToHPa(HPaToIn(pressureHPa) * Power10(0.00813 * MToFt(sensorElevationM - stationElevationM) / FToR(CToF(currentTempC)))) + return Result + + # FIXME: still to do + #class function TWxUtils.AltimeterToStationPressure(pressureHPa: TWxReal; + # elevationM: TWxReal; + # algorithm: TAltimeterAlgorithm = DefaultAltimeterAlgorithm): TWxReal; + #begin + #end; + #} + + @staticmethod + def SeaLevelToStationPressure(pressureHPa, elevationM, + currentTempC, meanTempC, humidity, + algorithm = 'paManBar'): + Result = pressureHPa / TWxUtils.PressureReductionRatio(pressureHPa, + elevationM, + currentTempC, + meanTempC, + humidity, + algorithm) + return Result + + @staticmethod + def PressureReductionRatio(pressureHPa, elevationM, + currentTempC, meanTempC, humidity, + algorithm = 'paManBar'): + if algorithm == 'paUnivie': + # http://www.univie.ac.at/IMG-Wien/daquamap/Parametergencom.html + geopElevationM = TWxUtils.GeopotentialAltitude(elevationM) + Result = Exp(((TWxUtils.gravity/TWxUtils.gasConstantAir) * geopElevationM) / (TWxUtils.VirtualTempK(pressureHPa, meanTempC, humidity) + (geopElevationM * TWxUtils.standardLapseRate/2))) + + elif algorithm == 'paDavisVp': + # http://www.exploratorium.edu/weather/barometer.html + if (humidity > 0): + hCorr = (9.0/5.0) * TWxUtils.HumidityCorrection(currentTempC, elevationM, humidity, 'vaDavisVp') + else: + hCorr = 0 + # In the case of DavisVp, take the constant values literally. + Result = Power(10, (MToFt(elevationM) / (122.8943111 * (CToF(meanTempC) + 460 + (MToFt(elevationM) * TWxUtils.vpLapseRateUS/2) + hCorr)))) + + elif algorithm == 'paManBar': + # see WMO Instruments and Observing Methods Report No.19 + # http://www.wmo.int/pages/prog/www/IMOP/publications/IOM-19-Synoptic-AWS.pdf + # http://www.wmo.ch/web/www/IMOP/publications/IOM-19-Synoptic-AWS.pdf + if (humidity > 0): + hCorr = (9.0/5.0) * TWxUtils.HumidityCorrection(currentTempC, elevationM, humidity, 'vaBuck') + else: + hCorr = 0 + geopElevationM = TWxUtils.GeopotentialAltitude(elevationM) + Result = Exp(geopElevationM * 6.1454E-2 / (CToF(meanTempC) + 459.7 + (geopElevationM * TWxUtils.manBarLapseRate / 2) + hCorr)) + + else: + raise ValueError("Unknown PressureReductionRatio algorithm '%s'" % + algorithm) + return Result + + @staticmethod + def ActualVaporPressure(tempC, humidity, algorithm='vaBolton'): + result = (humidity * TWxUtils.SaturationVaporPressure(tempC, algorithm)) / 100.0 + return result + + @staticmethod + def SaturationVaporPressure(tempC, algorithm='vaBolton'): + # comparison of vapor pressure algorithms + # http://cires.colorado.edu/~voemel/vp.html + # (for DavisVP) http://www.exploratorium.edu/weather/dewpoint.html + if algorithm == 'vaDavisVp': + # Davis Calculations Doc + Result = 6.112 * Exp((17.62 * tempC)/(243.12 + tempC)) + elif algorithm == 'vaBuck': + # Buck(1996) + Result = 6.1121 * Exp((18.678 - (tempC/234.5)) * tempC / (257.14 + tempC)) + elif algorithm == 'vaBuck81': + # Buck(1981) + Result = 6.1121 * Exp((17.502 * tempC)/(240.97 + tempC)) + elif algorithm == 'vaBolton': + # Bolton(1980) + Result = 6.112 * Exp(17.67 * tempC / (tempC + 243.5)) + elif algorithm == 'vaTetenNWS': + # Magnus Teten + # www.srh.weather.gov/elp/wxcalc/formulas/vaporPressure.html + Result = 6.112 * Power(10,(7.5 * tempC / (tempC + 237.7))) + elif algorithm == 'vaTetenMurray': + # Magnus Teten (Murray 1967) + Result = Power(10, (7.5 * tempC / (237.5 + tempC)) + 0.7858) + elif algorithm == 'vaTeten': + # Magnus Teten + # www.vivoscuola.it/US/RSIGPP3202/umidita/attivita/relhumONA.htm + Result = 6.1078 * Power(10, (7.5 * tempC / (tempC + 237.3))) + else: + raise ValueError("Unknown SaturationVaporPressure algorithm '%s'" % + algorithm) + return Result + + @staticmethod + def MixingRatio(pressureHPa, tempC, humidity): + k1 = TWxUtils.moleWater / TWxUtils.moleAir # 0.62198 + # http://www.wxqa.com/archive/obsman.pdf + # http://www.vivoscuola.it/US/RSIGPP3202/umidita/attiviat/relhumONA.htm + vapPres = TWxUtils.ActualVaporPressure(tempC, humidity, 'vaBuck') + Result = 1000 * ((k1 * vapPres) / (pressureHPa - vapPres)) + return Result + + @staticmethod + def VirtualTempK(pressureHPa, tempC, humidity): + epsilon = 1 - (TWxUtils.moleWater / TWxUtils.moleAir) # 0.37802 + # http://www.univie.ac.at/IMG-Wien/daquamap/Parametergencom.html + # http://www.vivoscuola.it/US/RSIGPP3202/umidita/attiviat/relhumONA.htm + # http://wahiduddin.net/calc/density_altitude.htm + vapPres = TWxUtils.ActualVaporPressure(tempC, humidity, 'vaBuck') + Result = (CToK(tempC)) / (1-(epsilon * (vapPres/pressureHPa))) + return Result + + @staticmethod + def HumidityCorrection(tempC, elevationM, humidity, algorithm='vaBolton'): + vapPress = TWxUtils.ActualVaporPressure(tempC, humidity, algorithm) + Result = (vapPress * ((2.8322E-9 * (elevationM**2)) + (2.225E-5 * elevationM) + 0.10743)) + return Result + + @staticmethod + def GeopotentialAltitude(geometricAltitudeM): + Result = (TWxUtils.earthRadius45 * 1000 * geometricAltitudeM) / ((TWxUtils.earthRadius45 * 1000) + geometricAltitudeM) + return Result + + +#============================================================================== +# class TWxUtilsUS +#============================================================================== + +class TWxUtilsUS(object): + + """This class provides US unit versions of the functions in uWxUtils. + Refer to uWxUtils for documentation. All input and output paramters are + in the following US units: + pressure in inches of mercury + temperature in Fahrenheit + wind in MPH + elevation in feet""" + + @staticmethod + def StationToSensorPressure(pressureIn, sensorElevationFt, + stationElevationFt, currentTempF): + Result = pressureIn / Power10(0.00813 * (sensorElevationFt - stationElevationFt) / FToR(currentTempF)) + return Result + + @staticmethod + def StationToAltimeter(pressureIn, elevationFt, + algorithm='aaMADIS'): + """Example: + >>> p = TWxUtilsUS.StationToAltimeter(24.692, 5431, 'aaASOS') + >>> print("Station pressure to altimeter = %.3f" % p) + Station pressure to altimeter = 30.153 + """ + Result = HPaToIn(TWxUtils.StationToAltimeter(InToHPa(pressureIn), + FtToM(elevationFt), + algorithm)) + return Result + + @staticmethod + def StationToSeaLevelPressure(pressureIn, elevationFt, + currentTempF, meanTempF, humidity, + algorithm='paManBar'): + """Example: + >>> p = TWxUtilsUS.StationToSeaLevelPressure(24.692, 5431, 59.0, 50.5, 40.5) + >>> print("Station to SLP = %.3f" % p) + Station to SLP = 30.006 + """ + Result = pressureIn * TWxUtilsUS.PressureReductionRatio(pressureIn, + elevationFt, + currentTempF, + meanTempF, + humidity, + algorithm) + return Result + + @staticmethod + def SensorToStationPressure(pressureIn, + sensorElevationFt, stationElevationFt, + currentTempF): + Result = pressureIn * Power10(0.00813 * (sensorElevationFt - stationElevationFt) / FToR(currentTempF)) + return Result + + @staticmethod + def AltimeterToStationPressure(pressureIn, elevationFt, + algorithm='aaMADIS'): + Result = TWxUtils.AltimeterToStationPressure(InToHPa(pressureIn), + FtToM(elevationFt), + algorithm) + return Result + + @staticmethod + def SeaLevelToStationPressure(pressureIn, elevationFt, + currentTempF, meanTempF, humidity, + algorithm='paManBar'): + """Example: + >>> p = TWxUtilsUS.SeaLevelToStationPressure(30.153, 5431, 59.0, 50.5, 40.5) + >>> print("Station to SLP = %.3f" % p) + Station to SLP = 24.813 + """ + Result = pressureIn / TWxUtilsUS.PressureReductionRatio(pressureIn, + elevationFt, + currentTempF, + meanTempF, + humidity, + algorithm) + return Result + + @staticmethod + def PressureReductionRatio(pressureIn, elevationFt, + currentTempF, meanTempF, humidity, + algorithm='paManBar'): + Result = TWxUtils.PressureReductionRatio(InToHPa(pressureIn), + FtToM(elevationFt), + FToC(currentTempF), + FToC(meanTempF), + humidity, algorithm) + return Result + + @staticmethod + def ActualVaporPressure(tempF, humidity, algorithm='vaBolton'): + Result = (humidity * TWxUtilsUS.SaturationVaporPressure(tempF, algorithm)) / 100 + return Result + + @staticmethod + def SaturationVaporPressure(tempF, algorithm='vaBolton'): + Result = HPaToIn(TWxUtils.SaturationVaporPressure(FToC(tempF), + algorithm)) + return Result + + @staticmethod + def MixingRatio(pressureIn, tempF, humidity): + Result = HPaToIn(TWxUtils.MixingRatio(InToHPa(pressureIn), + FToC(tempF), humidity)) + return Result + + @staticmethod + def HumidityCorrection(tempF, elevationFt, humidity, algorithm='vaBolton'): + Result = TWxUtils.HumidityCorrection(FToC(tempF), + FtToM(elevationFt), + humidity, + algorithm) + return Result + + @staticmethod + def GeopotentialAltitude(geometricAltitudeFt): + Result = MToFt(TWxUtils.GeopotentialAltitude(FtToM(geometricAltitudeFt))) + return Result + +#============================================================================== +# class TWxUtilsVP +#============================================================================== + +class uWxUtilsVP(object): + """ This class contains functions for calculating the raw sensor pressure + of a Vantage Pro weather station from the sea level reduced pressure it + provides. + + The sensor pressure can then be used to calcuate altimeter setting using + other functions in the uWxUtils and uWxUtilsUS units. + + notes about input parameters: + currentTemp - current instantaneous station temperature + temp12HrsAgoF - temperature from 12 hours ago. If the 12 hour temp is + not known, simply pass the same value as currentTemp + for the 12 hour temp. For the vantage pro sea level + to sensor pressure conversion, the 12 hour temp + should be the hourly temp that is 11 hours to 11:59 + in the past. For example, if the current time is + 3:59pm, use the 4:00am temp, and if it is currently + 4:00pm, use the 5:00am temp. Also, the vantage pro + seems to use only whole degree temp values in the sea + level calculation, so the function performs rounding + on the temperature. + meanTemp - average of current temp and the temperature 12 hours in + the past. If the 12 hour temp is not known, simply pass + the same value as currentTemp for the mean temp. For the + Vantage Pro, the mean temperature should come from the + BARDATA.VirtualTemp. The value in BARDATA is an integer + (whole degrees). The vantage pro calculates the mean by + Round(((Round(currentTempF - 0.01) + + Round(temp12HrsAgoF - 0.01)) / 2) - 0.01); + humidity - Value should be 0 to 100. For the pressure conversion + functions, pass a value of zero if you do not want to + the algorithm to include the humidity correction factor + in the calculation. If you provide a humidity value + > 0, then humidity effect will be included in the + calculation. + elevation - This should be the geometric altitude of the station + (this is the elevation provided by surveys and normally + used by people when they speak of elevation). Some + algorithms will convert the elevation internally into + a geopotential altitude.""" + + # this function is used if you have access to BARDATA (Davis Serial docs) + # meanTempF is from BARDATA.VirtualTemp + # humidityCorr is from BARDATA.C (remember to first divide C by 10) + @staticmethod + def SeaLevelToSensorPressure_meanT(pressureIn, elevationFt, meanTempF, + humidityCorr): + Result = TWxUtilsUS.SeaLevelToStationPressure( + pressureIn, elevationFt, meanTempF, + meanTempF + humidityCorr, 0, 'paDavisVp') + return Result + + # this function is used if you do not have access to BARDATA. The function + # will internally calculate the mean temp and the humidity correction + # the would normally come from the BARDATA. + # currentTempF is the value of the current sensor temp + # temp12HrsAgoF is the temperature from 12 hours ago (see comments on + # temp12Hr from earlier in this document for more on this). + @staticmethod + def SeaLevelToSensorPressure_12(pressureIn, elevationFt, currentTempF, + temp12HrsAgoF, humidity): + Result = TWxUtilsUS.SeaLevelToStationPressure( + pressureIn, elevationFt, currentTempF, + Round(((Round(currentTempF - 0.01) + Round(temp12HrsAgoF - 0.01)) / 2) - 0.01), + humidity, 'paDavisVp') + return Result + + +if __name__ == "__main__": + + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weewx/wxengine.py b/dist/weewx-4.10.1/bin/weewx/wxengine.py new file mode 100644 index 0000000..8df2fe6 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/wxengine.py @@ -0,0 +1,11 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +from __future__ import absolute_import +import weewx.engine + +# For backwards compatibility: +StdService = weewx.engine.StdService \ No newline at end of file diff --git a/dist/weewx-4.10.1/bin/weewx/wxformulas.py b/dist/weewx-4.10.1/bin/weewx/wxformulas.py new file mode 100644 index 0000000..b9aaf32 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/wxformulas.py @@ -0,0 +1,964 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Various weather related formulas and utilities.""" + +from __future__ import absolute_import +from __future__ import print_function + +import logging +import cmath +import math +import time + +import weewx.uwxutils +import weewx.units +from weewx.units import CtoK, CtoF, FtoC, mph_to_knot, kph_to_knot, mps_to_knot +from weewx.units import INHG_PER_MBAR, METER_PER_FOOT, METER_PER_MILE, MM_PER_INCH + +log = logging.getLogger(__name__) + + +def dewpointF(T, R): + """Calculate dew point. + + T: Temperature in Fahrenheit + + R: Relative humidity in percent. + + Returns: Dewpoint in Fahrenheit + Examples: + + >>> print("%.1f" % dewpointF(68, 50)) + 48.7 + >>> print("%.1f" % dewpointF(32, 50)) + 15.5 + >>> print("%.1f" % dewpointF(-10, 50)) + -23.5 + """ + + if T is None or R is None: + return None + + TdC = dewpointC(FtoC(T), R) + + return CtoF(TdC) if TdC is not None else None + + +def dewpointC(T, R): + """Calculate dew point. + http://en.wikipedia.org/wiki/Dew_point + + T: Temperature in Celsius + + R: Relative humidity in percent. + + Returns: Dewpoint in Celsius + """ + + if T is None or R is None: + return None + R = R / 100.0 + try: + _gamma = 17.27 * T / (237.7 + T) + math.log(R) + TdC = 237.7 * _gamma / (17.27 - _gamma) + except (ValueError, OverflowError): + TdC = None + return TdC + + +def windchillF(T_F, V_mph): + """Calculate wind chill. + http://www.nws.noaa.gov/om/cold/wind_chill.shtml + + T_F: Temperature in Fahrenheit + + V_mph: Wind speed in mph + + Returns Wind Chill in Fahrenheit + """ + + if T_F is None or V_mph is None: + return None + + # only valid for temperatures below 50F and wind speeds over 3.0 mph + if T_F >= 50.0 or V_mph <= 3.0: + return T_F + + WcF = 35.74 + 0.6215 * T_F + (-35.75 + 0.4275 * T_F) * math.pow(V_mph, 0.16) + return WcF + + +def windchillMetric(T_C, V_kph): + """Wind chill, metric version, with wind in kph. + + T: Temperature in Celsius + + V: Wind speed in kph + + Returns wind chill in Celsius""" + + if T_C is None or V_kph is None: + return None + + T_F = CtoF(T_C) + V_mph = 0.621371192 * V_kph + + WcF = windchillF(T_F, V_mph) + + return FtoC(WcF) if WcF is not None else None + + +# For backwards compatibility +windchillC = windchillMetric + + +def windchillMetricWX(T_C, V_mps): + """Wind chill, metric version, with wind in mps. + + T: Temperature in Celsius + + V: Wind speed in mps + + Returns wind chill in Celsius""" + + if T_C is None or V_mps is None: + return None + + T_F = CtoF(T_C) + V_mph = 2.237 * V_mps + + WcF = windchillF(T_F, V_mph) + + return FtoC(WcF) if WcF is not None else None + + +def heatindexF(T, R, algorithm='new'): + """Calculate heat index. + + The 'new' algorithm uses: https://www.wpc.ncep.noaa.gov/html/heatindex_equation.shtml + + T: Temperature in Fahrenheit + + R: Relative humidity in percent + + Returns heat index in Fahrenheit + + Examples (Expected values obtained from https://www.wpc.ncep.noaa.gov/html/heatindex.shtml): + + >>> print("%0.0f" % heatindexF(75.0, 50.0)) + 75 + >>> print("%0.0f" % heatindexF(80.0, 50.0)) + 81 + >>> print("%0.0f" % heatindexF(80.0, 95.0)) + 88 + >>> print("%0.0f" % heatindexF(90.0, 50.0)) + 95 + >>> print("%0.0f" % heatindexF(90.0, 95.0)) + 127 + + """ + if T is None or R is None: + return None + + if algorithm == 'new': + # Formula only valid for temperatures over 40F: + if T <= 40.0: + return T + + # Use simplified formula + hi_F = 0.5 * (T + 61.0 + ((T - 68.0) * 1.2) + (R * 0.094)) + + # Apply full formula if the above, averaged with temperature, is greater than 80F: + if (hi_F + T) / 2.0 >= 80.0: + hi_F = -42.379 \ + + 2.04901523 * T \ + + 10.14333127 * R \ + - 0.22475541 * T * R \ + - 6.83783e-3 * T ** 2 \ + - 5.481717e-2 * R ** 2 \ + + 1.22874e-3 * T ** 2 * R \ + + 8.5282e-4 * T * R ** 2 \ + - 1.99e-6 * T ** 2 * R ** 2 + # Apply an adjustment for low humidities + if R < 13 and 80 < T < 112: + adjustment = ((13 - R) / 4.0) * math.sqrt((17 - abs(T - 95.)) / 17.0) + hi_F -= adjustment + # Apply an adjustment for high humidities + elif R > 85 and 80 <= T < 87: + adjustment = ((R - 85) / 10.0) * ((87 - T) / 5.0) + hi_F += adjustment + else: + # Formula only valid for temperatures 80F or more, and RH 40% or more: + if T < 80.0 or R < 40.0: + return T + + hi_F = -42.379 \ + + 2.04901523 * T \ + + 10.14333127 * R \ + - 0.22475541 * T * R \ + - 6.83783e-3 * T ** 2 \ + - 5.481717e-2 * R ** 2 \ + + 1.22874e-3 * T ** 2 * R \ + + 8.5282e-4 * T * R ** 2 \ + - 1.99e-6 * T ** 2 * R ** 2 + if hi_F < T: + hi_F = T + + return hi_F + + +def heatindexC(T_C, R, algorithm='new'): + if T_C is None or R is None: + return None + T_F = CtoF(T_C) + hi_F = heatindexF(T_F, R, algorithm) + return FtoC(hi_F) + + +def heating_degrees(t, base): + return max(base - t, 0) if t is not None else None + + +def cooling_degrees(t, base): + return max(t - base, 0) if t is not None else None + + +def altimeter_pressure_US(SP_inHg, Z_foot, algorithm='aaASOS'): + """Calculate the altimeter pressure, given the raw, station pressure in inHg and the altitude + in feet. + + Examples: + >>> print("%.2f" % altimeter_pressure_US(28.0, 0.0)) + 28.00 + >>> print("%.2f" % altimeter_pressure_US(28.0, 1000.0)) + 29.04 + """ + if SP_inHg is None or Z_foot is None: + return None + if SP_inHg <= 0.008859: + return None + return weewx.uwxutils.TWxUtilsUS.StationToAltimeter(SP_inHg, Z_foot, + algorithm=algorithm) + + +def altimeter_pressure_Metric(SP_mbar, Z_meter, algorithm='aaASOS'): + """Convert from (uncorrected) station pressure to altitude-corrected + pressure. + + Examples: + >>> print("%.1f" % altimeter_pressure_Metric(948.08, 0.0)) + 948.2 + >>> print("%.1f" % altimeter_pressure_Metric(948.08, 304.8)) + 983.4 + """ + if SP_mbar is None or Z_meter is None: + return None + if SP_mbar <= 0.3: + return None + return weewx.uwxutils.TWxUtils.StationToAltimeter(SP_mbar, Z_meter, + algorithm=algorithm) + + +def _etterm(elev_meter, t_C): + """Calculate elevation/temperature term for sea level calculation.""" + t_K = CtoK(t_C) + return math.exp(-elev_meter / (t_K * 29.263)) + + +def sealevel_pressure_Metric(sp_mbar, elev_meter, t_C): + """Convert station pressure to sea level pressure. This implementation was copied from wview. + + sp_mbar - station pressure in millibars + + elev_meter - station elevation in meters + + t_C - temperature in degrees Celsius + + bp - sea level pressure (barometer) in millibars + """ + if sp_mbar is None or elev_meter is None or t_C is None: + return None + pt = _etterm(elev_meter, t_C) + bp_mbar = sp_mbar / pt if pt != 0 else 0 + return bp_mbar + + +def sealevel_pressure_US(sp_inHg, elev_foot, t_F): + if sp_inHg is None or elev_foot is None or t_F is None: + return None + sp_mbar = sp_inHg / INHG_PER_MBAR + elev_meter = elev_foot * METER_PER_FOOT + t_C = FtoC(t_F) + slp_mbar = sealevel_pressure_Metric(sp_mbar, elev_meter, t_C) + slp_inHg = slp_mbar * INHG_PER_MBAR + return slp_inHg + + +def calculate_delta(newtotal, oldtotal, delta_key='rain'): + """Calculate the differential given two cumulative measurements.""" + if newtotal is not None and oldtotal is not None: + if newtotal >= oldtotal: + delta = newtotal - oldtotal + else: + log.info("'%s' counter reset detected: new=%s old=%s", delta_key, + newtotal, oldtotal) + delta = None + else: + delta = None + return delta + +# For backwards compatibility: +calculate_rain = calculate_delta + +def solar_rad_Bras(lat, lon, altitude_m, ts=None, nfac=2): + """Calculate maximum solar radiation using Bras method + http://www.ecy.wa.gov/programs/eap/models.html + + lat, lon - latitude and longitude in decimal degrees + + altitude_m - altitude in meters + + ts - timestamp as unix epoch + + nfac - atmospheric turbidity (2=clear, 4-5=smoggy) + + Example: + + >>> for t in range(0,24): + ... print("%.2f" % solar_rad_Bras(42, -72, 0, t*3600+1422936471)) + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 1.86 + 100.81 + 248.71 + 374.68 + 454.90 + 478.76 + 443.47 + 353.23 + 220.51 + 73.71 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + """ + from weewx.almanac import Almanac + if ts is None: + ts = time.time() + sr = 0.0 + try: + alm = Almanac(ts, lat, lon, altitude_m) + el = alm.sun.alt # solar elevation degrees from horizon + R = alm.sun.earth_distance + # NREL solar constant W/m^2 + nrel = 1367.0 + # radiation on horizontal surface at top of atmosphere (bras eqn 2.9) + sinel = math.sin(math.radians(el)) + io = sinel * nrel / (R * R) + if sinel >= 0: + # optical air mass (bras eqn 2.22) + m = 1.0 / (sinel + 0.15 * math.pow(el + 3.885, -1.253)) + # molecular scattering coefficient (bras eqn 2.26) + a1 = 0.128 - 0.054 * math.log(m) / math.log(10.0) + # clear-sky radiation at earth surface W / m^2 (bras eqn 2.25) + sr = io * math.exp(-nfac * a1 * m) + except (AttributeError, ValueError, OverflowError): + sr = None + return sr + + +def solar_rad_RS(lat, lon, altitude_m, ts=None, atc=0.8): + """Calculate maximum solar radiation + Ryan-Stolzenbach, MIT 1972 + http://www.ecy.wa.gov/programs/eap/models.html + + lat, lon - latitude and longitude in decimal degrees + + altitude_m - altitude in meters + + ts - time as unix epoch + + atc - atmospheric transmission coefficient (0.7-0.91) + + Example: + + >>> for t in range(0,24): + ... print("%.2f" % solar_rad_RS(42, -72, 0, t*3600+1422936471)) + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.09 + 79.31 + 234.77 + 369.80 + 455.66 + 481.15 + 443.44 + 346.81 + 204.64 + 52.63 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + """ + from weewx.almanac import Almanac + if atc < 0.7 or atc > 0.91: + atc = 0.8 + if ts is None: + ts = time.time() + sr = 0.0 + try: + alm = Almanac(ts, lat, lon, altitude_m) + el = alm.sun.alt # solar elevation degrees from horizon + R = alm.sun.earth_distance + z = altitude_m + nrel = 1367.0 # NREL solar constant, W/m^2 + sinal = math.sin(math.radians(el)) + if sinal >= 0: # sun must be above horizon + rm = math.pow((288.0 - 0.0065 * z) / 288.0, 5.256) \ + / (sinal + 0.15 * math.pow(el + 3.885, -1.253)) + toa = nrel * sinal / (R * R) + sr = toa * math.pow(atc, rm) + except (AttributeError, ValueError, OverflowError): + sr = None + return sr + + +def cloudbase_Metric(t_C, rh, altitude_m): + """Calculate the cloud base in meters + + t_C - temperature in degrees Celsius + + rh - relative humidity [0-100] + + altitude_m - altitude in meters + """ + dp_C = dewpointC(t_C, rh) + if dp_C is None: + return None + cb = (t_C - dp_C) * 1000 / 2.5 + return altitude_m + cb * METER_PER_FOOT if cb is not None else None + + +def cloudbase_US(t_F, rh, altitude_ft): + """Calculate the cloud base in feet + + t_F - temperature in degrees Fahrenheit + + rh - relative humidity [0-100] + + altitude_ft - altitude in feet + """ + dp_F = dewpointF(t_F, rh) + if dp_F is None: + return None + cb = altitude_ft + (t_F - dp_F) * 1000.0 / 4.4 + return cb + + +def humidexC(t_C, rh): + """Calculate the humidex + Reference (look under heading "Humidex"): + http://climate.weather.gc.ca/climate_normals/normals_documentation_e.html?docID=1981 + + t_C - temperature in degree Celsius + + rh - relative humidity [0-100] + + Examples: + >>> print("%.2f" % humidexC(30.0, 80.0)) + 43.64 + >>> print("%.2f" % humidexC(30.0, 20.0)) + 30.00 + >>> print("%.2f" % humidexC(0, 80.0)) + 0.00 + >>> print(humidexC(30.0, None)) + None + """ + try: + dp_C = dewpointC(t_C, rh) + dp_K = CtoK(dp_C) + e = 6.11 * math.exp(5417.7530 * (1 / 273.16 - 1 / dp_K)) + h = 0.5555 * (e - 10.0) + except (ValueError, OverflowError, TypeError): + return None + + return t_C + h if h > 0 else t_C + + +def humidexF(t_F, rh): + """Calculate the humidex in degree Fahrenheit + + t_F - temperature in degree Fahrenheit + + rh - relative humidity [0-100] + """ + if t_F is None: + return None + h_C = humidexC(FtoC(t_F), rh) + return CtoF(h_C) if h_C is not None else None + + +def apptempC(t_C, rh, ws_mps): + """Calculate the apparent temperature in degree Celsius + + t_C - temperature in degree Celsius + + rh - relative humidity [0-100] + + ws_mps - wind speed in meters per second + + http://www.bom.gov.au/info/thermal_stress/#atapproximation + AT = Ta + 0.33*e - 0.70*ws - 4.00 + where + AT and Ta (air temperature) are deg-C + e is water vapor pressure + ws is wind speed (m/s) at elevation of 10 meters + e = rh / 100 * 6.105 * exp(17.27 * Ta / (237.7 + Ta)) + rh is relative humidity + + http://www.ncdc.noaa.gov/societal-impacts/apparent-temp/ + AT = -2.7 + 1.04*T + 2.0*e -0.65*v + where + AT and T (air temperature) are deg-C + e is vapor pressure in kPa + v is 10m wind speed in m/sec + """ + if t_C is None: + return None + if rh is None or rh < 0 or rh > 100: + return None + if ws_mps is None or ws_mps < 0: + return None + try: + e = (rh / 100.0) * 6.105 * math.exp(17.27 * t_C / (237.7 + t_C)) + at_C = t_C + 0.33 * e - 0.7 * ws_mps - 4.0 + except (ValueError, OverflowError): + at_C = None + return at_C + + +def apptempF(t_F, rh, ws_mph): + """Calculate apparent temperature in degree Fahrenheit + + t_F - temperature in degree Fahrenheit + + rh - relative humidity [0-100] + + ws_mph - wind speed in miles per hour + """ + if t_F is None: + return None + if rh is None or rh < 0 or rh > 100: + return None + if ws_mph is None or ws_mph < 0: + return None + t_C = FtoC(t_F) + ws_mps = ws_mph * METER_PER_MILE / 3600.0 + at_C = apptempC(t_C, rh, ws_mps) + return CtoF(at_C) if at_C is not None else None + + +def beaufort(ws_kts): + """Return the beaufort number given a wind speed in knots""" + if ws_kts is None: + return None + mag_knts = abs(ws_kts) + if mag_knts is None: + beaufort_mag = None + elif mag_knts < 1: + beaufort_mag = 0 + elif mag_knts < 4: + beaufort_mag = 1 + elif mag_knts < 7: + beaufort_mag = 2 + elif mag_knts < 11: + beaufort_mag = 3 + elif mag_knts < 17: + beaufort_mag = 4 + elif mag_knts < 22: + beaufort_mag = 5 + elif mag_knts < 28: + beaufort_mag = 6 + elif mag_knts < 34: + beaufort_mag = 7 + elif mag_knts < 41: + beaufort_mag = 8 + elif mag_knts < 48: + beaufort_mag = 9 + elif mag_knts < 56: + beaufort_mag = 10 + elif mag_knts < 64: + beaufort_mag = 11 + else: + beaufort_mag = 12 + + if isinstance(ws_kts, complex): + return cmath.rect(beaufort_mag, cmath.phase(ws_kts)) + else: + return beaufort_mag + + +weewx.units.conversionDict['mile_per_hour']['beaufort'] = lambda x : beaufort(mph_to_knot(x)) +weewx.units.conversionDict['knot']['beaufort'] = beaufort +weewx.units.conversionDict['km_per_hour']['beaufort'] = lambda x: beaufort(kph_to_knot(x)) +weewx.units.conversionDict['meter_per_second']['beaufort'] = lambda x : beaufort(mps_to_knot(x)) +weewx.units.default_unit_format_dict['beaufort'] = "%d" + +def equation_of_time(doy): + """Equation of time in minutes. Plus means sun leads local time. + + Example (1 October): + >>> print("%.4f" % equation_of_time(274)) + 0.1889 + """ + b = 2 * math.pi * (doy - 81) / 364.0 + return 0.1645 * math.sin(2 * b) - 0.1255 * math.cos(b) - 0.025 * math.sin(b) + + +def hour_angle(t_utc, longitude, doy): + """Solar hour angle at a given time in radians. + + t_utc: The time in UTC. + longitude: the longitude in degrees + doy: The day of year + + Returns hour angle in radians. 0 <= omega < 2*pi + + Example: + >>> print("%.4f radians" % hour_angle(15.5, -16.25, 274)) + 0.6821 radians + >>> print("%.4f radians" % hour_angle(0, -16.25, 274)) + 2.9074 radians + """ + Sc = equation_of_time(doy) + omega = (math.pi / 12.0) * (t_utc + longitude / 15.0 + Sc - 12) + if omega < 0: + omega += 2.0 * math.pi + return omega + + +def solar_declination(doy): + """Solar declination for the day of the year in radians + + Example (1 October is the 274th day of the year): + >>> print("%.6f" % solar_declination(274)) + -0.075274 + """ + return 0.409 * math.sin(2.0 * math.pi * doy / 365 - 1.39) + + +def sun_radiation(doy, latitude_deg, longitude_deg, tod_utc, interval): + """Extraterrestrial radiation. Radiation at the top of the atmosphere + + doy: Day-of-year + + latitude_deg, longitude_deg: Lat and lon in degrees + + tod_utc: Time-of-day (UTC) at the end of the interval in hours (0-24) + + interval: The time interval over which the radiation is to be calculated in hours + + Returns the (average?) solar radiation over the time interval in MJ/m^2/hr + + Example: + >>> print("%.3f" % sun_radiation(doy=274, latitude_deg=16.217, + ... longitude_deg=-16.25, tod_utc=16.0, interval=1.0)) + 3.543 + """ + + # Solar constant in MJ/m^2/hr + Gsc = 4.92 + + delta = solar_declination(doy) + + earth_distance = 1.0 + 0.033 * math.cos(2.0 * math.pi * doy / 365.0) # dr + + start_utc = tod_utc - interval + stop_utc = tod_utc + start_omega = hour_angle(start_utc, longitude_deg, doy) + stop_omega = hour_angle(stop_utc, longitude_deg, doy) + + latitude_radians = math.radians(latitude_deg) + + part1 = (stop_omega - start_omega) * math.sin(latitude_radians) * math.sin(delta) + part2 = math.cos(latitude_radians) * math.cos(delta) * (math.sin(stop_omega) + - math.sin(start_omega)) + + # http://www.fao.org/docrep/x0490e/x0490e00.htm Eqn 28 + Ra = (12.0 / math.pi) * Gsc * earth_distance * (part1 + part2) + + if Ra < 0: + Ra = 0 + + return Ra + + +def longwave_radiation(Tmin_C, Tmax_C, ea, Rs, Rso, rh): + """Calculate the net long-wave radiation. + Ref: http://www.fao.org/docrep/x0490e/x0490e00.htm Eqn 39 + + Tmin_C: Minimum temperature during the calculation period + Tmax_C: Maximum temperature during the calculation period + ea: Actual vapor pressure in kPa + Rs: Measured radiation. See below for units. + Rso: Calculated clear-wky radiation. See below for units. + rh: Relative humidity in percent + + Because the formula uses the ratio of Rs to Rso, their actual units do not matter, + so long as they use the same units. + + Returns back radiation in MJ/m^2/day + + Example: + >>> print("%.1f mm/day" % longwave_radiation(Tmin_C=19.1, Tmax_C=25.1, ea=2.1, + ... Rs=14.5, Rso=18.8, rh=50)) + 3.5 mm/day + + Night time example. Set rh = 40% to reproduce the Rs/Rso ratio of 0.8 used in the paper. + >>> print("%.1f mm/day" % longwave_radiation(Tmin_C=28, Tmax_C=28, ea=3.402, + ... Rs=0, Rso=0, rh=40)) + 2.4 mm/day + """ + # Calculate temperatures in Kelvin: + Tmin_K = Tmin_C + 273.16 + Tmax_K = Tmax_C + 273.16 + + # Stefan-Boltzman constant in MJ/K^4/m^2/day + sigma = 4.903e-09 + + # Use the ratio of measured to expected radiation as a measure of cloudiness, but + # only if it's daylight + if Rso: + cloud_factor = Rs / Rso + else: + # If it's nighttime (no expected radiation), then use this totally made up formula + if rh > 80: + # Humid. Lots of clouds + cloud_factor = 0.3 + elif rh > 40: + # Somewhat humid. Modest cloud cover + cloud_factor = 0.5 + else: + # Low humidity. No clouds. + cloud_factor = 0.8 + + # Calculate the longwave (back) radiation (Eqn 39). Result will be in MJ/m^2/day. + Rnl_part1 = sigma * (Tmin_K ** 4 + Tmax_K ** 4) / 2.0 + Rnl_part2 = (0.34 - 0.14 * math.sqrt(ea)) + Rnl_part3 = (1.35 * cloud_factor - 0.35) + Rnl = Rnl_part1 * Rnl_part2 * Rnl_part3 + + return Rnl + + +def evapotranspiration_Metric(Tmin_C, Tmax_C, rh_min, rh_max, sr_mean_wpm2, + ws_mps, wind_height_m, latitude_deg, longitude_deg, altitude_m, + timestamp, albedo=0.23, cn=37, cd=0.34): + """Calculate the rate of evapotranspiration during a one-hour time period. + Ref: http://www.fao.org/docrep/x0490e/x0490e00.htm. + + The document "Step by Step Calculation of the Penman-Monteith Evapotranspiration" + https://edis.ifas.ufl.edu/pdf/AE/AE45900.pdf is also helpful. See it for values + of cn and cd. + + Args: + + Tmin_C (float): Minimum temperature during the hour in degrees Celsius. + Tmax_C (float): Maximum temperature during the hour in degrees Celsius. + rh_min (float): Minimum relative humidity during the hour in percent. + rh_max (float): Maximum relative humidity during the hour in percent. + sr_mean_wpm2 (float): Mean solar radiation during the hour in watts per sq meter. + ws_mps (float): Average wind speed during the hour in meters per second. + wind_height_m (float): Height in meters at which windspeed is measured. + latitude_deg (float): Latitude of the station in degrees. + longitude_deg (float): Longitude of the station in degrees. + altitude_m (float): Altitude of the station in meters. + timestamp (float): The time, as unix epoch time, at the end of the hour. + albedo (float): Albedo. Default is 0.23 (grass reference crop). + cn (float): The numerator constant for the reference crop type and time step. + Default is 37 (short reference crop). + cd (float): The denominator constant for the reference crop type and time step. + Default is 0.34 (daytime short reference crop). + + Returns: + float: Evapotranspiration in mm/hr + + Example (Example 19 in the reference document): + >>> sr_mean_wpm2 = 680.56 # == 2.45 MJ/m^2/hr + >>> timestamp = 1475337600 # 1-Oct-2016 at 16:00UTC + >>> print("ET0 = %.2f mm/hr" % evapotranspiration_Metric(Tmin_C=38, Tmax_C=38, + ... rh_min=52, rh_max=52, + ... sr_mean_wpm2=sr_mean_wpm2, ws_mps=3.3, wind_height_m=2, + ... latitude_deg=16.217, longitude_deg=-16.25, altitude_m=8, + ... timestamp=timestamp)) + ET0 = 0.63 mm/hr + + Another example, this time for night + >>> sr_mean_wpm2 = 0.0 # night time + >>> timestamp = 1475294400 # 1-Oct-2016 at 04:00UTC (0300 local) + >>> print("ET0 = %.2f mm/hr" % evapotranspiration_Metric(Tmin_C=28, Tmax_C=28, + ... rh_min=90, rh_max=90, + ... sr_mean_wpm2=sr_mean_wpm2, ws_mps=3.3, wind_height_m=2, + ... latitude_deg=16.217, longitude_deg=-16.25, altitude_m=8, + ... timestamp=timestamp)) + ET0 = 0.03 mm/hr + """ + if None in (Tmin_C, Tmax_C, rh_min, rh_max, sr_mean_wpm2, ws_mps, + latitude_deg, longitude_deg, timestamp): + return None + + if wind_height_m is None: + wind_height_m = 2.0 + if altitude_m is None: + altitude_m = 0.0 + + # figure out the day of year [1-366] from the timestamp + doy = time.localtime(timestamp)[7] - 1 + # Calculate the UTC time-of-day in hours + time_tt_utc = time.gmtime(timestamp) + tod_utc = time_tt_utc.tm_hour + time_tt_utc.tm_min / 60.0 + time_tt_utc.tm_sec / 3600.0 + + # Calculate mean temperature + tavg_C = (Tmax_C + Tmin_C) / 2.0 + + # Mean humidity + rh_avg = (rh_min + rh_max) / 2.0 + + # Adjust windspeed for height + u2 = 4.87 * ws_mps / math.log(67.8 * wind_height_m - 5.42) + + # Calculate the atmospheric pressure in kPa + p = 101.3 * math.pow((293.0 - 0.0065 * altitude_m) / 293.0, 5.26) + # Calculate the psychrometric constant in kPa/C (Eqn 8) + gamma = 0.665e-03 * p + + # Calculate mean saturation vapor pressure, converting from hPa to kPa (Eqn 12) + etmin = weewx.uwxutils.TWxUtils.SaturationVaporPressure(Tmin_C, 'vaTeten') / 10.0 + etmax = weewx.uwxutils.TWxUtils.SaturationVaporPressure(Tmax_C, 'vaTeten') / 10.0 + e0T = (etmin + etmax) / 2.0 + + # Calculate the slope of the saturation vapor pressure curve in kPa/C (Eqn 13) + delta = 4098.0 * (0.6108 * math.exp(17.27 * tavg_C / (tavg_C + 237.3))) / \ + ((tavg_C + 237.3) * (tavg_C + 237.3)) + + # Calculate actual vapor pressure from relative humidity (Eqn 17) + ea = (etmin * rh_max + etmax * rh_min) / 200.0 + + # Convert solar radiation from W/m^2 to MJ/m^2/hr + Rs = sr_mean_wpm2 * 3.6e-3 + + # Net shortwave (measured) radiation in MJ/m^2/hr (eqn 38) + Rns = (1.0 - albedo) * Rs + + # Extraterrestrial radiation in MJ/m^2/hr + Ra = sun_radiation(doy, latitude_deg, longitude_deg, tod_utc, interval=1.0) + # Clear sky solar radiation in MJ/m^2/hr (eqn 37) + Rso = (0.75 + 2e-5 * altitude_m) * Ra + + # Longwave (back) radiation. Convert from MJ/m^2/day to MJ/m^2/hr (Eqn 39): + Rnl = longwave_radiation(Tmin_C, Tmax_C, ea, Rs, Rso, rh_avg) / 24.0 + + # Calculate net radiation at the surface in MJ/m^2/hr (Eqn. 40) + Rn = Rns - Rnl + + # Calculate the soil heat flux. (see section "For hourly or shorter + # periods" in http://www.fao.org/docrep/x0490e/x0490e07.htm#radiation + G = 0.1 * Rn if Rs else 0.5 * Rn + + # Put it all together. Result is in mm/hr (Eqn 53) + ET0 = (0.408 * delta * (Rn - G) + gamma * (cn / (tavg_C + 273)) * u2 * (e0T - ea)) \ + / (delta + gamma * (1 + cd * u2)) + + # We don't allow negative ET's + if ET0 < 0: + ET0 = 0 + + return ET0 + + +def evapotranspiration_US(Tmin_F, Tmax_F, rh_min, rh_max, + sr_mean_wpm2, ws_mph, wind_height_ft, + latitude_deg, longitude_deg, altitude_ft, timestamp, + albedo=0.23, cn=37, cd=0.34): + """Calculate the rate of evapotranspiration during a one-hour time period, + returning result in inches/hr. + + See function evapotranspiration_Metric() for references. + + Args: + + Tmin_F (float): Minimum temperature during the hour in degrees Fahrenheit. + Tmax_F (float): Maximum temperature during the hour in degrees Fahrenheit. + rh_min (float): Minimum relative humidity during the hour in percent. + rh_max (float): Maximum relative humidity during the hour in percent. + sr_mean_wpm2 (float): Mean solar radiation during the hour in watts per sq meter. + ws_mph (float): Average wind speed during the hour in miles per hour. + wind_height_ft (float): Height in feet at which windspeed is measured. + latitude_deg (float): Latitude of the station in degrees. + longitude_deg (float): Longitude of the station in degrees. + altitude_ft (float): Altitude of the station in feet. + timestamp (float): The time, as unix epoch time, at the end of the hour. + albedo (float): Albedo. Default is 0.23 (grass reference crop). + cn (float): The numerator constant for the reference crop type and time step. + Default is 37 (short reference crop). + cd (float): The denominator constant for the reference crop type and time step. + Default is 0.34 (daytime short reference crop). + + Returns: + float: Evapotranspiration in inches/hr + + Example (using data from HR station): + >>> sr_mean_wpm2 = 860 + >>> timestamp = 1469829600 # 29-July-2016 22:00 UTC (15:00 local time) + >>> print("ET0 = %.3f in/hr" % evapotranspiration_US(Tmin_F=87.8, Tmax_F=89.1, + ... rh_min=34, rh_max=38, + ... sr_mean_wpm2=sr_mean_wpm2, ws_mph=9.58, wind_height_ft=6, + ... latitude_deg=45.7, longitude_deg=-121.5, altitude_ft=700, + ... timestamp=timestamp)) + ET0 = 0.028 in/hr + """ + try: + Tmin_C = FtoC(Tmin_F) + Tmax_C = FtoC(Tmax_F) + ws_mps = ws_mph * METER_PER_MILE / 3600.0 + wind_height_m = wind_height_ft * METER_PER_FOOT + altitude_m = altitude_ft * METER_PER_FOOT + except TypeError: + return None + evt = evapotranspiration_Metric(Tmin_C=Tmin_C, Tmax_C=Tmax_C, + rh_min=rh_min, rh_max=rh_max, sr_mean_wpm2=sr_mean_wpm2, + ws_mps=ws_mps, wind_height_m=wind_height_m, + latitude_deg=latitude_deg, longitude_deg=longitude_deg, + altitude_m=altitude_m, timestamp=timestamp, + albedo=albedo, cn=cn, cd=cd) + return evt / MM_PER_INCH if evt is not None else None + + +if __name__ == "__main__": + + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-4.10.1/bin/weewx/wxmanager.py b/dist/weewx-4.10.1/bin/weewx/wxmanager.py new file mode 100644 index 0000000..b1e4f11 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/wxmanager.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2009-2019 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Weather-specific database manager.""" + +import weewx.manager + + +class WXDaySummaryManager(weewx.manager.DaySummaryManager): + """Daily summaries, suitable for WX applications. + + OBSOLETE. Provided for backwards compatibility. + + """ diff --git a/dist/weewx-4.10.1/bin/weewx/wxservices.py b/dist/weewx-4.10.1/bin/weewx/wxservices.py new file mode 100644 index 0000000..8a848e0 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/wxservices.py @@ -0,0 +1,156 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Calculate derived variables, depending on software/hardware preferences. + +While this is named 'StdWXCalculate' for historical reasons, it can actually calculate +non-weather related derived types as well. +""" +from __future__ import absolute_import + +import logging + +import weeutil.weeutil +import weewx.engine +import weewx.units + +log = logging.getLogger(__name__) + + +class StdWXCalculate(weewx.engine.StdService): + + def __init__(self, engine, config_dict): + """Initialize an instance of StdWXCalculate and determine the calculations to be done. + + Directives look like: + + obs_type = [prefer_hardware|hardware|software], [loop|archive] + + where: + + obs_type is an observation type to be calculated, such as 'heatindex' + + The choice [prefer_hardware|hardware|software] determines how the value is to be + calculated. Option "prefer_hardware" means that if the hardware supplies a value, it will + be used, otherwise the value will be calculated in software. + + The choice [loop|archive] indicates whether the calculation is to be done for only LOOP + packets, or only archive records. If left out, it will be done for both. + + Examples: + + cloudbase = software,loop + The derived type 'cloudbase' will always be calculated in software, but only for LOOP + packets + + cloudbase = software, record + The derived type 'cloudbase' will always be calculated in software, but only for archive + records. + + cloudbase = software + The derived type 'cloudbase' will always be calculated in software, for both LOOP packets + and archive records""" + + super(StdWXCalculate, self).__init__(engine, config_dict) + + self.loop_calc_dict = dict() # map {obs->directive} for LOOP packets + self.archive_calc_dict = dict() # map {obs->directive} for archive records + + for obs_type, rule in config_dict.get('StdWXCalculate', {}).get('Calculations', {}).items(): + # Ensure we have a list: + words = weeutil.weeutil.option_as_list(rule) + # Split the list up into a directive, and (optionally) which bindings it applies to + # (loop or archive). + directive = words[0].lower() + bindings = [w.lower() for w in words[1:]] + if not bindings or 'loop' in bindings: + # no bindings mentioned, or 'loop' plus maybe others + self.loop_calc_dict[obs_type] = directive + if not bindings or 'archive' in bindings: + # no bindings mentioned, or 'archive' plus maybe others + self.archive_calc_dict[obs_type] = directive + + # Backwards compatibility for configuration files v4.1 or earlier: + self.loop_calc_dict.setdefault('windDir', 'software') + self.archive_calc_dict.setdefault('windDir', 'software') + self.loop_calc_dict.setdefault('windGustDir', 'software') + self.archive_calc_dict.setdefault('windGustDir', 'software') + # For backwards compatibility: + self.calc_dict = self.archive_calc_dict + + if weewx.debug > 1: + log.debug("Calculations for LOOP packets: %s", self.loop_calc_dict) + log.debug("Calculations for archive records: %s", self.archive_calc_dict) + + # Get the data binding. Default to 'wx_binding'. + data_binding = config_dict.get('StdWXCalculate', + {'data_binding': 'wx_binding'}).get('data_binding', + 'wx_binding') + # Log the data binding we are to use + log.info("StdWXCalculate will use data binding %s" % data_binding) + # If StdArchive and StdWXCalculate use different data bindings it could + # be a problem. Get the data binding to be used by StdArchive. + std_arch_data_binding = config_dict.get('StdArchive', {}).get('data_binding', + 'wx_binding') + # Is the data binding the same as will be used by StdArchive? + if data_binding != std_arch_data_binding: + # The data bindings are different, don't second guess the user but + # log the difference as this could be an oversight + log.warning("The StdWXCalculate data binding (%s) does not " + "match the StdArchive data binding (%s).", + data_binding, std_arch_data_binding) + # Now obtain a database manager using the data binding + self.db_manager = engine.db_binder.get_manager(data_binding=data_binding, + initialize=True) + + # We will process both loop and archive events + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + self.do_calculations(event.packet, self.loop_calc_dict) + + def new_archive_record(self, event): + self.do_calculations(event.record, self.archive_calc_dict) + + def do_calculations(self, data_dict, calc_dict=None): + """Augment the data dictionary with derived types as necessary. + + data_dict: The incoming LOOP packet or archive record. + calc_dict: the directives to apply + """ + + if calc_dict is None: + calc_dict = self.archive_calc_dict + + # Go through the list of potential calculations and see which ones need to be done + for obs in calc_dict: + # Keys in calc_dict are in unicode. Keys in packets and records are in native strings. + # Just to keep things consistent, convert. + obs_type = str(obs) + if calc_dict[obs] == 'software' \ + or (calc_dict[obs] == 'prefer_hardware' and data_dict.get(obs_type) is None): + # We need to do a calculation for type 'obs_type'. This may raise an exception, + # so be prepared to catch it. + try: + val = weewx.xtypes.get_scalar(obs_type, data_dict, self.db_manager) + except weewx.CannotCalculate: + # XTypes is aware of the type, but can't calculate it, probably because of + # missing data. Set the type to None. + data_dict[obs_type] = None + except weewx.NoCalculate: + # XTypes is aware of the type, but does not need to calculate it. + pass + except weewx.UnknownType as e: + log.debug("Unknown extensible type '%s'" % e) + except weewx.UnknownAggregation as e: + log.debug("Unknown aggregation '%s'" % e) + else: + # If there was no exception, then all is good. Convert to the same unit + # as the record... + new_value = weewx.units.convertStd(val, data_dict['usUnits']) + # ... then add the results to the dictionary + data_dict[obs_type] = new_value[0] + diff --git a/dist/weewx-4.10.1/bin/weewx/wxxtypes.py b/dist/weewx-4.10.1/bin/weewx/wxxtypes.py new file mode 100644 index 0000000..6308264 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/wxxtypes.py @@ -0,0 +1,821 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""A set of XTypes extensions for calculating weather-related derived observation types.""" +from __future__ import absolute_import + +import logging +import threading + +import weedb +import weeutil.config +import weeutil.logger +import weewx.engine +import weewx.units +import weewx.wxformulas +import weewx.xtypes +from weeutil.weeutil import to_int, to_float, to_bool +from weewx.units import ValueTuple, mps_to_mph, kph_to_mph, METER_PER_FOOT, CtoF + +log = logging.getLogger(__name__) + +DEFAULTS_INI = """ +[StdWXCalculate] + [[WXXTypes]] + [[[windDir]]] + force_null = True + [[[maxSolarRad]]] + algorithm = rs + atc = 0.8 + nfac = 2 + [[[ET]]] + et_period = 3600 + wind_height = 2.0 + albedo = 0.23 + cn = 37 + cd = 0.34 + [[[heatindex]]] + algorithm = new + [[PressureCooker]] + max_delta_12h = 1800 + [[[altimeter]]] + algorithm = aaASOS # Case-sensitive! + [[RainRater]] + rain_period = 900 + retain_period = 930 + [[Delta]] + [[[rain]]] + input = totalRain +""" +defaults_dict = weeutil.config.config_from_str(DEFAULTS_INI) + +first_time = True + + +class WXXTypes(weewx.xtypes.XType): + """Weather extensions to the WeeWX xtype system that are relatively simple. These types + are generally stateless, such as dewpoint, heatindex, etc. """ + + def __init__(self, altitude_vt, latitude_f, longitude_f, + atc=0.8, + nfac=2, + force_null=True, + maxSolarRad_algo='rs', + heatindex_algo='new' + ): + # Fail hard if out of range: + if not 0.7 <= atc <= 0.91: + raise weewx.ViolatedPrecondition("Atmospheric transmission coefficient (%f) " + "out of range [.7-.91]" % atc) + self.altitude_vt = altitude_vt + self.latitude_f = latitude_f + self.longitude_f = longitude_f + self.atc = atc + self.nfac = nfac + self.force_null = force_null + self.maxSolarRad_algo = maxSolarRad_algo.lower() + self.heatindex_algo = heatindex_algo.lower() + + def get_scalar(self, obs_type, record, db_manager, **option_dict): + """Invoke the proper method for the desired observation type.""" + try: + # Form the method name, then call it with arguments + return getattr(self, 'calc_%s' % obs_type)(obs_type, record, db_manager) + except AttributeError: + raise weewx.UnknownType(obs_type) + + def calc_windDir(self, key, data, db_manager): + """ Set windDir to None if windSpeed is zero. Otherwise, raise weewx.NoCalculate. """ + if 'windSpeed' not in data \ + or not self.force_null\ + or data['windSpeed']: + raise weewx.NoCalculate + return ValueTuple(None, 'degree_compass', 'group_direction') + + def calc_windGustDir(self, key, data, db_manager): + """ Set windGustDir to None if windGust is zero. Otherwise, raise weewx.NoCalculate.If""" + if 'windGust' not in data \ + or not self.force_null\ + or data['windGust']: + raise weewx.NoCalculate + return ValueTuple(None, 'degree_compass', 'group_direction') + + def calc_maxSolarRad(self, key, data, db_manager): + altitude_m = weewx.units.convert(self.altitude_vt, 'meter')[0] + if self.maxSolarRad_algo == 'bras': + val = weewx.wxformulas.solar_rad_Bras(self.latitude_f, self.longitude_f, altitude_m, + data['dateTime'], self.nfac) + elif self.maxSolarRad_algo == 'rs': + val = weewx.wxformulas.solar_rad_RS(self.latitude_f, self.longitude_f, altitude_m, + data['dateTime'], self.atc) + else: + raise weewx.ViolatedPrecondition("Unknown solar algorithm '%s'" + % self.maxSolarRad_algo) + return ValueTuple(val, 'watt_per_meter_squared', 'group_radiation') + + def calc_cloudbase(self, key, data, db_manager): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + # Convert altitude to the same unit system as the incoming record + altitude = weewx.units.convertStd(self.altitude_vt, data['usUnits']) + # Use the appropriate formula + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.cloudbase_US(data['outTemp'], + data['outHumidity'], altitude[0]) + u = 'foot' + else: + val = weewx.wxformulas.cloudbase_Metric(data['outTemp'], + data['outHumidity'], altitude[0]) + u = 'meter' + return ValueTuple(val, u, 'group_altitude') + + @staticmethod + def calc_dewpoint(key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.dewpointF(data['outTemp'], data['outHumidity']) + u = 'degree_F' + else: + val = weewx.wxformulas.dewpointC(data['outTemp'], data['outHumidity']) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_inDewpoint(key, data, db_manager=None): + if 'inTemp' not in data or 'inHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.dewpointF(data['inTemp'], data['inHumidity']) + u = 'degree_F' + else: + val = weewx.wxformulas.dewpointC(data['inTemp'], data['inHumidity']) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_windchill(key, data, db_manager=None): + if 'outTemp' not in data or 'windSpeed' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.windchillF(data['outTemp'], data['windSpeed']) + u = 'degree_F' + elif data['usUnits'] == weewx.METRIC: + val = weewx.wxformulas.windchillMetric(data['outTemp'], data['windSpeed']) + u = 'degree_C' + elif data['usUnits'] == weewx.METRICWX: + val = weewx.wxformulas.windchillMetricWX(data['outTemp'], data['windSpeed']) + u = 'degree_C' + else: + raise weewx.ViolatedPrecondition("Unknown unit system %s" % data['usUnits']) + return ValueTuple(val, u, 'group_temperature') + + def calc_heatindex(self, key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.heatindexF(data['outTemp'], data['outHumidity'], + algorithm=self.heatindex_algo) + u = 'degree_F' + else: + val = weewx.wxformulas.heatindexC(data['outTemp'], data['outHumidity'], + algorithm=self.heatindex_algo) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_humidex(key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.humidexF(data['outTemp'], data['outHumidity']) + u = 'degree_F' + else: + val = weewx.wxformulas.humidexC(data['outTemp'], data['outHumidity']) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_appTemp(key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data or 'windSpeed' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.apptempF(data['outTemp'], data['outHumidity'], + data['windSpeed']) + u = 'degree_F' + else: + # The metric equivalent needs wind speed in mps. Convert. + windspeed_vt = weewx.units.as_value_tuple(data, 'windSpeed') + windspeed_mps = weewx.units.convert(windspeed_vt, 'meter_per_second')[0] + val = weewx.wxformulas.apptempC(data['outTemp'], data['outHumidity'], windspeed_mps) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_beaufort(key, data, db_manager=None): + global first_time + if first_time: + print("Type beaufort has been deprecated. Use unit beaufort instead.") + log.info("Type beaufort has been deprecated. Use unit beaufort instead.") + first_time = False + if 'windSpeed' not in data: + raise weewx.CannotCalculate + windspeed_vt = weewx.units.as_value_tuple(data, 'windSpeed') + windspeed_kn = weewx.units.convert(windspeed_vt, 'knot')[0] + return ValueTuple(weewx.wxformulas.beaufort(windspeed_kn), None, None) + + @staticmethod + def calc_windrun(key, data, db_manager=None): + """Calculate wind run. Requires key 'interval'""" + if 'windSpeed' not in data or 'interval' not in data: + raise weewx.CannotCalculate(key) + + if data['windSpeed'] is not None: + if data['usUnits'] == weewx.US: + val = data['windSpeed'] * data['interval'] / 60.0 + u = 'mile' + elif data['usUnits'] == weewx.METRIC: + val = data['windSpeed'] * data['interval'] / 60.0 + u = 'km' + elif data['usUnits'] == weewx.METRICWX: + val = data['windSpeed'] * data['interval'] * 60.0 / 1000.0 + u = 'km' + else: + raise weewx.ViolatedPrecondition("Unknown unit system %s" % data['usUnits']) + else: + val = None + u = 'mile' + return ValueTuple(val, u, 'group_distance') + + +# +# ########################### Class ETXType ################################## +# + +class ETXType(weewx.xtypes.XType): + """XType extension for calculating ET""" + + def __init__(self, altitude_vt, + latitude_f, longitude_f, + et_period=3600, + wind_height=2.0, + albedo=0.23, + cn=37, + cd=.34): + """ + + Args: + altitude_vt (ValueTuple): Altitude as a ValueTuple + latitude_f (float): Latitude in decimal degrees. + longitude_f (float): Longitude in decimal degrees. + et_period (float): Window of time for ET calculation in seconds. + wind_height (float):Height above ground at which the wind is measured in meters. + albedo (float): The albedo to use. + cn (float): The numerator constant for the reference crop type and time step. + cd (float): The denominator constant for the reference crop type and time step. + """ + self.altitude_vt = altitude_vt + self.latitude_f = latitude_f + self.longitude_f = longitude_f + self.et_period = et_period + self.wind_height = wind_height + self.albedo = albedo + self.cn = cn + self.cd = cd + + def get_scalar(self, obs_type, data, db_manager, **option_dict): + """Calculate ET as a scalar""" + if obs_type != 'ET': + raise weewx.UnknownType(obs_type) + + if 'interval' not in data: + # This will cause LOOP data not to be processed. + raise weewx.CannotCalculate(obs_type) + + interval = data['interval'] + end_ts = data['dateTime'] + start_ts = end_ts - self.et_period + try: + r = db_manager.getSql("SELECT MAX(outTemp), MIN(outTemp), " + "AVG(radiation), AVG(windSpeed), " + "MAX(outHumidity), MIN(outHumidity), " + "MAX(usUnits), MIN(usUnits) FROM %s " + "WHERE dateTime>? AND dateTime <=?" + % db_manager.table_name, (start_ts, end_ts)) + except weedb.DatabaseError: + return ValueTuple(None, None, None) + + # Make sure everything is there: + if r is None or None in r: + return ValueTuple(None, None, None) + + # Unpack the results + T_max, T_min, rad_avg, wind_avg, rh_max, rh_min, std_unit_min, std_unit_max = r + + # Check for mixed units + if std_unit_min != std_unit_max: + log.info("Mixed unit system not allowed in ET calculation. Skipped.") + return ValueTuple(None, None, None) + std_unit = std_unit_min + if std_unit == weewx.METRIC or std_unit == weewx.METRICWX: + T_max = CtoF(T_max) + T_min = CtoF(T_min) + if std_unit == weewx.METRICWX: + wind_avg = mps_to_mph(wind_avg) + else: + wind_avg = kph_to_mph(wind_avg) + # Wind height is in meters, so convert it: + height_ft = self.wind_height / METER_PER_FOOT + # Get altitude in feet + altitude_ft = weewx.units.convert(self.altitude_vt, 'foot')[0] + + try: + ET_rate = weewx.wxformulas.evapotranspiration_US( + T_min, T_max, rh_min, rh_max, rad_avg, wind_avg, height_ft, + self.latitude_f, self.longitude_f, altitude_ft, end_ts, + self.albedo, self.cn, self.cd) + except ValueError as e: + log.error("Calculation of evapotranspiration failed: %s", e) + weeutil.logger.log_traceback(log.error) + ET_inch = None + else: + # The formula returns inches/hour. We need the total ET over the interval, so multiply + # by the length of the interval in hours. Remember that 'interval' is actually in + # minutes. + ET_inch = ET_rate * interval / 60.0 if ET_rate is not None else None + + return ValueTuple(ET_inch, 'inch', 'group_rain') + + +# +# ######################## Class PressureCooker ############################## +# + +class PressureCooker(weewx.xtypes.XType): + """Pressure related extensions to the WeeWX type system. """ + + def __init__(self, altitude_vt, + max_delta_12h=1800, + altimeter_algorithm='aaASOS'): + + # Algorithms can be abbreviated without the prefix 'aa': + if not altimeter_algorithm.startswith('aa'): + altimeter_algorithm = 'aa%s' % altimeter_algorithm + + self.altitude_vt = altitude_vt + self.max_delta_12h = max_delta_12h + self.altimeter_algorithm = altimeter_algorithm + + # Timestamp (roughly) 12 hours ago + self.ts_12h = None + # Temperature 12 hours ago as a ValueTuple + self.temp_12h_vt = None + + def _get_temperature_12h(self, ts, dbmanager): + """Get the temperature as a ValueTuple from 12 hours ago. The value will + be None if no temperature is available. + """ + + ts_12h = ts - 12 * 3600 + + # Look up the temperature 12h ago if this is the first time through, + # or we don't have a usable temperature, or the old temperature is too stale. + if self.ts_12h is None \ + or self.temp_12h_vt is None \ + or abs(self.ts_12h - ts_12h) < self.max_delta_12h: + # Hit the database to get a newer temperature. + record = dbmanager.getRecord(ts_12h, max_delta=self.max_delta_12h) + if record and 'outTemp' in record: + # Figure out what unit the record is in ... + unit = weewx.units.getStandardUnitType(record['usUnits'], 'outTemp') + # ... then form a ValueTuple. + self.temp_12h_vt = weewx.units.ValueTuple(record['outTemp'], *unit) + else: + # Invalidate the temperature ValueTuple from 12h ago + self.temp_12h_vt = None + # Save the timestamp + self.ts_12h = ts_12h + + return self.temp_12h_vt + + def get_scalar(self, key, record, dbmanager, **option_dict): + if key == 'pressure': + return self.pressure(record, dbmanager) + elif key == 'altimeter': + return self.altimeter(record) + elif key == 'barometer': + return self.barometer(record) + else: + raise weewx.UnknownType(key) + + def pressure(self, record, dbmanager): + """Calculate the observation type 'pressure'.""" + + # All of the following keys are required: + if any(key not in record for key in ['usUnits', 'outTemp', 'barometer', 'outHumidity']): + raise weewx.CannotCalculate('pressure') + + # Get the temperature in Fahrenheit from 12 hours ago + temp_12h_vt = self._get_temperature_12h(record['dateTime'], dbmanager) + if temp_12h_vt is None \ + or temp_12h_vt[0] is None \ + or record['outTemp'] is None \ + or record['barometer'] is None \ + or record['outHumidity'] is None: + pressure = None + else: + # The following requires everything to be in US Customary units. + # Rather than convert the whole record, just convert what we need: + record_US = weewx.units.to_US({'usUnits': record['usUnits'], + 'outTemp': record['outTemp'], + 'barometer': record['barometer'], + 'outHumidity': record['outHumidity']}) + # Get the altitude in feet + altitude_ft = weewx.units.convert(self.altitude_vt, "foot") + # The outside temperature in F. + temp_12h_F = weewx.units.convert(temp_12h_vt, "degree_F") + pressure = weewx.uwxutils.uWxUtilsVP.SeaLevelToSensorPressure_12( + record_US['barometer'], + altitude_ft[0], + record_US['outTemp'], + temp_12h_F[0], + record_US['outHumidity'] + ) + + return ValueTuple(pressure, 'inHg', 'group_pressure') + + def altimeter(self, record): + """Calculate the observation type 'altimeter'.""" + if 'pressure' not in record: + raise weewx.CannotCalculate('altimeter') + + # Convert altitude to same unit system of the incoming record + altitude = weewx.units.convertStd(self.altitude_vt, record['usUnits']) + + # Figure out which altimeter formula to use, and what unit the results will be in: + if record['usUnits'] == weewx.US: + formula = weewx.wxformulas.altimeter_pressure_US + u = 'inHg' + else: + formula = weewx.wxformulas.altimeter_pressure_Metric + u = 'mbar' + # Apply the formula + altimeter = formula(record['pressure'], altitude[0], self.altimeter_algorithm) + + return ValueTuple(altimeter, u, 'group_pressure') + + def barometer(self, record): + """Calculate the observation type 'barometer'""" + + if 'pressure' not in record or 'outTemp' not in record: + raise weewx.CannotCalculate('barometer') + + # Convert altitude to same unit system of the incoming record + altitude = weewx.units.convertStd(self.altitude_vt, record['usUnits']) + + # Figure out what barometer formula to use: + if record['usUnits'] == weewx.US: + formula = weewx.wxformulas.sealevel_pressure_US + u = 'inHg' + else: + formula = weewx.wxformulas.sealevel_pressure_Metric + u = 'mbar' + # Apply the formula + barometer = formula(record['pressure'], altitude[0], record['outTemp']) + + return ValueTuple(barometer, u, 'group_pressure') + + +# +# ######################## Class RainRater ############################## +# + +class RainRater(weewx.xtypes.XType): + + def __init__(self, rain_period=900, retain_period=930): + + self.rain_period = rain_period + self.retain_period = retain_period + # This will be a list of two-way tuples (timestamp, rain) + self.rain_events = [] + self.unit_system = None + self.augmented = False + self.run_lock = threading.Lock() + + def add_loop_packet(self, packet): + """Process LOOP packets, adding them to the list of recent rain events.""" + with self.run_lock: + self._add_loop_packet(packet) + + def _add_loop_packet(self, packet): + # Was there any rain? If so, convert the rain to the unit system we are using, + # then intern it + if 'rain' in packet and packet['rain']: + if self.unit_system is None: + # Adopt the unit system of the first record. + self.unit_system = packet['usUnits'] + # Get the unit system and group of the incoming rain. In theory, this should be + # the same as self.unit_system, but ... + u, g = weewx.units.getStandardUnitType(packet['usUnits'], 'rain') + # Convert to the unit system that we are using + rain = weewx.units.convertStd((packet['rain'], u, g), self.unit_system)[0] + # Add it to the list of rain events + self.rain_events.append((packet['dateTime'], rain)) + + # Trim any old packets: + self.rain_events = [x for x in self.rain_events + if x[0] >= packet['dateTime'] - self.rain_period] + + def get_scalar(self, key, record, db_manager, **option_dict): + """Calculate the rainRate""" + if key != 'rainRate': + raise weewx.UnknownType(key) + + with self.run_lock: + # First time through, augment the event queue from the database + if not self.augmented: + self._setup(record['dateTime'], db_manager) + self.augmented = True + + # Sum the rain events within the time window... + rainsum = sum(x[1] for x in self.rain_events + if x[0] > record['dateTime'] - self.rain_period) + # ...then divide by the period and scale to an hour + val = 3600 * rainsum / self.rain_period + # Get the unit and unit group for rainRate + u, g = weewx.units.getStandardUnitType(self.unit_system, 'rainRate') + return ValueTuple(val, u, g) + + def _setup(self, stop_ts, db_manager): + """Initialize the rain event list""" + + # Beginning of the window + start_ts = stop_ts - self.retain_period + + # Query the database for only the events before what we already have + if self.rain_events: + first_event = min(x[0] for x in self.rain_events) + stop_ts = min(first_event, stop_ts) + + # Get all rain events since the window start from the database. Put it in + # a 'try' block because the database may not have a 'rain' field. + try: + for row in db_manager.genSql("SELECT dateTime, usUnits, rain FROM %s " + "WHERE dateTime>? AND dateTime<=?;" + % db_manager.table_name, (start_ts, stop_ts)): + # Unpack the row: + time_ts, unit_system, rain = row + # Skip the row if we already have it in rain_events + if not any(x[0] == time_ts for x in self.rain_events): + self._add_loop_packet({'dateTime': time_ts, + 'usUnits': unit_system, + 'rain': rain}) + except weedb.DatabaseError as e: + log.debug("Database error while initializing rainRate: '%s'" % e) + + # It's not strictly necessary to sort the rain event list for things to work, but it + # makes things easier to debug + self.rain_events.sort(key=lambda x: x[0]) + + +# +# ######################## Class Delta ############################## +# + +class Delta(weewx.xtypes.XType): + """Derived types that are the difference between two adjacent measurements. + + For example, this is useful for calculating observation type 'rain' from a daily total, + such as 'dayRain'. In this case, the configuration would look like: + + [StdWXCalculate] + [[Calculations]] + ... + [[Delta]] + [[[rain]]] + input = totalRain + """ + + def __init__(self, delta_config={}): + # The dictionary 'totals' will hold two-way lists. The first element of the list is the key + # to be used for the cumulative value. The second element holds the previous total (None + # to start). The result will be something like + # {'rain' : ['totalRain', None]} + self.totals = {k: [delta_config[k]['input'], None] for k in delta_config} + + def get_scalar(self, key, record, db_manager, **option_dict): + # See if we know how to handle this type + if key not in self.totals: + raise weewx.UnknownType(key) + + # Get the key of the type to be used for the cumulative total. This is + # something like 'totalRain': + total_key = self.totals[key][0] + if total_key not in record: + raise weewx.CannotCalculate(key) + # Calculate the delta + delta = weewx.wxformulas.calculate_delta(record[total_key], + self.totals[key][1], + total_key) + # Save the new total + self.totals[key][1] = record[total_key] + + # Get the unit and group of the key. This will be the same as for the result + unit_and_group = weewx.units.getStandardUnitType(record['usUnits'], key) + # ... then form and return the ValueTuple. + return ValueTuple(delta, *unit_and_group) + + +# +# ########## Services that instantiate the above XTypes extensions ########## +# + +class StdWXXTypes(weewx.engine.StdService): + """Instantiate and register the xtype extension WXXTypes.""" + + def __init__(self, engine, config_dict): + """Initialize an instance of StdWXXTypes""" + super(StdWXXTypes, self).__init__(engine, config_dict) + + altitude_vt = engine.stn_info.altitude_vt + latitude_f = engine.stn_info.latitude_f + longitude_f = engine.stn_info.longitude_f + + # These options were never documented. They have moved. Fail hard if they are present. + if 'StdWXCalculate' in config_dict \ + and any(key in config_dict['StdWXCalculate'] + for key in ['rain_period', 'et_period', 'wind_height', + 'atc', 'nfac', 'max_delta_12h']): + raise ValueError("Undocumented options for [StdWXCalculate] have moved. " + "See User's Guide.") + + # Get any user-defined overrides + try: + override_dict = config_dict['StdWXCalculate']['WXXTypes'] + except KeyError: + override_dict = {} + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['WXXTypes']) + option_dict.merge(override_dict) + + # Get force_null from the option dictionary + force_null = to_bool(option_dict['windDir'].get('force_null', True)) + + # Option ignore_zero_wind has also moved, but we will support it in a backwards-compatible + # way, provided that it doesn't conflict with any setting of force_null. + try: + # Is there a value for ignore_zero_wind as well? + ignore_zero_wind = to_bool(config_dict['StdWXCalculate']['ignore_zero_wind']) + except KeyError: + # No. We're done + pass + else: + # No exception, so there must be a value for ignore_zero_wind. + # Is there an explicit value for 'force_null'? That is, a default was not used? + if 'force_null' in override_dict: + # Yes. Make sure they match + if ignore_zero_wind != to_bool(override_dict['force_null']): + raise ValueError("Conflicting values for " + "ignore_zero_wind (%s) and force_null (%s)" + % (ignore_zero_wind, force_null)) + else: + # No explicit value for 'force_null'. Use 'ignore_zero_wind' in its place + force_null = ignore_zero_wind + + # maxSolarRad-related options + maxSolarRad_algo = option_dict['maxSolarRad'].get('algorithm', 'rs').lower() + # atmospheric transmission coefficient [0.7-0.91] + atc = to_float(option_dict['maxSolarRad'].get('atc', 0.8)) + # atmospheric turbidity (2=clear, 4-5=smoggy) + nfac = to_float(option_dict['maxSolarRad'].get('nfac', 2)) + # heatindex-related options + heatindex_algo = option_dict['heatindex'].get('algorithm', 'new').lower() + + # Instantiate an instance of WXXTypes and register it with the XTypes system + self.wxxtypes = WXXTypes(altitude_vt, latitude_f, longitude_f, + atc=atc, + nfac=nfac, + force_null=force_null, + maxSolarRad_algo=maxSolarRad_algo, + heatindex_algo=heatindex_algo) + weewx.xtypes.xtypes.append(self.wxxtypes) + + # ET-related options + # height above ground at which wind is measured, in meters + wind_height = to_float(weeutil.config.search_up(option_dict['ET'], 'wind_height', 2.0)) + # window of time for evapotranspiration calculation, in seconds + et_period = to_int(option_dict['ET'].get('et_period', 3600)) + # The albedo to use + albedo = to_float(option_dict['ET'].get('albedo', 0.23)) + # The numerator constant for the reference crop type and time step. + cn = to_float(option_dict['ET'].get('cn', 37)) + # The denominator constant for the reference crop type and time step. + cd = to_float(option_dict['ET'].get('cd', 0.34)) + + # Instantiate an instance of ETXType and register it with the XTypes system + self.etxtype = ETXType(altitude_vt, + latitude_f, longitude_f, + et_period=et_period, + wind_height=wind_height, + albedo=albedo, + cn=cn, + cd=cd) + weewx.xtypes.xtypes.append(self.etxtype) + + + def shutDown(self): + """Engine shutting down. """ + # Remove from the XTypes system: + weewx.xtypes.xtypes.remove(self.etxtype) + weewx.xtypes.xtypes.remove(self.wxxtypes) + + +class StdPressureCooker(weewx.engine.StdService): + """Instantiate and register the XTypes extension PressureCooker""" + + def __init__(self, engine, config_dict): + """Initialize the PressureCooker. """ + super(StdPressureCooker, self).__init__(engine, config_dict) + + try: + override_dict = config_dict['StdWXCalculate']['PressureCooker'] + except KeyError: + override_dict = {} + + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['PressureCooker']) + option_dict.merge(override_dict) + + max_delta_12h = to_float(option_dict.get('max_delta_12h', 1800)) + altimeter_algorithm = option_dict['altimeter'].get('algorithm', 'aaASOS') + + self.pressure_cooker = PressureCooker(engine.stn_info.altitude_vt, + max_delta_12h, + altimeter_algorithm) + + # Add pressure_cooker to the XTypes system + weewx.xtypes.xtypes.append(self.pressure_cooker) + + def shutDown(self): + """Engine shutting down. """ + weewx.xtypes.xtypes.remove(self.pressure_cooker) + + +class StdRainRater(weewx.engine.StdService): + """"Instantiate and register the XTypes extension RainRater.""" + + def __init__(self, engine, config_dict): + """Initialize the RainRater.""" + super(StdRainRater, self).__init__(engine, config_dict) + + # Get any user-defined overrides + try: + override_dict = config_dict['StdWXCalculate']['RainRater'] + except KeyError: + override_dict = {} + + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['RainRater']) + option_dict.merge(override_dict) + + rain_period = to_int(option_dict.get('rain_period', 900)) + retain_period = to_int(option_dict.get('retain_period', 930)) + + self.rain_rater = RainRater(rain_period, retain_period) + # Add to the XTypes system + weewx.xtypes.xtypes.append(self.rain_rater) + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + + def shutDown(self): + """Engine shutting down. """ + # Remove from the XTypes system: + weewx.xtypes.xtypes.remove(self.rain_rater) + + def new_loop_packet(self, event): + self.rain_rater.add_loop_packet(event.packet) + + +class StdDelta(weewx.engine.StdService): + """Instantiate and register the XTypes extension Delta.""" + + def __init__(self, engine, config_dict): + super(StdDelta, self).__init__(engine, config_dict) + + # Get any user-defined overrides + try: + override_dict = config_dict['StdWXCalculate']['Delta'] + except KeyError: + override_dict = {} + + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['Delta']) + option_dict.merge(override_dict) + + self.delta = Delta(option_dict) + weewx.xtypes.xtypes.append(self.delta) + + def shutDown(self): + weewx.xtypes.xtypes.remove(self.delta) diff --git a/dist/weewx-4.10.1/bin/weewx/xtypes.py b/dist/weewx-4.10.1/bin/weewx/xtypes.py new file mode 100644 index 0000000..b8997d3 --- /dev/null +++ b/dist/weewx-4.10.1/bin/weewx/xtypes.py @@ -0,0 +1,1126 @@ +# +# Copyright (c) 2019-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""User-defined extensions to the WeeWX type system""" + +import datetime +import time +import math + +import weedb +import weeutil.weeutil +import weewx +import weewx.units +import weewx.wxformulas +from weeutil.weeutil import isStartOfDay +from weewx.units import ValueTuple + +# A list holding the type extensions. Each entry should be a subclass of XType, defined below. +xtypes = [] + + +class XType(object): + """Base class for extensions to the WeeWX type system.""" + + def get_scalar(self, obs_type, record, db_manager=None, **option_dict): + """Calculate a scalar. Specializing versions should raise... + + - an exception of type `weewx.UnknownType`, if the type `obs_type` is unknown to the + function. + - an exception of type `weewx.CannotCalculate` if the type is known to the function, but + all the information necessary to calculate the type is not there. + """ + raise weewx.UnknownType + + def get_series(self, obs_type, timespan, db_manager, aggregate_type=None, + aggregate_interval=None, **option_dict): + """Calculate a series, possibly with aggregation. Specializing versions should raise... + + - an exception of type `weewx.UnknownType`, if the type `obs_type` is unknown to the + function. + - an exception of type `weewx.CannotCalculate` if the type is known to the function, but + all the information necessary to calculate the series is not there. + """ + raise weewx.UnknownType + + def get_aggregate(self, obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Calculate an aggregation. Specializing versions should raise... + + - an exception of type `weewx.UnknownType`, if the type `obs_type` is unknown to the + function. + - an exception of type `weewx.UnknownAggregation` if the aggregation type `aggregate_type` + is unknown to the function. + - an exception of type `weewx.CannotCalculate` if the type is known to the function, but + all the information necessary to calculate the type is not there. + """ + raise weewx.UnknownAggregation + + def shut_down(self): + """Opportunity to do any clean up.""" + pass + + +# ##################### Retrieval functions ########################### + +def get_scalar(obs_type, record, db_manager=None, **option_dict): + """Return a scalar value""" + + # Search the list, looking for a get_scalar() method that does not raise an UnknownType + # exception + for xtype in xtypes: + try: + # Try this function. Be prepared to catch the TypeError exception if it is a legacy + # style XType that does not accept kwargs. + try: + return xtype.get_scalar(obs_type, record, db_manager, **option_dict) + except TypeError: + # We likely have a legacy style XType, so try calling it again, but this time + # without the kwargs. + return xtype.get_scalar(obs_type, record, db_manager) + except weewx.UnknownType: + # This function does not know about the type. Move on to the next one. + pass + # None of the functions worked. + raise weewx.UnknownType(obs_type) + + +def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Return a series (aka vector) of, possibly aggregated, values.""" + + # Search the list, looking for a get_series() method that does not raise an UnknownType or + # UnknownAggregation exception + for xtype in xtypes: + try: + # Try this function. Be prepared to catch the TypeError exception if it is a legacy + # style XType that does not accept kwargs. + try: + return xtype.get_series(obs_type, timespan, db_manager, aggregate_type, + aggregate_interval, **option_dict) + except TypeError: + # We likely have a legacy style XType, so try calling it again, but this time + # without the kwargs. + return xtype.get_series(obs_type, timespan, db_manager, aggregate_type, + aggregate_interval) + except (weewx.UnknownType, weewx.UnknownAggregation): + # This function does not know about the type and/or aggregation. + # Move on to the next one. + pass + # None of the functions worked. Raise an exception with a hopefully helpful error message. + if aggregate_type: + msg = "'%s' or '%s'" % (obs_type, aggregate_type) + else: + msg = obs_type + raise weewx.UnknownType(msg) + + +def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Calculate an aggregation over a timespan""" + # Search the list, looking for a get_aggregate() method that does not raise an + # UnknownAggregation exception + for xtype in xtypes: + try: + # Try this function. It will raise an exception if it doesn't know about the type of + # aggregation. + return xtype.get_aggregate(obs_type, timespan, aggregate_type, db_manager, + **option_dict) + except (weewx.UnknownType, weewx.UnknownAggregation): + pass + raise weewx.UnknownAggregation("%s('%s')" % (aggregate_type, obs_type)) + + +# +# ######################## Class ArchiveTable ############################## +# + +class ArchiveTable(XType): + """Calculate types and aggregates directly from the archive table""" + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Get a series, possibly with aggregation, from the main archive database. + + The general strategy is that if aggregation is asked for, chop the series up into separate + chunks, calculating the aggregate for each chunk. Then assemble the results. + + If no aggregation is called for, just return the data directly out of the database. + """ + + startstamp, stopstamp = timespan + start_vec = list() + stop_vec = list() + data_vec = list() + + if aggregate_type: + # With aggregation + unit, unit_group = None, None + if aggregate_type == 'cumulative': + do_aggregate = 'sum' + total = 0 + else: + do_aggregate = aggregate_type + for stamp in weeutil.weeutil.intervalgen(startstamp, stopstamp, aggregate_interval): + # Get the aggregate as a ValueTuple + agg_vt = get_aggregate(obs_type, stamp, do_aggregate, db_manager) + if agg_vt[0] is None: + continue + if unit: + # It's OK if the unit is unknown (=None). + if agg_vt[1] is not None and (unit != agg_vt[1] or unit_group != agg_vt[2]): + raise weewx.UnsupportedFeature("Cannot change unit groups " + "within an aggregation.") + else: + unit, unit_group = agg_vt[1], agg_vt[2] + start_vec.append(stamp.start) + stop_vec.append(stamp.stop) + if aggregate_type == 'cumulative': + if agg_vt[0] is not None: + total += agg_vt[0] + data_vec.append(total) + else: + data_vec.append(agg_vt[0]) + + else: + + # No aggregation + sql_str = "SELECT dateTime, %s, usUnits, `interval` FROM %s " \ + "WHERE dateTime > ? AND dateTime <= ?" % (obs_type, db_manager.table_name) + + std_unit_system = None + + # Hit the database. It's possible the type is not in the database, so be prepared + # to catch a NoColumnError: + try: + for record in db_manager.genSql(sql_str, (startstamp, stopstamp)): + + # Unpack the record + timestamp, value, unit_system, interval = record + + if std_unit_system: + if std_unit_system != unit_system: + raise weewx.UnsupportedFeature("Unit type cannot change " + "within an aggregation interval.") + else: + std_unit_system = unit_system + start_vec.append(timestamp - interval * 60) + stop_vec.append(timestamp) + data_vec.append(value) + except weedb.NoColumnError: + # The sql type doesn't exist. Convert to an UnknownType error + raise weewx.UnknownType(obs_type) + + unit, unit_group = weewx.units.getStandardUnitType(std_unit_system, obs_type, + aggregate_type) + + return (ValueTuple(start_vec, 'unix_epoch', 'group_time'), + ValueTuple(stop_vec, 'unix_epoch', 'group_time'), + ValueTuple(data_vec, unit, unit_group)) + + # Set of SQL statements to be used for calculating aggregates from the main archive table. + agg_sql_dict = { + 'diff': "SELECT (b.%(sql_type)s - a.%(sql_type)s) FROM archive a, archive b " + "WHERE b.dateTime = (SELECT MAX(dateTime) FROM archive " + "WHERE dateTime <= %(stop)s) " + "AND a.dateTime = (SELECT MIN(dateTime) FROM archive " + "WHERE dateTime >= %(start)s);", + 'first': "SELECT %(sql_type)s FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY dateTime ASC LIMIT 1", + 'firsttime': "SELECT MIN(dateTime) FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL", + 'last': "SELECT %(sql_type)s FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY dateTime DESC LIMIT 1", + 'lasttime': "SELECT MAX(dateTime) FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL", + 'maxtime': "SELECT dateTime FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY %(sql_type)s DESC LIMIT 1", + 'mintime': "SELECT dateTime FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY %(sql_type)s ASC LIMIT 1", + 'not_null': "SELECT 1 FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL LIMIT 1", + 'tderiv': "SELECT (b.%(sql_type)s - a.%(sql_type)s) / (b.dateTime-a.dateTime) " + "FROM archive a, archive b " + "WHERE b.dateTime = (SELECT MAX(dateTime) FROM archive " + "WHERE dateTime <= %(stop)s) " + "AND a.dateTime = (SELECT MIN(dateTime) FROM archive " + "WHERE dateTime >= %(start)s);", + 'gustdir': "SELECT windGustDir FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "ORDER BY windGust DESC limit 1", + # Aggregations 'vecdir' and 'vecavg' require built-in math functions, + # which were introduced in sqlite v3.35.0, 12-Mar-2021. If they don't exist, then + # weewx will raise an exception of type "weedb.OperationalError". + 'vecdir': "SELECT SUM(`interval` * windSpeed * COS(RADIANS(90 - windDir))), " + " SUM(`interval` * windSpeed * SIN(RADIANS(90 - windDir))) " + "FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s ", + 'vecavg': "SELECT SUM(`interval` * windSpeed * COS(RADIANS(90 - windDir))), " + " SUM(`interval` * windSpeed * SIN(RADIANS(90 - windDir))), " + " SUM(`interval`) " + "FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND windSpeed is not null" + } + + valid_aggregate_types = set(['sum', 'count', 'avg', 'max', 'min']).union(agg_sql_dict.keys()) + + simple_agg_sql = "SELECT %(aggregate_type)s(%(sql_type)s) FROM %(table_name)s " \ + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " \ + "AND %(sql_type)s IS NOT NULL" + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns an aggregation of an observation type over a given time period, using the + main archive table. + + Args: + obs_type (str): The type over which aggregation is to be done (e.g., 'barometer', + 'outTemp', 'rain', ...) + timespan (TimeSpan): An instance of weeutil.Timespan with the time period over which + aggregation is to be done. + aggregate_type (str): The type of aggregation to be done. + db_manager (weewx.manager.Manager): An instance of weewx.manager.Manager or subclass. + option_dict (dict): Not used in this version. + + Returns: + ValueTuple: A ValueTuple containing the result. + """ + + if aggregate_type not in ArchiveTable.valid_aggregate_types: + raise weewx.UnknownAggregation(aggregate_type) + + # For older versions of sqlite, we need to do these calculations the hard way: + if obs_type == 'wind' \ + and aggregate_type in ('vecdir', 'vecavg') \ + and not db_manager.connection.has_math: + return ArchiveTable.get_wind_aggregate_long(obs_type, + timespan, + aggregate_type, + db_manager) + + if obs_type == 'wind': + sql_type = 'windGust' if aggregate_type in ('max', 'maxtime') else 'windSpeed' + else: + sql_type = obs_type + + interpolate_dict = { + 'aggregate_type': aggregate_type, + 'sql_type': sql_type, + 'table_name': db_manager.table_name, + 'start': timespan.start, + 'stop': timespan.stop + } + + select_stmt = ArchiveTable.agg_sql_dict.get(aggregate_type, + ArchiveTable.simple_agg_sql) % interpolate_dict + + try: + row = db_manager.getSql(select_stmt) + except weedb.NoColumnError: + raise weewx.UnknownType(aggregate_type) + + if aggregate_type == 'not_null': + value = row is not None + elif aggregate_type == 'vecdir': + if None in row or row == (0.0, 0.0): + value = None + else: + deg = 90.0 - math.degrees(math.atan2(row[1], row[0])) + value = deg if deg >= 0 else deg + 360.0 + elif aggregate_type == 'vecavg': + value = math.sqrt((row[0] ** 2 + row[1] ** 2) / row[2] ** 2) if row[2] else None + else: + value = row[0] if row else None + + # Look up the unit type and group of this combination of observation type and aggregation: + u, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + + # Time derivatives have special rules. For example, the time derivative of watt-hours is + # watts, scaled by the number of seconds in an hour. The unit group also changes to + # group_power. + if aggregate_type == 'tderiv': + if u == 'watt_second': + u = 'watt' + elif u == 'watt_hour': + u = 'watt' + value *= 3600 + elif u == 'kilowatt_hour': + u = 'kilowatt' + value *= 3600 + g = 'group_power' + + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, u, g) + + @staticmethod + def get_wind_aggregate_long(obs_type, timespan, aggregate_type, db_manager): + """Calculate the math algorithm for vecdir and vecavg in Python. Suitable for + versions of sqlite that do not have math functions.""" + + # This should never happen: + if aggregate_type not in ['vecdir', 'vecavg']: + raise weewx.UnknownAggregation(aggregate_type) + + # Nor this: + if obs_type != 'wind': + raise weewx.UnknownType(obs_type) + + sql_stmt = "SELECT `interval`, windSpeed, windDir " \ + "FROM %(table_name)s " \ + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s;" \ + % { + 'table_name': db_manager.table_name, + 'start': timespan.start, + 'stop': timespan.stop + } + xsum = 0.0 + ysum = 0.0 + sumtime = 0.0 + for row in db_manager.genSql(sql_stmt): + if row[1] is not None: + sumtime += row[0] + if row[2] is not None: + xsum += row[0] * row[1] * math.cos(math.radians(90.0 - row[2])) + ysum += row[0] * row[1] * math.sin(math.radians(90.0 - row[2])) + + if not sumtime: + value = None + elif aggregate_type == 'vecdir': + deg = 90.0 - math.degrees((math.atan2(ysum, xsum))) + value = deg if deg >= 0 else deg + 360.0 + elif aggregate_type == 'vecavg': + value = math.sqrt((xsum ** 2 + ysum ** 2) / sumtime ** 2) + + # Look up the unit type and group of this combination of observation type and aggregation: + u, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, u, g) + +# +# ######################## Class DailySummaries ############################## +# + +class DailySummaries(XType): + """Calculate from the daily summaries.""" + + # Set of SQL statements to be used for calculating simple aggregates from the daily summaries. + agg_sql_dict = { + 'avg': "SELECT SUM(wsum),SUM(sumtime) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'avg_ge': "SELECT SUM((wsum/sumtime) >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s and sumtime <> 0", + 'avg_le': "SELECT SUM((wsum/sumtime) <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s and sumtime <> 0", + 'count': "SELECT SUM(count) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'gustdir': "SELECT max_dir FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "ORDER BY max DESC, maxtime ASC LIMIT 1", + 'max': "SELECT MAX(max) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'max_ge': "SELECT SUM(max >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'max_le': "SELECT SUM(max <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'maxmin': "SELECT MAX(min) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'maxmintime': "SELECT mintime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND mintime IS NOT NULL " + "ORDER BY min DESC, mintime ASC LIMIT 1", + 'maxsum': "SELECT MAX(sum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'maxsumtime': "SELECT maxtime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND maxtime IS NOT NULL " + "ORDER BY sum DESC, maxtime ASC LIMIT 1", + 'maxtime': "SELECT maxtime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND maxtime IS NOT NULL " + "ORDER BY max DESC, maxtime ASC LIMIT 1", + 'meanmax': "SELECT AVG(max) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'meanmin': "SELECT AVG(min) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'min': "SELECT MIN(min) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'min_ge': "SELECT SUM(min >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'min_le': "SELECT SUM(min <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'minmax': "SELECT MIN(max) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'minmaxtime': "SELECT maxtime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND maxtime IS NOT NULL " + "ORDER BY max ASC, maxtime ASC ", + 'minsum': "SELECT MIN(sum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'minsumtime': "SELECT mintime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND mintime IS NOT NULL " + "ORDER BY sum ASC, mintime ASC LIMIT 1", + 'mintime': "SELECT mintime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND mintime IS NOT NULL " + "ORDER BY min ASC, mintime ASC LIMIT 1", + 'not_null': "SELECT count>0 as c FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s ORDER BY c DESC LIMIT 1", + 'rms': "SELECT SUM(wsquaresum),SUM(sumtime) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'sum': "SELECT SUM(sum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'sum_ge': "SELECT SUM(sum >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'sum_le': "SELECT SUM(sum <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'vecavg': "SELECT SUM(xsum),SUM(ysum),SUM(sumtime) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'vecdir': "SELECT SUM(xsum),SUM(ysum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + } + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns an aggregation of a statistical type for a given time period, + by using the daily summaries. + + obs_type: The type over which aggregation is to be done (e.g., 'barometer', + 'outTemp', 'rain', ...) + + timespan: An instance of weeutil.Timespan with the time period over which + aggregation is to be done. + + aggregate_type: The type of aggregation to be done. + + db_manager: An instance of weewx.manager.Manager or subclass. + + option_dict: Not used in this version. + + returns: A ValueTuple containing the result.""" + + # We cannot use the daily summaries if there is no aggregation + if not aggregate_type: + raise weewx.UnknownAggregation(aggregate_type) + + aggregate_type = aggregate_type.lower() + + # Raise exception if we don't know about this type of aggregation + if aggregate_type not in DailySummaries.agg_sql_dict: + raise weewx.UnknownAggregation(aggregate_type) + + # Check to see whether we can use the daily summaries: + DailySummaries._check_eligibility(obs_type, timespan, db_manager, aggregate_type) + + val = option_dict.get('val') + if val is None: + target_val = None + else: + # The following is for backwards compatibility when ValueTuples had + # just two members. This hack avoids breaking old skins. + if len(val) == 2: + if val[1] in ['degree_F', 'degree_C']: + val += ("group_temperature",) + elif val[1] in ['inch', 'mm', 'cm']: + val += ("group_rain",) + target_val = weewx.units.convertStd(val, db_manager.std_unit_system)[0] + + # Form the interpolation dictionary + inter_dict = { + 'start': weeutil.weeutil.startOfDay(timespan.start), + 'stop': timespan.stop, + 'obs_key': obs_type, + 'aggregate_type': aggregate_type, + 'val': target_val, + 'table_name': db_manager.table_name + } + + # Run the query against the database: + row = db_manager.getSql(DailySummaries.agg_sql_dict[aggregate_type] % inter_dict) + + # Each aggregation type requires a slightly different calculation. + if not row or None in row: + # If no row was returned, or if it contains any nulls (meaning that not + # all required data was available to calculate the requested aggregate), + # then set the resulting value to None. + value = None + + elif aggregate_type in ['min', 'maxmin', 'max', 'minmax', 'meanmin', 'meanmax', + 'maxsum', 'minsum', 'sum', 'gustdir']: + # These aggregates are passed through 'as is'. + value = row[0] + + elif aggregate_type in ['mintime', 'maxmintime', 'maxtime', 'minmaxtime', 'maxsumtime', + 'minsumtime', 'count', 'max_ge', 'max_le', 'min_ge', 'min_le', + 'not_null', 'sum_ge', 'sum_le', 'avg_ge', 'avg_le']: + # These aggregates are always integers: + value = int(row[0]) + + elif aggregate_type == 'avg': + value = row[0] / row[1] if row[1] else None + + elif aggregate_type == 'rms': + value = math.sqrt(row[0] / row[1]) if row[1] else None + + elif aggregate_type == 'vecavg': + value = math.sqrt((row[0] ** 2 + row[1] ** 2) / row[2] ** 2) if row[2] else None + + elif aggregate_type == 'vecdir': + if row == (0.0, 0.0): + value = None + else: + deg = 90.0 - math.degrees(math.atan2(row[1], row[0])) + value = deg if deg >= 0 else deg + 360.0 + else: + # Unknown aggregation. Should not have gotten this far... + raise ValueError("Unexpected error. Aggregate type '%s'" % aggregate_type) + + # Look up the unit type and group of this combination of observation type and aggregation: + t, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, t, g) + + # These are SQL statements used for calculating series from the daily summaries. + # They include "group_def", which will be replaced with a database-specific GROUP BY clause + common = { + 'min': "SELECT MIN(dateTime), MAX(dateTime), MIN(min) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'max': "SELECT MIN(dateTime), MAX(dateTime), MAX(max) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'avg': "SELECT MIN(dateTime), MAX(dateTime), SUM(wsum), SUM(sumtime) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'sum': "SELECT MIN(dateTime), MAX(dateTime), SUM(sum) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'count': "SELECT MIN(dateTime), MAX(dateTime), SUM(count) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + } + # Database- and interval-specific "GROUP BY" clauses. + group_defs = { + 'sqlite': { + 'day': "GROUP BY CAST(" + " (julianday(dateTime,'unixepoch','localtime') - 0.5 " + " - CAST(julianday(%(sod)s, 'unixepoch','localtime') AS int)) " + " / %(agg_days)s " + "AS int)", + 'month': "GROUP BY strftime('%%Y-%%m',dateTime,'unixepoch','localtime') ", + 'year': "GROUP BY strftime('%%Y',dateTime,'unixepoch','localtime') ", + }, + 'mysql': { + 'day': "GROUP BY TRUNCATE((TO_DAYS(FROM_UNIXTIME(dateTime)) " + "- TO_DAYS(FROM_UNIXTIME(%(sod)s)))/ %(agg_days)s, 0) ", + 'month': "GROUP BY DATE_FORMAT(FROM_UNIXTIME(dateTime), '%%%%Y-%%%%m') ", + 'year': "GROUP BY DATE_FORMAT(FROM_UNIXTIME(dateTime), '%%%%Y') ", + }, + } + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + + # We cannot use the daily summaries if there is no aggregation + if not aggregate_type: + raise weewx.UnknownAggregation(aggregate_type) + + aggregate_type = aggregate_type.lower() + + # Raise exception if we don't know about this type of aggregation + if aggregate_type not in DailySummaries.common: + raise weewx.UnknownAggregation(aggregate_type) + + # Check to see whether we can use the daily summaries: + DailySummaries._check_eligibility(obs_type, timespan, db_manager, aggregate_type) + + # We also have to make sure the aggregation interval is either the length of a nominal + # month or year, or some multiple of a calendar day. + aggregate_interval = weeutil.weeutil.nominal_spans(aggregate_interval) + if aggregate_interval != weeutil.weeutil.nominal_intervals['year'] \ + and aggregate_interval != weeutil.weeutil.nominal_intervals['month'] \ + and aggregate_interval % 86400: + raise weewx.UnknownAggregation(aggregate_interval) + + # We're good. Proceed. + dbtype = db_manager.connection.dbtype + interp_dict = { + 'agg_days': aggregate_interval / 86400, + 'day_table': "%s_day_%s" % (db_manager.table_name, obs_type), + 'obs_type': obs_type, + 'sod': weeutil.weeutil.startOfDay(timespan.start), + 'start': timespan.start, + 'stop': timespan.stop, + } + if aggregate_interval == weeutil.weeutil.nominal_intervals['year']: + group_by_group = 'year' + elif aggregate_interval == weeutil.weeutil.nominal_intervals['month']: + group_by_group = 'month' + else: + group_by_group = 'day' + # Add the database-specific GROUP_BY clause to the interpolation dictionary + interp_dict['group_def'] = DailySummaries.group_defs[dbtype][group_by_group] % interp_dict + # This is the final SELECT statement. + sql_stmt = DailySummaries.common[aggregate_type] % interp_dict + + start_list = list() + stop_list = list() + data_list = list() + + for row in db_manager.genSql(sql_stmt): + # Find the start of this aggregation interval. That's easy: it's the minimum value. + start_time = row[0] + # The stop is a little trickier. It's the maximum dateTime in the interval, plus one + # day. The extra day is needed because the timestamp marks the beginning of a day in a + # daily summary. + stop_date = datetime.date.fromtimestamp(row[1]) + datetime.timedelta(days=1) + stop_time = int(time.mktime(stop_date.timetuple())) + + if aggregate_type in {'min', 'max', 'sum', 'count'}: + data = row[2] + elif aggregate_type == 'avg': + data = row[2] / row[3] if row[3] else None + else: + # Shouldn't really have made it here. Fail hard + raise ValueError("Unknown aggregation type %s" % aggregate_type) + + start_list.append(start_time) + stop_list.append(stop_time) + data_list.append(data) + + # Look up the unit type and group of this combination of observation type and aggregation: + unit, unit_group = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + return (ValueTuple(start_list, 'unix_epoch', 'group_time'), + ValueTuple(stop_list, 'unix_epoch', 'group_time'), + ValueTuple(data_list, unit, unit_group)) + + @staticmethod + def _check_eligibility(obs_type, timespan, db_manager, aggregate_type): + + # It has to be a type we know about + if not hasattr(db_manager, 'daykeys') or obs_type not in db_manager.daykeys: + raise weewx.UnknownType(obs_type) + + # We cannot use the day summaries if the starting and ending times of the aggregation + # interval are not on midnight boundaries, and are not the first or last records in the + # database. + if db_manager.first_timestamp is None or db_manager.last_timestamp is None: + raise weewx.UnknownAggregation(aggregate_type) + if not (isStartOfDay(timespan.start) or timespan.start == db_manager.first_timestamp) \ + or not (isStartOfDay(timespan.stop) or timespan.stop == db_manager.last_timestamp): + raise weewx.UnknownAggregation(aggregate_type) + + +# +# ######################## Class AggregateHeatCool ############################## +# + +class AggregateHeatCool(XType): + """Calculate heating and cooling degree-days.""" + + # Default base temperature and unit type for heating and cooling degree days, + # as a value tuple + default_heatbase = (65.0, "degree_F", "group_temperature") + default_coolbase = (65.0, "degree_F", "group_temperature") + default_growbase = (50.0, "degree_F", "group_temperature") + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns heating and cooling degree days over a time period. + + obs_type: The type over which aggregation is to be done. Must be one of 'heatdeg', + 'cooldeg', or 'growdeg'. + + timespan: An instance of weeutil.Timespan with the time period over which + aggregation is to be done. + + aggregate_type: The type of aggregation to be done. Must be 'avg' or 'sum'. + + db_manager: An instance of weewx.manager.Manager or subclass. + + option_dict: Not used in this version. + + returns: A ValueTuple containing the result. + """ + + # Check to see whether heating or cooling degree days are being asked for: + if obs_type not in ['heatdeg', 'cooldeg', 'growdeg']: + raise weewx.UnknownType(obs_type) + + # Only summation (total) or average heating or cooling degree days is supported: + if aggregate_type not in ['sum', 'avg']: + raise weewx.UnknownAggregation(aggregate_type) + + # Get the base for heating and cooling degree-days + units_dict = option_dict.get('skin_dict', {}).get('Units', {}) + dd_dict = units_dict.get('DegreeDays', {}) + heatbase = dd_dict.get('heating_base', AggregateHeatCool.default_heatbase) + coolbase = dd_dict.get('cooling_base', AggregateHeatCool.default_coolbase) + growbase = dd_dict.get('growing_base', AggregateHeatCool.default_growbase) + # Convert to a ValueTuple in the same unit system as the database + heatbase_t = weewx.units.convertStd((float(heatbase[0]), heatbase[1], "group_temperature"), + db_manager.std_unit_system) + coolbase_t = weewx.units.convertStd((float(coolbase[0]), coolbase[1], "group_temperature"), + db_manager.std_unit_system) + growbase_t = weewx.units.convertStd((float(growbase[0]), growbase[1], "group_temperature"), + db_manager.std_unit_system) + + total = 0.0 + count = 0 + for daySpan in weeutil.weeutil.genDaySpans(timespan.start, timespan.stop): + # Get the average temperature for the day as a value tuple: + Tavg_t = DailySummaries.get_aggregate('outTemp', daySpan, 'avg', db_manager) + # Make sure it's valid before including it in the aggregation: + if Tavg_t is not None and Tavg_t[0] is not None: + if obs_type == 'heatdeg': + total += weewx.wxformulas.heating_degrees(Tavg_t[0], heatbase_t[0]) + elif obs_type == 'cooldeg': + total += weewx.wxformulas.cooling_degrees(Tavg_t[0], coolbase_t[0]) + else: + total += weewx.wxformulas.cooling_degrees(Tavg_t[0], growbase_t[0]) + + count += 1 + + if aggregate_type == 'sum': + value = total + else: + value = total / count if count else None + + # Look up the unit type and group of the result: + t, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + # Return as a value tuple + return weewx.units.ValueTuple(value, t, g) + + +class XTypeTable(XType): + """Calculate a series for an xtype. An xtype may not necessarily be in the database, so + this version calculates it on the fly. Note: this version only works if no aggregation has + been requested.""" + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Get a series of an xtype, by using the main archive table. Works only for no + aggregation. """ + + start_vec = list() + stop_vec = list() + data_vec = list() + + if aggregate_type: + # This version does not know how to do aggregations, although this could be + # added in the future. + raise weewx.UnknownAggregation(aggregate_type) + + else: + # No aggregation + + std_unit_system = None + + # Hit the database. + for record in db_manager.genBatchRecords(*timespan): + + if std_unit_system: + if std_unit_system != record['usUnits']: + raise weewx.UnsupportedFeature("Unit system cannot change " + "within a series.") + else: + std_unit_system = record['usUnits'] + + # Given a record, use the xtypes system to calculate a value: + try: + value = get_scalar(obs_type, record, db_manager) + data_vec.append(value[0]) + except weewx.CannotCalculate: + data_vec.append(None) + start_vec.append(record['dateTime'] - record['interval'] * 60) + stop_vec.append(record['dateTime']) + + unit, unit_group = weewx.units.getStandardUnitType(std_unit_system, obs_type) + + return (ValueTuple(start_vec, 'unix_epoch', 'group_time'), + ValueTuple(stop_vec, 'unix_epoch', 'group_time'), + ValueTuple(data_vec, unit, unit_group)) + + +# ############################# WindVec extensions ######################################### + +class WindVec(XType): + """Extensions for calculating special observation types 'windvec' and 'windgustvec' from the + main archive table. It provides functions for calculating series, and for calculating + aggregates. + """ + + windvec_types = { + 'windvec': ('windSpeed', 'windDir'), + 'windgustvec': ('windGust', 'windGustDir') + } + + agg_sql_dict = { + 'count': "SELECT COUNT(dateTime), usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL", + 'first': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY dateTime ASC LIMIT 1", + 'last': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY dateTime DESC LIMIT 1", + 'min': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY %(mag)s ASC LIMIT 1;", + 'max': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY %(mag)s DESC LIMIT 1;", + 'not_null' : "SELECT 1, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(mag)s IS NOT NULL LIMIT 1;" + } + # for types 'avg', 'sum' + complex_sql_wind = 'SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s WHERE dateTime > ? ' \ + 'AND dateTime <= ?' + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Get a series, possibly with aggregation, for special 'wind vector' types. These are + typically used for the wind vector plots. + """ + + # Check to see if the requested type is not 'windvec' or 'windgustvec' + if obs_type not in WindVec.windvec_types: + # The type is not one of the extended wind types. We can't handle it. + raise weewx.UnknownType(obs_type) + + # It is an extended wind type. Prepare the lists that will hold the + # final results. + start_vec = list() + stop_vec = list() + data_vec = list() + + # Is aggregation requested? + if aggregate_type: + # Yes. Just use the regular series function. When it comes time to do the aggregation, + # the specialized function WindVec.get_aggregate() (defined below), will be used. + return ArchiveTable.get_series(obs_type, timespan, db_manager, aggregate_type, + aggregate_interval, **option_dict) + + else: + # No aggregation desired. However, we have will have to assemble the wind vector from + # its flattened types. This SQL select string will select the proper wind types + sql_str = 'SELECT dateTime, %s, %s, usUnits, `interval` FROM %s ' \ + 'WHERE dateTime >= ? AND dateTime <= ?' \ + % (WindVec.windvec_types[obs_type][0], WindVec.windvec_types[obs_type][1], + db_manager.table_name) + std_unit_system = None + + for record in db_manager.genSql(sql_str, timespan): + ts, magnitude, direction, unit_system, interval = record + if std_unit_system: + if std_unit_system != unit_system: + raise weewx.UnsupportedFeature( + "Unit type cannot change within a time interval.") + else: + std_unit_system = unit_system + + value = weeutil.weeutil.to_complex(magnitude, direction) + + start_vec.append(ts - interval * 60) + stop_vec.append(ts) + data_vec.append(value) + + unit, unit_group = weewx.units.getStandardUnitType(std_unit_system, obs_type, + aggregate_type) + + return (ValueTuple(start_vec, 'unix_epoch', 'group_time'), + ValueTuple(stop_vec, 'unix_epoch', 'group_time'), + ValueTuple(data_vec, unit, unit_group)) + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns an aggregation of a wind vector type over a timespan by using the main archive + table. + + obs_type: The type over which aggregation is to be done. For this function, it must be + 'windvec' or 'windgustvec'. Anything else will cause weewx.UnknownType to be raised. + + timespan: An instance of weeutil.Timespan with the time period over which aggregation is to + be done. + + aggregate_type: The type of aggregation to be done. For this function, must be 'avg', + 'sum', 'count', 'first', 'last', 'min', or 'max'. Anything else will cause + weewx.UnknownAggregation to be raised. + + db_manager: An instance of weewx.manager.Manager or subclass. + + option_dict: Not used in this version. + + returns: A ValueTuple containing the result. Note that the value contained in the + ValueTuple will be a complex number. + """ + if obs_type not in WindVec.windvec_types: + raise weewx.UnknownType(obs_type) + + aggregate_type = aggregate_type.lower() + + # Raise exception if we don't know about this type of aggregation + if aggregate_type not in ['avg', 'sum'] + list(WindVec.agg_sql_dict.keys()): + raise weewx.UnknownAggregation(aggregate_type) + + # Form the interpolation dictionary + interpolation_dict = { + 'dir': WindVec.windvec_types[obs_type][1], + 'mag': WindVec.windvec_types[obs_type][0], + 'start': timespan.start, + 'stop': timespan.stop, + 'table_name': db_manager.table_name + } + + if aggregate_type in WindVec.agg_sql_dict: + # For these types (e.g., first, last, etc.), we can do the aggregation in a SELECT + # statement. + select_stmt = WindVec.agg_sql_dict[aggregate_type] % interpolation_dict + try: + row = db_manager.getSql(select_stmt) + except weedb.NoColumnError as e: + raise weewx.UnknownType(e) + + if aggregate_type == 'not_null': + value = row is not None + std_unit_system = db_manager.std_unit_system + else: + if row: + if aggregate_type == 'count': + value, std_unit_system = row + else: + magnitude, direction, std_unit_system = row + value = weeutil.weeutil.to_complex(magnitude, direction) + else: + std_unit_system = db_manager.std_unit_system + value = None + else: + # The requested aggregation must be either 'sum' or 'avg', which will require some + # arithmetic in Python, so it cannot be done by a simple query. + std_unit_system = None + xsum = ysum = 0.0 + count = 0 + select_stmt = WindVec.complex_sql_wind % interpolation_dict + + for rec in db_manager.genSql(select_stmt, timespan): + + # Unpack the record + mag, direction, unit_system = rec + + # Ignore rows where magnitude is NULL + if mag is None: + continue + + # A good direction is necessary unless the mag is zero: + if mag == 0.0 or direction is not None: + if std_unit_system: + if std_unit_system != unit_system: + raise weewx.UnsupportedFeature( + "Unit type cannot change within a time interval.") + else: + std_unit_system = unit_system + + # An undefined direction is OK (and expected) if the magnitude + # is zero. But, in that case, it doesn't contribute to the sums either. + if direction is None: + # Sanity check + if weewx.debug: + assert (mag == 0.0) + else: + xsum += mag * math.cos(math.radians(90.0 - direction)) + ysum += mag * math.sin(math.radians(90.0 - direction)) + count += 1 + + # We've gone through the whole interval. Were there any good data? + if count: + # Form the requested aggregation: + if aggregate_type == 'sum': + value = complex(xsum, ysum) + else: + # Must be 'avg' + value = complex(xsum, ysum) / count + else: + value = None + + # Look up the unit type and group of this combination of observation type and aggregation: + t, g = weewx.units.getStandardUnitType(std_unit_system, obs_type, aggregate_type) + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, t, g) + + +class WindVecDaily(XType): + """Extension for calculating the average windvec, using the daily summaries.""" + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Optimization for calculating 'avg' aggregations for type 'windvec'. The + timespan must be on a daily boundary.""" + + # We can only do observation type 'windvec' + if obs_type != 'windvec': + # We can't handle it. + raise weewx.UnknownType(obs_type) + + # We can only do 'avg' or 'not_null + if aggregate_type not in ['avg', 'not_null']: + raise weewx.UnknownAggregation(aggregate_type) + + # We cannot use the day summaries if the starting and ending times of the aggregation + # interval are not on midnight boundaries, and are not the first or last records in the + # database. + if not (isStartOfDay(timespan.start) or timespan.start == db_manager.first_timestamp) \ + or not (isStartOfDay(timespan.stop) or timespan.stop == db_manager.last_timestamp): + raise weewx.UnknownAggregation(aggregate_type) + + if aggregate_type == 'not_null': + # Aggregate type 'not_null' is actually run against 'wind'. + return DailySummaries.get_aggregate('wind', timespan, 'not_null', db_manager, + **option_dict) + + sql = 'SELECT SUM(xsum), SUM(ysum), SUM(dirsumtime) ' \ + 'FROM %s_day_wind WHERE dateTime>=? AND dateTime +# +# See the file LICENSE.txt for your rights. +# +"""Entry point to the weewx weather system.""" +from __future__ import absolute_import +from __future__ import print_function + +import locale +import logging +import os +import platform +import signal +import sys +import time +from optparse import OptionParser + +import configobj +import daemon +import weecfg +import weedb +from weeutil.weeutil import to_bool +import weeutil.logger + +# First import any user extensions... +# noinspection PyUnresolvedReferences +import user.extensions +# ...then import the engine +import weewx.engine + +log = logging.getLogger(__name__) + +usagestr = """Usage: weewxd --help + weewxd --version + weewxd [CONFIG_FILE|--config=CONFIG_FILE] + [--daemon] [--pidfile=PIDFILE] + [--exit] [--loop-on-init] + [--log-label=LABEL] + + Entry point to the weewx weather program. Can be run directly, or as a daemon + by specifying the '--daemon' option. + +Arguments: + CONFIG_FILE: The weewx configuration file to be used. Optional. +""" + + +# =============================================================================== +# Main entry point +# =============================================================================== + +def main(): + parser = OptionParser(usage=usagestr) + parser.add_option("--config", dest="config_path", type=str, + metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + parser.add_option("-d", "--daemon", action="store_true", dest="daemon", help="Run as a daemon") + parser.add_option("-p", "--pidfile", type="string", dest="pidfile", + help="Store the process ID in PIDFILE", + default="/var/run/weewx.pid", metavar="PIDFILE") + parser.add_option("-v", "--version", action="store_true", dest="version", + help="Display version number then exit") + parser.add_option("-x", "--exit", action="store_true", dest="exit", + help="Exit on I/O and database errors instead of restarting") + parser.add_option("-r", "--loop-on-init", action="store_true", dest="loop_on_init", + help="Retry forever if device is not ready on startup") + parser.add_option("-n", "--log-label", type="string", dest="log_label", + help="Label to use in syslog entries", + default="weewx", metavar="LABEL") + + # Get the command line options and arguments: + options, args = parser.parse_args() + + if options.version: + print(weewx.__version__) + sys.exit(0) + + if args and options.config_path: + print("Specify CONFIG_PATH as an argument, or by using --config, but not both", + file=sys.stderr) + sys.exit(weewx.CMD_ERROR) + + # Read the configuration file + try: + # Pass in a copy of the command line arguments. read_config() will change it. + config_path, config_dict = weecfg.read_config(options.config_path, list(args)) + except (IOError, configobj.ConfigObjError) as e: + print("Error parsing config file: %s" % e, file=sys.stderr) + weeutil.logger.log_traceback(log.critical, " **** ") + sys.exit(weewx.CMD_ERROR) + + weewx.debug = int(config_dict.get('debug', 0)) + + # Now that we have the config_dict and debug setting, we can customize the + # logging with user additions + weeutil.logger.setup(options.log_label, config_dict) + + # Log key bits of information. + log.info("Initializing weewx version %s", weewx.__version__) + log.info("Using Python %s", sys.version) + log.info("Located at %s", sys.executable) + log.info("Platform %s", platform.platform()) + log.info("Locale is '%s'", locale.setlocale(locale.LC_ALL)) + log.info("Using configuration file %s", config_path) + log.info("Debug is %s", weewx.debug) + + # If no command line --loop-on-init was specified, look in the config file. + if options.loop_on_init is None: + loop_on_init = to_bool(config_dict.get('loop_on_init', False)) + else: + loop_on_init = options.loop_on_init + + # Save the current working directory. A service might + # change it. In case of a restart, we need to change it back. + cwd = os.getcwd() + + # Make sure the system time is not out of date (a common problem with the Raspberry Pi). + # Do this by making sure the system time is later than the creation time of this file. + sane = os.stat(__file__).st_ctime + n = 0 + while weewx.launchtime_ts < sane: + # Log any problems every minute. + if n % 120 == 0: + log.info("Waiting for sane time. Current time is %s", + weeutil.weeutil.timestamp_to_string(weewx.launchtime_ts)) + n += 1 + time.sleep(0.5) + weewx.launchtime_ts = time.time() + + # Set up a handler for a termination signal + signal.signal(signal.SIGTERM, sigTERMhandler) + + if options.daemon: + log.info("PID file is %s", options.pidfile) + daemon.daemonize(pidfile=options.pidfile) + + # Main restart loop + while True: + + os.chdir(cwd) + + try: + log.debug("Initializing engine") + + # Create and initialize the engine + engine = weewx.engine.StdEngine(config_dict) + + log.info("Starting up weewx version %s", weewx.__version__) + + # Start the engine. It should run forever unless an exception + # occurs. Log it if the function returns. + engine.run() + log.critical("Unexpected exit from main loop. Program exiting.") + + # Catch any console initialization error: + except weewx.engine.InitializationError as e: + # Log it: + log.critical("Unable to load driver: %s", e) + # See if we should loop, waiting for the console to be ready. + # Otherwise, just exit. + if loop_on_init: + log.critical(" **** Waiting 60 seconds then retrying...") + time.sleep(60) + log.info("retrying...") + else: + log.critical(" **** Exiting...") + sys.exit(weewx.IO_ERROR) + + # Catch any recoverable weewx I/O errors: + except weewx.WeeWxIOError as e: + # Caught an I/O error. Log it, wait 60 seconds, then try again + log.critical("Caught WeeWxIOError: %s", e) + if options.exit: + log.critical(" **** Exiting...") + sys.exit(weewx.IO_ERROR) + log.critical(" **** Waiting 60 seconds then retrying...") + time.sleep(60) + log.info("retrying...") + + # Catch any database connection errors: + except (weedb.CannotConnectError, weedb.DisconnectError) as e: + # No connection to the database server. Log it, wait 60 seconds, then try again + log.critical("Database connection exception: %s", e) + if options.exit: + log.critical(" **** Exiting...") + sys.exit(weewx.DB_ERROR) + log.critical(" **** Waiting 60 seconds then retrying...") + time.sleep(60) + log.info("retrying...") + + except weedb.OperationalError as e: + # Caught a database error. Log it, wait 120 seconds, then try again + log.critical("Database OperationalError exception: %s", e) + if options.exit: + log.critical(" **** Exiting...") + sys.exit(weewx.DB_ERROR) + log.critical(" **** Waiting 2 minutes then retrying...") + time.sleep(120) + log.info("retrying...") + + except OSError as e: + # Caught an OS error. Log it, wait 10 seconds, then try again + log.critical("Caught OSError: %s", e) + weeutil.logger.log_traceback(log.critical, " **** ") + log.critical(" **** Waiting 10 seconds then retrying...") + time.sleep(10) + log.info("retrying...") + + except Terminate: + log.info("Terminating weewx version %s", weewx.__version__) + weeutil.logger.log_traceback(log.debug, " **** ") + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(0, signal.SIGTERM) + + # Catch any keyboard interrupts and log them + except KeyboardInterrupt: + log.critical("Keyboard interrupt.") + # Reraise the exception (this should cause the program to exit) + raise + + # Catch any non-recoverable errors. Log them, exit + except Exception as ex: + # Caught unrecoverable error. Log it, exit + log.critical("Caught unrecoverable exception:") + log.critical(" **** %s" % ex) + # Include a stack traceback in the log: + weeutil.logger.log_traceback(log.critical, " **** ") + log.critical(" **** Exiting.") + # Reraise the exception (this should cause the program to exit) + raise + + +# ============================================================================== +# Signal handlers +# ============================================================================== + +class Terminate(Exception): + """Exception raised when terminating the engine.""" + + +def sigTERMhandler(signum, _frame): + log.info("Received signal TERM (%s).", signum) + raise Terminate + + +# Start up the program +main() diff --git a/dist/weewx-4.10.1/bin/wunderfixer b/dist/weewx-4.10.1/bin/wunderfixer new file mode 100755 index 0000000..08a0948 --- /dev/null +++ b/dist/weewx-4.10.1/bin/wunderfixer @@ -0,0 +1,576 @@ +#!/usr/bin/env python +# =============================================================================== +# Copyright (c) 2009-2021 Tom Keffer +# +# This software may be used and redistributed under the +# terms of the GNU General Public License version 3.0 +# or, at your option, any higher version. +# +# See the file LICENSE.txt for your full rights. +# +# =============================================================================== +"""This utility fills in missing data on the Weather Underground. It goes through all the records +in a weewx archive file for a given day, comparing to see whether a corresponding record exists on +the Weather Underground. If not, it will publish a new record on the Weather Underground with the +missing data. + +Details of the API for downloading historical data from the WU can be found here: +https://docs.google.com/document/d/1w8jbqfAk0tfZS5P7hYnar1JiitM0gQZB-clxDfG3aD0/edit + +Wunderground response codes: +- When using the "1day" API + o Normal | 200 + o Non-existent station | 204 + o Bad api key | 401 +- When using the "history" API + o Normal | 200 + o Non-existent station | 204 + o Good station, but no data | 204 + o Bad api key | 401 + +Unfortunately, there is no reliable way to tell the difference between a request for a non-existing +station, and a request for a date with no data. + +CHANGE HISTORY +-------------------------------- +1.9.1 05/02/2020 +Fixed problem under Python 3 where response was not converted to str before attempting +to parse the JSON. +Option --test now requires api_key and password, then goes ahead with querying the WU. + +1.9.0 02/10/2020 +With response code of 204, changed the default to assume a good station with no data +(rather than a bad station ID). + +1.8.0 12/15/2019 +Fixed bug where epsilon was not recognized. +Added option 'upload-only', with default of 300 seconds. + +1.7.0 12/03/2019 +Now uses "dual APIs." One for today, one for historical data. + +1.6.0 08/17/2019 +Use Python 'logging' package + +1.5.1 07/19/2019 +More refined error handling. + +1.5.0 07/18/2019 +Ported to new WU API. Now requires an API key. + +1.4.1 05/14/2019 +Made WunderStation class consistent with restx.AmbientThread parameters + +1.4.0 05/07/2019 +Ported to Python 3 + +1.3.0 04/30/19 +Added option --timeout. + +1.2.1 02/07/19 +Keep going even if an observation does not satisfy [[Essentials]]. + +1.2.0 11/12/18 +Now honors an [[[Essentials]]] section in the configuration file. + +1.1.0 10/11/16 +Now uses restx API to publish the requests. +Standardised option syntax. + +1.0.0 8/16/15 +Published version. + +1.0.0a1 2/28/15 +Now uses weewx API allowing use with any database supported by weewx. +Now supports weewx databases using any weewx supported unit system (eg US, +METRIC and METRIXWX). +Database is no longer specified by file name rather path to weewx.conf and a +binding are specified. +Now posts wind speeds with 1 decimal place and barometer with 3 decimal places. +Now has option to log to syslog. + +0.5.2 11/17/12 +Adds radiation and UV to the types posted on WU. + +0.5.1 11/05/12 +Now assumes sqlite3 will be present. If not, it falls back to pysqlite2. + +0.5.0 10/31/11 +Fixed bug in fuzzy compares, which were introduced in V0.3. +Timestamps within an epsilon (default 120 seconds) of each other are +considered the same. Epsilon can be specified on the command line. + +0.4.0 04/10/10 +Now tries up to max_tries times to publish to the WU before giving up. +""" +from __future__ import print_function +import datetime +import gzip +import json +import logging +import optparse +import socket +import sys +import time + +# Python 2/3 compatiblity shims +import six +from six.moves import urllib, input + +import weecfg +import weewx.manager +import weewx.restx +import weeutil.logger +from weeutil.config import search_up +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +usagestr = """%prog CONFIG_FILE|--config=CONFIG_FILE + [--binding=BINDING] + [--station=STATION] [--password=PASSWORD] [--api-key=API_KEY] + [--date=YYYY-mm-dd] [--epsilon=SECONDS] [--upload-only=SECONDS] + [--verbose] [--test] [--query] [--timeout=SECONDS] + [--help] + +This utility fills in missing data on the Weather Underground. It goes through +all the records in a weewx archive for a given day, comparing to see whether a +corresponding record exists on the Weather Underground. If not, it will publish +a new record on the Weather Underground with the missing data. + +Be sure to use the --test switch first to see whether you like what it +proposes!""" + +epilog = """Options 'station', 'password', and 'api-key' must be supplied either +on the command line, or in the configuration file.""" + +__version__ = "1.9.1" + +# The number of seconds difference in the timestamp between two records +# and still have them considered to be the same: +epsilon = None + + +def main(): + """main program body for wunderfixer""" + + global epsilon + + parser = optparse.OptionParser(usage=usagestr, epilog=epilog) + parser.add_option("-c", "--config", metavar="CONFIG_PATH", + help="Use configuration file CONFIG_PATH. " + "Default is /etc/weewx/weewx.conf or /home/weewx/weewx.conf.") + parser.add_option("-b", "--binding", default='wx_binding', + help="The database binding to be used. Default is 'wx_binding'.") + parser.add_option("-s", "--station", + help="Weather Underground station to check. Optional. " + "Default is to take from configuration file.") + parser.add_option("-p", "--password", + help="Weather Underground station password. Optional. " + "Default is to take from configuration file.") + parser.add_option("-k", "--api-key", + help="Weather Underground API key. Optional. " + "Default is to take from configuration file.") + parser.add_option("-d", "--date", metavar="YYYY-mm-dd", + help="Date to check as a string of form YYYY-mm-dd. Default is today.") + parser.add_option("-e", "--epsilon", type="int", metavar="SECONDS", default=120, + help="Timestamps within this value in seconds compare true. " + "Default is 120.") + parser.add_option("-u", "--upload-only", type="int", metavar="SECONDS", default=300, + help="Upload only records every SECONDS apart or more. " + "Default is 300.") + parser.add_option("-v", "--verbose", action="store_true", + help="Print useful extra output.") + parser.add_option("-l", "--log", type="string", dest="logging", metavar="LOG_FACILITY", + help="OBSOLETE. Logging will always occur.") + parser.add_option("-t", "--test", action="store_true", dest="simulate", + help="Test what would happen, but don't do anything.") + parser.add_option("-q", "--query", action="store_true", + help="For each record, query the user before making a change.") + parser.add_option("-o", "--timeout", type="int", metavar="SECONDS", default=10, + help="Socket timeout in seconds. Default is 10.") + + (options, args) = parser.parse_args() + + socket.setdefaulttimeout(options.timeout) + + if options.verbose: + weewx.debug = 1 + else: + logging.disable(logging.INFO) + + # get our config file + config_fn, config_dict = weecfg.read_config(options.config, args) + + # Now we can set up the user-customized logging: + weeutil.logger.setup('wunderfixer', config_dict) + + print("Using configuration file %s." % config_fn) + log.info("Using weewx configuration file %s." % config_fn) + + # Retrieve the station ID and password from the config file + try: + if not options.station: + options.station = config_dict['StdRESTful']['Wunderground']['station'] + if not options.password: + options.password = config_dict['StdRESTful']['Wunderground']['password'] + if not options.api_key: + options.api_key = config_dict['StdRESTful']['Wunderground']['api_key'] + except KeyError: + log.error("Missing Wunderground station, password, and/or api_key") + exit("Missing Wunderground station, password, and/or api_key") + + # exit if any essential arguments are not present + if not options.station or not options.password or not options.api_key: + print("Missing argument(s).\n") + print(parser.parse_args(["--help"])) + log.error("Missing argument(s). Wunderfixer exiting.") + exit() + + # get our binding and database and say what we are using + db_binding = options.binding + database = config_dict['DataBindings'][db_binding]['database'] + print("Using database binding '%s', which is bound to database '%s'" + % (db_binding, database)) + log.info("Using database binding '%s', which is bound to database '%s'" + % (db_binding, database)) + + # get the manager object for our db_binding + dbmanager_t = weewx.manager.open_manager_with_config(config_dict, db_binding) + + _ans = 'y' + if options.simulate: + options.query = False + _ans = 'n' + + if options.query: + options.verbose = True + + if options.date: + date_tt = time.strptime(options.date, "%Y-%m-%d") + date_date = datetime.date(date_tt[0], date_tt[1], date_tt[2]) + else: + # If no date option was specified on the command line, use today's date: + date_date = datetime.date.today() + + epsilon = options.epsilon + + _essentials_dict = search_up(config_dict['StdRESTful']['Wunderground'], 'Essentials', {}) + log.debug("WU essentials: %s" % _essentials_dict) + + if options.verbose: + print("Weather Underground Station: ", options.station) + print("Date to check: ", date_date) + log.info("Checking Weather Underground station '%s' data " + "for date %s" % (options.station, date_date)) + + group_by = options.upload_only if options.upload_only else None + + # Get all the time stamps in the archive for the given day: + archive_results = getArchiveDayTimeStamps(dbmanager_t, date_date, group_by) + + if options.verbose: + print("Number of archive records: ", len(archive_results)) + + # Get a WunderStation object so we can interact with Weather Underground + wunder = WunderStation(options.api_key, + q=None, # Bogus queue. We will not be using it. + manager_dict=dbmanager_t, + station=options.station, + password=options.password, + server_url=weewx.restx.StdWunderground.pws_url, + protocol_name="wunderfixer", + essentials=_essentials_dict, + softwaretype="wunderfixer-%s" % __version__) + + try: + # Get all the time stamps on the Weather Underground for the given day: + wunder_results = wunder.get_day_timestamps(date_date) + except Exception as e: + print("Could not get Weather Underground data.", file=sys.stderr) + print("Reason: %s" % e, file=sys.stderr) + log.error("Could not get Weather Underground data. Exiting.") + exit("Exiting.") + + if options.verbose: + print("Number of WU records: ", len(wunder_results)) + log.debug("Found %d archive records and %d WU records" + % (len(archive_results), len(wunder_results))) + + # =========================================================================== + # Unfortunately, the WU does not signal an error if you ask for a non-existent station. So, + # there's no way to tell the difference between asking for results from a non-existent station, + # versus a legitimate station that has no data for the given day. Warn the user, then proceed. + # =========================================================================== + if not wunder_results: + sys.stdout.flush() + print("\nNo results returned from Weather Underground " + "(perhaps a bad station name??).", file=sys.stderr) + print("Publishing anyway.", file=sys.stderr) + log.error("No results returned from Weather Underground for station '%s'" + "(perhaps a bad station name??). Publishing anyway." % options.station) + + # Find the difference between the two lists, then sort them + missing_records = sorted([ts for ts in archive_results if ts not in wunder_results]) + + if options.verbose: + print("Number of missing records: ", len(missing_records)) + if missing_records: + print("\nMissing records:") + log.info("%d Weather Underground records missing." % len(missing_records)) + + no_published = 0 + # Loop through the missing time stamps: + for time_TS in missing_records: + # Get the archive record for this timestamp: + record = dbmanager_t.getRecord(time_TS.ts) + # Print it out: + print(print_record(record), end=' ', file=sys.stdout) + sys.stdout.flush() + + # If this is an interactive session (option "-q") see if the + # user wants to change it: + if options.query: + _ans = input("...fix? (y/n/a/q):") + if _ans == "q": + print("Quitting.") + log.debug("... exiting") + exit() + if _ans == "a": + _ans = "y" + options.query = False + + if _ans == 'y': + try: + # Post the data to the WU: + wunder.process_record(record, dbmanager_t) + no_published += 1 + print(" ...published.", file=sys.stdout) + log.debug("%s ...published" % timestamp_to_string(record['dateTime'])) + except weewx.restx.BadLogin as e: + print("Bad login", file=sys.stderr) + print(e, file=sys.stderr) + exit("Bad login") + except weewx.restx.FailedPost as e: + print(e, file=sys.stderr) + print("Aborted.", file=sys.stderr) + log.error("%s ...error %s. Aborting.", timestamp_to_string(record['dateTime']), e) + exit("Failed post") + except weewx.restx.AbortedPost as e: + print(" ... not published.", file=sys.stderr) + print("Reason: ", e) + log.error("%s ...not published. Reason '%s'", + timestamp_to_string(record['dateTime']), e) + except IOError as e: + print(" ... not published.", file=sys.stderr) + print("Reason: ", e) + log.error("%s ...not published. Reason '%s'", + timestamp_to_string(record['dateTime']), e) + if hasattr(e, 'reason'): + print("Failed to reach server. Reason: %s" % e.reason, file=sys.stderr) + log.error("%s ...not published. Failed to reach server. Reason '%s'", + timestamp_to_string(record['dateTime']), e.reason) + if hasattr(e, 'code'): + print("Failed to reach server. Error code: %s" % e.code, file=sys.stderr) + log.error("%s ...not published. Failed to reach server. Error code '%s'", + timestamp_to_string(record['dateTime']), e.code) + + else: + print(" ... skipped.") + log.debug("%s ...skipped", timestamp_to_string(record['dateTime'])) + log.info("%s out of %s missing records published to '%s' for date %s." + " Wunderfixer exiting.", + no_published, len(missing_records), options.station, date_date) + + +# =============================================================================== +# class WunderStation +# =============================================================================== + +class WunderStation(weewx.restx.AmbientThread): + """Class to interact with the Weather Underground.""" + + def __init__(self, api_key, **kargs): + # Get the API key, and pass the rest on to my super class + self.api_key = api_key + weewx.restx.AmbientThread.__init__(self, **kargs) + + def get_day_timestamps(self, day_requested): + """Returns all time stamps for a given weather underground station for a given day. + + day_requested: An instance of datetime.date with the requested date + + return: a set containing the timestamps in epoch time + """ + # We need to do different API calls depending on whether we are asking for today's weather, + # or historical weather. Go figure. + if day_requested >= datetime.date.today(): + # WU API URL format for today's weather + url = "https://api.weather.com/v2/pws/observations/all/1day?stationId=%s" \ + "&format=json&units=m&apiKey=%s" \ + % (self.station, self.api_key) + else: + # WU API URL format for historical weather + day_tt = day_requested.timetuple() + url = "https://api.weather.com/v2/pws/history/all?stationId=%s&format=json" \ + "&units=m&date=%4.4d%2.2d%2.2d&apiKey=%s" \ + % (self.station, day_tt[0], day_tt[1], day_tt[2], self.api_key) + + request = urllib.request.Request(url) + request.add_header('Accept-Encoding', 'gzip') + request.add_header('User-Agent', 'Mozilla') + + try: + response = urllib.request.urlopen(url) + except urllib.error.URLError as e: + print("Unable to open Weather Underground station " + self.station, " or ", e, + file=sys.stderr) + log.error("Unable to open Weather Underground station %s or %s" % (self.station, e)) + raise + except socket.timeout as e: + print("Socket timeout for Weather Underground station " + self.station, + file=sys.stderr) + log.error("Socket timeout for Weather Underground station %s", self.station) + raise + + if hasattr(response, 'code') and response.code != 200: + if response.code == 204: + log.debug("Bad station (%s) or date (%s)" % (self.station, day_requested)) + return [] + elif response.code == 401: + # This should not happen, as it should have been caught earlier as an URLError + # exception, but just in case they change the API... + raise weewx.restx.BadLogin("Bad login") + else: + raise IOError("Bad response code returned: %d" % response.code) + + # The WU API says that compression is required, yet it seems to always returns uncompressed + # JSON. Just in case they decide to turn that requirement on, let's be ready for it: + if response.info().get('Content-Encoding') == 'gzip': + buf = six.StringIO(response.read()) + f = gzip.GzipFile(fileobj=buf) + data = f.read() + else: + data = six.ensure_str(response.read()) + + wu_data = json.loads(data) + + # We are only interested in the time stamps. Form a list of them + time_stamps = [TimeStamp(record['epoch']) for record in wu_data['observations']] + + return time_stamps + + def handle_exception(self, e, count): + """Override method that prints to the console, as well as the log""" + + # First call my superclass's method... + super(WunderStation, self).handle_exception(e, count) + # ... then print to the console + print("%s: Failed upload attempt %d: %s" % (self.protocol_name, count, e)) + + +# =============================================================================== +# class TimeStamp +# =============================================================================== + +class TimeStamp(object): + """This class represents a timestamp. It uses a 'fuzzy' compare. + That is, if the times are within epsilon seconds of each other, they compare true.""" + + def __init__(self, ts): + self.ts = ts + + def __cmp__(self, other_ts): + if self.__eq__(other_ts): + return 0 + return 1 if self.ts > other_ts.ts else -1 + + def __hash__(self): + return hash(self.ts) + + def __eq__(self, other_ts): + return abs(self.ts - other_ts.ts) <= epsilon + + def __lt__(self, other_ts): + return self.ts < other_ts.ts + + def __str__(self): + return timestamp_to_string(self.ts) + + +# =============================================================================== +# Utility functions +# =============================================================================== + + +# The formats to be used to print the record. For each type, there are two +# formats, the first to be used for a valid value, the second for value +# 'None' +_formats = (('barometer', ('%7.3f"', ' N/A ')), + ('outTemp', ('%6.1fF', ' N/A ')), + ('outHumidity', ('%4.0f%%', ' N/A ')), + ('windSpeed', ('%4.1f mph', ' N/A mph')), + ('windDir', ('%4.0f deg', ' N/A deg')), + ('windGust', ('%4.1f mph gust', ' N/A mph gust')), + ('dewpoint', ('%6.1fF', ' N/A ')), + ('rain', ('%5.2f" rain', ' N/A rain'))) + + +def print_record(record): + # Start with a formatted version of the time: + _strlist = [timestamp_to_string(record['dateTime'])] + + # Now add the other types, in the order given by _formats: + for (_type, _format) in _formats: + _val = record.get(_type) + _strlist.append(_format[0] % _val if _val is not None else _format[1]) + # _strlist is a list of strings. Convert it into one long string: + _string_result = ';'.join(_strlist) + return _string_result + + +def getArchiveDayTimeStamps(dbmanager, day_requested, group_by): + """Returns all time stamps in a weewx archive file for a given day + + day_requested: An instance of datetime.date + + group_by: If present, group by this number of seconds. + + returns: A set containing instances of TimeStamps + """ + + # Get the ordinal number for today and tomorrow + start_ord = day_requested.toordinal() + end_ord = start_ord + 1 + + # Convert them to instances of datetime.date + start_date = datetime.date.fromordinal(start_ord) + end_date = datetime.date.fromordinal(end_ord) + + # Finally, convert those to epoch time stamps. + # The result will be two timestamps for the two midnights + # E.G., 2009-10-25 00:00:00 and 2009-10-26 00:00:00 + start_ts = time.mktime(start_date.timetuple()) + end_ts = time.mktime(end_date.timetuple()) + + if group_by: + sql_stmt = "SELECT MIN(dateTime) FROM archive WHERE dateTime>=? AND dateTime=? AND dateTime=3. Also, extended the number of battery codes. Thanks to Per +Edström for his patience in figuring this out! + +For WMR200 stations, altitude-corrected pressure is now emitted correctly. + +ws28xx driver improvements, including: better thread control; better logging +for debugging/diagnostics; better timing to reduce dropouts; eliminate writes +to disk to reduce wear when used on flash devices. Plus, support for +multiple stations on the same USB. + +Fixed rain units in ws28xx driver. + +The LOOP value for daily ET on Vantages was too high by a factor of 10. +This has been corrected. + +Fixed a bug that caused values of ET to be miscalculated when using +software record generation. + +Ported to Korora 19 (Fedora 19). Thanks to user zmodemguru! + +Plots under 16 hours in length, now use 1 hour increments (instead of +3 hours). + +No longer emits "deprecation" warning when working with some versions +of the MySQLdb python driver. + +Added ability to build platform-specific RPMs, e.g., one for RedHat-based +distributions and one for SuSE-based distributions. + +Fixed the 'stop' and 'restart' options in the SuSE rc script. + +The weewx logwatch script now recognizes more log entries and errors. + + +2.4.0 08/03/13 + +The configuration utility wee_config_vantage now allows you to set +DST to 'auto', 'off', or 'on'. It also lets you set either a time +zone code, or a time zone offset. + +The service StdTimeSync now catches startup events and syncs the clock +on them. It has now been moved to the beginning of the list +"service_list" in weewx.conf. Users may want to do the same with their +old configuration file. + +A new event, END_ARCHIVE_PERIOD has been added, signaling the end of +the archive period. + +The LOOP packets emitted by the driver for the Davis Vantage series +now includes the max wind gust and direction seen since the beginning +of the current archive period. + +Changed the null value from zero (which the Davis documentation specifies) +to 0x7fff for the VP2 type 'highRadiation'. + +Archive record packets with date and time equal to zero or 0xff now +terminate dumps. + +The code that picks a filename for "summary by" reports has now been +factored out into a separate function (getSummaryByFileName). This +allows the logic to be changed by subclassing. + +Fixed a bug that did not allow plots with aggregations less than 60 minutes +across a DST boundary. + +Fixed bug in the WMR100 driver that prevented UV indexes from being +reported. + +The driver for the LaCrosse WS-28XX weather series continues to evolve and +mature. However, you should still consider it experimental. + + +2.3.3 06/21/13 + +The option week_start now works. + +Updated WMR200 driver from Chris Manton. + +Fixed bug that prevented queries from being run against a MySQL database. + + +2.3.2 06/16/13 + +Added support for the temperature-only sensor THWR800. Thanks to +user fstuyk! + +Fixed bug that prevented overriding the FTP directory in section +[[FTP]] of the configuration file. + +Day plots now show 24 hours instead of 27. If you want the old +behavior, then change option "time_length" to 97200. + +Plots shorter than 24 hours are now possible. Thanks to user Andrew Tridgell. + +If one of the sections SummaryByMonth, SummaryByYear, or ToDate is missing, +the report engine no longer crashes. + +If you live at a high latitude and the sun never sets, the Almanac now +does the right thing. + +Fixed bug that caused the first day in the stats database to be left out +of calculations of all-time stats. + + +2.3.1 04/15/13 + +Fixed bug that prevented Fine Offset stations from downloading archive +records if the archive database had no records in it. + +rsync should now work with Python 2.5 and 2.6 (not just 2.7) + + +2.3.0 04/10/13 + +Davis Vantage stations can now produce station pressures (aka, "absolute +pressure"), altimeter pressures, as well as sea-level pressure. These will +be put in the archive database. + +Along the same line, 'altimeter' pressure is now reported to CWOP, rather +than the 'barometer' pressure. If altimeter pressure is not available, +no pressure is reported. + +Fixed bug in CWOP upload that put spaces in the upload string if the pressure +was under 1000 millibars. + +A bad record archive type now causes a catch up to be abandoned, rather +than program termination. + +Fixed bug in trends, when showing changes in temperature. NB: this fix will +not work with explicit unit conversion. I.e., $trend.outTemp.degree_C will +not work. + +Modified wee_config_vantage and wee_config_fousb so that the configuration +file will be guessed if none is specified. + +Fixed wxformulas.heatindexC to handle arguments of None type. + +Fixed bug that causes Corrections to be applied twice to archive records if +software record generation is used. + +rsync now allows a port to be specified. + +Fixed day/night transition bug. + +Added gradients to the day/night transitions. + +Numerous fixes to the WMR200 driver. Now has a "watchdog" thread. + +All of the device drivers have now been put in their own package +'weewx.drivers' to keep them together. Many have also had name changes +to make them more consistent: + OLD NEW + VantagePro.py (Vantage) vantage.py (Vantage) + WMR918.py (WMR-918) wmr9x8.py (WMR9x8) + wmrx.py (WMR-USB) wmr100.py (WMR100) + + new (experimental) drivers: + wmr200.py (WMR200) + ws28xx.py (WS28xx) + +The interface to the device driver "loader" function has changed slightly. It +now takes a second parameter, "engine". Details are in the Upgrading doc. + +The FineOffsetUSB driver now supports hardware archive record generation. + +When starting weewx, the FineOffsetUSB driver will now try to 'catch up' - it +will read the console memory for any records that are not yet in the database. + +Added illuminance-to-radiation conversion in FineOffsetUSB driver. + +Added pressure calibration option to wee_config_fousb and explicit support for +pressure calibration in FineOffsetUSB driver. + +Fixed windchill calculation in FineOffsetUSB driver. + +Fixed FineOffsetUSB driver to handle cases where the 'delay' is undefined, +resulting in a TypeError that caused weewx to stop. + +The FineOffsetUSB driver now uses 'max_rain_rate' (measured in cm/hr) instead +of 'max_sane_rain' (measured in mm) to filter spurious rain sensor readings. +This is done in the driver instead of StdQC so that a single parameter can +apply to both LOOP and ARCHIVE records. + +2.2.1 02/15/13 + +Added a function call to the Vantage driver that allows the lamp to be +turned on and off. Added a corresponding option to wee_config_vantage. + +Fixed bug where an undefined wind direction caused an exception when using +ordinal wind directions. + +2.2.0 02/14/13 + +Weewx can now be installed using Debian (DEB) or Redhat (RPM) packages, as well +as with the old 'setup.py' method. Because they install things in different +places, you should stick with one method or another. Don't mix and match. +Thanks to Matthew Wall for putting this together! + +Added plot options line_gap_fraction and bar_gap_fraction, which control how +gaps in the data are handled by the plots. Also, added more flexible control of +plot colors, using a notation such as 0xBBGGRR, #RRGGBB, or the English name, +such as 'yellow'. Finally, added day/night bands to the plots. All contributed +by Matthew Wall. Thanks again, Matthew! + +Ordinal wind directions can now be shown, just by adding the tag suffix +".ordinal_compass". For example, $current.windDir.ordinal_compass might show +'SSE' The abbreviations are set in the skin configuration file. + +Fixed bug that caused rain totals to be misreported to Weather Underground when +using a metric database. + +Generalized the weewx machinery so it can be used for applications other than +weather applications. + +Got rid of option stats_types in weewx.conf and put it in +bin/user/schemas.py. See upgrading.html if you have a specialized stats +database. + +The stats database now includes an internal table of participating observation +types. This allows it to be easily combined with the archive database, should +you choose to do so. The table is automatically created for older stats +databases. + +Added rain rate calculation to FineOffsetUSB driver. Added adaptive polling +option to FineOffsetUSB driver. Fixed barometric pressure calculation for +FineOffsetUSB driver. + +Changed the name of the utilities, so they will be easier to find in /usr/bin: + weewxd.py -> weewxd + runreports.py -> wee_reports + config_database.py -> wee_config_database + config_vp.py -> wee_config_vantage + config_fousb.py -> wee_config_fousb + +2.1.1 01/02/13 + +Fixed bug that shows itself when one of the variables is 'None' when +calculating a trend. + +2.1.0 01/02/13 + +Now supports the Oregon Scientific WMR918/968 series, courtesy of user +William Page. Thanks, William!! + +Now supports the Fine Offset series of weather stations, thanks to user +Matthew Wall. Thanks, Matthew!! + +Now includes a Redhat init.d script, contributed by Mark Jenks. Thanks, +Mark!! + +Added rsync report type as an alternative to the existing FTP report. +Another thanks to William Page! + +Fill color for bar charts can now be specified separately from the outline +color, resulting in much more attractive charts. Another thanks to Matthew +Wall!! + +Added a tag for trends. The barometer trend can now be returned as +$trend.barometer. Similar syntax for other observation types. + +config_vp.py now returns the console version number if available (older +consoles do not offer this). + +Hardware dewpoint calculations with the WMR100 seem to be unreliable below +about 20F, so these are now done in software. Thanks to user Mark Jenks for +sleuthing this. + +2.0.2 11/23/12 + +Now allows both the archive and stats data to be held in the same database. + +Improved chances of weewx.Archive being reused by allowing optional table +name to be specified. + +2.0.1 11/05/12 + +Fixed problem with reconfiguring databases to a new unit system. + +2.0.0 11/04/12 + +A big release with lots of changes. The two most important are the support +of additional weather hardware, and the support of the MySQL database. + +All skin configurations are backwardly compatible, but the configuration +file, weewx.conf, is not. The install utility setup.py will install a fresh +version, which you will then have to edit by hand. + +If you have written a custom service, see the upgrade guide on how to port +your service to the new architecture. + +Added the ability to generate archive records in software, thus opening the +door for supporting weather stations that do not have a logger. + +Support for the Oregon Scientific WMR100, the cheapest weather station I +could find, in order to demonstrate the above! + +Added a software weather station simulator. + +Introduced weedb, a database-independent Python wrapper around sqlite3 and +MySQLdb, which fixes some of their flaws. + +Ported everything to use weedb, and thus MySQL (as well as sqlite) + +Internally, the databases can now use either Metric units, or US Customary. +NB: you cannot switch systems in the middle of a database. You have to +stick to one or other other. However, the utility config_database.py does +have a reconfigure option that allows copying the data to a new database, +2performing the conversion along the way. See the Customizing Guide. + +You can now use "mmHg" as a unit of pressure. + +Added new almanac information, such as first and last quarter moons, and +civil twilight. + +Changed the engine architecture so it is more event driven. It now uses +callbacks, making it easier to add new event types. + +Added utility config_vp.py, for configuring the VantagePro hardware. + +Added utility config_database.py, for configuring the databases. + +Made it easier to write custom RESTful protocols. Thanks to user Brad, for +the idea and the use case! + +The stats type 'squarecount' now contains the number of valid wind +directions that went into calculating 'xsum' and 'ysum'. It used to be the +number of valid wind speeds. Wind direction is now calculated using +'squarecount' (instead of 'count'). + +Simplified and reduced the memory requirements of the CRC16 calculations. + +Improved test suites. + +Lots of little nips and tucks here and there, mostly to reduce the coupling +between different modules. In particular, now a service generally gets +configured only using its section of weewx.conf. + +I also worked hard at making sure that cursors, connections, files, and +lots of other bits and pieces get properly closed instead of relying on +garbage collection. Hopefully, this will reduce the long-term growth of +memory usage. + +1.14.1 07/06/12 + +Hardened retry strategy for the WeatherLink IP. If the port fails to open +at all, or a socket error occurs, it will thrown an exception (resulting in +a retry in 60 seconds). If a socket returns an incomplete result, it will +continue to retry until everything has been read. + +Fixed minor bug that causes the reporting thread to prematurely terminate +if an exception is thrown while doing an FTP. + +1.14.0 06/18/12 + +Added smartphone formatted mobile webpage, contributed by user Torbjörn +Einarsson. If you are doing a fresh install, then these pages will be +generated automatically. If you are doing an upgrade, then see the upgrade +guide on how to have these webpages generated. Thanks, Tobbe! + +Three changes suggested by user Charlie Spirakis: o Changed umask in +daemon.py to 0022; o Allow location of process ID file to be specified on +the command line of weewx; o Start script allows daemon to be run as a +specific user. Thanks, Charlie! + +Corrected bug in humidity reports to CWOP that shows itself when the +humidity is in the single digits. + +Now includes software in CWOP APRS equipment field. + +1.13.2 05/02/12 + +Now allows CWOP stations with prefix 'EW'. + +Fixed bug that showed itself in the line color with plots with 3 or more +lines. + +Changed debug message when reaching the end of memory in the VP2 to +something slightly less alarming. + +1.13.1 03/25/12 + +Added finer control over the line plots. Can now add optional markers. The +marker_type can be 'none' (the default), 'cross', 'box', 'circle', or 'x'. +Also, line_type can now either be 'solid' (the default) or 'none' (for +scatter plots). Same day I'll add 'dashed', but not now. :-) + +Conditionally imports sqlite3. If it does not support the "with" statement, +then imports pysqlite2 as sqlite3. + +1.13.0 03/13/12 + +The binding to the SQL database to be used now happens much later when +running reports. This allows more than one database to be used when running +a report. Extra databases can be specified in the option list for a report. +I use this to display broadband bandwidth information, which was collected +by a separate program. Email me for details on how to do this. Introducing +this feature changed the signature of a few functions. See the upgrade +guide for details. + +1.12.4 02/13/12 + +User Alf Høgemark found an error in the encoding of solar data for CWOP +and sent me a fix. Thanks, Alf! + +Now always uses "import sqlite3", resulting in using the version of +pysqlite that comes with Python. This means the install instructions have +been simplified. + +Now doesn't choke when using the (rare) Python version of NameMapper used +by Cheetah. + +1.12.3 02/09/12 + +Added start script for FreeBSD, courtesy of user Fabian Abplanalp. Thanks, +Fabian! + +Added the ability to respond to a "status" query to the Debian startup +script. + +RESTful posts can now recover from more HTTP errors. + +Station serial port can now recover from a SerialException error (usually +caused when there is a process competing for the serial port). + +Continue to fiddle with the retry logic when reading LOOP data. + +1.12.2 01/18/12 + +Added check for FTP error code '521' to the list of possibilities if a +directory already exists. Thanks to user Clyde! + +More complete information when unable to load a module file. Thanks, Jason! + +Added a few new unit types to the list of possible target units when using +explicit conversion. Thanks, Antonio! + +Discovered and fixed problem caused by the Davis docs giving the wrong +"resend" code (should be decimal 21, not hex 21). + +Improved robustness of VantagePro configuration utility. + +Fixed problem where an exception gets thrown when changing VP archive +interval. + +Simplified some of the logic in the VP2 driver. + +1.12.1 11/03/11 + +Now corrects for rain bucket size if it is something other than the +standard 0.01 inch bucket. + +1.12.0 10/29/11 + +Added the ability to change bucket type, rain year start, and barometer +calibration data in the console using the utility configure.py. Added +option "--info", which queries the console and returns information about +EEPROM settings. Changed configure.py so it can do hardware-specific +configurations, in anticipation of supporting hardware besides the Davis +series. + +Reorganized the documentation. + +1.11.0 10/06/11 + +Added support for the Davis WeatherLinkIP. Thanks, Peter Nock and Travis +Pickle! + +Added support for older Rev A type archive records. + +Added patch from user Dan Haller that sends UV and radiation data to the +WeatherUnderground if available. Thanks, Dan! + +Added patch from user Marijn Vriens that allows fallback to the version of +pysqlite that comes with many versions of Python. Thanks, Marijn! + +Now does garbage collection after an archive record is obtained and before +the main loop is restarted. + +1.10.2 04/14/11 + +Added RA and declination for the Sun and Moon to the Daily Almanac. Equinox +and solstice are now displayed in chronological order. Same with new and +full moons. + +Examples alarm.py and lowBattery.py now include more error checks, allow an +optional 'subject' line to the sent email, and allow a comma separated list +of recipients. + +1.10.1 03/30/11 + +Substitutes US Units if a user does not specify anything (instead of +exception KeyError). + +Almanac uses default temperature and pressure if they are 'None'. + +Prettied up web page almanac data in the case where pyephem has not been +installed. + +Fixed up malformed CSS script weewx.css. + +1.10.0 03/29/11 + +Added extensive almanac information if the optional package 'pyephem' has +been installed + +Added a weewx "favorite icon" favicon.ico that displays in your browser +toolbar. + +Added a mobile formatted HTML page, courtesy of user Vince Skahan (thanks, +Vince!!). + +Tags can now be ended with a unit type to convert to a new unit. For +example, say your pressure group ("group_pressure") has been set to show +inHg. The normal tag notation of "$day.barometer.avg" will show something +like "30.05 inHg". However, the tag "$day.barometer.avg.mbar" will show +"1017.5 mbar". + +Added special tag "exists" to test whether an observation type exists. +Example "$year.foo.exists" will return False if there is no type "foo" in +the statistical database. + +Added special tag "has_data" to test whether an observation type exists and +has a non-zero number of data points over the aggregation period. For +example, "$year.soilMoist1.has_data" will return "True" if soilMoist1 both +exists in the stats database and contains some data (meaning, you have the +hardware). + +Y-axis plot labels (such as "°F") can now be overridden in the plot +configuration section of skin.conf by using option "y_label". + +Added executable module "runreports.py" for running report generation only. + +Added package "user", which can contain any user extensions. This package +will not get overridden in the upgrade process. + +Added the ability to reconfigure the main database, i.e., add or drop data +types. Along the same line, statistical types can also be added or dropped. +Email me for details on how to do this. + +Now makes all of the LOOP and archive data available to services. This +includes new keys: + + LOOP data: 'extraAlarm1' 'extraAlarm2' 'extraAlarm3' 'extraAlarm4' +'extraAlarm5' 'extraAlarm6' 'extraAlarm7' 'extraAlarm8' 'forecastIcon' +'forecastRule' 'insideAlarm' 'outsideAlarm1' 'outsideAlarm2' 'rainAlarm' +'soilLeafAlarm1' 'soilLeafAlarm2' 'soilLeafAlarm3' 'soilLeafAlarm4' +'sunrise' 'sunset' + + Archive data: 'forecastRule' 'highOutTemp' 'highRadiation' 'highUV' +'lowOutTemp' + +Started a more formal test suite. There are now tests for the report +generators. These are not included in the normal distribution, but can be +retrieved from SourceForge via svn. + +1.9.3 02/04/11 + +Now correctly decodes temperatures from LOOP packets as signed shorts +(rather than unsigned). + +Now does a CRC check on LOOP data. + +Changed VantagePro.accumulateLoop to make it slightly more robust. + +1.9.2 11/20/10 + +Now catches exception of type OverflowError when calculating celsius +dewpoint. (Despite the documentation indicating otherwise, math.log() can +still throw an OverflowError) + +Fixed bug that causes crash in VantagePro.accumulateLoop() during fall DST +transition in certain situations. + +VP2 does not store records during the one hour fall DST transition. +Improved logic in dealing with this. + +Changed install so that it backs up the ./bin subdirectory, then overwrites +the old one. Also, does not install the ./skins subdirectory at all if one +already exists (thus preserving any user customization). + +1.9.1 09/09/10 + +Now catches exceptions of type httplib.BadStatusLine when doing RESTful +posts. + +Added an extra decimal point of precision to dew point reports to the +Weather Underground and PWS. + +1.9.0 07/04/10 + +Added a new service, StdQC, that offers a rudimentary data check. + +Corrected error in rain year total if rain year does not start in January. + +Moved option max_drift (the max amount of clock drift to tolerate) to +section [Station]. + +Added check for a bad storm start time. + +Added checks for bad dateTime. + +Simplified VantagePro module. + +1.8.4 06/06/10 + +Fixed problem that shows itself if weewx starts up at precisely the +beginning of an archive interval. Symptom is max recursion depth exceeded. + +Units for UV in LOOP records corrected. Also, introduced new group for UV, +group_uv_index. Thanks to user A. Burriel for this fix! + +1.8.3 05/20/10 + +Problem with configuring archive interval found and fixed by user A. +Burriel (thanks, Antonio!) + +1.8.2 05/09/10 + +Added check to skip calibration for a type that doesn't exist in LOOP or +archive records. This allows windSpeed and windGust to be calibrated +separately. + +1.8.1 05/01/10 + +Ported to Cheetah V2.4.X + +1.8.0 04/28/10 + +Added CWOP support. + +Storage of LOOP and archive data into the SQL databases is now just another +service, StdArchive. + +Added a calibration service, StdCalibrate, that can correct LOOP and +archive data. + +Average console battery voltage is now calculated from LOOP data, and saved +to the archive as 'consBatteryVoltage'. + +Transmitter battery status is now ORd together from LOOP data, and saved to +the archive as 'txBatteryStatus'. + +Added stack tracebacks for unrecoverable exceptions. + +Added a wrapper to the serial port in the VantagePro code. When used in a +Python "with" statement, it automatically releases the serial port if an +exception happens, allowing a more orderly shutdown. + +Offered some hints in the documentation on how to automount your VP2 when +using a USB connection. + +Corrected error in units. getTargetType() that showed itself with when the +console memory was freshly cleared, then tried to graph something +immediately. + +1.7.0 04/15/10 + +Big update. + +Reports now use skins for their "look or feel." Options specific to the +presentation layer have been moved out of the weewx configuration file +'weewx.conf' to a skin configuration file, 'skin.conf'. Other options have +remained behind. + +Because the configuration file weewx.conf was split, the installation +script setup.py will NOT merge your old configuration file into the new +one. You will have to reedit weewx.conf to put in your customizations. + +FTP is treated as just another report, albeit with an unusual generator. +You can have multiple FTP sessions, each to a different server, or +uploading to or from a different area. + +Rewrote the FTP upload package so that it allows more than one FTP session +to be active in the same local directory. This version also does fewer hits +on the server, so it is significantly faster. + +The configuration files weewx.conf and skin.conf now expect UTF-8 +characters throughout. + +The encoding for reports generated from templates can be chosen. By +default, the day, week, month, and year HTML files are encoded using HTML +entities; the NOAA reports encoded using 'strict ascii.' Optionally, +reports can be encoded using UTF-8. + +Revamped the template formatting. No longer use class ModelView. Went to a +simpler system built around classes ValueHelper and UnitInfo. + +Optional formatting was added to all tags in the templates. There are now +optional endings: 'string': Use specified string for None value. +'formatted': No label. 'format': Format using specified string format. +'nolabel': Format using specified string format; no label. 'raw': return +the underlying data with no string formatting or label. + +For the index, week, month, and year template files, added conditional to +not include ISS extended types (UV, radiation, ET) unless they exist. + +Added an RSS feed. + +Added support for PWSweather.com + +Both WeatherUnderground and PWSweather posts are now retried up to 3 times +before giving up. + +Now offer a section 'Extras' in the skin configuration file for including +tags added by the user. As an example, the tag radar_url has been moved +into here. + +Data files used in reports (such as weewx.css) are copied over to the HTML +directory on program startup. + +Included an example of a low-battery alarm. + +Rearranged distribution directory structure so that it matches the install +directory structure. + +Moved base temperature for heating and cooling degree days into skin.conf. +They now also require a unit. + +Now require unit to be specified for 'altitude'. + +1.5.0 03/07/10 + +Added support for other units besides the U.S. Customary. Plots and HTML +reports can be prepared using any arbitrary combination of units. For +example, pressure could be in millibars, while everything else is in U.S. +Customary. + +Because the configuration file weewx.conf changed significantly, the +installation script setup.py will NOT merge your old configuration file +into the new one. You will have to reedit weewx.conf to put in your +customizations. + +Added an exception handler for exception OSError, which is typically thrown +when another piece of software attempts to access the same device port. +Weewx catches the exception, waits 10 seconds, then starts again from the +top. + +1.4.0 02/22/10 + +Changed the architecture of stats.py to one that uses very late binding. +The SQL statements are not run until template evaluation. This reduces the +amount of memory required (by about 1/2), reduces memory fragmentation, as +well as greatly simplifying the code (file stats.py shed over 150 lines of +non-test code). Execution time is slightly slower for NOAA file generation, +slightly faster for HTML file generation, the same for image generation, +although your actual results will depend on your disk speed. + +Now possible to tell weewx to reread the configuration file without +stopping it. Send signal HUP to the process. + +Added option week_start, for specifying which day a calendar week starts +on. Default is 6 (Sunday). + +Fixed reporting bug when the reporting time falls on a calendar month or +year boundary. + +1.3.4 02/08/10 + +Fixed problem when plotting data where all data points are bad (None). + +1.3.3 01/10/10 + +Fixed reporting bug that shows itself if rain year does not start in +January. + +1.3.2 12/26/09 + +LOOP data added to stats database. + +1.3.1 12/22/09 + +Added a call to syslog.openlog() that inadvertently got left out when +switching to the engine driven architecture. + +1.3.0 12/21/09 + +Moved to a very different architecture to drive weewx. Consists of an +engine, that manages a list of 'services.' At key events, each service is +given a chance to participate. Services are easy to add, to allow easy +customization. An example is offered of an 'alarm' service. + +Checking the clock of the weather station for drift is now a service, so +the option clock_check was moved from the station specific [VantagePro] +section to the more general [Station] section. + +Added an example service 'MyAlarm', which sends out an email should the +outside temperature drop below 40 degrees. + +In a similar manner, all generated files, images, and reports are the +product of a report engine, which can run any number of reports. New +reports are easily added. + +Moved the compass rose used in progressive vector plots into the interior +of the plot. + +Install now deletes public_html/#upstream.last, thus forcing all files to +be uploaded to the web server at the next opportunity. + +1.2.0 11/22/09 + +Added progressive vector plots for wind data. + +Improved axis scaling. The automatic axis scaling routine now does a better +job for ranges less than 1.0. The user can also hardwire in min and max +values, as well as specify a minimum increment, through parameter 'yscale' +in section [Images] in the configuration file. + +Now allows the same SQL type to be used more than once in a plot. This +allows, say, instantaneous and average wind speed to be shown in the same +plot. + +Rain year is now parameterized in file templates/year.tmpl (instead of +being hardwired in). + +Now does LOOP caching by default. + +When doing backfilling to the stats database, configure now creates the +stats database if it doesn't already exist. + +setup.py now more robust to upgrading the FTP and Wunderground sections + +1.1.0 11/14/09 + +Added the ability to cache LOOP data. This can dramatically reduce the +number of writes to the stats database, reducing wear on solid-state disk +stores. + +Introduced module weewx.mainloop. Introduced class weewx.mainloop.MainLoop +This class offers many opportunities to customize weewx through +subclassing, then overriding an appropriate member function. + +Refactored module weewx.wunderground so it more closely resembles the +(better) logic in wunderfixer. + +setup.py no longer installs a daemon startup script to /etc/init.d. It must +now be done by hand. + +setup.py now uses the 'home' value in setup.cfg to set WEEWX_ROOT in +weewx.conf and in the daemon start up scripts + +Now uses FTP passive mode by default. + +1.0.1 11/09/09 + +Fixed bug that prevented backfilling the stats database after modifying the +main archive. + +1.0.0 10/26/09 + +Took the module weewx.factory back out, as it was too complicated and hard +to understand. + +Added support for generating NOAA monthly and yearly reports. Completely +rewrote the filegenerator.py module, to allow easy subclassing and +specialization. + +Completely rewrote the stats.py module. All aggregate quantities are now +calculated dynamically. + +Labels for HTML generation are now held separately from labels used for +image generation. This allows entities such as '°' to be used for the +former. + +LOOP mode now requests only 200 LOOP records (instead of the old 2000). It +then renews the request should it run out. This was to get around an +(undocumented) limitation in the VP2 that limits the number of LOOP records +that can be requested to something like 220. This was a problem when +supporting VP2s that use long archive intervals. + +Cut down the amount of computing that went on before the processing thread +was spawned, thus allowing the main thread to get back into LOOP mode more +quickly. + +Added type 'rainRate' to the types decoded from a Davis archive record. For +some reason it was left out. + +Added retries when doing FTP uploads. It will now attempt the upload +several times before giving up. + +Much more extensive DEBUG analysis. + +Nipped and tucked here and there, trying to simplify. + +0.6.5 10/11/09 + +Ported to Cheetah V2.2.X. Mostly, this is making sure that all strings that +cannot be converted with the 'ascii' codec are converted to Unicode first +before feeding to Cheetah. + +0.6.4 09/22/09 + +Fixed an error in the calculation of heat index. + +0.6.3 08/25/09 + +FTP transfers now default to ACTIVE mode, but a configuration file option +allows PASSIVE mode. This was necessary to support Microsoft FTP servers. + +0.6.2 08/01/09 + +Exception handling in weewx/ftpdata.py used socket.error but failed to +declare it. Added 'import socket' to fix. + +Added more complete check for unused pages in weewx/VantagePro.py. Now the +entire record must be filled with 0xff, not just the time field. This fixes +a bug where certain time stamps could look like unused records. + +0.6.1 06/22/09 + +Fixed minor ftp bug. + +0.6.0 05/20/09 + +Changed the file, imaging, ftping functions into objects, so they can be +more easily specialized by the user. + +Introduced a StationData object. + +Introduced module weewx.factory that produces these things, so the user has +a place to inject his/her new types. + +0.5.1 05/13/09 + +1. Weather Underground thread now run as daemon thread, allowing the +program to exit even if it is running. + +2. WU queue now hold an instance of archive and the time to be published, +rather than a record. This allows dailyrain to be published as well. + +3. WU date is now given in the format "2009-05-13+12%3A35%3A00" rather than +"2009-05-13 12:35:00". Seems to be more reliable. But, maybe I'm imagining +things... + diff --git a/dist/weewx-4.10.1/docs/copyright.htm b/dist/weewx-4.10.1/docs/copyright.htm new file mode 100644 index 0000000..6e0fd85 --- /dev/null +++ b/dist/weewx-4.10.1/docs/copyright.htm @@ -0,0 +1,80 @@ + + + + + weewx: Copyright + + + + + +
+ +

WeeWX Copyright

+ +

(c) 2009-2020 by Tom Keffer <tkeffer@gmail.com> +

+

+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, either version 3 of the License, or (at your + option) any later version. +

+

+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + more details. +

+

+ http://www.gnu.org/licenses +

+ +

 

+

 

+ +

+ The WMR9x8 driver is Copyright Will Page. +

+

+ The FineOffsetUSB driver is Copyright Matthew Wall, based on the open source pywws by Jim Easterbrook. +

+

+ The WS23xx driver is Copyright Matthew Wall, including significant portions from ws2300 by Russel Stuart. +

+

+ The WS28xx driver is Copyright Matthew Wall, based on the original Python implementation by Eddi de Pieri and + with significant contributions by LJM Heijst. +

+

+ The TE923 driver is Copyright Matthew Wall with significant contributions from Andrew Miles. +

+

+ The CC3000 driver is Copyright Matthew Wall, thanks to hardware contributed by Annie Brox. +

+

+ The WS1 driver is Copyright Matthew Wall. +

+

+ The Ultimeter driver is Copyright Matthew Wall and Nate Bargmann. +

+

+ The Acurite driver is Copyright Matthew Wall. +

+

+ The WMR300 driver is Copyright Matthew Wall, thanks to hardware contributed by EricG. +

+

+ Some icons are Copyright Tatice (http://tatice.deviantart.com), licensed under the terms of Creative Commons + Attribution-NonCommercial-NoDerivs 3.0. +

+

+ The debian, redhat, and suse packaging configurations are Copyright Matthew Wall, licensed under the terms of + GNU Public License 3. +

+ + + +
+ + diff --git a/dist/weewx-4.10.1/docs/css/tocbot-4.12.0.css b/dist/weewx-4.10.1/docs/css/tocbot-4.12.0.css new file mode 100644 index 0000000..0632de2 --- /dev/null +++ b/dist/weewx-4.10.1/docs/css/tocbot-4.12.0.css @@ -0,0 +1 @@ +.toc{overflow-y:auto}.toc>.toc-list{overflow:hidden;position:relative}.toc>.toc-list li{list-style:none}.toc-list{margin:0;padding-left:10px}a.toc-link{color:currentColor;height:100%}.is-collapsible{max-height:1000px;overflow:hidden;transition:all 300ms ease-in-out}.is-collapsed{max-height:0}.is-position-fixed{position:fixed !important;top:0}.is-active-link{font-weight:700}.toc-link::before{background-color:#EEE;content:' ';display:inline-block;height:inherit;left:0;margin-top:-1px;position:absolute;width:2px}.is-active-link::before{background-color:#54BC4B} diff --git a/dist/weewx-4.10.1/docs/css/tocbot-4.3.1.css b/dist/weewx-4.10.1/docs/css/tocbot-4.3.1.css new file mode 100644 index 0000000..0632de2 --- /dev/null +++ b/dist/weewx-4.10.1/docs/css/tocbot-4.3.1.css @@ -0,0 +1 @@ +.toc{overflow-y:auto}.toc>.toc-list{overflow:hidden;position:relative}.toc>.toc-list li{list-style:none}.toc-list{margin:0;padding-left:10px}a.toc-link{color:currentColor;height:100%}.is-collapsible{max-height:1000px;overflow:hidden;transition:all 300ms ease-in-out}.is-collapsed{max-height:0}.is-position-fixed{position:fixed !important;top:0}.is-active-link{font-weight:700}.toc-link::before{background-color:#EEE;content:' ';display:inline-block;height:inherit;left:0;margin-top:-1px;position:absolute;width:2px}.is-active-link::before{background-color:#54BC4B} diff --git a/dist/weewx-4.10.1/docs/css/weewx_ui.css b/dist/weewx-4.10.1/docs/css/weewx_ui.css new file mode 100644 index 0000000..5ba5f24 --- /dev/null +++ b/dist/weewx-4.10.1/docs/css/weewx_ui.css @@ -0,0 +1,696 @@ +/* Styles for the weewx documentation + * + * Copyright (c) 2009-2019 Tom Keffer + * + * See the file LICENSE.txt for your rights. + * + */ +/*noinspection CssUnknownTarget,CssUnknownTarget*/ +@import url('https://fonts.googleapis.com/css?family=Roboto:700|Noto+Sans|Inconsolata:400,700|Droid+Serif'); + +body { + font-family: 'Noto Sans', sans-serif; + margin-top: 4px; +} + +@media (min-width: 320px) { + .sidebar { + display: none; + } + + .main { + width: 100%; + } + + .tr { + font-size: 80%; + } +} + +@media (min-width: 641px) { + .sidebar { + display: block; + width: 190px; + } + + .main { + margin-left: 210px; + width: auto; + } + + .tr { + font-size: 80%; + } +} + +@media (min-width: 961px) { + .sidebar { + display: block; + width: 190px; + } + + .main { + margin-left: 210px; + } +} + +@media (min-width: 1025px) { + .sidebar { + display: block; + width: 220px; + } + + .main { + margin-left: 230px; + width: auto; + } +} + +@media (min-width: 1281px) { + .sidebar { + display: block; + width: 250px; + } + + .main { + margin-left: 270px; + } +} + +@media (max-height: 800px) { + .sidebar { + max-height: 60%; + } +} + +div.sidebar { + position: fixed; + top: 4px; + left: 4px; + bottom: 4px; +} + +div.main { + overflow-x: hidden; +} + +.header { + margin-top: 0; + padding-left: 4px; + padding-bottom: 24px; + border: 1px solid #999999; + background-color: #aacccc; + border-radius: 3px; +} + +.content { + padding-left: 4px; +} + +.footer { + margin-top: 50px; +} + +.doclist { + padding: 4px; + margin-bottom: 16px; + border: 1px solid #999999; + border-radius: 3px; +} + +.title { + font-family: 'Roboto', sans-serif; + font-size: 180%; + font-weight: bold; + margin-top: 0; +} + +li { + margin-right: 10%; + margin-top: 10px; +} + +dt { + margin-top: 10px; +} + +dd { + margin-top: 5px; +} + +h1 { + background-color: #aacccc; + border-radius: 3px; + border: 1px solid #999999; + clear:both; + color: black; + font-family: 'Roboto', sans-serif; + font-size: 160%; + font-weight: bold; + margin-bottom: 0; + margin-top: 2em; + padding-left: .5em; + padding-right: .5em; +} + +h2 { + border-bottom: 1px solid #999999; + clear:both; + color: teal; + font-family: 'Roboto', sans-serif; + font-size: 140%; + font-weight: bold; + margin-bottom: 0; + margin-top: 2em; +} + +h3 { + clear:left; + color: teal; + font-family: 'Roboto', sans-serif; + font-size: 120%; + font-weight: bold; + margin-bottom: 0; + margin-top: 1.5em; +} + +h4 { + clear:left; + color: teal; + font-family: 'Roboto', sans-serif; + font-size: 100%; + font-weight: bold; +} + + +table { + background-color: white; + border-collapse: collapse; + border: 1px solid #cccccc; + width: 98%; + margin: 1%; +} + +table .tty { + margin: 0; +} + +tr { + vertical-align: top; + font-size: 100%; +} + +td { + border: 1px solid #cccccc; + padding: 2px 2px 2px 8px; +} + +table .first_row { + font-weight: bold; + background-color: #ddefef; + padding-left: 10px; + padding-right: 10px; +} + +table.fixed_width td { + width: 10%; +} + +caption { + background-color: #aacccc; + margin: 0 0 8px; + border: 1px solid #888888; + padding: 6px 16px; + font-weight: bold; +} + + +.code { + font-family: 'Inconsolata', monospace; +} + +p .code, td .code, li .code, dd .code { + background-color: #ecf3f3; + padding-left: 3px; + padding-right: 3px; +} + +.symcode { + font-family: 'Inconsolata', monospace; + font-style: italic; +} + +p .symcode { + background-color: #ecf3f3; + padding-left: 1px; + padding-right: 1px; +} + +.indent { + margin-left: 30px; + width: 95%; +} + +.station_data { + margin-left: 40px; + margin-right: 80px; + width: 500px; +} + +.station_data_key { + font-size: 80%; + font-style: italic; + margin-left: 40px; + margin-right: 80px; + width: 500px; +} + +.cmd { + font-weight: bold; +} + +.tty { + font-family: 'Inconsolata', monospace; + background-color: #ecf3f3; + border: 1px solid #ccd3d3; + padding: 3px 8px 3px 8px; + margin: 5px 15px 5px 15px; + white-space: pre; + line-height: normal; +} + +.config_section { +} + +.config_option, .config_important { + font-family: 'Inconsolata', monospace; + font-weight: bold; + color: black; + margin-top: 1.5em; + margin-bottom: 0; +} + +.config_important { + color: #bb9900; +} + +.highlight { + background-color: #fff777; +} + +.text_highlight, .first_col { + font-weight: bold; + background-color: #eef0f0; + padding-left: 10px; + padding-right: 10px; +} + +.center { + text-align: center; +} + +.example_output { + font-family: 'Droid Serif', serif; + padding: 15px 20px 15px 20px; + margin: 5px 15px 5px 15px; + border: 1px solid #cccccc; + box-shadow: 2px 2px 2px #dddddd; + display: inline-block; +} + +.example_text { + font-family: 'Noto Sans', sans-serif; + font-weight: bold; + background-color: #ecf3f3; + padding: 0px 4px 0px 4px; +} + +.image { + padding: 5px; +} + +.image-right { + padding-left: 50px; + padding-right: 20px; + float: right; +} + +.image_caption { + font-size: 80%; + text-align: center; + padding: 5px; +} + +.note { + background-color: #ddf0e0; + border: 1px solid #bbd0c0; + margin: 10px 30px 10px 30px; + padding: 10px; + border-radius: 6px; +} + +.warning { + background-color: #ffeeee; + border: 1px solid #ffdddd; + margin: 10px 30px 10px 30px; + padding: 10px; + border-radius: 6px; +} + +.copyright { + font-style: italic; + text-align: right; +} + +.prompt { + font-weight: bold; +} + +.thumbnail { + width: 12px; +} + +.locations { + border: none; + margin-left: 20px; + margin-right: 20px; +} + +.locations tr { + border: none; + vertical-align: middle; +} + +.version { + font-size: 60%; +} + +.logo { + height: 24px; + padding-right: 0; + padding-top: 5px; + padding-bottom: 5px; +} + +.logoref { + float: right; + padding-right: 10px; +} + +.os-icons { + float: right; + margin-left: 20px; +} + +/* Multi-column list */ +.mc-list { + column-count: 3; + list-style: none; +} + +ul.mc-list > li { + display: inline-block; + width: 100%; +} + +/* + * The stats styles mimic the styles used in the default standard template + * output so that examples in the docs match those of the standard template. + */ +.stats { + font-family: 'Noto Sans', sans-serif; + padding: 13px 58px 13px 58px; +} + +.stats table { + border: thin solid #000000; + width: 100%; +} + +.stats td { + border: thin solid #000000; + padding: 2px; +} + +.stats_label { + color: green; +} + +.stats_data { + color: red; +} + + +/********************************* settings for printing *********************************/ + +@media print { + /* Impose portrait printing with forced margins */ + @page { + size: landscape; + margin: 1.8cm 1cm; + } + + body { + font-family: sans-serif; + font-size: 10pt; + background: none; + } + + div.sidebar { + display: none; /* Delete the left menu */ + } + + div.main { + margin: 0; /* Do not make any margin for sidebar since there is none */ + } + + /* underline headers */ + .header { + border: none; + border-bottom: 1px solid #999999; + } + + .footer { + display: none; + } + + .title { + text-align: center; + font-size: 250%; + } + + .image { + max-width: 98%; /* To be responsive the size of the image must at most be in width that of the article container */ + height: auto; /* Keep the ratio when the image is resized */ + } + + table { + max-width: 98%; /* To be responsive the size of the image must at most be in width that of the article container */ + height: auto; /* Keep the ratio when the image is resized */ + } + + p, blockquote { + orphans: 3; /* No orphan line down */ + widows: 3; /* No orphan line up */ + } + + /* No cut in these elements */ + blockquote, ul, ol, table, .tty { + page-break-inside: avoid; + } + + /* Justify text for paragraphs */ + p, ul, ol { + text-align: justify; + } + + /* No jump after these elements */ + h1, h2, h3, h4, caption { + border: none; + border-radius: 0; + page-break-after: avoid; + } + + /* Each title begins on a new page */ + h1 { + border: none; + border-radius: 0; + border-bottom: 1px solid #999999; + page-break-before: always; + } + + a { + text-decoration: none; + } + + table { + border: none; + } + + td { + border: 1px solid #eeeeee; + } + + caption { + border: none; + } + + .note { + border: 4px solid #aac0b0; + background-color: #cce0d0 !important; + } + + .warning { + border: 4px solid #ffdddd; + background-color: #ffeeee !important; + } + + p .code, td .code, li .code { + background-color: #ecf3f3; + border: none; + } +} + +/********************************* Modal dialog box *********************************/ +/* + * The following dialog CSS was stolen from http://bit.ly/1cYAqTr + */ +.modal-dialog { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 99999; + opacity: 0; + transition: opacity 400ms ease-in; + pointer-events: none; +} + +.modal-dialog:target { + opacity: 1; + pointer-events: auto; +} + +.modal-dialog > div { + width: 400px; + position: relative; + margin: 10% auto; + padding: 5px 20px 13px 20px; + border-radius: 10px; + background: #fff; +} + +.close-dialog { + background: #606061; + color: #FFFFFF; + line-height: 25px; + position: absolute; + right: -12px; + text-align: center; + top: -10px; + width: 24px; + text-decoration: none; + font-weight: bold; + border-radius: 12px; + box-shadow: 1px 1px 3px #000; +} + +.close-dialog:hover { + background: #00d9ff; +} + +/********************************* Table of contents *********************************/ + +#toc_parent { + border-radius: 3px; + border: 1px solid #aaaaaa; + max-height: 75%; + overflow-x: hidden; + overflow-y: scroll; + padding: 4px; +} + +.toc { + border-radius: 0; + border: 0; + font-size: 1.1em; + margin-left: 0; + overflow-x: hidden; + overflow-y: hidden; + position: relative; + width: auto; +} + +a.toc-link { + text-decoration-line: none; +} + +a.toc-link:hover { + background-color: #ddefef; +} + +/* lists inside of lists should use progressively smaller fonts: */ +.toc-list .toc-list { + font-size: 85%; +} + +.is-active-link { + font-weight: normal; + background-color: #aacccc; +} + +.toc-list li { + line-height: 1.3; + margin: 0 0 0 0; +} + +/********************************* Tabs *********************************/ + +.tabs { + clear: both; + float: left; + margin: 0 1em 1em 1em; + width: 95%; +} + +.tabs img { + display: inline; +} + +.tab { + background-color: white; + border-radius: 6px 6px 0 0; + border: 1px solid #cccccc; + color: #808080; + cursor: pointer; + float: left; + font-family: inherit; + font-size: 80%; + font-weight: bold; + margin:2px 4px 0 0; + padding: 4px 10px; + position: relative; +} + +.tab:hover { + /* A lighter version of the 'selected' background color: */ + background-color: #dfecec; +} + +.tab.selected { + background-color: #aacccc; + /* Darken borders; bottom border should be same color as content below me */ + border-color: #447777 #447777 #aacccc; + color: black; + /* When selected, shift myself down 1px to cover content border */ + top: 1px; +} + +.tab-content { + clear: both; + padding: 5px; + border: 1px solid #aacccc; + border-top: 5px solid #aacccc; +} diff --git a/dist/weewx-4.10.1/docs/customizing.htm b/dist/weewx-4.10.1/docs/customizing.htm new file mode 100644 index 0000000..d33c634 --- /dev/null +++ b/dist/weewx-4.10.1/docs/customizing.htm @@ -0,0 +1,7740 @@ + + + + WeeWX: Customization Guide + + + + + + + + + + + + + + + +
+ +
+
+
+Version: 4.10 + +
+
WeeWX Customization Guide
+
+ +
+ +

+ This document covers the customization of WeeWX. It assumes that you have read, and are + reasonably familiar with, the Users Guide. +

+

+ The introduction contains an overview of the architecture. If you are only interested + in customizing the generated reports you can probably skip the introduction and proceed + directly to the section Customizing reports. + With this approach you can easily add new plot images, change the titles of images, + change the units used in the reports, and so on. +

+ +

+ However, if your goal is a specialized application, such as adding alarms, RSS feeds, + etc., then it would be worth your while to read about the internal architecture. +

+ +

+ Most of the guide applies to any hardware, but the exact data types are + hardware-specific. See the WeeWX Hardware Guide + for details of how different observation types are handled by different types hardware. +

+ +

+ Warning!
WeeWX is still an experimental system and, as such, + its internal design is subject to change. Future upgrades may break any customizations + you have done, particularly if they involve the API (skin customizations tend to be + more stable). +

+ + + + +

Introduction

+ +

Overall system architecture

+ +

+ Below is a brief overview of the WeeWX system architecture, which is covered in much + more detail in the rest of this document. +

+ +
+ The WeeWX pipeline + +
A typical WeeWX pipeline. The actual pipeline depends + on what extensions are in use. Data, in the form of LOOP packets and archive + records, flows from top to bottom.
+
+
    +
  • + A WeeWX process normally handles the monitoring of one station — e.g. a + weather station. The process is configured using options in a configuration file, typically called + weewx.conf. +
  • + +
  • + A WeeWX process has at most one "driver" to communicate with the station hardware + and receive "high resolution" (i.e. every few seconds) measurement data in + the form of LOOP packets. The driver is single-threaded and blocking, so no more + than one driver can run in a WeeWX process. +
  • + + +
  • + LOOP packets may contain arbitrary data from the station/driver in the form of a + Python dictionary. Each LOOP packet must contain a time stamp, and a unit system, + in addition to any number of observations, such as temperature or humidity. + For extensive types, such as rain, the packet contains the total amount of + rain that fell during the observation period. +
  • +
  • + WeeWX then compiles these LOOP packets into regularly spaced "archive records." + For most types, the archive record contains the average value seen in all of the + LOOP packets over the archive interval (typically 5 minutes). For extensive types, + such as rain, it is the sum of all values over the archive interval. +
  • +
  • + Internally, the WeeWX engine uses a pipeline architecture, consisting + of many services. Services bind to events of interest, such as new + LOOP packets, or new archive records. Events are then run down the pipeline + in order — services at the top of the pipeline act on the data + before services farther down the pipe. +
  • + +
  • + Services can do things such as check the data quality, apply corrections, or save + data to a database. Users can easily add new services. +
  • + +
  • + WeeWX includes an ability to customize behavior by installing extensions. + Extensions may consist of one or more drivers, services, and/or skins, all in an + easy-to-install package. +
  • +
+ + +

Data architecture

+ +

+ WeeWX is data-driven. When the sensors spit out some data, WeeWX does something. The + "something" might be to print out the data, or to generate an HTML report, or to use + FTP to copy a report to a web server, or to perform some calculations using the data. +

+ +

+ A driver is Python code that communicates with the hardware. The driver reads data from + a serial port or a device on the USB or a network interface. It handles any decoding of + raw bits and bytes, and puts the resulting data into LOOP packets. The drivers for + some kinds of hardware (most notably, Davis Vantage) are capable of emitting archive + records as well. +

+

+ In addition to the primary observation types such as temperature, humidity, or solar + radiation, there are also many useful dependent types, such as wind chill, heat index, + or ET, which are calculated from the primary data. The firmware in some weather + stations are capable of doing many of these calculations on their own. For the rest, + should you choose to do so, the WeeWX service StdWXCalculate can + fill in the gaps. Sometimes the firmware simply does it wrong, and you may choose to + have WeeWX do the calculation, despite the type's presence in LOOP packets. +

+ +

LOOP packets vs. archive records

+ +

+ Generally, there are two types of data that flow through WeeWX: LOOP packets, and + archive records. Both are represented as Python dictionaries. +

+ +

LOOP packets

+

+ LOOP packets are the raw data generated by the device driver. They get their name from the Davis Instruments + documentation. For some devices they are generated at rigid intervals, such as every 2 seconds for the Davis + Vantage series, for others, irregularly, every 20 or 30 seconds or so. LOOP packets may or may not contain + all the data types. For example, a packet may contain only temperature data, another only barometric data, + etc. These kinds of packet are called partial record packets. By contrast, other types of + hardware (notably the Vantage series), every LOOP packet contains every data type. +

+ +

In summary, LOOP packets can be highly irregular, but they come in frequently.

+ +

Archive records

+ +

+ By contrast, archive records are highly regular. They are generated at regular intervals (generally every 5 + to 30 minutes), and all contain the same data types. They represent an aggregation of the LOOP + packets over the archive interval. The exact kind of aggregation depends on the data type. For example, for + temperature, it's generally the average temperature over the interval. For rain, it's the sum of rain over + the interval. For battery status it's the last value in the interval. +

+ +

+ Some hardware is capable of generating their own archive records (the Davis Vantage and + Oregon Scientific WMR200, for example), but for hardware that cannot, WeeWX generates + them. +

+ +

It is the archive data that is put in the SQL database, although, occasionally, the LOOP packets can be + useful (such as for the Weather Underground's "Rapidfire" mode). +

+ +

What to customize

+ +

+ For configuration changes, such as which skins to use, or enabling posts to the + Weather Underground, simply modify the WeeWX configuration file + weewx.conf. Any changes you make will be preserved during + an upgrade. +

+

+ Customization of reports may require changes to a skin configuration file skin.conf or template files ending in .tmpl or .inc. Anything in the skins subdirectory is also preserved across upgrades. +

+

+ You may choose to install one of the many third-party + extensions that are available for WeeWX. These are typically installed in either + the skins or user subdirectories, + both of which are preserved across upgrades. +

+ +

+ More advanced customizations may require new Python code or modifications of + example code. These should be placed in the user directory, + where they will be preserved across upgrades. For example, if you wish to modify one of + the examples that comes with WeeWX, copy it from the examples + directory to the user directory, then modify it there. This + way, your modifications will not be touched if you upgrade. +

+

+ For code that must run before anything else in WeeWX runs (for example, to set up an + environment), put it in the file extensions.py in the user directory. It is always run before the WeeWX engine starts up. + Because it is in the user subdirectory, it is preserved + between upgrades. +

+ +

Do I need to restart WeeWX?

+

+ If you make a change in weewx.conf, you will need to restart + WeeWX. +

+

+ If you modify Python code in the user directory or elsewhere, + you will need to restart WeeWX. +

+

+ If you install an extension, you will need to restart WeeWX. +

+

+ If you make a change to a template or to a skin.conf file, + you do not need to restart WeeWX. The change will be adopted at the next reporting + cycle, typically at the end of an archive interval. +

+

+ The utility wee_reports +

+ +

+ If you make changes, how do you know what the results will look like? You could just run WeeWX and wait + until the next reporting cycle kicks off but, depending on your archive interval, that could be a 30 minute + wait or more. +

+ +

+ The utility wee_reports allows you to run a report whenever you like. To use it, + just run it from a command line, with the location of your configuration file weewx.conf as the first argument. Optionally, if you include a unix epoch timestamp as a + second argument, then the report will use that as the "Current" time; otherwise, the time of the last record + in the archive database will be used. Here is an example, using 1 May 2014 00:00 PDT as the "Current" time. +

+
wee_reports weewx.conf 1398927600
+ +

+ For more information about wee_reports, see the Utilities Guide +

+ +

The WeeWX service architecture

+ +

+ At a high-level, WeeWX consists of an engine class called StdEngine. It is + responsible for loading services, then arranging for them to be called when key events occur, such + as the arrival of LOOP or archive data. The default install of WeeWX includes the following services: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
The standard WeeWX services
ServiceFunction
weewx.engine.StdTimeSynchArrange to have the clock on the station synchronized at regular intervals. +
weewx.engine.StdConvertConverts the units of the input to a target unit system (such as US or Metric). +
weewx.engine.StdCalibrateAdjust new LOOP and archive packets using calibration expressions. +
weewx.engine.StdQCCheck quality of incoming data, making sure values fall within a specified range. +
weewx.wxservices.StdWXCalculateCalculate any missing, derived weather observation types, such a dewpoint, windchill, or + altimeter-corrected pressure. +
weewx.engine.StdArchiveArchive any new data to the SQL databases.
weewx.restx.StdStationRegistry
weewx.restx.StdWunderground
+ weewx.restx.StdPWSweather
weewx.restx.StdCWOP
weewx.restx.StdWOW
weewx.restx.StdAWEKAS +
Various RESTful services + (simple stateless client-server protocols), such as the Weather Underground, CWOP, etc. Each + launches its own, independent thread, which manages the post. +
weewx.engine.StdPrintPrint out new LOOP and archive packets on the console. +
weewx.engine.StdReportLaunch a new thread to do report processing after a new archive record arrives. Reports do things + such as generate HTML or CSV files, generate images, or transfer files using FTP/rsync. +
+

+ It is easy to extend old services or to add new ones. The source distribution includes an example new + service called MyAlarm, which sends an email when an arbitrary expression + evaluates True. These advanced topics are covered later in the section Customizing the WeeWX service engine. +

+ + +

+ The standard reporting service, StdReport +

+ +

+ For the moment, let us focus on the last service, weewx.engine.StdReport, the + standard service for creating reports. This will be what most users will want to customize, even if it means + just changing a few options. +

+ +

Reports

+ +

+ The standard reporting service, StdReport, runs zero or more reports. The + specific reports which get run are set in the configuration file weewx.conf, in + section [StdReport]. +

+ +

+ The default distribution of WeeWX includes six reports: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReportDefault functionality
SeasonsReportIntroduced with WeeWX V3.9, this report generates a single HTML file with day, week, month and year + "to-date" summaries, as well as the plot images to go along with them. Buttons select which time + scale the user wants. It also generates HTML files with more details on celestial bodies and + statistics. Also generates NOAA monthly and yearly summaries. +
SmartphoneReportA simple report that generates an HTML file, which allows "drill down" to show more detail about + observations. Suitable for smaller devices, such as smartphones. +
MobileReportA super simple HTML file that just shows the basics. Suitable for low-powered or + bandwidth-constrained devices. +
StandardReportThis is an older report that has been used for many years in WeeWX. It generates day, week, month + and year "to-date" summaries in HTML, as well as the plot images to go along with them. Also + generates NOAA monthly and yearly summaries. It typically loads faster than the + SeasonsReport. +
FTPTransfer everything in the HTML_ROOT directory to a remote server using + ftp. +
RSYNCTransfer everything in the HTML_ROOT directory to a remote server using + the utility rsync. +
+

Note that the FTP and RSYNC "reports" are a funny kind of report in that it they do not actually generate + anything. Instead, they use the reporting service engine to transfer files and folders to a remote server. +

+ +

Skins

+ +

+ Each report has a skin associated with it. For most reports, the relationship with the skin is an + obvious one: the skin contains the templates, any auxiliary files such as background GIFs or CSS style sheets, + files with localization data, and a skin configuration file, skin.conf. + If you will, the skin controls the look and feel of the report. Note that more than one report can + use the same skin. For example, you might want to run a report that uses US Customary units, then run + another report against the same skin, but using metric units and put the results in a different place. All + this is possible by either overriding configuration options in the WeeWX configuration file or the skin configuration file. +

+ +

Like all reports, the FTP and RSYNC "reports" also use a skin, and include a skin configuration file, + although they are quite minimal. +

+ +

+ Skins live in their own directory called skins, whose location is referred to as + SKIN_ROOT. + +

+ + +

Generators

+ +

+ To create their output, skins rely on one or more generators, which are what do the actual work, + such as creating HTML files or plot images. Generators can also copy files around or FTP/rsync them to + remote locations. The default install of WeeWX includes the following generators: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
GeneratorFunction
weewx.cheetahgenerator.CheetahGeneratorGenerates files from templates, using the Cheetah template engine. Used to generate HTML and text + files. +
weewx.imagegenerator.ImageGeneratorGenerates graph plots.
weewx.reportengine.FtpGeneratorUploads data to a remote server using FTP.
weewx.reportengine.RsyncGeneratorUploads data to a remote server using rsync.
weewx.reportengine.CopyGeneratorCopies files locally.
+

+ Note that the three generators FtpGenerator, RsyncGenerator, and CopyGenerator do not actually generate + anything having to do with the presentation layer. Instead, they just move files around. +

+ +

+ Which generators are to be run for a given skin is specified in the skin's configuration file skin.conf, in section [Generators]. +

+ +

Templates

+ +

+ A template is a text file that is processed by a template engine to create a new file. WeeWX uses + the Cheetah template engine. The generator weewx.cheetahgenerator.CheetahGenerator is responsible for running Cheetah at + appropriate times. +

+ +

A template may be used to generate HTML, XML, CSV, Javascript, or any other type of text file. A template + typically contains variables that are replaced when creating the new file. Templates may also contain simple + programming logic. +

+ +

+ Each template file lives in the skin directory of the skin that uses it. By convention, a template file ends + with the .tmpl extension. There are also template files that end with the .inc extension. These templates are included in other templates. +

+ +

The database

+ +

+ WeeWX uses a single database to store and retrieve the records it needs. It can be implemented by using + either SQLITE3, an open-source, lightweight SQL database, or MySQL, an open-source, full-featured database server. +

+ +

Structure

+ +

+ Inside this database are several tables. The most important is the archive table, a big flat table, + holding one record for each archive interval, keyed by dateTime, the time at the + end of the archive interval. It looks something like this: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Structure of the archive database table +
dateTimeusUnitsintervalbarometerpressurealtimeterinTempoutTemp...
14139378001529.938nullnull71.256.0...
14139381001529.941nullnull71.255.9...
...........................
+ +

+ The first three columns are required. Here's what they mean: +

+ + + + + + + + + + + + + + + + + +
NameMeaning
dateTimeThe time at the end of the archive interval in unix + epoch time. This is the primary key in the database. It must be unique, and it cannot + be null. +
usUnitsThe unit system the record is in. It cannot be null. See the Appendix: + Units for how these systems are encoded. +
intervalThe length of the archive interval in minutes. It cannot be null. +
+ +

+ In addition to the main archive table, there are a number of smaller tables inside the database, one for + each observation type, which hold daily summaries of the type, such as the minimum and maximum value + seen during the day, and at what time. These tables have names such as archive_day_outTemp + or archive_day_barometer. Their existence is generally transparent to the user. + For more details, see the section Daily + summaries in the document Developer's Notes. +

+ +

Binding names

+ +

+ While most users will only need the one weather database that comes with WeeWX, the reporting engine allows + you to use multiple databases in the same report. For example, if you have installed the cmon computer monitoring + package, which uses its own database, you may want to include some statistics or graphs about your server in + your reports, using that database. +

+ +

+ An additional complication is that WeeWX can use more than one database implementation: SQLite or MySQL. + Making users specify in the templates not only which database to use, but also which implementation, would + be unreasonable. +

+ +

+ The solution, like so many other problems in computer science, is to introduce another level of indirection, + a database binding. Rather than specify which database to use, you specify which binding + to use. Bindings do not change with the database implementation, so, for example, you know that wx_binding will always point to the weather database, no matter if its implementation is + a sqlite database or a MySQL database. Bindings are listed in section [DataBindings] in weewx.conf. +

+ +

+ The standard weather database binding that WeeWX uses is wx_binding. This is the + binding that you will be using most of the time and, indeed, it is the default. You rarely have to specify + it explicitly. +

+ +

Programming interface

+ +

+ WeeWX includes a module called weedb that provides a single interface for many of + the differences between database implementations such as SQLite and MySQL. However, it is not uncommon to + make direct SQL queries within services or search list extensions. In such cases, the SQL should be generic + so that it will work with every type of database. +

+ +

The database manager class provides methods to create, open, and query a database. These are the canonical + forms for obtaining a database manager. +

+ +

If you are opening a database from within a WeeWX service:

+
db_manager = self.engine.db_binder.get_manager(data_binding='name_of_binding', initialize=True)
+
+# Sample query:
+db_manager.getSql("SELECT SUM(rain) FROM %s "\
+    "WHERE dateTime>? AND dateTime<=?" % db_manager.table_name, (start_ts, stop_ts))
+

+ If you are opening a database from within a WeeWX search list extension, you will be passed in a function + db_lookup() as a parameter, which can be used to bind to a database. By default, + it returns a manager bound to wx_binding: +

+
wx_manager    = db_lookup()                                    # Get default binding
+other_manager = db_lookup(data_binding='some_other_binding')   # Get an explicit binding
+
+# Sample queries:
+wx_manager.getSql("SELECT SUM(rain) FROM %s "\
+    "WHERE dateTime>? AND dateTime<=?" % wx_manager.table_name, (start_ts, stop_ts))
+other_manager.getSql("SELECT SUM(power) FROM %s"\
+    "WHERE dateTime>? AND dateTime<=?" % other_manager.table_name, (start_ts, stop_ts))
+

+ If opening a database from somewhere other than a service, and there is no DBBinder available: +

+
db_manager = weewx.manager.open_manager_with_config(config_dict, data_binding='name_of_binding')
+
+# Sample query:
+db_manager.getSql("SELECT SUM(rain) FROM %s "\
+    "WHERE dateTime>? AND dateTime<=?" % db_manager.table_name, (start_ts, stop_ts))
+

+ The DBBinder caches managers, and thus database connections. It cannot be shared + between threads. +

+ +

Units

+ +

+ The unit architecture in WeeWX is designed to make basic unit conversions and display of units easy. It is + not designed to provide dimensional analysis, arbitrary conversions, and indications of compatibility. +

+ +

+ The driver reads observations from an instrument and converts them, as necessary, into a standard + set of units. The actual units used by each instrument vary widely; some instruments use Metric units, + others use US Customary units, and many use a mixture. The driver ensures that the units are consistent for + storage in the WeeWX database. By default, and to maintain compatibility with wview, the default database units are US Customary, although this can be changed. +

+ +

+ Note that whatever unit system is used in the database, data can be displayed using any unit + system. So, in practice, it does not matter what unit system is used in the database. +

+ +

+ Each observation type, such as outTemp or pressure, is associated with a unit group, such as group_temperature + or group_pressure. Each unit group is associated with a unit type such as + degree_F or mbar. The reporting service uses this + architecture to convert observations into a target unit system, to be displayed in your reports. +

+ +

With this architecture one can easily create reports with, say, wind measured in knots, rain measured in mm, + and temperatures in degree Celsius. Or one can create a single set of templates, but display data in + different unit systems with only a few stanzas in a configuration file. +

+ + + + +

Customizing reports

+ +

+ There are two general mechanisms for customizing reports: change options in one or more configuration files, + or change the template files. The former is generally easier, but occasionally the latter is necessary. +

+ +

Options

+ +

+ Options are used to specify how reports will look and what they will contain. For example, they control + which units to use, how to format dates and times, which data should be in each plot, the colors of plot + elements, etc. +

+ +

+ For a complete listing of the report options, see the section Reference: + report options. +

+ +

+ Options are read from three different types of configuration files: +

+ + + + + + + + + + + + + + + + + + + + + + +
Configuration files
FileUse
weewx.confThis is the application configuration file. It contains general configuration information, such which drivers and services to load, as well as which + reports to run. Report options can also be specified in this file. +
skin.confThis is the skin configuration file. It contains information specific to a skin, in particular, which template files to process, and + which plots to generate. Typically this file is supplied by the skin author. +
en.conf
de.conf
fr.conf
etc.
These are internationalization files. They contain language and locale information for a specific skin.
+ +

+ Configuration files are read and processed using the Python utility ConfigObj, using a format similar to + the MS-DOS "INI" format. Here's a simple example: +

+
+[Section1]
+    # A comment
+    key1 = value1
+    [[SubSectionA]]
+        key2 = value2
+[Section2]
+    key3=value3
+

+ This example uses two sections at root level (sections Section1 and Section2), and one sub-section (SubSectionA), which is nested + under Section1. The option key1 is nested under Section1, option key3 is nested under Section2, + while option key2 is nested under sub-section SubSectionA. +

+

+ Note that while this example indents sub-sections and options, this is strictly for readability — this + isn't Python! It's the number of brackets that counts in determining nesting, not the indentation! +

+ +

+ Configuration files take advantage of ConfigObj's ability to organize options + hierarchically into stanzas. For example, the [Labels] stanza contains + the text that should be displayed for each observation. The [Units] stanza + contains other stanzas, each of which contains parameters that control the display of units. +

+ + +

Processing order

+

+ Configuration files and their sections are processed in a specific order. Generally, the values from the + skin configuration file (skin.conf) are processed first, then options in the WeeWX + configuration file (nominally weewx.conf) are applied last. This order allows skin + authors to specify the basic look and feel of a report, while ensuring that users of the skin have the final + say. +

+

+ To illustrate the processing order, here are the steps for the skin Seasons: +

+
    +
  • + First, a set of options defined in the Python module weewx.defaults serve as + the starting point. +
  • +
  • + Next, options from the configuration file for Seasons, located in skins/Seasons/skin.conf, + are merged. +
  • +
  • + Next, any options that apply to all skins, specified in the [StdReport] / [[Defaults]] + section of the WeeWX configuration file, are merged. +
  • +
  • + Finally, any skin-specific options, specified in the [StdReport] / [[Seasons]] + section of the WeeWX configuration, are merged. These options have the final say. +
  • +
+ +

+ At all four steps, if a language specification is encountered (option lang), then + the corresponding language file will be read and merged. If a unit specification (option unit_system) + is encountered, then the appropriate unit groups are set. For example, if unit_system=metricwx, + then the unit for group pressure will be set to mbar, etc. +

+ +

+ The result is the following option hierarchy, listed in order of increasing precedence. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Option hierarchy, lowest to highest
FileExampleComments
weewx/defaults.py + [Units]
  [[Labels]]
    mbar=" mbar" +
+ These are the hard-coded default values for every option. They are used when an option is not + specified anywhere else. These should not be modified unless you propose a change to the WeeWX code; + any changes made here will be lost when the software is updated. +
skin.conf + [Units]
  [[Labels]]
    mbar=" hPa" +
+ Supplied by the skin author, the skin configuration file, skin.conf, + contains options that define the baseline behavior of the skin. In this example, for whatever + reasons, the skin author has decided that the label for units in millibars should be " hPa" (which is equivalent). +
weewx.conf + [StdReport]
  [[Defaults]]
    [[[Labels]]]
      [[[[Generic]]]]
+         rain=Rainfall +
+ Options specified under [[Defaults]] apply to all reports. This + example indicates that the label Rainfall should be used for the + observation rain, in all reports. +
weewx.conf + [StdReport]
  [[SeasonsReport]]
    [[[Labels]]]
      [[[[Generic]]]]
+         inTemp=Kitchen temperature +
+ Highest precedence. Has the final say. Options specified here apply to a single report. + This example indicates that the label Kitchen temperature should + be used for the observation inTemp, but only for the report SeasonsReport. +
+ +

+ Note: When specifying options, you must pay attention to the number of brackets! In the + table above, there are two different nesting depths used: one for weewx.conf, and + one for weewx/defaults.py and skin.conf. This is because + the stanzas defined in weewx.conf start two levels down in the hierarchy [StdReport], whereas the stanzas defined in skin.conf and + defaults.py are at the root level. Therefore, options specified in weewx.conf must use two extra sets of brackets. +

+ +

+ Other skins are processed in a similar manner although, of course, their name will be something other + than Seasons. +

+ +

+ Although it is possible to modify the options at any level, as the user of a skin, it is usually best to + keep your modifications in the WeeWX configuration file (weewx.conf) if you can. + That way you can apply any fixes or changes when the skin author updates the skin, and your customizations + will not be overwritten. +

+

+ If you are a skin author, then you should provide the skin configuration file (skin.conf), and put in it only the options necessary to make the skin render the way you + intend it. Any options that are likely to be localized for a specific language (in particular, text), should + be put in the appropriate language file. +

+ +

Changing languages

+

+ By default, the skins that come with WeeWX are set up for the English language, but suppose you wish to + switch to another language. How you do so will depend on whether the skin you are using has been internationalized + and, if so, whether it offers your local language. +

+ +

Internationalized skins

+

+ All of the skins included with WeeWX have been internationalized, so if you're working with one of them, + this is the section you want. Next, you need to check whether there is a localization file for your + particular language. To check, look in the contents of subdirectory lang in the + skin's directory. For example, if you used a package installer and are using the Seasons skin, you + will want to look in /etc/weewx/skins/Seasons/lang. Inside, you will see something + like this: +

+
ls -l /etc/weewx/skins/Seasons/lang
+total 136
+-rw-rw-r-- 1 tkeffer tkeffer  9447 Jul  1 11:11 cn.conf
+-rw-rw-r-- 1 tkeffer tkeffer  9844 Mar 13 12:31 cz.conf
+-rw-rw-r-- 1 tkeffer tkeffer  9745 Mar 13 12:31 de.conf
+-rw-rw-r-- 1 tkeffer tkeffer  9459 Mar 13 12:31 en.conf
+-rw-rw-r-- 1 tkeffer tkeffer 10702 Mar 13 12:31 es.conf
+-rw-rw-r-- 1 tkeffer tkeffer 10673 May 31 07:50 fr.conf
+-rw-rw-r-- 1 tkeffer tkeffer 11838 Mar 13 12:31 gr.conf
+-rw-rw-r-- 1 tkeffer tkeffer  9947 Mar 13 12:31 it.conf
+-rw-rw-r-- 1 tkeffer tkeffer  9548 Mar 13 12:31 nl.conf
+-rw-rw-r-- 1 tkeffer tkeffer 10722 Apr 15 14:52 no.conf
+-rw-rw-r-- 1 tkeffer tkeffer 15356 Mar 13 12:31 th.conf
+
+ +

+ This means that the Seasons skin has been localized for the following languages: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileLanguage
cn.confTraditional Chinese
cz.confCzeck
de.confGerman
en.confEnglish
es.confSpanish
fr.confFrench
it.confItalian
gr.confGreek
nl.confDutch
th.confThai
+ +

+ If you want to use the Seasons skin and are working with one of these languages, then you are in + luck: you can simply override the lang option. For example, to change the language + displayed by the Seasons skin from English to German, edit weewx.conf, + and change the highlighted section: +

+ +
+[StdReport]
+    ...
+    [[SeasonsReport]]
+        # The SeasonsReport uses the 'Seasons' skin, which contains the
+        # images, templates and plots for the report.
+        skin = Seasons
+        enable = true
+        lang = de
+
+ +

+ By contrast, if the skin has been internationalized, but there is no localization file for your language, + then you will have to supply one. See the section Internationalized, + but your language is missing. +

+ + +

Changing date and time formats

+

+ Date and time formats are specified using the same format strings used by strftime(). For example, %Y indicates the 4-digit year, and %H:%M indicates the time in hours:minutes. The default values for date and time formats are generally %x %X, which indicates "use the format for the locale of the computer". +

+

+ Since date formats default to the locale of the computer, a date might appear with the format of "month/day/year". What if you prefer dates to have the format "year.month.day"? How do you indicate 24-hour time format versus 12-hour? +

+

+ Dates and times generally appear in two places: in plots and in tags. +

+ +

Date and time formats in images

+

+ Most plots have a label on the horizontal axis that indicates when the plot was generated. By default, the format for this label uses the locale of the computer on which WeeWX is running, but you can modify the format by specifying the option bottom_label_format. +

+

+ For example, this would result in a date/time string such as "2021.12.13 12:45" no matter what the computer's locale: +

+ +
+[StdReport]
+    ...
+    [[Defaults]]
+        [[[ImageGenerator]]]
+            [[[[day_images]]]]
+                bottom_label_format = %Y.%m.%d %H:%M
+            [[[[week_images]]]]
+                bottom_label_format = %Y.%m.%d %H:%M
+            [[[[month_images]]]]
+                bottom_label_format = %Y.%m.%d %H:%M
+            [[[[year_images]]]]
+                bottom_label_format = %Y.%m.%d %H:%M
+ +

Date and time formats for tags

+

+ Each aggregation period has a format for the times associated with that period. These formats are defined in the TimeFormats section. The default format for each uses the date and/or time for the computer of the locale on which WeeWX is running. +

+

+ For example, this would result in a date/time string such as "2021.12.13 12:45" no matter what the computer's locale: +

+ +
+[StdReport]
+    ...
+    [[Defaults]]
+        [[[Units]]]
+            [[[[TimeFormats]]]]
+                hour        = %H:%M
+                day         = %Y.%m.%d
+                week        = %Y.%m.%d (%A)
+                month       = %Y.%m.%d %H:%M
+                year        = %Y.%m.%d %H:%M
+                rainyear    = %Y.%m.%d %H:%M
+                current     = %Y.%m.%d %H:%M
+                ephem_day   = %H:%M
+                ephem_year  = %Y.%m.%d %H:%M
+ +

Changing unit systems

+ +

+ Each unit system is a set of units. For example, the METRIC unit system + uses centimeters for rain, kilometers per hour for wind speed, and degree Celsius for temperature. The + option unit_system controls which + unit system will be used in your reports. The available choices are US, METRIC, or METRICWX. The option is case-insensitive. See the + Appendix Units for the unit defined in each of these unit systems. +

+

+ By default, WeeWX uses US (US Customary) system. Suppose you would rather use the + METRICWX system for all your reports? Then change this +

+ +
+[StdReport]
+    ...
+    [[Defaults]]
+
+        # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'.
+        # You can override this for individual reports.
+        unit_system = us
+

+ to this +

+ +
+[StdReport]
+    ...
+    [[Defaults]]
+
+        # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'.
+        # You can override this for individual reports.
+        unit_system = metricwx
+ +

Mixed units

+

+ However, what if you want a mix? For example, suppose you generally want US Customary units, but you want + barometric pressures to be in millibars? This can be done by overriding the appropriate unit group. +

+ +
+[StdReport]
+    ...
+    [[Defaults]]
+
+        # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'.
+        # You can override this for individual reports.
+        unit_system = us
+
+        # Override the units used for pressure:
+        [[[Units]]]
+            [[[[Groups]]]]
+                group_pressure = mbar
+
+ +

+ This says that you generally want the US systems of units for all reports, but + want pressure to be reported in millibars. Other units can be overridden in a similar manner. +

+ +

Multiple unit systems

+ +

Another example. Suppose we want to generate two reports, one in the US Customary system, the other + using the METRICWX system. The first, call it SeasonsUSReport, will go in + the regular directory HTML_ROOT. However, the latter, call it SeasonsMetricReport, + will go in a subdirectory, HTML_ROOT/metric. Here's + how you would do it +

+
+[StdReport]
+
+    # Where the skins reside, relative to WEEWX_ROOT
+    SKIN_ROOT = skins
+
+    # Where the generated reports should go, relative to WEEWX_ROOT
+    HTML_ROOT = public_html
+
+    # The database binding indicates which data should be used in reports.
+    data_binding = wx_binding
+
+    [[SeasonsUSReport]]
+        skin = Seasons
+        unit_system = us
+        enable = true
+
+    [[SeasonsMetricReport]]
+        skin = Seasons
+        unit_system = metricwx
+        HTML_ROOT = public_html/metric
+        enable = true
+
+ +

+ Note how both reports use the same skin (that is, skin Seasons), but different unit + systems, and different destinations. The first, SeasonsUSReport sets option unit_system + to us, and uses the default destination. By contrast, the second, SeasonsMetricReport, + uses unit system metricwx, and a different destination, public_html/metric. +

+ + +

Changing labels

+

+ Every observation type is associated with a default label. For example, in the English language, + the default label for observation type outTemp is generally Outside + Temperature. You can change this label by overriding the default. How you do so will depend on + whether the skin you are using has been internationalized and, if so, whether it offers your local + language. +

+ +

+ Let's look at an example. If you take a look inside the file skins/Seasons/lang/en.conf, you will see it contains what looks like a big configuration + file. Among other things, it has two entries that look like this: +

+
+...
+[Labels]
+    ...
+    [[Generic]]
+        ...
+        inTemp = Inside Temperature
+        outTemp = Outside Temperature
+        ...
+ +

+ This tells the report generators that when it comes time to label the observation variables inTemp and outTemp, use the strings Inside Temperature and Outside + Temperature, respectively. +

+

+ However, let's say that we have actually located our outside temperature sensor in the barn, and wish to + label it accordingly. We need to override the label that comes in the localization file. We could + just change the localization file en.conf, but then if the author of the skin came + out with a new version, our change could get lost. Better to override the default by making the change in + weewx.conf. To do this, make the following changes in weewx.conf: +

+
+    [[SeasonsReport]]
+        # The SeasonsReport uses the 'Seasons' skin, which contains the
+        # images, templates and plots for the report.
+        skin = Seasons
+        lang = en
+        unit_system = US
+        enable = true
+        [[[Labels]]]
+            [[[[Generic]]]]
+                outTemp = Barn Temperature
+        
+ +

+ This will cause the default label Outside Temperature to be replaced with + the new label Barn + Temperature everywhere in your report. The label for type inTemp will be + untouched. +

+ +

Scheduling report generation

+ +

+ Normal WeeWX operation is to run each report defined in weewx.conf every archive period. While this may suit most situations, there may be + occasions when it is desirable to run a report less frequently than every archive period. For example, the + archive interval might be 5 minutes, but you only want to FTP files every 30 minutes, once per day, or at a + set time each day. WeeWX has two mechanisms that provide the ability to control when files are generated. + The stale_age option allows control over the age + of a file before it is regenerated, and the report_timing option allows precise + control over when individual reports are run. +

+ +

+ Note
While report_timing specifies when a given report + should be generated, the generation of reports is still controlled by the WeeWX report cycle, so reports can + never be generated more frequently than once every archive period. +

+ +

The report_timing option

+ +

+ The report_timing option uses a CRON-like format to control when a report is to be + run. While a CRON-like format is used, the control of WeeWX report generation using the report_timing option is confined completely to WeeWX and has no interraction with the + system CRON service. +

+ +

+ The report_timing option consists of five parameters separated by white-space: +

+ +
report_timing = minutes hours day_of_month months day_of_week
+ +

+ The report_timing parameters are summarised in the following table: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterFunctionAllowable values
minutesSpecifies the minutes of the hour when the report will be run + *, or
numbers in the range 0..59 inclusive +
hoursSpecifies the hours of the day when the report will be run + *, or
numbers in the range 0..23 inclusive +
day_of_monthSpecifies the days of the month when the report will be run + *, or
numbers in the range 1..31 inclusive +
monthsSpecifies the months of the year when the report will be run + *, or
numbers in the range 1..12 inclusive, or
abbreviated names in the range jan..dec + inclusive +
day_of_weekSpecifies the days of the week when the report will be run + *, or
numbers in the range 0..7 inclusive (0,7 = Sunday, 1 = Monday etc), or
abbreviated + names in the range sun..sat inclusive +
+ +

+ The report_timing option may only be used in weewx.conf. + When set in the [StdReport] section of weewx.conf the + option will apply to all reports listed under [StdReport]. When specified within a + report section, the option will override any setting in [StdReport] for that + report. In this manner it is possible to have different reports run at different times. The following sample + weewx.conf excerpt illustrates this: +

+ +
+[StdReport]
+
+    # Where the skins reside, relative to WEEWX_ROOT
+    SKIN_ROOT = skins
+
+    # Where the generated reports should go, relative to WEEWX_ROOT
+    HTML_ROOT = public_html
+
+    # The database binding indicates which data should be used in reports.
+    data_binding = wx_binding
+
+    # Report timing parameter
+    report_timing = 0 * * * *
+
+    # Each of the following subsections defines a report that will be run.
+
+    [[AReport]]
+        skin = SomeSkin
+
+    [[AnotherReport]]
+        skin = SomeOtherSkin
+        report_timing = */10 * * * *
+ +

+ In this case, the [[AReport]] report would be run under under control of the 0 * * * * setting (on the hour) under [StdReport] and the + [[AnotherReport]] report would be run under control of the */10 * * * * + setting (every 10 minutes) which has overriden the [StdReport] setting. +

+ +

How report_timing controls reporting

+ +

+ The syntax and interpretation of the report_timing parameters are largely the same + as those of the CRON service in many Unix and Unix-like operating systems. The syntax and interpretation are + outlined below. +

+ +

+ When the report_timing option is in use WeeWX will run a report when the minute, + hour and month of year parameters match the report time, and at least one of the two day parameters (day of + month or day of week) match the report time. This means that non-existent times, such as "missing hours" + during daylight savings changeover, will never match, causing reports scheduled during the "missing times" + not to be run. Similarly, times that occur more than once (again, during daylight savings changeover) will + cause matching reports to be run more than once. +

+ +

+ Note
Report time does not refer to the time at which the report is run, but rather the + date and time of the latest data the report is based upon. If you like, it is the effective date and time of + the report. For normal WeeWX operation, the report time aligns with the dateTime + of the most recent archive record. When reports are run using the wee_reports + utility, the report time is either the dateTime of the most recent archive record + (the default) or the optional timestamp command line argument. +

+ +

+ Note
The day a report is to be run can be specified by two parameters; day of month + and/or day of week. If both parameters are restricted (i.e., not an asterisk), the report will be run when + either field matches the current time. For example,
report_timing = 30 4 + 1,15 * 5
would cause the report to be run at 4:30am on the 1st and 15th of each month as well as + 4:30am every Friday. +

+ +

The relationship between report_timing and archive period

+ +

+ A traditional CRON service has a resolution of one minute, meaning that the CRON service checks each minute + as to whether to execute any commands. On the other hand, the WeeWX report system checks which reports are + to be run once per archive period, where the archive period may be one minute, five minutes, or some other + user defined period. Consequently, the report_timing option may specify a report + to be run at some time that does not align with the WeeWX archive period. In such cases the report_timing option does not cause a report to be run outside of the normal WeeWX + report cycle, rather it will cause the report to be run during the next report cycle. At the start of each + report cycle, and provided a report_timing option is set, WeeWX will check each + minute boundary from the current report time back until the report time of the previous report cycle. If a + match is found on any of these one minute boundaries the report will be run during the + report cycle. This may be best described through some examples: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
report_timingArchive periodWhen the report will be run
0 * * * *5 minutesThe report will be run only during the report cycle commencing on the hour.
5 * * * *5 minutesThe report will be run only during the report cycle commencing at 5 minutes past the hour.
3 * * * *5 minutesThe report will be run only during the report cycle commencing at 5 minutes past the hour.
10 * * * *15 minutesThe report will be run only during the report cycle commencing at 15 minutes past the hour
10,40 * * * *15 minutesThe report will be run only during the report cycles commencing at 15 minutes past the hour and 45 + minutes past the hour. +
5,10 * * * *15 minutesThe report will be run once only during the report cycle commencing at 15 minutes past the hour. +
+ +

Lists, ranges and steps

+ +

+ The report_timing option supports lists, ranges, and steps for all parameters. + Lists, ranges, and steps may be used as follows: +

+ +
    +
  • + Lists. A list is a set of numbers (or ranges) separated by commas, for example 1, 2, + 5, 9 or 0-4, 8-12. A match with any of the elements of the list will + result in a match for that particular parameter. If the examples were applied to the minutes parameter, + and subject to other parameters in the report_timing option, the report would + be run at minutes 1, 2, 5, and 9 and 0, 1, 2, 3, 4, 8, 9, 10, 11, and 12 respectively. Abbreviated month + and day names cannot be used in a list. +
  • + +
  • + Ranges. Ranges are two numbers separated with a hyphen, for example 8-11. The specified range is inclusive. A match with any of the values included in + the range will result in a match for that particular parameter. If the example was applied to the hours + parameter, and subject to other parameters in the report_timing option, the + report would be run at hours 8, 9, 10, and 11. A range may be included as an element of a list. + Abbreviated month and day names cannot be used in a range. +
  • + +
  • + Steps. A step can be used in conjunction with a range or asterisk and are denoted by a '/' followed by a number. Following a range with a step specifies skips of the step + number's value through the range. For example, 0-12/2 used in the hours + parameter would, subject to other parameter in the report_timing option, run + the report at hours 0, 2, 4, 6, 8, and 12. Steps are also permitted after an asterisk in which case the + skips of the step number's value occur through the all possible values of the parameter. For example, + */3 can be used in the hours parameter to, subject to other parameter in the + report_timing option, run the report at hours 0, 3, 6, 9, 12, 15, 18, and 21. +
  • +
+ +

Nicknames

+ +

+ The report_timing option supports a number of time specification 'nicknames'. + These nicknames are prefixed by the '@' character and replace the five parameters + in the report_timing option. The nicknames supported are: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NicknameEquivalent setting + When the report will be run
@yearly
@annually +
0 0 1 1 *Once per year at midnight on 1 January.
@monthly0 0 1 * *Monthly at midnight on the 1st of the month.
@weekly0 0 * * 0Every week at midnight on Sunday.
@daily0 0 * * *Every day at midnight.
@hourly0 * * * *Every hour on the hour.
+ +

Examples of report_timing

+ +

+ Numeric settings for report_timing can be at times difficult to understand due to + the complex combinations of parameters. The following table shows a number of example report_timing + options and the corresponding times when the report would be run. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
report_timingWhen the report will be run
* * * * *Every archive period. This setting is effectively the default WeeWX method of operation. +
25 * * * *25 minutes past every hour.
0 * * * *Every hour on the hour.
5 0 * * *00:05 daily.
25 16 * * *16:25 daily.
25 16 1 * *16:25 on the 1st of each month.
25 16 1 2 *16:25 on the 1st of February.
25 16 * * 016:25 each Sunday.
*/10 * * * *On the hour and 10, 20, 30, 40 and 50 mnutes past the hour. +
*/9 * * * *On the hour and 9, 18, 27, 36, 45 and 54 minutes past the hour. +
*/10 */2 * * *0, 10, 20, 30, 40 and 50 minutes after the even hour. +
* 6-17 * * *Every archive period from 06:00 (inclusive) up until, but excluding, 18:00. +
* 1,4,14 * * *Every archive period in the hour starting 01:00 to 01:59, 04:00 to 04:59 amd 14:00 to 14:59 (Note + excludes report times at 02:00, 05:00 and 15:00). +
0 * 1 * 0,3On the hour on the first of the month and on the hour every Sunday and Wednesday. +
* * 21,1-10/3 6 *Every archive period on the 1st, 4th, 7th, 10th and 21st of June. +
@monthlyMidnight on the 1st of the month.
+ +

+ The wee_reports utility and the report_timing option +

+ +

+ The report_timing option is ignored when using the wee_reports + utility. +

+ + + + +

The Cheetah generator

+ +

+ This section gives an overview of the Cheetah generator. For details about each of its various options, see + the section [CheetahGenerator] in the Reference: report options. +

+ +

+ File generation is done using the Cheetah templating engine, + which processes a template, replacing any symbolic tags, then produces an output file. + Typically, it runs after each new archive record (usually about every five minutes), but it can also run on + demand using the wee_reports utility. +

+

+ The Cheetah engine is very powerful, essentially letting you have the full semantics of Python available in + your templates. As this would make the templates incomprehensible to anyone but a Python programmer, WeeWX + adopts a very small subset of its power. +

+

+ The Cheetah generator is controlled by the section [CheetahGenerator]. + Let's take a look at how this works. +

+ +

Which files get processed?

+ +

+ Each template file is named something like D/F.E.tmpl, where D is the (optional) directory the template sits in and will also be the directory the + results will be put in, and F.E is the generated file name. So, given a template + file with name Acme/index.html.tmpl, the results will be put in HTML_ROOT/Acme/index.html. +

+ +

+ The configuration for a group of templates will look something like this: +

+
[CheetahGenerator]
+    [[index]]
+        template = index.html.tmpl
+    [[textfile]]
+        template = filename.txt.tmpl
+    [[xmlfile]]
+        template = filename.xml.tmpl
+

+ There can be only one template in each block. In most cases, the block + name does not matter — it is used only to isolate each template. However, there are four block names + that have special meaning: SummaryByDay, SummaryByMonth, + SummaryByYear, and ToDate. +

+ +

Specifying template files

+ +

+ By way of example, here is the [CheetahGenerator] section from the skin.conf for the skin Seasons. +

+ +
[CheetahGenerator]
+    # The CheetahGenerator creates files from templates.  This section
+    # specifies which files will be generated from which template.
+
+    # Possible encodings include 'html_entities', 'strict_ascii', 'normalized_ascii',
+    # as well as those listed in https://docs.python.org/3/library/codecs.html#standard-encodings
+    encoding = html_entities
+
+    [[SummaryByMonth]]
+        # Reports that summarize "by month"
+        [[[NOAA_month]]]
+            encoding = normalized_ascii
+            template = NOAA/NOAA-%Y-%m.txt.tmpl
+
+    [[SummaryByYear]]
+        # Reports that summarize "by year"
+        [[[NOAA_year]]]
+            encoding = normalized_ascii
+            template = NOAA/NOAA-%Y.txt.tmpl
+
+    [[ToDate]]
+        # Reports that show statistics "to date", such as day-to-date,
+        # week-to-date, month-to-date, etc.
+        [[[index]]]
+            template = index.html.tmpl
+        [[[statistics]]]
+            template = statistics.html.tmpl
+        [[[telemetry]]]
+            template = telemetry.html.tmpl
+        [[[tabular]]]
+            template = tabular.html.tmpl
+        [[[celestial]]]
+            template = celestial.html.tmpl
+            # Uncomment the following to have WeeWX generate a celestial page only once an hour:
+            # stale_age = 3600
+        [[[RSS]]]
+            template = rss.xml.tmpl
+    
+ +

The skin contains three different kinds of generated output:

+
    +
  1. Summary by Month. The skin uses SummaryByMonth to produce NOAA summaries, one + for each month, as a simple text file. +
  2. +
  3. Summary by Year. The skin uses SummaryByYear to produce NOAA summaries, one + for each year, as a simple text file. +
  4. +
  5. Summary "To Date". The skin produces an HTML index.html page, as well as HTML + files for detailed statistics, telemetry, and celestial information. It also includes a master page + (tabular.html) in which NOAA information is displayed. All these files are + HTML. +
  6. +
+

+ Because the option +

+
+    encoding = html_entities
+        
+

+ appears directly under [StdReport], this will be the default encoding of the + generated files unless explicitly overridden. We see an example of this under [SummaryByMonth] + and + [SummaryByYear], which use option normalized_ascii instead (replaces + accented characters with a non-accented analog). +

+ +

+ Other than SummaryByMonth and SummaryByYear, the section + names are arbitrary. ToDate could just as well have been called files_to_date, and the sections index, statistics, + and telemetry could just as well have been called tom, + dick, and harry. +

+ +

[[SummaryByYear]]

+ +

+ Use SummaryByYear to generate a set of files, one file per year. The name of the + template file should contain a strftime() code for + the year; this will be replaced with the year of the data in the file. +

+
+[CheetahGenerator]
+    [[SummaryByYear]]
+        # Reports that summarize "by year"
+        [[[NOAA_year]]]
+            encoding = normalized_ascii
+            template = NOAA/NOAA-%Y.txt.tmpl
+        
+ +

+ The template NOAA/NOAA-%Y.txt.tmpl might look something like this: +

+
+           SUMMARY FOR YEAR $year.dateTime
+
+MONTHLY TEMPERATURES AND HUMIDITIES:
+#for $record in $year.records
+$record.dateTime $record.outTemp $record.outHumidity
+#end for
+        
+ +

[[SummaryByMonth]]

+ +

+ Use SummaryByMonth to generate a set of files, one file per month. The name of the + template file should contain a strftime() code for + year and month; these will be replaced with the year and month of the data in the file. +

+
+[CheetahGenerator]
+    [[SummaryByMonth]]
+        # Reports that summarize "by month"
+        [[[NOAA_month]]]
+            encoding = normalized_ascii
+            template = NOAA/NOAA-%Y-%m.txt.tmpl
+        
+ +

+ The template NOAA/NOAA-%Y-%m.txt.tmpl might look something like this: +

+
+           SUMMARY FOR MONTH $month.dateTime
+
+DAILY TEMPERATURES AND HUMIDITIES:
+#for $record in $month.records
+$record.dateTime $record.outTemp $record.outHumidity
+#end for
+        
+ +

[[SummaryByDay]]

+

+ While the Seasons skin does not make use of it, there is also a SummaryByDay capability. As the name suggests, this results in one file per day. The + name of the template file should contain a strftime() code for + the year, month and day; these will be replaced with the year, month, and day of the data in the file. +

+
+[CheetahGenerator]
+    [[SummaryByDay]]
+        # Reports that summarize "by day"
+        [[[NOAA_day]]]
+            encoding = normalized_ascii
+            template = NOAA/NOAA-%Y-%m-%d.txt.tmpl
+        
+ +

+ The template NOAA/NOAA-%Y-%m-%d.txt.tmpl might look something like this: +

+
+           SUMMARY FOR DAY $day.dateTime
+
+HOURLY TEMPERATURES AND HUMIDITIES:
+#for $record in $day.records
+$record.dateTime $record.outTemp $record.outHumidity
+#end for
+        
+

+ Note
This can create a lot of files — one per day. If you have 3 years + of records, this would be more than 1,000 files! +

+ +

Tags

+ +

+ If you look inside a template, you will see it makes heavy use of tags. As the Cheetah generator + processes the template, it replaces each tag with an appropriate value and, sometimes, a label. This section + discusses the details of how that happens. +

+ +

+ If there is a tag error during template generation, the error will show up in the log file. Many errors are + obvious — Cheetah will display a line number and list the template file in which the error occurred. + Unfortunately, in other cases, the error message can be very cryptic and not very useful. So make small + changes and test often. Use the utility wee_reports to + speed up the process. +

+

Here are some examples of tags:

+
$current.outTemp
+$month.outTemp.max
+$month.outTemp.maxtime
+

+ These code the current outside temperature, the maximum outside temperature for the month, and the time that + maximum occurred, respectively. So a template file that contains: +

+
<html>
+    <head>
+        <title>Current conditions</title>
+    </head>
+    <body>
+        <p>Current temperature = $current.outTemp</p>
+        <p>Max for the month is $month.outTemp.max, which occurred at $month.outTemp.maxtime</p>
+    </body>
+</html>
+

+ would be all you need for a very simple HTML page that would display the text (assuming that the unit group + for temperature is degree_F): +

+ +

+ Current temperature = 51.0°F
Max for the month is 68.8°F, which occurred at 07-Oct-2009 15:15 +

+ +

+ The format that was used to format the temperature (51.0) is specified in section + [Units][[StringFormat]]. The unit label °F is from section [Units][[Labels]], while the time format is from [Units][[TimeFormats]]. +

+ +

As we saw above, the tags can be very simple:

+
## Output max outside temperature using an appropriate format and label:
+$month.outTemp.max
+

+ Most of the time, tags will "do the right thing" and are all you will need. However, WeeWX offers extensive + customization of the generated output for specialized applications such as XML RSS feeds, or rigidly + formatted reports (such as the NOAA reports). This section specifies the various tag options available. +

+ +

There are two different versions of the tags, depending on whether the data is "current", or an aggregation + over time. However, both versions are similar. +

+ +

+ Time period $current +

+ +

+ Time period $current represents a current observation. An example would + be the current barometric pressure: +

+
$current.barometer
+ +

+ Formally, WeeWX first looks for the observation type in the record emitted by the NEW_ARCHIVE_RECORD + event. This is generally the data emitted by the station console, augmented by any derived variables (e.g. + wind chill) that you might have specified. If the observation type cannot be found there, the most recent + record in the database will be searched. +

+

The most general tag for a "current" observation looks like:

+
$current($timestamp=some_time, $max_delta=delta_t,$data_binding=binding_name).obstype[.optional_unit_conversion][.optional_rounding][.optional_formatting]
+

Where:

+ +

+ some_time is a timestamp that you want to display. It is optional, The default is + to display the value for the current time. +

+ +

+ delta_t is the largest time difference (in seconds) between the time specified and + a timestamp of a record in the database that will be returned. By default, it is zero, which means there + must be an exact match with a specified time for a record to be retrieved. +

+ + +

+ binding_name is a binding name to a database. An example would be wx_binding. See the section Binding names for more + details. +

+ +

+ obstype is an observation type, such as barometer. This type must appear either as a field in the database, + or in the current (usually, the latest) record. +

+ +

+ optional_unit_conversion is an optional unit conversion tag. If provided, the + results will be converted into the specified units, otherwise the default units specified in the skin + configuration file (in section [Units][[Groups]]) will be used. See the section + Unit conversion options. +

+ +

+ optional_rounding allows the results to be rounded to a fixed number of decimal + digits. See the section Optional rounding +

+ +

+ optional_formatting is a set of optional formatting tags, which control how the + value will appear. See the section Formatting options below. +

+ +

+ Time period $latest +

+ +

+ Time period $latest is very similar to $current, except + that it uses the last available timestamp in a database. Usually, $current and + $latest are the same, but if a data binding points to a remote database, they may + not be. See the section Using multiple bindings for an example where + this happened. +

+ +

Aggregation periods

+ +

Aggregation periods is the other kind of tag. For example,

+
$week.rain.sum
+

+ represents an aggregation over time, using a certain aggregation type. In this example, + the aggregation time is a week, and the aggregation type is summation. So, this tag represents the total + rainfall over a week. +

+ +

The most general tag for an aggregation over time looks like:

+
$period($data_binding=binding_name, $optional_ago=delta).statstype.aggregation[.optional_unit_conversion][.optional_rounding][.optional_formatting]
+        
+

Where:

+ +

+ period is the time period over which the aggregation is to be done. Possible + choices are listed in the table below. +

+ +

+ binding_name is a binding name to a database. An example would be wx_binding. See the section Binding names for more + details. +

+ +

+ optional_ago is a keyword that depends on the aggregation period. For + example, for week, it would be weeks_ago, for day, it would be days_ago, etc. +

+ +

+ delta is an integer indicating which aggregation period is desired. For example + $week($weeks_ago=1) indicates last week, $day($days_ago=2) would be the day-before-yesterday, etc. The default is zero: + that is, this aggregation period. +

+ +

+ statstype is a statistical type. This is generally any observation type + that appears in the database, as well as a few synthetic types (such as heating and cooling degree-days). + Not all aggregations are supported for all types. +

+ +

+ aggregation is an aggregation type. If you ask for $month.outTemp.avg + you are asking for the average outside temperature for the month. Possible aggregation types are + given in Appendix: Aggregation types. +

+ +

+ optional_unit_conversion is an optional unit conversion tag. If provided, the + results will be converted into the specified units, otherwise the default units specified in the skin + configuration file (in section [Units][[Groups]]) will be used. See the section + Unit Conversion Options. +

+ +

+ optional_rounding allows the results to be rounded to a fixed number of decimal + digits. See the section Optional rounding +

+ +

+ optional_formatting is a set of optional formatting tags, which control how the + value will appear. See the section Formatting Options below. +

+ +

+ There are several different aggregation periods that can be used: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aggregation periods
Aggregation periodMeaningExampleMeaning of example
$hourThis hour.$hour.outTemp.maxtimeThe time of the max temperature this hour.
$dayToday (since midnight).$day.outTemp.maxThe max temperature since midnight
$yesterdayYesterday. Synonym for $day($days_ago=1). + $yesterday.outTemp.maxtimeThe time of the max temperature yesterday.
$weekThis week. The start of the week is set by option week_start. + $week.outTemp.maxThe max temperature this week.
$monthThis month (since the first of the month).$month.outTemp.minThe minimum temperature this month.
$yearThis year (since 1-Jan).$year.outTemp.maxThe max temperature since the start of the year.
$rainyearThis rain year. The start of the rain year is set by option rain_year_start. + $rainyear.rain.sumThe total rainfall for this rain year. The start of the rain year is set by option rain_year_start. +
$alltime + All records in the database given by binding_name. + $alltime.outTemp.max + The maximum outside temperature in the default database. +
+ +

+ The $optional_ago parameters can be useful for statistics farther in the past. Here are some + examples: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aggregation periodExampleMeaning
$hour($hours_ago=h) + $hour($hours_ago=1).outTemp.avgThe average temperature last hour (1 hour ago).
$day($days_ago=d) + $day($days_ago=2).outTemp.avgThe average temperature day before yesterday (2 days ago). +
$week($weeks_ago=d) + $week($weeks_ago=1).outTemp.maxThe maximum temperature last week.
$month($months_ago=m) + $month($months_ago=1).outTemp.maxThe maximum temperature last month.
$year($years_ago=m) + $year($years_ago=1).outTemp.maxThe maximum temperature last year.
+ +

Unit conversion options

+ +

+ The tag optional_unit_conversion can be used with either current observations or + aggregations. If supplied, the results will be converted to the specified units. For example, if you have + set group_pressure to inches of mercury (inHg), then the + tag +

+
Today's average pressure=$day.barometer.avg 
+

would normally give a result such as

+ +

Today's average pressure=30.05 inHg +

+ +

+ However, if you add mbar to the end of the tag, +

+
Today's average pressure=$day.barometer.avg.mbar
+

then the results will be in millibars:

+ +

Today's average pressure=1017.5 mbar +

+ +

Illegal conversions

+ +

+ If an inappropriate or nonsense conversion is asked for, e.g., +

+
Today's minimum pressure in mbars: $day.barometer.min.mbar
+or in degrees C: $day.barometer.min.degree_C
+or in foobar units: $day.barometer.min.foobar
+
+

then the offending tag(s) will be put in the output:

+ +

+ Today's minimum pressure in mbars: 1015.3
or in degrees C: $day.barometer.min.degree_C
or in + foobar units: $day.barometer.min.foobar +

+ +

Optional rounding

+

+ The data in the resultant tag can be optionally rounded to a fixed number of decimal digits. This is useful + when emitting raw data or JSON strings. It should not be used with formatted data (using a + format string would be a better choice). +

+

+ The structure of the tag is +

+
.round(ndigits=None)
+

where

+

+ ndigits is the number of decimal digits to retain. If + None (the default), then all digits will be retained. +

+ +

Formatting options

+ +

+ A variety of tags and arguments are available to you to customize the formatting of the final observation + value. This table lists the tags: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Optional formatting tag
Optional formatting tagComment
.format(args)Format the value as a string, according to a set of optional args (see below).
.ordinal_compassFormat the value as a compass ordinals (e.g."SW"), useful for wind directions. + The ordinal abbreviations are set by option directions in the skin + configuration file skin.conf. +
.long_form + Format delta times in the "long form". A "delta time" is the difference between two times. An + example is the amount of uptime (difference between start and current time). By default, this will + be formatted as the number of elapsed seconds (e.g., 45000 seconds). The "long form" breaks the time down into constituent time + elements (e.g., 12 hours, 30 minutes, 0 seconds). +
.json + Format the value as a JSON string. +
.rawReturn the value "as is", without being converted to a string and without any formatting applied. + This can be useful for doing arithmetic directly within the templates. You must be prepared to deal + with a potential None value. +
+ +

The first of these tags (.format()) has the formal structure:

+
.format(format_string=None, None_string=None, add_label=True, localize=True)
+ +

Here is the meaning of each of the optional arguments:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Optional arguments for .format()
Optional argumentComment
format_stringUse the optional string to format the value. If set to None, then an + appropriate string format from skin.conf will be used. +
None_stringShould the observation value be NONE, then use the supplied string + (typically, something like "N/A"). If None_string is set to None, then the value for NONE in [Units][[StringFormats]] + will be used. +
add_labelIf set to True (the default), then a unit label (e.g., °F) from + skin.conf will be attached to the end. Otherwise, it will be left out. +
localizeIf set to True (the default), then localize the results. Otherwise, do + not. +
+ +

If you're willing to honor the ordering of the arguments, the argument name can be omitted.

+ +

Formatting examples

+ +

This section gives a number of example tags, and their expected output. The following values are assumed:

+ + + + + + + + + + + + + + + + + + + + + + + +
Values used in the examples below
ObservationValue
+ outTemp + 45.2°F
+ UV + + None +
+ windDir + 138°
+ dateTime + 1270250700
+ +

Here are the examples:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Formatting options with expected results
TagResultResult
type
Comment
$current.outTemp45.2°Fstr + String formatting from [Units][[StringFormats]]. Label from [Units][[Labels]]. +
$current.outTemp.format45.2°Fstr + Same as the $current.outTemp. +
$current.outTemp.format()45.2°Fstr + Same as the $current.outTemp. +
$current.outTemp.format(format_string="%.3f")45.200°Fstr + Specified string format used; label from [Units][[Labels]]. +
$current.outTemp.format("%.3f")45.200°Fstr + As above, except a positional argument, instead of the named argument, is being used. +
$current.outTemp.format(add_label=False)45.2str + No label. The string formatting is from [Units][[StringFormats]]. +
$current.UVN/Astr + The string specified by option NONE in [Units][[StringFormats]]. +
$current.UV.format(None_string="No UV")No UVstr + Specified None_string is used. +
$current.windDir138°str + Formatting is from option degree_compass in [Units][[StringFormats]]. +
$current.windDir.ordinal_compassSWstr + Ordinal direction from section [Units][[Ordinates]] is being substituted. +
$current.dateTime02-Apr-2010 16:25str + Time formatting from [Units][[TimeFormats]] is being used. +
$current.dateTime.format(format_string="%H:%M")16:25str + Specified time format used. +
$current.dateTime.format("%H:%M")16:25str + As above, except a positional argument, instead of the named argument, is being used. +
$current.dateTime.raw1270250700int + Raw Unix epoch time. The result is an integer. +
$current.outTemp.raw45.2float + Raw float value. The result is a float. +
$current.outTemp.degree_C.raw7.33333333float + Raw float value in degrees Celsius. The result is a float. +
$current.outTemp.degree_C.json7.33333333str + Value in degrees Celsius, converted to a JSON string. +
$current.outTemp.degree_C.round(2).json7.33str + Value in degrees Celsius, rounded to two decimal digits, then converted to a JSON string. +
+ +

Note that the same formatting conventions can be used for aggregation periods, such as $month, + as well as $current. +

+ +

+ Start, end, and dateTime +

+ +

+ While not an observation type, in many ways the time of an observation, dateTime, + can be treated as one. A tag such as +

+
$current.dateTime
+

+ represents the current time (more properly, the time as of the end of the last archive interval) + and would produce something like +

+
01/09/2010 12:30:00
+

+ Like true observation types, explicit formats can be specified, except that they require a strftime() time + format , rather than a string format. +

+ +

For example, adding a format descriptor like this:

+
$current.dateTime.format("%d-%b-%Y %H:%M")
+

produces

+ +

09-Jan-2010 12:30

+ +

+ For aggregation periods, such as $month, you can request the + start, end, or length of the period, by using suffixes .start, .end, or .length, respectively. For example, +

+
The current month runs from $month.start to $month.end and has $month.length.format("%(day)d %(day_label)s").
+

results in

+
The current month runs from 01/01/2010 12:00:00 AM to 02/01/2010 12:00:00 AM and has 31 days.
+ +

+ In addition to the suffixes .start and .end, the suffix + .dateTime is provided for backwards compatibility. Like .start, it refers to the start of the interval. +

+ +

+ The returned string values will always be in local time. However, if you ask for the raw value +

+
$current.dateTime.raw
+ +

+ the returned value will be in Unix Epoch Time (number of seconds since 00:00:00 UTC 1 Jan 1970, + i.e., a large number), which you must convert yourself. It is guaranteed to never be None, so you don't worry have to worry about handling a None + value. +

+ + +

+ Tag $trend +

+ +

+ The tag $trend is available for time trends, such as changes in barometric + pressure. Here are some examples: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagResults
$trend.barometer-.05 inHg
$trend($time_delta=3600).barometer-.02 inHg
$trend.outTemp1.1 °C
$trend.time_delta10800 secs
$trend.time_delta.hour3 hrs
+

+ Note how you can explicitly specify a value in the tag itself (2nd row in the table above). If you do not + specify a value, then a default time interval, set by option time_delta in the skin configuration file, will be used. This value can be + retrieved by using the syntax $trend.time_delta (3rd row in the table). +

+ +

For example, the template expression

+
The barometer trend over $trend.time_delta.hour is $trend.barometer.format("%+.2f")
+

would result in

+ +

The barometer trend over 3 hrs is +.03 inHg.

+ +

+ Tag $span +

+ +

+ The tag $span allows aggregation over a user defined period up to and including + the current time. Its most general form looks like: +

+ +
$span([data_binding=binding_name][,optional_delta=delta][,boundary=[None|'midnight'])
+            .obstype
+            .aggregation
+            [.optional_unit_conversion]
+            [.optional_formatting]
+ +

Where:

+

+ binding_name is a binding name to a database. An example would be wx_binding. See the section Binding names for more + details. +

+ +

+ optional_delta=delta is one or more comma separated delta + settings from the table below. If more than one delta setting is included then the period used for the + aggregate is the sum of the individual delta settings. If no delta setting is included, or all included + delta settings are zero, the returned aggregate is based on the current obstype + only. +

+ +

+ boundary is an optional specifier that can force the starting time to a time + boundary. If set to 'midnight', then the starting time will be at the previous midnight. If left out, + then the start time will be the sum of the optional deltas. +

+ +

+ obstype is a observation type, such as outTemp. +

+ +

+ aggregation is an aggregation type. Possible aggregation types are given + in Appendix: Aggregation types. +

+ +

+ optional_unit_conversion is an optional unit conversion tag. See the section + Unit conversion options. +

+ +

+ optional_formatting is an optional formatting tag that controls how the value will + appear. See the section Formatting options. +

+

There are several different delta settings that can be used:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Delta SettingExampleMeaning
$time_delta=seconds$span($time_delta=1800).outTemp.avgThe average temperature over the last immediate 30 minutes (1800 seconds). +
$hour_delta=hours$span($hour_delta=6).outTemp.avgThe average temperature over the last immediate 6 hours. +
$day_delta=days$span($day_delta=1).rain.sumThe total rainfall over the last immediate 24 hours. +
$week_delta=weeks$span($week_delta=2).barometer.maxThe maximum barometric pressure over the last immediate 2 weeks. +
+ +

For example, the template expressions

+
The total rainfall over the last 30 hours is $span($hour_delta=30).rain.sum
+

and

+
The total rainfall over the last 30 hours is $span($hour_delta=6, $day_delta=1).rain.sum
+

would both result in

+ +

The total rainfall over the last 30 hours is 1.24 in

+ +

+ Tag $unit +

+ +

The type, label, and string formats for all units are also available, allowing you to do highly customized + labels: +

+ + + + + + + + + + + + + + + + + + + +
TagResults
$unit.unit_type.outTempdegree_C
$unit.label.outTemp°C
$unit.format.outTemp%.1f
+

For example, the tag

+
$day.outTemp.max.format(add_label=False)$unit.label.outTemp
+

would result in

+ +

21.2°C

+ +

+ (assuming metric values have been specified for group_temperature), essentially + reproducing the results of the simpler tag $day.outTemp.max. +

+ +

+ Tag $obs +

+ +

+ The labels used for the various observation types are available using tag $obs. + These are basically the values given in the skin dictionary, section [Labels][[Generic]]. +

+ + + + + + + + + + + + + + + +
TagResults
$obs.label.outTempOutside Temperature
$obs.label.UVUV Index
+ + +

Iteration

+ +

It is possible to iterate over the following:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tag suffixResults
.recordsIterate over every record
.hoursIterate by hours
.daysIterate by days
.monthsIterate by months
.yearsIterate by years
.spans(interval=seconds) + Iterate by custom length spans. The default interval is 10800 seconds (3 hours). The spans will + align to local time boundaries. +
+ +

+ The following template uses a Cheetah for loop to iterate over all months in a + year, printing out each month's min and max temperature. The iteration loop is  highlighted . +

+
Min, max temperatures by month
+#for $month in $year.months
+$month.dateTime.format("%B"): Min, max temperatures: $month.outTemp.min $month.outTemp.max
+#end for
+      
+

The result is:

+ +

+ Min, max temperatures by month:
January: Min, max temperatures: 30.1°F 51.5°F
February: Min, max + temperatures: 24.4°F 58.6°F
March: Min, max temperatures: 27.3°F 64.1°F
April: Min, max + temperatures: 33.2°F 52.5°F
May: Min, max temperatures: N/A N/A
June: Min, max temperatures: N/A + N/A
July: Min, max temperatures: N/A N/A
August: Min, max temperatures: N/A N/A
September: + Min, max temperatures: N/A N/A
October: Min, max temperatures: N/A N/A
November: Min, max + temperatures: N/A N/A
December: Min, max temperatures: N/A N/A +

+ +

+ The following template again uses a Cheetah for loop, this time to iterate over + 3-hour spans over the last 24 hours, displaying the averages in each span. The iteration loop is  highlighted . +

+
<p>3 hour averages over the last 24 hours</p>
+<table>
+  <tr>
+    <td>Date/time</td><td>outTemp</td><td>outHumidity</td>
+  </tr>
+#for $_span in $span($day_delta=1).spans(interval=10800)
+  <tr>
+    <td>$_span.start.format("%d/%m %H:%M")</td><td>$_span.outTemp.avg</td><td>$_span.outHumidity.avg</td>
+  </tr>
+#end for
+</table>
+
+

The result is:

+ +
+

3 hour averages over the last 24 hours

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date/timeoutTempoutHumidity
21/01 18:5033.4°F95%
21/01 21:5032.8°F96%
22/01 00:5033.2°F96%
22/01 03:5033.2°F96%
22/01 06:5033.8°F96%
22/01 09:5036.8°F95%
22/01 12:5039.4°F91%
22/01 15:5035.4°F93%
+ +
+ +

+ See the NOAA template files NOAA/NOAA-YYYY.txt.tmpl and NOAA/NOAA-YYYY-MM.txt.tmpl + for other examples using iteration, as well as explicit formatting. +

+ +

Comprehensive example

+

+ This example is designed to put together a lot of the elements described above, including iteration, + aggregation period starts and ends, formatting, and overriding units. Click + here for the results. +

+ +
+<html>
+  <head>
+    <style>
+      td { border: 1px solid #cccccc; padding: 5px; }
+    </style>
+  </head>
+
+  <body>
+    <table border=1 style="border-collapse:collapse;">
+      <tr style="font-weight:bold">
+        <td>Time interval</td>
+        <td>Max temperature</td>
+        <td>Time</td>
+      </tr>
+#for $hour in $day($days_ago=1).hours
+      <tr>
+        <td>$hour.start.format("%H:%M")-$hour.end.format("%H:%M")</td>
+        <td>$hour.outTemp.max ($hour.outTemp.max.degree_C)</td>
+        <td>$hour.outTemp.maxtime.format("%H:%M")</td>
+      </tr>
+#end for
+      <caption>
+        <p>
+          Hourly max temperatures yesterday<br/>
+          $day($days_ago=1).start.format("%d-%b-%Y")
+        </p>
+      </caption>
+    </table>
+  </body>
+</html>
+
+

Helper functions

+

+ WeeWX includes a number of helper functions that may be useful when writing templates. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Cheetah helper functions
FunctionDescription
$rnd(x, ndigits=None) + Round x to ndigits decimal digits. The argument + x can be a float or a list of floats. Values of None are passed through. +
$jsonize(seq)Convert the iterable seq to a JSON string.
$to_int(x) + Convert x to an integer. The argument x can be + of type float or str. Values of None are passed through. +
$to_bool(x) + Convert x to a boolean. + The argument x can be + of type int, + float, or + str. + If lowercase x is + 'true', 'yes', or 'y' the function returns + True. + If it is 'false', 'no', or 'n' it returns + False. + Other string values raise a + ValueError. + In case of an numeric argument 0 means + False, all other + values True. +
$to_list(x) + Convert x to a list. + If x is already a list, + nothing changes. If it is a single value it + is converted to a list with this value as the + only list element. Values of None are passed through. +
$getobs(plot_name) + For a given plot name, this function will return the set of all observation types used by the plot. + More information. + +
+ +

Support for series

+

+ This is an experimental API that could change. +

+

+ WeeWX V4.5 introduced some experimental tags for producing series of data, possibly aggregated. + This can be useful for + creating the JSON data needed for JavaScript plotting packages, such + as HighCharts, + Google Charts, + or C3.js. +

+

+ For example, suppose you need the maximum temperature for each day of the month. This tag +

+
+$month.outTemp.series(aggregate_type='max', aggregate_interval='day', time_series='start').json
+        
+

+ would produce the following: +

+
+[[1614585600, 58.2], [1614672000, 55.8], [1614758400, 59.6], [1614844800, 57.8], ... ]
+        
+

+ This is a list of (time, temperature) for each day of the month, in JSON, easily consumed by many of these + plotting packages. +

+

+ Many other combinations are possible. See the Wiki article + Tags for series. +

+ +

General tags

+ +

+ There are some general tags that do not reflect observation data, but technical information about the + template files. They are frequently useful in #if expressions to control how + Cheetah processes the template. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagDescription
$encodingCharacter encoding, to which the file is converted + after creation. Possible values are + html_entities, + strict_ascii, + normalized_ascii, and + utf-8. +
$filename + Name of the file to be created including relative path. + Can be used to set the canonical URL for search engines. +
<link rel="canonical" href="$station.station_url/$filename" />
+
$langLanguage code set by the lang option for the report. For example, + fr, or gr. +
$month_nameFor templates listed under SummaryByMonth, this will contain the localized + month name (e.g., "Sep").
$pageThe section name from skin.conf where the template is described.
$skinThe value of option skin in weewx.conf.
$SKIN_NAME + All skin included with WeeWX, version 4.6 or later, include the tag + $SKIN_NAME. For example, for + the Seasons skin, $SKIN_NAME would return + Seasons. +
$SKIN_VERSION + All skin included with WeeWX, version 4.6 or later, include the tag + $SKIN_VERSION, which returns the WeeWX version number of when the skin was + installed. Because skins are not touched during the upgrade process, this shows the origin of the + skin. +
$SummaryByDay + A list of year-month-day strings (e.g., ["2018-12-31", "2019-01-01"]) + for which a summary-by-day has been generated. + The [[SummaryByDay]] section must have been processed before this tag + will be valid, otherwise it will be empty. +
$SummaryByMonth + A list of year-month strings (e.g., ["2018-12", "2019-01"]) + for which a summary-by-month has been generated. + The [[SummaryByMonth]] section must have been processed before this tag + will be valid, otherwise it will be empty. +
$SummaryByYear + A list of year strings (e.g., ["2018", "2019"]) + for which a summary-by-year has been generated. + The [[SummaryByYear]] section must have been processed before this tag + will be valid, otherwise it will be empty. +
$year_nameFor templates listed under SummaryByMonth or + SummaryByYear, this will contain the year (e.g., "2018").
+ + +

+ Internationalization support with $gettext +

+

+ Pages generated by WeeWX not only contain observation data, but also static text. The WeeWX + $gettext + tag provides internationalization support for these kinds of texts. It is structured very similarly to the GNU gettext facility, but its implementation is very + different. To support internationalization of your template, do not use static text in your templates, but + rather use $gettext. Here's how. +

+

+ Suppose you write a skin called "YourSkin", and you want to include a headline + labelled "Current Conditions" in English, "aktuelle Werte" in German, "Conditions actuelles" in French, + etc. Then the template file could contain: +

+
...
+
+<h1>$gettext("Current Conditions")</h1>
+
+...
+

+ The section of weewx.conf configuring your skin would look something like this: +

+
...
+[StdReport]
+    ...
+    [[YourSkinReport]]
+        skin = YourSkin
+        lang = fr
+    ...
+

+ With lang = fr the report + is in French. To get it in English, replace the language + code fr by the code for English + en. And to get it in German + use de. +

+

+ To make this all work a language file has to be created for each supported language. The language files + reside in the lang subdirectory of the skin directory that is defined by the skin option. The file name of the language file is the language code appended by .conf, for example en.conf, de.conf, + or fr.conf. +

+

+ The language file has the same layout as + skin.conf, i.e. + you can put language specific versions of the labels there. + Additionally a section [Texts] + can be defined to hold the static texts used in the skin. + For the example above the language files would contain + the following: +

+

en.conf

+
...
+[Texts]
+    "Current Conditions" = Current Conditions
+    ...
+

de.conf

+
...
+[Texts]
+    "Current Conditions" = Aktuelle Werte
+    ...
+

fr.conf

+
...
+[Texts]
+    "Current Conditions" = Conditions actuelles
+    ...
+

+ While it is not technically necessary, we recommend using the whole English text for the key. This makes the + template easier to read, and easier for the translator. In the absence of a translation, it will also + be the default, so the skin will still be usable, even if a translation is not available. +

+

+ See the subdirectory SKIN_ROOT/Seasons/lang + for examples of language files. +

+ +

+ Context sensitive lookups: $pgettext() +

+

+ A common problem is that the same string may have different translations, depending on its context. For + example, in English, the word "Altitude" is used to mean both height above sea level, and the angle of a + heavenly body from the horizon, but that's not necessarily true in other languages. For example, in Thai, + "ระดับความสูง" is used to mean the former, "อัลติจูด" the latter. The function pgettext() (the "p" stands for particular) allows you to distinguish between + the two. Its semantics are very similar to the GNU and Python versions of the function. + Here's an example: +

+ +
+            <p>$pgettext("Geographical","Altitude"): $station.altitude</p>
+            <p>$pgettext("Astronomical","Altitude"): $almanac.moon.alt</p>
+        
+ +

+ The [Texts] section of the language file should then contain a subsection for each context. For + example, the Thai language file would include: +

+
+[Texts]
+    ...
+    [[Geographical]]
+        "Altitude" = "ระดับความสูง"    # As in height above sea level
+    [[Astronomical]]
+        "Altitude" = "อัลติจูด"         # As in angle above the horizon
+    ...
+        
+ + +

Almanac

+ +

+ If module pyephem has been installed, then WeeWX can generate + extensive almanac information for the Sun, Moon, Venus, Mars, Jupiter, and other heavenly bodies, including + their rise, transit and set times, as well as their azimuth and altitude. Other information is also + available. +

+ +

Here is an example template:

+ +
Current time is $current.dateTime
+#if $almanac.hasExtras
+Sunrise, transit, sunset: $almanac.sun.rise $almanac.sun.transit $almanac.sun.set
+Moonrise, transit, moonset: $almanac.moon.rise $almanac.moon.transit $almanac.moon.set
+Mars rise, transit, set: $almanac.mars.rise $almanac.mars.transit $almanac.mars.set
+Azimuth, altitude of mars: $almanac.mars.az $almanac.mars.alt
+Next new, full moon: $almanac.next_new_moon $almanac.next_full_moon
+Next summer, winter solstice: $almanac.next_summer_solstice $almanac.next_winter_solstice
+#else
+Sunrise, sunset: $almanac.sunrise $almanac.sunset
+#end if
+ +

If pyephem is installed this would result in:

+ +

+ Current time is 29-Mar-2011 09:20
Sunrise, transit, sunset: 06:51 13:11 19:30
Moonrise, transit, + moonset: 04:33 09:44 15:04
Mars rise, transit, set: 06:35 12:30 18:26
Azimuth, altitude of mars: + 124.354959275 26.4808431952
Next new, full moon: 03-Apr-2011 07:32 17-Apr-2011 19:43
Next summer, + winter solstice: 21-Jun-2011 10:16 21-Dec-2011 21:29 +

+ +

Otherwise, a fallback of basic calculations is used, resulting in:

+ +

+ Current time is 29-Mar-2011 09:20
Sunrise, sunset: 06:51 19:30 +

+ +

+ As shown in the example, you can test whether this extended almanac information is available with the value + $almanac.hasExtras. +

+ +

The almanac information falls into three categories:

+
    +
  • Calendar events
  • +
  • Heavenly bodies
  • +
  • Functions
  • +
+

We will cover each of these separately.

+

Calendar events

+ +

+ "Calendar events" do not require a heavenly body. They cover things such as next_solstice, next_first_quarter_moon or sidereal_time. + The syntax here is: +

+
$almanac.next_solstice
+

or

+
$almanac.next_first_quarter_moon
+

or

+
$almanac.sidereal_time
+

Here is a table of the information that falls into this category:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Calendar events
previous_equinoxnext_equinoxprevious_solsticenext_solstice
previous_autumnal_equinoxnext_autumnal_equinoxprevious_vernal_equinoxnext_vernal_equinox
previous_winter_solsticenext_winter_solsticeprevious_summer_solsticenext_summer_solstice
previous_new_moonnext_new_moonprevious_first_quarter_moonnext_first_quarter_moon
previous_full_moonnext_full_moonprevious_last_quarter_moonnext_last_quarter_moon
sidereal_time
+ +

+ Note
The tag $almanac.sidereal_time returns a value in + decimal degrees rather than a customary value from 0 to 24 hours. +

+ +

Heavenly bodies

+ +

The second category does require a heavenly body. This covers queries such as, "When does Jupiter rise?" or, + "When does the sun transit?" Examples are +

+
$almanac.jupiter.rise
+

or

+
$almanac.sun.transit
+

+ To accurately calculate these times, WeeWX automatically uses the present temperature and pressure to + calculate refraction effects. However, you can override these values, which will be necessary if you wish to + match the almanac times published by the Naval Observatory as explained in the pyephem documentation. For + example, to match the sunrise time as published by the Observatory, instead of +

+
$almanac.sun.rise
+

use

+
$almanac(pressure=0, horizon=-34.0/60.0).sun.rise
+

By setting pressure to zero we are bypassing the refraction calculations and manually setting the horizon to + be 34 arcminutes lower than the normal horizon. This is what the Navy uses. +

+ +

+ If you wish to calculate the start of civil twilight, you can set the horizon to -6 degrees, and also tell + WeeWX to use the center of the sun (instead of the upper limb, which it normally uses) to do the calcuation: +

+
$almanac(pressure=0, horizon=-6).sun(use_center=1).rise
+

The general syntax is:

+
$almanac(almanac_time=time,            ## Unix epoch time
+         lat=latitude, lon=longitude,  ## degrees
+         altitude=altitude,            ## meters
+         pressure=pressure,            ## mbars
+         horizon=horizon,              ## degrees
+         temperature=temperature_C     ## degrees C
+       ).heavenly_body(use_center=[01]).attribute
+      
+

+ As you can see, many other properties can be overridden besides pressure and the horizon angle. +

+ +

+ PyEphem offers an extensive list of objects that can be used for the heavenly_body tag. All the planets and many stars are in the list. +

+ +

+ The possible values for the attribute tag are listed in the following table: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Attributes that can be used with heavenly bodies +
azalta_raa_dec
g_rarag_decdec
elongradiushlonghlat
sublatsublongnext_risingnext_setting
next_transitnext_antitransitprevious_risingprevious_setting
previous_transitprevious_antitransitriseset
transitvisiblevisible_change 
+ +

+ Note
The tags ra, a_ra and g_ra return values in decimal degrees rather than customary values from 0 to 24 hours. +

+ +

Functions

+

There is actually one one function in this category: separation. It returns the + angular separation between two heavenly bodies. For example, to calculate the angular separation between + Venus and Mars you would use: +

+
+<p>The separation between Venus and Mars is
+      $almanac.separation(($almanac.venus.alt,$almanac.venus.az), ($almanac.mars.alt,$almanac.mars.az))</p>
+     
+

This would result in:

+

The separation between Venus and Mars is 55:55:31.8

+ +

Adding new bodies to the almanac

+

+ It is possible to extend the WeeWX almanac, adding new bodies that it was not previously aware of. For + example, say we wanted to add 433 Eros, the + first asteroid visited by a spacecraft. Here is the process: +

+
    + +
  1. + Put the following in the file user/extensions.py: +
    import ephem
    +eros = ephem.readdb("433 Eros,e,10.8276,304.3222,178.8165,1.457940,0.5598795,0.22258902,71.2803,09/04.0/2017,2000,H11.16,0.46")
    +ephem.Eros = eros
    + This does two things: it adds orbital information about 433 Eros to the internal pyephem + database, and it makes that data available under the name Eros (note the + capital letter). +
  2. + +
  3. + You can then use 433 Eros like any other body in your templates. For example, to display when + it will rise above the horizon: + +
    $almanac.eros.rise
    + +
  4. +
+ +

Wind

+ +

+ Wind deserves a few comments because it is stored in the database in two different ways: as a set of + scalars, and as a vector of speed and direction. Here are the four wind-related scalars stored in + the main archive database: +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Archive typeMeaningValid contexts
windSpeedThe average wind speed seen during the archive period. + + $current, $latest, $hour, $day, $week, $month, $year, $rainyear +
windDirIf software record generation is used, this is the vector average over the archive period. If + hardware record generation is used, the value is hardware dependent. +
windGustThe maximum (gust) wind speed seen during the archive period. +
windGustDirThe direction of the wind when the gust was observed.
+ +

+ Some wind aggregation types, notably vecdir and vecavg, + require wind speed and direction. For these, WeeWX provides a composite observation type + called wind. It is stored directly in the daily summaries, but synthesized for + aggregations other than multiples of a day. +

+ + + + + + + + + + + + + +
Daily summary typeMeaningValid contexts
windA vector composite of the wind.$hour, $day, $week, $month, $year, $rainyear
+ +

Any of these can be used in your tags. Here are some examples:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagMeaning
$current.windSpeedThe average wind speed over the most recent archive interval. +
$current.windDirIf software record generation is used, this is the vector average over the archive interval. If + hardware record generation is used, the value is hardware dependent. +
$current.windGustThe maximum wind speed (gust) over the most recent archive interval. +
$current.windGustDirThe direction of the gust.
$day.windSpeed.avg
$day.wind.avg
The average wind speed since midnight. If the wind blows east at 5 m/s for 2 hours, then west at 5 + m/s for 2 hours, the average wind speed is 5 m/s. +
$day.wind.vecavgThe vector average wind speed since midnight. If the wind blows east at 5 m/s for 2 hours, + then west at 5 m/s for 2 hours, the vector average wind speed is zero. +
$day.wind.vecdirThe direction of the vector averaged wind speed. If the wind blows northwest at 5 m/s for two hours, + then southwest at 5 m/s for two hours, the vector averaged direction is west. +
$day.windGust.max
$day.wind.max
The maximum wind gust since midnight.
$day.wind.gustdirThe direction of the maximum wind gust.
$day.windGust.maxtime
$day.wind.maxtime
The time of the maximum wind gust.
$day.windSpeed.maxThe max average wind speed. The wind is averaged over each of the archive intervals. Then the + maximum of these values is taken. Note that this is not the same as the maximum wind gust. +
$day.windDir.avg + Not a very useful quantity. This is the strict, arithmetic average of all the compass wind + directions. If the wind blows at 350° for two hours then at 10° for two hours, + then the scalar average wind direction will be 180° — probably not what you + expect, nor want. +
+ + +

Defining new tags

+ +

+ We have seen how you can change a template and make use of the various tags available such as $day.outTemp.max for the maximum outside temperature for the day. But, what if you want + to introduce some new data for which no tag is available? +

+ +

+ If you wish to introduce a static tag, that is, one that will not change with time (such as a Google + analytics Tracker ID, or your name), then this is very easy: simply put it in section [Extras] in the skin configuration file. More information on how to do this can be + found there. +

+ +

+ But, what if you wish to introduce a more dynamic tag, one that requires some calculation, or perhaps uses + the database? Simply putting it in the [Extras] section won't do, because then it + cannot change. +

+ +

+ The answer is to write a search list extension. Complete directioins on how to do this are in + a companion document Writing search list extensions. +

+ + + + +

The Image generator

+ +

+ This section gives an overview of the Image generator. For details about each of its various options, see + the section [ImageGenerator] in the Reference: + report options. +

+ +

+ The installed version of WeeWX is configured to generate a set of useful plots. But, what if you don't like + how they look, or you want to generate different plots, perhaps with different aggregation types? This + section covers how to do this. +

+ +

+ Image generation is controlled by the section [ImageGenerator] in the skin configuration file skin.conf. + Let's take a look at the beginning part of this section. It looks like this: +

+
[ImageGenerator]
+    ...
+    image_width = 500
+    image_height = 180
+    image_background_color = #f5f5f5
+
+    chart_background_color = #d8d8d8
+    chart_gridline_color = #a0a0a0
+    ...
+

+ The options right under the section name [ImageGenerator] will apply to + all plots, unless overridden in subsections. So, unless otherwise changed, all plots will be 500 + pixels in width, 180 pixels in height, and will have an RGB background color of #f5f5f5, a very light gray + (HTML color "WhiteSmoke"). The chart itself will have a background color of #d8d8d8 (a little darker gray), + and the gridlines will be #a0a0a0 (still darker). The other options farther down (not shown) will also apply + to all plots. +

+ +

Time periods

+ +

+ After the "global" options at the top of section [ImageGenerator], comes a set of + sub-sections, one for each time period (day, week, month, and year). These sub-sections define the nature of + aggregation and plot types for that time period. For example, here is a typical set of options for + sub-section [[month_images]]. It controls which "monthly" images will get + generated, and what they will look like: +

+
    [[month_images]]
+        x_label_format = %d
+        bottom_label_format = %m/%d/%y %H:%M
+        time_length = 2592000    # == 30 days
+        aggregate_type = avg
+        aggregate_interval = 10800    # == 3 hours
+        show_daynight = false
+
+

+ The option x_label_format gives a strftime() type format + for the x-axis. In this example, it will only show days (format option %d). The + bottom_label_format is the format used to time stamp the image at the bottom. In + this example, it will show the time as something like 10/25/09 + 15:35. A plot will cover a nominal 30 days, and all items included in it will use an aggregate type of + averaging over 3 hours. Finally, by setting option show_daynight to false, we are requesting that day-night, shaded bands not be shown. +

+ +

Image files

+ +

+ Within each time period sub-section is another nesting, one for each image to be generated. The title of + each sub-sub-section is the filename to be used for the image. Finally, at one additional nesting level (!) + are the logical names of all the line types to be drawn in the image. Like elsewhere, the values specified + in the level above can be overridden. For example, here is a typical set of options for sub-sub-section + [[[monthrain]]]: +

+
        [[[monthrain]]]
+            plot_type = bar
+            yscale = None, None, 0.02
+            [[[[rain]]]]
+                aggregate_type = sum
+                aggregate_interval = day
+                label = Rain (daily total)
+

+ This will generate an image file with name monthrain.png. It will be a bar plot. + Option yscale controls the y-axis scaling — if left out, the scale will + automatically be chosen. However, in this example we are choosing to exercise some degree of control by + specifying values explicitly. The option is a 3-way tuple (ylow, yhigh, min_interval), where ylow and + yhigh are the minimum and maximum y-axis values, respectively, and min_interval is the minimum tick interval. If set to None, the + corresponding value will be automatically chosen. So, in this example, the setting +

+
yscale = None, None, 0.02
+

+ will cause WeeWX to pick sensible y minimum and maximum values, but require that the tick increment (min_interval) be at least 0.02. +

+ +

+ Continuing on with the example above, there will be only one plot "line" (it will actually be a series of + bars) and it will have logical name rain. Because we have not said otherwise, the + database column name to be used for this line will be the same as its logical name, that is, rain, but this can be overridden. The aggregation type will be summing (overriding the + averaging specified in sub-section [[month_images]]), so you get the total rain + over the aggregate period (rather than the average) over an aggregation interval of 86,400 seconds (one + day). The plot line will be titled with the indicated label of 'Rain (daily total)'. The result of all this + is the following plot: +

+ +

+ Sample monthly rain plot +

+ +

Including more than one type in a plot

+ +

More than one observation can be included in a plot. For example, here is how to generate a plot with the + week's outside temperature as well as dewpoint: +

+
[[[monthtempdew]]]
+    [[[[outTemp]]]]
+    [[[[dewpoint]]]]
+

+ This would create an image in file monthtempdew.png that includes a line plot of + both outside temperature and dewpoint: +

+

+ Monthly temperature and dewpoint +

+ +

Including a type more than once in a plot

+ +

+ Another example. Say you want a plot of the day's temperature, overlaid with hourly averages. Here, you are + using the same data type (outTemp) for both plot lines, the first with averages, + the second without. If you do the obvious it won't work: +

+
## WRONG ##
+[[[daytemp_with_avg]]]
+    [[[[outTemp]]]]
+        aggregate_type = avg
+        aggregate_interval = hour
+    [[[[outTemp]]]]  # OOPS! The same section name appears more than once!
+

+ The option parser does not allow the same section name (outTemp in this case) to + appear more than once at a given level in the configuration file, so an error will be declared (technical + reason: formally, the sections are an unordered dictionary). If you wish for the same observation to appear + more than once in a plot then there is a trick you must know: use option data_type. + This will override the default action that the logical line name is used for the database column. So, our + example would look like this: +

+
[[[daytemp_with_avg]]]
+    [[[[avgTemp]]]]
+        data_type = outTemp
+        aggregate_type = avg
+        aggregate_interval = hour
+        label = Avg. Temp.
+    [[[[outTemp]]]]
+

+ Here, the first plot line has been given the name avgTemp to distinguish it from + the second line outTemp. Any name will do — it just has to be different. We + have specified that the first line will use data type outTemp and that it will + use averaging over a one hour period. The second also uses outTemp, but will not + use averaging. +

+ +

The result is a nice plot of the day's temperature, overlaid with a one hour smoothed average:

+ +

+ Daytime temperature with running average +

+ +

One more example. This one shows daily high and low temperatures for a year:

+
[[year_images]]
+    [[[yearhilow]]]
+        [[[[hi]]]]
+            data_type = outTemp
+            aggregate_type = max
+            label = High
+        [[[[low]]]]
+            data_type = outTemp
+            aggregate_type = min
+            label = Low Temperature
+

+ This results in the plot yearhilow.png: +

+ +

+ Daily highs and lows +

+ +

Including arbitrary expressions

+

+ The option data_type can actually be any arbitrary SQL expression, + which is valid in the context of the available types in the schema. For + example, say you wanted to plot the difference between inside and outside temperature for the year. This + could be done with: +

+
[[year_images]]
+    [[[yeardiff]]]
+        [[[[diff]]]]
+            data_type = inTemp-outTemp
+            label = Inside - Outside
+
+ +

+ Note that the option data_type is now an expression representing the difference + between inTemp and outTemp, the inside and outside + temperature, respectively. This results in a plot yeardiff.png:

+ +

+ Inside - outside temperature +

+ +

Changing the unit used in a plot

+

+ Normally, the unit used in a plot is set by the unit group of the observation types in the plot. + For example, consider this plot of today's outside temperature and dewpoint:

+
+    [[day_images]]
+        ...
+        [[[daytempdew]]]
+            [[[[outTemp]]]]
+            [[[[dewpoint]]]]
+

+ Both outTemp and dewpoint belong to unit group group_temperature, so this plot will use whatever unit has been specified for that + group. See the section Mixed units for details. +

+

+ However, supposed you'd like to offer both Metric and US Customary versions of the same plot? You + can do this by using option unit + to override the unit used for individual plots: +

+ +
+    [[day_images]]
+        ...
+        [[[daytempdewUS]]]
+            unit = degree_F
+            [[[[outTemp]]]]
+            [[[[dewpoint]]]]
+
+        [[[daytempdewMetric]]]
+            unit = degree_C
+            [[[[outTemp]]]]
+            [[[[dewpoint]]]]
+ +

+ This will produce two plots: file daytempdewUS.png will be in degrees + Fahrenheit, while file dayTempMetric.png will use degrees Celsius. +

+ +

Line gaps

+ +

+ If there is a time gap in the data, the option line_gap_fraction controls how line + plots will be drawn. Here's what a plot looks like without and with this option being specified: +

+ +
+
+ Gap not shown + +
+ No line_gap_fraction specified +
+
+
+ Gap showing + +
+ With line_gap_fraction=0.01. Note how each line has been split into two + lines. +
+
+
+
+ +

Progressive vector plots

+ +

+ WeeWX can produce progressive vector plots as well as the more conventional x-y plots. To produce these, use + plot type vector. You need a vector type to produce this kind of plot. There are + two: windvec, and windgustvec. While they do not + actually appear in the database, WeeWX understands that they represent special vector-types. The first, + windvec, represents the average wind in an archive period, the second, windgustvec the max wind in an archive period. Here's how to produce a progressive + vector for one week that shows the hourly biggest wind gusts, along with hourly averages: +

+
[[[weekgustoverlay]]]
+    aggregate_interval = hour
+    [[[[windvec]]]]
+        label = Hourly Wind
+        plot_type = vector
+        aggregate_type = avg
+    [[[[windgustvec]]]]
+        label = Gust Wind
+        plot_type = vector
+        aggregate_type = max
+

+ This will produce an image file with name weekgustoverlay.png. It will consist of + two progressive vector plots, both using hourly aggregation (3,600 seconds). For the first set of vectors, + the hourly average will be used. In the second, the max of the gusts will be used: +

+ +

+ hourly average wind vector overlaid with gust vectors +

+ +

+ By default, the sticks in the progressive wind plots point towards the wind source. That is, the stick for a + wind from the west will point left. If you have a chronic wind direction (as I do), you may want to rotate + the default direction so that all the vectors do not line up over the x-axis, overlaying each other. Do this + by using option vector_rotate. For example, with my chronic westerlies, I set + vector_rotate to 90.0 for the plot above, so winds out of the west point straight + up. +

+ +

+ If you use this kind of plot (the out-of-the-box version of WeeWX includes daily, weekly, monthly, and + yearly progressive wind plots), a small compass rose will be put in the lower-left corner of the image to + show the orientation of North. +

+ +

Overriding values

+ +

+ Remember that values at any level can override values specified at a higher level. For example, say you want + to generate the standard plots, but for a few key observation types such as barometer, you want to also + generate some oversized plots to give you extra detail, perhaps for an HTML popup. The standard weewx.conf file specifies plot size of 300x180 pixels, which will be used for all plots + unless overridden: +

+
[ImageGenerator]
+    ...
+    image_width = 300
+    image_height = 180
+

+ The standard plot of barometric pressure will appear in daybarometer.png: +

+
[[[daybarometer]]]
+    [[[[barometer]]]] 
+

+ We now add our special plot of barometric pressure, but specify a larger image size. This image will be put + in file daybarometer_big.png. +

+
[[[daybarometer_big]]]
+    image_width  = 600
+    image_height = 360
+    [[[[barometer]]]]
+ + + + +

Using multiple bindings

+ +

+ It's easy to use more than one database in your reports. Here's an example. In my office I have two + consoles: a VantagePro2 connected to a Dell Optiplex, and a WMR100N, connected to a Raspberry Pi. Each is + running WeeWX. The Dell is using SQLite, the RPi, MySQL. +

+ +

Suppose I wish to compare the inside temperatures of the two consoles. How would I do that?

+ +

+ It's easier to access MySQL across a network than SQLite, so let's run the reports on the Dell, but access + the RPi's MySQL database remotely. Here's how the bindings and database sections of weewx.conf + would look on the Dell: +

+
[DataBindings]
+    # This section binds a data store to an actual database
+
+    [[wx_binding]]
+        # The database to be used - it should match one of the sections in [Databases]
+        database = archive_sqlite
+        # The name of the table within the database
+        table_name = archive
+        # The class to manage the database
+        manager = weewx.manager.DaySummaryManager
+        # The schema defines to structure of the database contents
+        schema = schemas.wview_extended.schema
+
+    [[wmr100_binding]]
+        # Binding for my WMR100 on the RPi
+        database = rpi_mysql
+        # The name of the table within the database
+        table_name = archive
+        # The class to manage the database
+        manager = weewx.manager.DaySummaryManager
+        # The schema defines to structure of the database contents
+        schema = schemas.wview_extended.schema
+
+[Databases]
+    # This section binds to the actual database to be used
+
+    [[archive_sqlite]]
+        database_type = SQLite
+        database_name = weewx.sdb
+
+    [[rpi_mysql]]
+        database_type = MySQL
+        database_name = weewx
+        host = rpi-bug
+
+[DatabaseTypes]
+    #   This section defines defaults for the different types of databases.
+
+    [[SQLite]]
+        driver = weedb.sqlite
+        # Directory in which the database files are located
+        SQLITE_ROOT = %(WEEWX_ROOT)s/archive
+
+    [[MySQL]]
+        driver = weedb.mysql
+        # The host where the database is located
+        host = localhost
+        # The user name for logging in to the host
+        user = weewx
+        # The password for the user name
+        password = weewx
+    
+ +

+ The two additions have been highlighted. The first, [[wmr100_binding]], + adds a new binding called wmr10_binding. It links ("binds") to the new database, + called rpi_mysql, through the option database. It also + defines some characteristics of the binding, such as which manager is to be used and what its schema looks + like. +

+ +

+ The second addition, [[rpi-mysql]] defines the new database. Option database_type is set to MySQL, indicating that it is a MySQL + database. Defaults for MySQL databases are defined in the section [[MySQL]]. The + new database accepts all of them, except for host, which as been set to the remote + host rpi-bug, the name of my Raspberry Pi. +

+ +

Explicit binding in tags

+ +

How do we use this new binding? First, let's do a text comparison, using tags. Here's what our template looks + like: +

+
<table>
+  <tr>
+    <td class="stats_label">Inside Temperature, Vantage</td>
+    <td class="stats_data">$current.inTemp</td>
+  </tr>
+  <tr>
+    <td class="stats_label">Inside Temperature, WMR100</td>
+    <td class="stats_data">$latest($data_binding='wmr100_binding').inTemp</td>
+  </tr>
+</table>
+ +

+ The explicit binding to wmr100_binding is highlighted. This tells the reporting + engine to override the default binding specifed in [StdReport], generally wx_binding, and use wmr100_binding instead. +

+ +

This results in an HTML output that looks like:

+ +
+
+ + + + + + + + + + + +
Inside Temperature, Vantage68.7°F
Inside Temperature, WMR10068.9°F
+
+
+ +

Explicit binding in images

+ +

+ How would we produce a graph of the two different temperatures? Here's what the relevant section of the + skin.conf file would look like. +

+
[[[daycompare]]]
+   [[[[inTemp]]]]
+       label = Vantage inTemp
+   [[[[WMR100Temp]]]]
+       data_type = inTemp
+       data_binding = wmr100_binding
+       label = WMR100 inTemp
+ +

+ This will produce an image with name daycompare.png, with two plot lines. The + first will be of the temperature from the Vantage. It uses the default binding, wx_binding, + and will be labeled Vantage inTemp. The second explicitly uses the wmr100_binding. Because it uses the same variable name (inTemp) + as the first line, we had to explicitly specify it using option data_type, in + order to avoid using the same sub-section name twice (see the section Including a type more than once in a plot for details). It will + be labeled WMR100 inTemp. The results look like this: +

+ Comparing temperatures + +

Stupid detail

+ +

+ At first, I could not get this example to work. The problem turned out to be that the RPi was processing + things just a beat behind the Dell, so the temperature for the "current" time wasn't ready when the Dell + needed it. I kept getting N/A. To avoid this, I introduced the tag $latest, which uses the last available timestamp in the binding, which may or may not be + the same as what $current uses. That's why the example above uses $latest instead of $current. +

+ + + + +

Localization

+ +

+ This section provides suggestions for localization, + including translation to different languages and + display of data in formats specific to a locale. +

+ +

If the skin has been internationalized

+

+ All of the skins that come with WeeWX have been internationalized, although they may not have + been localized to your specific language. See the section + Internationalized skins for how to tell. +

+

Internationalized, your language is available

+

+ This is the easy case: the skin has been internationalized, and your locale is available. + In this case, all you need to do is to select your locale + in weewx.conf. For example, to select German (code de) + for the Seasons skin, just add the highlighted line (or change, if it's already there): +

+
...
+[StdReport]
+    ...
+    [[SeasonsReport]]
+        # The SeasonsReport uses the 'Seasons' skin, which contains the
+        # images, templates and plots for the report.
+        skin = Seasons
+        enable = true
+        lang = de
+        ...
+ +

Internationalized, but your language is missing

+ +

+ If the lang subdirectory is present in the skin directory, then the skin has been + internationalized. However, if your language code is not included in the subdirectory, then you will have to + localize it to your language. To do so, copy the file en.conf and name it + according to the language code of your language. Then translate all the strings on the right side of the + equal signs to your language. For example, say you want to localize the skin in the French language. Then + copy en.conf to fr.conf +

+
cp en.conf fr.conf
+

+ Then change things that look like this: +

+
+...
+[Texts]
+    "Language" = "English"
+
+    "7-day" = "7-day"
+    "24h" = "24h"
+    "About this weather station" = "About this weather station"
+    ...
+
+

to something that looks like this:

+
+...
+[Texts]
+    Language = French
+
+    "7-day" = "7-jours"
+    "24h" = "24h"
+    "About this weather station" = "A propos de cette station"
+    ...
+
+

+ And so on. When you're done, the skin author may be interested in your localization file to ship it together + with the skin for the use of other users. If the skin is one that came with WeeWX, contact the WeeWX team + via a post to the weewx-user group and, + with your permission, we may include your localization file in a future WeeWX release. +

+

+ Finally, set the option lang in weewx.conf to your + language code (fr in this example) as described in the + User's Guide. +

+ +

How to internationalize a skin

+ +

+ What happens when you come across a skin that you like, but it has not been internationalized? This section + explains how to convert the report to local formats and language. +

+ +

+ Internationalization of WeeWX templates uses a pattern very similar to the well-known + GNU "gettext" approach. The + only difference is that we have leveraged the ConfigObj facility used throughout + WeeWX. +

+ +

Create the localization file

+ +

+ Create a subdirectory called lang in the skin directory. + Then create a file named by the language code with the suffix .conf. For example, + if you want to translate to Spanish, name the file es.conf. Include the following + in the file: +

+ +
+[Units]
+
+    [[Labels]]
+
+        # These are singular, plural
+        meter             = " meter",  " meters"
+        day               = " day",    " days"
+        hour              = " hour",   " hours"
+        minute            = " minute", " minutes"
+        second            = " second", " seconds"
+
+    [[Ordinates]]
+
+        # Ordinal directions. The last one should be for no wind direction
+        directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A
+
+[Labels]
+
+    # Set to hemisphere abbreviations suitable for your location:
+    hemispheres = N, S, E, W
+
+    # Generic labels, keyed by an observation type.
+    [[Generic]]
+        altimeter              = Altimeter                # QNH
+        altimeterRate          = Altimeter Change Rate
+        appTemp                = Apparent Temperature
+        appTemp1               = Apparent Temperature
+        barometer              = Barometer                # QFF
+        barometerRate          = Barometer Change Rate
+        cloudbase              = Cloud Base
+        dateTime               = Time
+        dewpoint               = Dew Point
+        ET                     = ET
+        extraTemp1             = Temperature1
+        extraTemp2             = Temperature2
+        extraTemp3             = Temperature3
+        heatindex              = Heat Index
+        inDewpoint             = Inside Dew Point
+        inHumidity             = Inside Humidity
+        inTemp                 = Inside Temperature
+        interval               = Interval
+        lightning_distance     = Lightning Distance
+        lightning_strike_count = Lightning Strikes
+        outHumidity            = Outside Humidity
+        outTemp                = Outside Temperature
+        pressure               = Pressure                 # QFE
+        pressureRate           = Pressure Change Rate
+        radiation              = Radiation
+        rain                   = Rain
+        rainRate               = Rain Rate
+        THSW                   = THSW Index
+        UV                     = UV Index
+        wind                   = Wind
+        windchill              = Wind Chill
+        windDir                = Wind Direction
+        windGust               = Gust Speed
+        windGustDir            = Gust Direction
+        windgustvec            = Gust Vector
+        windrun                = Wind Run
+        windSpeed              = Wind Speed
+        windvec                = Wind Vector
+
+
+[Almanac]
+
+    # The labels to be used for the phases of the moon:
+    moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent
+
+[Texts]
+
+    Language              = Español # Replace with the language you are targeting
+        
+ +

+ Go through the file, translating all phrases on the right-hand side of the equal signs to your target + language (Spanish in this example). +

+ +

Internationalize the template

+

+ You will need to internationalize every HTML template (these typically have a file suffix of + .html.tmpl). This is most easily done + by opening the template and the language file in different editor windows. + It is much easier if you can change both files simultaneously. +

+ +

Change the html lang attribute

+

+ At the top of the template, change the HTML "lang" attribute to a configurable value. +

+
+<!DOCTYPE html>
+<html lang="$lang">
+  <head>
+    <meta charset="UTF-8">
+    ...
+
+

+ The value $lang will get replaced by the actual language to be used. +

+ +

+ language codes
country codes
+

+ +

Change the body text

+

+ The next step is to go through the templates and change all natural language phrases into lookups using + $gettext. For example, suppose your skin has a section that looks like this: +

+ +
+<div>
+    Current Conditions
+    <table>
+        <tr>
+            <td>Outside Temperature</td>
+            <td>$current.outTemp</td>
+        </tr>
+    </table>
+</div>
+
+ +

+ There are two natural language phrases here: Current Conditions and Outside Temperature. + They would be changed to: +

+
+<div>
+    $gettext("Current Conditions")
+    <table>
+        <tr>
+            <td>$obs.label.outTemp</td>
+            <td>$current.outTemp</td>
+        </tr>
+    </table>
+</div>
+
+

+ We have done two replacements here. For the phrase Current Conditions, we substituted + $gettext("Current Conditions"). This will cause the Cheetah Generator to look up + the localized version of "Current Conditions" in the localization file and substitute it. We could have + done something similar for Outside Temperature, but in this case, we chose to use the localized + name for type outTemp, which you should have provided in your localization file, + under section [Labels] / [[Generic]]. +

+

+ In the localization file, include the translation for Current Conditions under the [Texts] section: +

+
...
+[Texts]
+
+    "Language"           = "Español"
+    "Current Conditions" = "Condiciones Actuales"
+    ...
+

+ Repeat this process for all the strings that you find. Make sure not to replace HTML tags and HTML + options. +

+ +

Think about time

+ +

+ Whenever a time is used in a template, it will need a format. WeeWX comes with the following set of + defaults, defined in defaults.py: +

+
+[Units]
+    ...
+    [[TimeFormats]]
+        day        = %X
+        week       = %X (%A)
+        month      = %x %X
+        year       = %x %X
+        rainyear   = %x %X
+        current    = %x %X
+        ephem_day  = %X
+        ephem_year = %x %X
+
+ +

+ These defaults will give something readable in every locale, but they may not be very pretty. Therefore, + you may want to change them to something more suitable for the locale you are targeting, using the Python + strftime() + specific directives. +

+

+ Example: the default time formatting for "Current" conditions is %x %x, which will + show today's date as 14/05/21 10:00:00 in the Spanish locale. Suppose you would rather see + 14-mayo-2021 10:00. You would add the following to your + Spanish localization file es.conf: +

+
+...
+
+[Units]
+    [[TimeFormats]]
+        current = %d-%B-%Y %H:%M
+
+...     
+ +

+ Set the environment variable LANG +

+ +

+ Finally, you will need to set the environment variable LANG to reflect your + locale. For example, assuming you set +

+
$ export LANG=es_ES.UTF-8
+

+ before running WeeWX, then the local Spanish names for days of the week and months of the year will be used. + The decimal point for numbers will also be modified appropriately. +

+ + + +

Customizing the WeeWX service engine

+ +

This is an advanced topic intended for those who wish to try their hand at extending the internal engine in + WeeWX. Before attempting these examples, you should be reasonably proficient with Python. +

+ +

Please note that the API to the service engine may change in future versions!

+ +

+ At a high level, WeeWX consists of an engine that is responsible for managing a set of + services. A service consists of a Python class which binds its member functions to various events. + The engine arranges to have the bound member function called when a specific event happens, such as a new + LOOP packet arriving. +

+ +

+ The services are specified in lists in the [Engine][[Services]] + stanza of the configuration file. The [[Services]] section lists all the services + to be run, broken up into different service lists. +

+ +

+ These lists are designed to orchestrate the data as it flows through the WeeWX engine. For example, you want + to make sure data has been processed by, for example, running it through the quality control service, StdQC, before putting them in the database. Similarly, the reporting system must come + after the archiving service. These groups insure that things happen in the proper sequence. +

+

+ See the table Default services for a list of the services that are normally + run. +

+ +

Modifying an existing service

+ +

+ The service weewx.engine.StdPrint prints out new LOOP and archive packets to the + console when they arrive. By default, it prints out the entire record, which generally includes a lot of + possibly distracting information and can be rather messy. Suppose you do not like this, and want it to print + out only the time, barometer reading, and the outside temperature whenever a new LOOP packet arrives. This + could be done by subclassing the default print service StdPrint and overriding + member function new_loop_packet(). +

+ +

+ Create the file user/myprint.py: +

+
from weewx.engine import StdPrint
+from weeutil.weeutil import timestamp_to_string
+
+class MyPrint(StdPrint):
+
+    # Override the default new_loop_packet member function:
+    def new_loop_packet(self, event):
+        packet = event.packet
+        print("LOOP: ", timestamp_to_string(packet['dateTime']),
+            "BAR=",  packet.get('barometer', 'N/A'),
+            "TEMP=", packet.get('outTemp', 'N/A'))
+

+ This service substitutes a new implementation for the member function new_loop_packet. This implementation prints out the time, then the barometer reading (or + N/A if it is not available) and the outside temperature (or N/A). +

+ +

+ You then need to specify that your print service class should be loaded instead of the default StdPrint service. This is done by substituting your service name for StdPrint + in service_list, located in [Engine][[Services]]: +

+
[Engine]
+    [[Services]]
+        ...
+        report_services = user.myprint.MyPrint, weewx.engine.StdReport
+

+ Note that the report_services must be all on one line. Unfortunately, the parser + ConfigObj does not allow options to be continued on to following lines. +

+ + +

Creating a new service

+ +

+ Suppose there is no service that can be easily customized for your needs. In this case, a new one can easily + be created by subclassing off the abstract base class StdService, and then adding + the functionality you need. Here is an example that implements an alarm, which sends off an email when an + arbitrary expression evaluates True. +

+ +

+ This example is included in the standard distribution as examples/alarm.py: +

+ +
+import smtplib
+import socket
+import syslog
+import threading
+import time
+from email.mime.text import MIMEText
+
+import weewx
+from weeutil.weeutil import timestamp_to_string, option_as_list
+from weewx.engine import StdService
+
+
+# Inherit from the base class StdService:
+class MyAlarm(StdService):
+    """Service that sends email if an arbitrary expression evaluates true"""
+
+    def __init__(self, engine, config_dict):
+        # Pass the initialization information on to my superclass:
+        super(MyAlarm, self).__init__(engine, config_dict)
+
+        # This will hold the time when the last alarm message went out:
+        self.last_msg_ts = 0
+
+        try:
+            # Dig the needed options out of the configuration dictionary.
+            # If a critical option is missing, an exception will be raised and
+            # the alarm will not be set.
+            self.expression    = config_dict['Alarm']['expression']
+            self.time_wait     = int(config_dict['Alarm'].get('time_wait', 3600))
+            self.timeout       = int(config_dict['Alarm'].get('timeout', 10))
+            self.smtp_host     = config_dict['Alarm']['smtp_host']
+            self.smtp_user     = config_dict['Alarm'].get('smtp_user')
+            self.smtp_password = config_dict['Alarm'].get('smtp_password')
+            self.SUBJECT       = config_dict['Alarm'].get('subject', "Alarm message from weewx")
+            self.FROM          = config_dict['Alarm'].get('from', 'alarm@example.com')
+            self.TO            = option_as_list(config_dict['Alarm']['mailto'])
+            syslog.syslog(syslog.LOG_INFO, "alarm: Alarm set for expression: '%s'" % self.expression)
+
+            # If we got this far, it's ok to start intercepting events:
+            self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record)    # NOTE 1
+        except KeyError as e:
+            syslog.syslog(syslog.LOG_INFO, "alarm: No alarm set.  Missing parameter: %s" % e)
+
+    def new_archive_record(self, event):
+        """Gets called on a new archive record event."""
+
+        # To avoid a flood of nearly identical emails, this will do
+        # the check only if we have never sent an email, or if we haven't
+        # sent one in the last self.time_wait seconds:
+        if not self.last_msg_ts or abs(time.time() - self.last_msg_ts) >= self.time_wait:
+            # Get the new archive record:
+            record = event.record
+
+            # Be prepared to catch an exception in the case that the expression contains
+            # a variable that is not in the record:
+            try:                                                              # NOTE 2
+                # Evaluate the expression in the context of the event archive record.
+                # Sound the alarm if it evaluates true:
+                if eval(self.expression, None, record):                       # NOTE 3
+                    # Sound the alarm!
+                    # Launch in a separate thread so it doesn't block the main LOOP thread:
+                    t = threading.Thread(target=MyAlarm.sound_the_alarm, args=(self, record))
+                    t.start()
+                    # Record when the message went out:
+                    self.last_msg_ts = time.time()
+            except NameError as e:
+                # The record was missing a named variable. Log it.
+                syslog.syslog(syslog.LOG_DEBUG, "alarm: %s" % e)
+
+    def sound_the_alarm(self, rec):
+        """Sound the alarm in a 'try' block"""
+
+        # Wrap the attempt in a 'try' block so we can log a failure.
+        try:
+            self.do_alarm(rec)
+        except socket.gaierror:
+            # A gaierror exception is usually caused by an unknown host
+            syslog.syslog(syslog.LOG_CRIT, "alarm: unknown host %s" % self.smtp_host)
+            # Reraise the exception. This will cause the thread to exit.
+            raise
+        except Exception as e:
+            syslog.syslog(syslog.LOG_CRIT, "alarm: unable to sound alarm. Reason: %s" % e)
+            # Reraise the exception. This will cause the thread to exit.
+            raise
+
+    def do_alarm(self, rec):
+        """Send an email out"""
+
+        # Get the time and convert to a string:
+        t_str = timestamp_to_string(rec['dateTime'])
+
+        # Log the alarm
+        syslog.syslog(syslog.LOG_INFO, 'alarm: Alarm expression "%s" evaluated True at %s' % (self.expression, t_str))
+
+        # Form the message text:
+        msg_text = 'Alarm expression "%s" evaluated True at %s\nRecord:\n%s' % (self.expression, t_str, str(rec))
+        # Convert to MIME:
+        msg = MIMEText(msg_text)
+
+        # Fill in MIME headers:
+        msg['Subject'] = self.SUBJECT
+        msg['From']    = self.FROM
+        msg['To']      = ','.join(self.TO)
+
+        try:
+            # First try end-to-end encryption
+            s = smtplib.SMTP_SSL(self.smtp_host, timeout=self.timeout)
+            syslog.syslog(syslog.LOG_DEBUG, "alarm: using SMTP_SSL")
+        except (AttributeError, socket.timeout, socket.error):
+            syslog.syslog(syslog.LOG_DEBUG, "alarm: unable to use SMTP_SSL connection.")
+            # If that doesn't work, try creating an insecure host, then upgrading
+            s = smtplib.SMTP(self.smtp_host, timeout=self.timeout)
+            try:
+                # Be prepared to catch an exception if the server
+                # does not support encrypted transport.
+                s.ehlo()
+                s.starttls()
+                s.ehlo()
+                syslog.syslog(syslog.LOG_DEBUG,
+                              "alarm: using SMTP encrypted transport")
+            except smtplib.SMTPException:
+                syslog.syslog(syslog.LOG_DEBUG,
+                              "alarm: using SMTP unencrypted transport")
+
+        try:
+            # If a username has been given, assume that login is required for this host:
+            if self.smtp_user:
+                s.login(self.smtp_user, self.smtp_password)
+                syslog.syslog(syslog.LOG_DEBUG, "alarm: logged in with user name %s" % self.smtp_user)
+
+            # Send the email:
+            s.sendmail(msg['From'], self.TO,  msg.as_string())
+            # Log out of the server:
+            s.quit()
+        except Exception as e:
+            syslog.syslog(syslog.LOG_ERR, "alarm: SMTP mailer refused message with error %s" % e)
+            raise
+
+        # Log sending the email:
+        syslog.syslog(syslog.LOG_INFO, "alarm: email sent to: %s" % self.TO)
+
+ +

+ This service expects all the information it needs to be in the configuration file weewx.conf + in a new section called [Alarm]. So, add the following lines to your configuration + file: +

+
[Alarm]
+    expression = "outTemp < 40.0"
+    time_wait = 3600
+    smtp_host = smtp.example.com
+    smtp_user = myusername
+    smtp_password = mypassword
+    mailto = auser@example.com, anotheruser@example.com
+    from   = me@example.com
+    subject = "Alarm message from WeeWX!"
+

+ There are three important points to be noted in this example, each marked with a NOTE flag in the code. +

+
    +
  1. Here is where the binding happens between an event, weewx.NEW_ARCHIVE_RECORD + in this example, and a member function, self.new_archive_record. When the + event NEW_ARCHIVE_RECORD occurs, the function self.new_archive_record + will be called. There are many other events that can be intercepted. Look in the file weewx/__init__.py. +
  2. +
  3. Some hardware does not emit all possible observation types in every record. So, it's possible that a + record may be missing some types that are used in the expression. This try block will catch the NameError exception that would be raised should this occur. +
  4. +
  5. This is where the test is done for whether or not to sound the alarm. The [Alarm] configuration options specify that the alarm be sounded when outTemp < 40.0 evaluates True, that is when the outside + temperature is below 40.0 degrees. Any valid Python expression can be used, although the only variables + available are those in the current archive record. +
  6. +
+

Another example expression could be:

+
expression = "outTemp < 32.0 and windSpeed > 10.0"
+

In this case, the alarm is sounded if the outside temperature drops below freezing and the wind speed is + greater than 10.0. +

+ +

+ Note that units must be the same as whatever is being used in your database. That is, the same as what you + specified in option target_unit. +

+ +

+ Option time_wait is used to avoid a flood of nearly identical emails. The new + service will wait this long before sending another email out. +

+ +

+ Email will be sent through the SMTP host specified by option smtp_host. The + recipient(s) are specified by the comma separated option mailto. +

+ +

+ Many SMTP hosts require user login. If this is the case, the user and password are specified with options + smtp_user and smtp_password, respectively. +

+ +

+ The last two options, from and subject are optional. If + not supplied, WeeWX will supply something sensible. Note, however, that some mailers require a valid "from" + email address and the one WeeWX supplies may not satisfy its requirements. +

+ +

+ To make this all work, you must first copy the alarm.py file to the user directory. Then tell the engine to load this new service by adding the service name + to the list report_services, located in [Engine][[Services]]: +

+
[Engine]
+   [[Services]]
+        report_services = weewx.engine.StdPrint, weewx.engine.StdReport, user.alarm.MyAlarm
+

+ Again, note that the option report_services must be all on one line — the + parser ConfigObj does not allow options to be continued on to following lines. +

+ +

+ In addition to this example, the distribution also includes a low-battery alarm (lowBattery.py), + which is similar, except that it intercepts LOOP events (instead of archiving events). +

+ + +

Adding a second data source

+ +

A very common problem is wanting to augment the data from your weather station with data from some other + device. Generally, you have two approaches for how to handle this: +

+
    +
  • Run two instances of WeeWX, each using its own database and weewx.conf + configuration file. The results are then combined in a final report, using WeeWX's ability to use more than one database. See the Wiki entry How to run multiple instances of + WeeWX for details on how to do this. +
  • +
  • Run one instance, but use a custom WeeWX service to augment the records coming from your weather station + with data from the other device. +
  • +
+ +

This section covers the latter approach.

+ +

Suppose you have installed an electric meter at your house and you wish to correlate electrical usage with + the weather. The meter has some sort of connection to your computer, allowing you to download the total + power consumed. At the end of every archive interval you want to calculate the amount of power consumed + during the interval, then add the results to the record coming off your weather station. How would you do + this? +

+ +

Here is the outline of a service that retrieves the electrical consumption data and adds it to the archive + record. It assumes that you already have a function download_total_power() that, + somehow, downloads the amount of power consumed since time zero. +

+ +

+ File user/electricity.py +

+
import weewx
+from weewx.engine import StdService
+
+class AddElectricity(StdService):
+
+    def __init__(self, engine, config_dict):
+
+      # Initialize my superclass first:
+      super(AddElectricity, self).__init__(engine, config_dict)
+
+      # Bind to any new archive record events:
+      self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record)
+
+      self.last_total = None
+
+    def new_archive_record(self, event):
+
+        total_power = download_total_power()
+
+        if self.last_total:
+            net_consumed = total_power - self.last_total
+            event.record['electricity'] = net_consumed
+
+        self.last_total = total_power
+

+ This adds a new key electricity to the record dictionary and sets it equal to the + difference between the amount of power currently consumed and the amount consumed at the last archive + record. Hence, it will be the amount of power consumed over the archive interval. The unit should be + Watt-hours. +

+ +

+ As an aside, it is important that the function download_total_power() does not + delay very long because it will sit right in the main loop of the WeeWX engine. If it's going to cause a + delay of more than a couple seconds you might want to put it in a separate thread and feed the results to + AddElectricity through a queue. +

+ +

+ To make sure your service gets run, you need to add it to one of the service lists in weewx.conf, + section [Engine], subsection [[Services]]. +

+ +

+ In our case, the obvious place for our new service is in data_services. When + you're done, your section [Engine] will look something like this: +

+
+#   This section configures the internal WeeWX engine.
+
+[Engine]
+
+    [[Services]]
+        # This section specifies the services that should be run. They are
+        # grouped by type, and the order of services within each group
+        # determines the order in which the services will be run.
+        xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta
+        prep_services = weewx.engine.StdTimeSynch
+        data_services = user.electricity.AddElectricity
+        process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate
+        archive_services = weewx.engine.StdArchive
+        restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS
+        report_services = weewx.engine.StdPrint, weewx.engine.StdReport
+
+ + + +

Customizing the database

+ +

+ For most users the database defaults will work just fine. However, there may be occasions when you may want + to add a new observation type to your database, or change its unit system. This section shows you how to do + this. +

+

+ Every relational database depends on a schema to specify which types to include in the database. + When a WeeWX database is first created, it uses a Python version of the schema to initialize the database. + However, once the database has been created, the schema is read directly from the database and the Python + version is not used again — any changes to it will have no effect. This means that the strategy for + modifying the schema depends on whether the database already exists. +

+ +

Specifying a schema for a new database

+

+ If the database does not exist yet, then you will want to pick an appropriate starting + schema. If it's not exactly what you want, you can modify it to fit your needs. +

+ +

Picking a starting schema

+

+ WeeWX gives you a choice of three different schemas to choose from when creating a new database: +

+ + + + + + + + + + + + + + + + + + + + + + + +
NameNumber of observation typesComment
schemas.wview.schema49The original schema that came with wview.
schemas.wview_extended.schema111A version of the wview schema, which has been extended with many new types. This is the default + version.
schemas.wview_small.schema20A minimalist version of the wview schema.
+ +

+ For most users, the default database schema, schemas.wview_extended.schema, + will work just fine. +

+ +

+ To specify which schema to use when creating a database, modify option schema in + section [DataBindings] in weewx.conf. For example, + suppose you wanted to use the classic (and smaller) schema schemas.wview.schema + instead of the default schemas.wview_extended.schema. Then the section [DataBindings] would look like: +

+ +
[DataBindings]
+    [[wx_binding]]
+        database = archive_sqlite
+        table_name = archive
+        manager = weewx.manager.DaySummaryManager
+        schema = schemas.wview.schema
+ +

+ Now, when you start WeeWX, it will use this new choice instead of the default. +

+

+ NOTE: This only works when the database is first created. Thereafter, WeeWX reads the schema + directly from the database. Changing this option will have no effect! +

+ +

Modifying a starting schema

+ +

+ If none of the three starting schemas that come with WeeWX suits your purposes, you can easily create your + own. Just pick one of the three schemas as a starting point, then modify it. Put the results in the user subdirectory, where it will be safe from upgrades. For example, suppose you like + the schemas.wview_small schema, but you need to store the type electricity from the example Adding a second data + source above. The type electricity does not appear in the schema, so + you'll have to add it before starting up WeeWX. We will call the resulting new schema + user.myschema.schema. +

+

+ If you did a Debian install, here's how you would do this: +

+
# Copy the wview_small schema over to the user subdirectory and rename it myschema:
+sudo cp /usr/share/weewx/schemas/wview_small.py /usr/share/weewx/user/myschema.py
+
+# Edit it using your favorite text editor
+sudo nano /usr/share/weewx/user/myschema.py
+
+ +

If you did a setup.py install, it would look like this:

+
# Copy the wview_small schema over to the user subdirectory and rename it myschema:
+cp /home/weewx/bin/schemas/wview_small.py /home/weewx/bin/user/myschema.py
+
+# Edit it using your favorite text editor
+nano /home/weewx/bin/user/myschema.py
+
+ +

In myschema.py change this:

+
         ...
+         ('windchill',            'REAL'),
+         ('windDir',              'REAL'),
+         ('windGust',             'REAL'),
+         ('windGustDir',          'REAL'),
+         ('windSpeed',            'REAL'),
+         ]
+
+

to this

+
         ...
+         ('windchill',            'REAL'),
+         ('windDir',              'REAL'),
+         ('windGust',             'REAL'),
+         ('windGustDir',          'REAL'),
+         ('windSpeed',            'REAL'),
+         ('electricity',          'REAL'),
+         ]
+
+

+ The only change was the addition (highlighted) of electricity to the list of observation names. +

+ +

+ Now change option schema under [DataBindings] in + weewx.conf to use your new schema: +

+
[DataBindings]
+    [[wx_binding]]
+        database = archive_sqlite
+        table_name = archive
+        manager = weewx.manager.DaySummaryManager
+        schema = user.myschema.schema
+ +

+ Start WeeWX. When the new database is created, it will use your modified schema instead of the default. +

+

+ NOTE: This will only work when the database is first created! Thereafter, WeeWX reads the schema directly + from the database and your changes will have no effect! +

+ + +

Modifying an existing database

+

+ The previous section covers the case where you do not have an existing database, so you modify a starting + schema, then use it to initialize the database. But, what if you already have a database, and you want to + modify it, perhaps by adding a column or two? You cannot create a new starting schema, because it is only + used when the database is first created. Here is where the tool wee_database can be useful. Be sure + to stop WeeWX before attempting to use it. +

+

+ There are two ways to do this. Both are covered below. +

+
    +
  1. Modify the database in situ by using the tool wee_database. This + choice works best for small changes. +
  2. +
  3. Transfer the old database to a new one while modifying it along the way, again by using the tool wee_database. This choice is best for large modifications. +
  4. +
+ +

+ NOTE: Before using the tool wee_database, MAKE A BACKUP FIRST! +

+ +

Modify the database in situ

+

+ If you want to make some minor modifications to an existing database, perhaps adding or removing a column, + then this can easily be done using the tool wee_database with an appropriate + option. We will cover the cases of adding, removing, and renaming a type. See the documentation for wee_database for more details. +

+ +

Adding a type

+ +

+ Suppose you have an existing database and you want to add a type, such as the type electricity + from the example above Adding a second data source. This can be + done in one easy step using the tool wee_database with the option --add-column: +

+
wee_database --add-column=electricity
+

+ The tool not only adds electricity to the main archive table, but also to the + daily summaries. +

+ +

Removing a type

+

+ In a similar manner, the tool can remove any unneeded types from an existing database. For example, suppose + you are using the schemas.wview schema, but you're pretty sure you're not going to + need to store soil moisture. You can drop the unnecessary types this way: +

+
wee_database --drop-columns=soilMoist1,soilMoist2,soilMoist3,soilMoist4
+

+ Unlike the option --add-column, the option --drop-columns can take more than one type. This is done in the interest of efficiency: + adding new columns is easy and fast with the SQLite database, but dropping columns requires copying the + whole database. By specifying more than one type, you can amortize the cost over a single invocation of the + utility. +

+

+ NOTE: Dropping types from a database means you will lose any data associated with them! The data + cannot be recovered. +

+ +

Renaming a type

+

+ Suppose you just want to rename a type? This can be done using the option --to-name. + Here's an example where you rename soilMoist1 to soilMoistGarden: +

+
wee_database --rename-column=soilMoist1 --to-name=soilMoistGarden
+ +

+ Note how the option --rename-column also requires option + --to-name, which specifies the target name. +

+ +

Transfer database using new schema

+

+ If you are making major changes to your database, you may find it easier to create a brand new database + using the schema you want, then transfer all data from the old database into the new one. This approach + is more work, and takes more processing time than the in situ strategies outlines above, + but has the advantage that it leaves behind a record of exactly the schema you are using. +

+ +

Here is the general strategy of how to do this.

+ +
    +
  1. Create a new schema that includes exactly the types that you want.
  2. +
  3. Specify this schema as the starting schema for the database.
  4. +
  5. Make sure you have the necessary permissions to create the new database.
  6. +
  7. + Use the utility wee_database + to create the new database and populate it with data from the old database. +
  8. +
  9. Shuffle databases around so WeeWX will use the new database.
  10. +
+

Here are the details:

+ +
    +
  1. +

    Create a new schema. First step is to create a new schema with exactly the types + you want. See the instructions above Modify + a starting schema. As an example, suppose your new schema is called + user.myschema.schema. +

    +
  2. +
  3. +

    Set as starting schema. Set your new schema as the starting schema with + whatever database binding you are working with (generally, wx_binding). + For example:

    +
    [DataBindings]
    +
    +    [[wx_binding]]
    +        database = archive_sqlite
    +        table_name = archive
    +        manager = weewx.manager.DaySummaryManager
    +        schema = user.myschema.schema
    +
  4. +
  5. +

    + Check permissions. The reconfiguration utility will create a new database with the + same name as the old, except with the suffix _new attached to the end. + Make sure you have the necessary permissions to do this. In particular, if you are using MySQL, you + will need CREATE privileges. +

    +
  6. +
  7. +

    + Create and populate the new database. Use the utility + wee_database with the --reconfigure option. +

    +
    wee_database weewx.conf --reconfigure
    +

    + This will create a new database (nominally, weewx.sdb_new if you are using + SQLite, weewx_new if you are using MySQL), using the schema found in user.myschema.schema, and populate it with data from the old database. +

    +
  8. +
  9. +

    + Shuffle the databases. Now arrange things so WeeWX can find the new database. +

    + +

    + Warning!
    Make a backup of the data before doing any of the next steps! +

    + +

    + You can either shuffle the databases around so the new database has the same name as the old + database, or edit weewx.conf to use the new database name. To do the + former: +

    + +

    For SQLite:

    +
    cd SQLITE_ROOT
    +mv weewx.sdb_new weewx.sdb
    +

    For MySQL:

    +
    mysql -u <username> --password=<mypassword>
    +mysql> DROP DATABASE weewx;                             # Delete the old database
    +mysql> CREATE DATABASE weewx;                           # Create a new one with the same name
    +mysql> RENAME TABLE weewx_new.archive TO weewx.archive; # Rename to the nominal name
    +
  10. +
  11. +

    + It's worth noting that there's actually a hidden, last step: rebuilding the daily summaries inside + the new database. This will be done automatically by WeeWX at the next startup. Alternatively, it + can be done manually using the wee_database + utility and the --rebuild-daily option: +

    +
    wee_database weewx.conf --rebuild-daily
    +
  12. +
+ + +

Changing the unit system in an existing database

+

+ Normally, data are stored in the databases using US Customary units, and you shouldn't care; it is an + "implementation detail". Data can always be displayed using any set of units you want — the section How to change units explains how to change the reporting units. + Nevertheless, there may be special situations where you wish to store the data in Metric units. For example, + you may need to allow direct programmatic access to the database from another piece of software that expects + metric units. +

+ +

+ You should not change the database unit system midstream. That is, do not start with one unit system then, + some time later, switch to another. WeeWX cannot handle databases with mixed unit systems — see the + section [StdConvert] in the WeeWX User's + Guide. However, you can reconfigure the database by copying it to a new database, performing the unit + conversion along the way. You then use this new database. +

+ +

+ The general strategy is identical to the strategy outlined above in the section + Transfer database using new schema. The only + difference is that instead of specifying a new starting schema, you specify a different database + unit system. + This means that instead of steps 1 and 2 above, you edit the configuration file and change + option target_unit in section [StdConvert] to reflect your choice. For example, if you are + switching to metric units, the option will look like: +

+
[StdConvert]
+    target_unit = METRICWX
+ +

+ After changing target_unit, you then go ahead with the rest of the steps. That is + run wee_database with the --reconfigure option, then + shuffle the databases. +

+ + + + +

Rebuilding the daily summaries

+ +

+ The wee_database utility can also be used to rebuild the daily summaries: +

+ +
wee_database weewx.conf --rebuild-daily
+ +

In most cases this will be sufficient; however, if anomalies remain in the daily summaries the daily summary + tables may be dropped first before rebuilding: +

+ +
wee_database weewx.conf --drop-daily
+ +

+ The summaries will automatically be rebuilt the next time WeeWX starts, or they can be rebuilt with the + utility: +

+ +
wee_database weewx.conf --rebuild-daily
+ + + + +

Customizing units and unit groups

+ +

+ Warning!
This is an area that is changing rapidly in WeeWX. Presently, new units and + unit groups are added by manipulating the internal dictionaries in WeeWX (as described below). In the + future, they may be specified in weewx.conf. +

+ +

Assigning a unit group

+

+ In the examples above, we created a new observation type, electricity, and added + it to the database schema. Now we would like to recognize that it is a member of the unit group group_energy (which already exists), so it can enjoy the labels and formats already + provided for this group. This is done by extending the dictionary weewx.units.obs_group_dict. +

+ +

Add the following to our new services file user/electricity.py, just after the last + import statement: +

+
import weewx
+from weewx.engine import StdService
+
+import weewx.units
+weewx.units.obs_group_dict['electricity'] = 'group_energy'
+
+class AddElectricity(StdService):
+
+   # [...]
+ +

When our new service gets loaded by the WeeWX engine, these few lines will get run. They tell WeeWX that our + new observation type, electricity, is part of the unit group group_energy. + Once the observation has been associated with a unit group, the unit labels and other tag syntax will work + for that observation. So, now a tag like: +

+
$month.electricity.sum
+

will return the total amount of electricity consumed for the month, in Watt-hours.

+ +

Creating a new unit group

+

+ That was an easy one, because there was already an existing group, group_energy, + that covered our new observation type. But, what if we are measuring something entirely new, like force with + time? There is nothing in the existing system of units that covers things like newtons or pounds. We will + have to define these new units, as well as the unit group they can belong to. +

+

+ We assume we have a new observation type, rocketForce, which we are measuring over time, + for a service named Rocket, located in + user/rocket.py. We will create a new unit group, group_force, and new units, + newton and pound. Our new observation, rocketForce, will belong to group_force, and will be measured in + units of newton or pound. +

+

+ To make this work, we need to add the following to user/rocket.py. +

+
    +
  1. As before, we start by specifying what group our new observation type belongs to: +
    +import weewx.units
    +weewx.units.obs_group_dict['rocketForce'] = 'group_force'
    +
  2. +
  3. Next, we specify what unit is used to measure force in the three standard unit systems used by weewx. +
    +weewx.units.USUnits['group_force'] = 'pound'
    +weewx.units.MetricUnits['group_force'] = 'newton'
    +weewx.units.MetricWXUnits['group_force'] = 'newton'
    +
  4. +
  5. Then we specify what formats and labels to use for newton and pound: +
    +weewx.units.default_unit_format_dict['newton'] = '%.1f'
    +weewx.units.default_unit_format_dict['pound']  = '%.1f'
    +
    +weewx.units.default_unit_label_dict['newton'] = ' newton'
    +weewx.units.default_unit_label_dict['pound']  = ' pound'
    +
  6. +
  7. Finally, we specify how to convert between them: +
    +weewx.units.conversionDict['newton'] = {'pound':  lambda x : x * 0.224809}
    +weewx.units.conversionDict['pound']  = {'newton': lambda x : x * 4.44822}
    +
    + +
  8. +
+

+ Now, when the service Rocket gets loaded, these lines of code will get executed, + adding the necessary unit extensions to WeeWX. +

+ +

Using the new units

+ +

Now you've added a new type of units. How do you use it?

+ +

+ Pretty much like any other units. For example, to do a plot of the month's electric consumption, totaled by + day, add this section to the [[month_images]] section of skin.conf: +

+
[[[monthelectric]]]
+    [[[[electricity]]]]
+        aggregate_type = sum
+        aggregate_interval = day
+        label = Electric consumption (daily total)
+

+ This will cause the generation of an image monthelectric.png, showing a plot of + each day's consumption for the past month. +

+ +

If you wish to use the new type in the templates, it will be available using the same syntax as any other + type. Here are some other tags that might be useful: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagMeaning
$day.electricity.sumTotal consumption since midnight
$year.electricity.sumTotal consumption since the first of the year
$year.electricity.maxThe most consumed during any archive period
$year.electricity.maxsumThe most consumed during a day
$year.electricity.maxsumtimeThe day it happened.
$year.electricity.sum_ge((5000.0, 'kWh', 'group_energy'))The number of days of the year where more than 5.0 kWh of energy was consumed. The argument + is a ValueTuple. +
+ + + + +

Adding new, derived types

+

+ In the section Adding a second data source, we saw an example of + how to create a new type for a new data source. But, what if you just want to add a type that is a + derivation of existing types? The WeeWX type dewpoint is an example of this: it's + a function of two observables, outTemp, and outHumidity. + WeeWX calculates it automatically for you. +

+

+ Calculating new, derived types is the job of the WeeWX XTypes system. It can also allow you to add new + aggregatioin types. +

+

+ See the Wiki article WeeWX V4 + user defined types for complete details on how the XTypes system works. +

+ + + +

Porting to new hardware

+ +

Naturally, this is an advanced topic but, nevertheless, I'd like to encourage any Python wizards out there to + give it a try. Of course, I have selfish reasons for this: I don't want to have to buy every weather station + ever invented, and I don't want my roof to look like a weather station farm! +

+ +

+ A driver communicates with hardware. Each driver is a single python file that contains the code + that is the interface between a device and WeeWX. A driver may communicate directly with hardware using a + MODBus, USB, serial, or other physical interface. Or it may communicate over a network to a physical device + or a web service. +

+ +

General guidelines

+ +
    +
  • The driver should emit data as it receives it from the hardware (no caching). +
  • +
  • The driver should emit only data it receives from the hardware (no "filling in the gaps"). +
  • +
  • The driver should not modify the data unless the modification is directly related to the hardware (e.g., + decoding a hardware-specific sensor value). +
  • +
  • If the hardware flags "bad data", then the driver should emit a null value for that datum (Python None). +
  • +
  • The driver should not calculate any derived variables (such as dewpoint). The service StdWXService will do that. +
  • +
  • However, if the hardware emits a derived variable, then the driver should emit it. +
  • +
+ +

Implement the driver

+ +

+ Create a file in the user directory, say mydriver.py. This file will contain the + driver class as well as any hardware-specific code. Do not put it in the weewx/drivers directory or it will be deleted when you upgrade WeeWX. +

+ +

+ Inherit from the abstract base class weewx.drivers.AbstractDevice. Try to + implement as many of its methods as you can. At the very minimum, you must implement the first three + methods, loader, hardware_name, and genLoopPackets. +

+ +

+ loader +

+ +

This is a factory function that returns an instance of your driver. It has two arguments: the configuration + dictionary, and a reference to the WeeWX engine. +

+ +

+ hardware_name +

+ +

+ Return a string with a short nickname for the hardware, such as "ACME X90" +

+ +

+ genLoopPackets +

+ +

+ This should be a Python generator function that yields + loop packets, one after another. Don't worry about stopping it: the engine will do this when an archive + record is due. A "loop packet" is a dictionary. At the very minimum it must contain keys for the observation + time and for the units used within the packet. +

+ + + + + + + + + + + + +
Required keys
dateTimeThe time of the observation in unix epoch time.
usUnits + The unit system used. weewx.US for US customary, weewx.METRICWX, or weewx.METRIC for metric. See the + Appendix Units for their exact definitions. The dictionaries USUnits, MetricWXUnits, and MetricUnits in file units.py, can also be useful. +
+

+ Then include any observation types you have in the dictionary. Every packet need not contain the same set of + observation types. Different packets can use different unit systems, but all observations within a packet + must use the same unit system. If your hardware is capable of measuing an observation type but, for whatever + reason, its value is bad (maybe a bad checksum?), then set its value to None. If + your hardware is incapable of measuring an observation type, then leave it out of the dictionary. +

+ +

+ A couple of observation types are tricky, in particular, rain. The field rain in a LOOP packet should be the amount of rain that has fallen since the last + packet. Because LOOP packets are emitted fairly frequently, this is likely to be a small number. If + your hardware does not provide this value, you might have to infer it from changes in whatever value it + provides, for example changes in the daily or monthly rainfall. +

+ +

+ Wind is another tricky one. It is actually broken up into four different observations: windSpeed, + windDir, windGust, and windGustDir. Supply as many as you can. The directions should be compass directions in + degrees (0=North, 90=East, etc.). +

+ +

Be careful when reporting pressure. There are three observations related to pressure. Some stations report + only the station pressure, others calculate and report sea level pressures. +

+ + + + + + + + + + + + + + + + +
Pressure types
pressureThe Station Pressure (SP), which is the raw, absolute pressure measured by the station. + This is the true barometric pressure for the station. +
barometerThe Sea Level Pressure (SLP) obtained by correcting the Station Pressure for + altitude and local temperature. This is the pressure reading most commonly used by meteorologist to + track weather systems at the surface, and this is the pressure that is uploaded to weather services + by WeeWX. It is the station pressure reduced to mean sea level using local altitude and local + temperature. +
altimeterThe Altimeter Setting (AS) obtained by correcting the Station Pressure for + altitude. This is the pressure reading most commonly heard in weather reports. It is not the true + barometric pressure of a station, but rather the station pressure reduced to mean sea level using + altitude and an assumed temperature average. +
+ +

+ genArchiveRecords() +

+ +

+ If your hardware does not have an archive record logger, then WeeWX can do the record generation for you. It + will automatically collect all the types it sees in your loop packets then emit a record with the averages + (in some cases the sum or max value) of all those types. If it doesn't see a type, then it won't appear in + the emitted record. +

+ +

+ However, if your hardware does have a logger, then you should implement method genArchiveRecords() + as well. It should be a generator function that returns all the records since a given time. +

+ +

+ archive_interval +

+ +

+ If you implement function genArchiveRecords(), then you should also implement + archive_interval as either an attribute, or as a property function. It should return the + archive interval in seconds. +

+ +

+ getTime() +

+ +

If your hardware has an onboard clock and supports reading the time from it, then you may want to implement + this method. It takes no argument. It should return the time in Unix Epoch Time. +

+ +

+ setTime() +

+ +

+ If your hardware has an onboard clock and supports setting it, then you may want to implement this + method. It takes no argument and does not need to return anything. +

+ +

+ closePort() +

+ +

+ If the driver needs to close a serial port, terminate a thread, close a database, or perform any other + activity before the application terminates, then you must supply this function. WeeWX will call it if it + needs to shut down your console (usually in the case of an error). +

+ +

Define the configuration

+ +

+ You then include a new section in the configuration file weewx.conf that includes + any options your driver needs. It should also include an entry driver that points + to where your driver can be found. Set option station_type to your new section + type and your driver will be loaded. +

+ +

Examples

+ +

+ The fileparse driver is perhaps the simplest example of a WeeWX driver. It reads + name-value pairs from a file and uses the values as sensor 'readings'. The code is actually packaged as an + extension, located in examples/fileparse, making it a good example of not only + writing a device driver, but also of how to package an extension. The actual driver itself is in examples/fileparse/bin/user/fileparse.py. +

+ +

+ Another good example is the simulator code located in weewx/drivers/simulator.py. + It's dirt simple and you can easily play with it. Many people have successfully used it as a starting point + for writing their own custom driver. +

+ +

+ The Ultimeter (ultimeter.py) and WMR100 (wmr100.py) + drivers illustrate how to communicate with serial and USB hardware, respectively. They also show different + approaches for decoding data. Nevertheless, they are pretty straightforward. +

+ +

+ The driver for the Vantage series is by far the most complicated. It actually multi-inherits from not only + AbstractDevice, but also StdService. That is, it also + participates in the engine as a service. +

+ +

Naturally, there are a lot of subtleties that have been glossed over in this high-level description. If you + run into trouble, look for help in the + weewx-development forum. +

+ + + +

Extensions

+ +

+ A key feature of WeeWX is its ability to be extended by installing 3rd party extensions. Extensions + are a way to package one or more customizations so they can be installed and distributed as a functional + group. +

+ +

Customizations typically fall into one of these categories:

+
    +
  • search list extension
  • +
  • template
  • +
  • skin
  • +
  • service
  • +
  • generator
  • +
  • driver
  • +
+ +

+ Take a look at the WeeWX wiki for a sampling of some of + the extensions that are available. +

+ + + + + +

Creating an extension

+ +

+ Now that you have made some customizations, you might want to share those changes with other WeeWX users. + Put your customizations into an extension to make installation, removal, and distribution easier. +

+ +

Here are a few guidelines for creating extensions:

+
    +
  • Extensions should not modify or depend upon existing skins. An extension should include its own, + standalone skin to illustrate any templates, search list extension, or generator features. +
  • +
  • Extensions should not modify the database schemas. If it requires data not found in the default + databases, an extension should provide its own database and schema. +
  • +
+ +

+ Although one extension might use another extension, take care to write the dependent extension so that it + fails gracefully. For example, a skin might use data the forecast extension, but what happens if the + forecast extension is not installed? Make the skin display a message about "forecast not installed" but + otherwise continue to function. +

+ +

Packaging an extension

+ +

+ The structure of an extension mirrors that of WeeWX itself. If the customizations include a skin, the + extension will have a skins directory. If the customizations include python code, the extension will have a + bin/user directory. +

+ +

Each extension should also include:

+
    +
  • readme.txt - a summary of what the extension does, list of pre-requisites (if + any), and instructions for installing the extension manually +
  • +
  • changelog - an enumeration of changes in each release +
  • +
  • install.py - python code used by the WeeWX ExtensionInstaller +
  • +
+

+ For example, here is the structure of a skin called basic: +

+ +
basic/
+basic/changelog
+basic/install.py
+basic/readme.txt
+basic/skins/
+basic/skins/basic/
+basic/skins/basic/basic.css
+basic/skins/basic/current.inc
+basic/skins/basic/favicon.ico
+basic/skins/basic/hilo.inc
+basic/skins/basic/index.html.tmpl
+basic/skins/basic/skin.conf
+ +

+ Here is the structure of a search list extension called xstats: +

+ +
xstats/
+xstats/changelog
+xstats/install.py
+xstats/readme.txt
+xstats/bin/
+xstats/bin/user/
+xstats/bin/user/xstats.py
+ +

+ See the extensions directory of the WeeWX source for examples. +

+ +

To distribute an extension, simply create a compressed archive of the extension directory.

+ +

+ For example, create the compressed archive for the basic skin like this: +

+ +

tar cvfz basic.tar.gz basic

+ +

Once an extension has been packaged, it can be installed using the wee_extension utility. +

+ +

Default values

+ +

+ Whenever possible, an extension should just work, with a minimum of input from the user. At the + same time, parameters for the most frequently requested options should be easily accessible and easy to + modify. For skins, this might mean parameterizing strings into [Labels] for easier + customization. Or it might mean providing parameters in [Extras] to control skin + behavior or to parameterize links. +

+

+ Some parameters must be specified, and no default value would be appropriate. For example, an + uploader may require a username and password, or a driver might require a serial number or IP address. In + these cases, use a default value in the configuration that will obviously require modification. The username + might default to REPLACE_ME. Also be sure to add a log entry that indicates the feature is disabled + until the value has been specified. +

+

+ In the case of drivers, use the configuration editor to prompt for this type of required value. +

+ + + + +

Reference: report options

+ +

+ This section contains the options available in the skin configuration file, skin.conf. The same options apply to the + language files found in the subdirectory + lang, like + lang/en.conf for English. +

+

+ We recommend to put +

+
    +
  • + options that control the behavior of the skin into + skin.conf; and +
  • +
  • + language dependent labels and texts into the + language files. +
  • +
+ +

+ The most important options, the ones you are likely to have to customize, are highlighted. +

+ +

+ It is worth noting that, like the main configuration file weewx.conf, UTF-8 is + used throughout. +

+ + +

[Extras]

+ +

This section is available to add any static tags you might want to use in your templates.

+ +

+ As an example, the skin.conf file for the Seasons skin includes three + options: +

+ + + + + + + + + + + + + + + + + +
Skin optionTemplate tag
radar_img$Extras.radar_img
radar_url$Extras.radar_url
googleAnalyticsId$Extras.googleAnalyticsId
+

+ If you take a look at the template radar.inc you will see examples of testing for + these tags. +

+ +

radar_img

+ +

Set to an URL to show a local radar image for your region.

+ +

radar_url

+ +

If the radar image is clicked, the browser will go to this URL. This is usually used to show a more detailed, + close-up, radar picture. +

+ +

For me in Oregon, setting the two options to:

+
+[Extras]
+    radar_img = http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif
+    radar_url = http://radar.weather.gov/ridge/radar.php?product=NCR&rid=RTX&loop=yes
+

+ results in a nice image of a radar centered on Portland, Oregon. When you click on it, it gives you a + detailed, animated view. If you live in the USA, take a look at the NOAA + radar website to find a nice one that will work for you. In other countries, you will have to consult + your local weather service. +

+ +

googleAnalyticsId

+ +

+ If you have a Google Analytics ID, you can set it here. The + Google Analytics Javascript code will then be included, enabling analytics of your website usage. If + commented out, the code will not be included. +

+ +

+ Extending [Extras] +

+ +

Other tags can be added in a similar manner, including sub-sections. For example, say you have added a video + camera and you would like to add a still image with a hyperlink to a page with the video. You want all of + these options to be neatly contained in a sub-section. +

+
[Extras]
+    [[video]]
+        still = video_capture.jpg
+        hyperlink = http://www.eatatjoes.com/video.html
+      
+

Then in your template you could refer to these as:

+
<a href="$Extras.video.hyperlink">
+    <img src="$Extras.video.still" alt="Video capture"/>
+</a>
+ +

[Labels]

+ +

This section defines various labels.

+ +

hemispheres

+ +

+ Comma separated list for the labels to be used for the four hemispheres. The default is N, S, E, W. +

+ +

latlon_formats

+ +

Comma separated list for the formatting to be used when converting latitude and longitude to strings. There + should be three elements: +

+
    +
  1. The format to be used for whole degrees of latitude
  2. +
  3. The format to be used for whole degrees of longitude
  4. +
  5. The format to be used for minutes.
  6. +
+

This allows you to decide whether or not you want leading zeroes. The default includes leading zeroes and is + "%02d", "%03d", "%05.2f" +

+ +

[[Generic]]

+ +

This sub-section specifies default labels to be used for each observation type. For example, options

+
inTemp  = Temperature inside the house
+outTemp = Outside Temperature
+UV      = UV Index
+

+ would cause the given labels to be used for plots of inTemp and outTemp. If no option is given, then the observation type itself will be used + (e.g., outTemp). +

+ +

[Almanac]

+ +

This section controls what text to use for the almanac. It consists of only one entry

+ +

moon_phases

+ +

+ This option is a comma separated list of labels to be used for the eight phases of the moon. Default is + New, + Waxing crescent, First quarter, Waxing gibbous, Full, Waning + gibbous, Last quarter, Waning crescent. +

+ +

[Units]

+ +

This section controls how units are managed and displayed.

+ +

[[Groups]]

+ +

+ This sub-section lists all the Unit Groups and specifies which measurement unit is to be used for + each one of them. +

+ +

+ As there are many different observational measurement types (such as outTemp, + barometer, etc.) used in WeeWX (more than 50 at last count), it would be tedious, + not to say possibly inconsistent, to specify a different measurement system for each one of them. At the + other extreme, requiring all of them to be "U.S. Customary" or "Metric" seems overly restrictive. WeeWX has + taken a middle route and divided all the different observation types into 12 different unit groups. + A unit group is something like group_temperature. It represents the measurement + system to be used by all observation types that are measured in temperature, such as inside temperature + (type inTemp), outside temperature (outTemp), dewpoint (dewpoint), wind chill (windchill), and so on. If you decide + that you want unit group group_temperature to be measured in degree_C + then you are saying all members of its group will be reported in degrees Celsius. +

+ +

+ Note that the measurement unit is always specified in the singular. That is, specify degree_C or foot, not degrees_C or + feet. See the Appendix: Units for more information, + including a concise summary of the groups, their members, and which options can be used for each group. +

+ +

+ group_altitude +

+ +

+ Which measurement unit to be used for altitude. Possible options are foot or meter. +

+ +

group_direction

+ +

+ Which measurement unit to be used for direction. The only option is degree_compass. +

+ +

group_distance

+ +

+ Which measurement unit to be used for distance (such as for wind run). Possible options are mile or km. +

+ +

group_moisture

+ +

+ The measurement unit to be used for soil moisture. The only option is centibar. +

+ +

group_percent

+ +

+ The measurement unit to be used for percentages. The only option is percent. +

+ +

group_pressure

+ +

+ The measurement unit to be used for pressure. Possible options are one of inHg + (inches of mercury), mbar, hPa, or kPa. +

+ +

group_pressurerate

+ +

+ The measurement unit to be used for rate of change in pressure. Possible options are one of + inHg_per_hour (inches of mercury per hour), + mbar_per_hour, hPa_per_hour, + or kPa_per_hour. +

+ +

group_radiation

+ +

+ The measurement unit to be used for radiation. The only option is watt_per_meter_squared. +

+ +

group_rain

+ +

+ The measurement unit to be used for precipitation. Options are inch, cm, or mm. +

+ +

group_rainrate

+ +

+ The measurement unit to be used for rate of precipitation. Possible options are one of inch_per_hour, + cm_per_hour, or mm_per_hour. +

+ +

group_speed

+ +

+ The measurement unit to be used for wind speeds. Possible options are one of mile_per_hour, + km_per_hour, knot, meter_per_second, + or beaufort. +

+ +

group_speed2

+ +

+ This group is similar to group_speed, but is used for calculated wind speeds which + typically have a slightly higher resolution. Possible options are one mile_per_hour2, km_per_hour2, knot2, + or meter_per_second2. +

+ +

+ group_temperature +

+ +

+ The measurement unit to be used for temperatures. Options are degree_C, + degree_E, degree_F, or degree_K. +

+ +

group_volt

+ +

+ The measurement unit to be used for voltages. The only option is volt. +

+ +

[[StringFormats]]

+ +

This sub-section is used to specify what string format is to be used for each unit when a quantity needs to + be converted to a string. Typically, this happens with y-axis labeling on plots and for statistics in HTML + file generation. For example, the options +

+
degree_C = %.1f
+inch     = %.2f
+

+ would specify that the given string formats are to be used when formatting any temperature measured in + degrees Celsius or any precipitation amount measured in inches, respectively. The formatting codes are + those used by Python, and are very similar to C's sprintf() codes. +

+ +

+ You can also specify what string to use for an invalid or unavailable measurement (value None). + For example, +

+
NONE = " N/A "
+ +

[[Labels]]

+ +

This sub-section specifies what label is to be used for each measurement unit type. For example, the options +

+
degree_F = °F
+inch     = ' in'
+

+ would cause all temperatures to have unit labels °F and all precipitation to have + labels in. If any special symbols are to be used (such as the degree sign) they + should be encoded in UTF-8. This is generally what most text editors use if you cut-and-paste from a + character map. +

+ +

If the label includes two values, then the first is assumed to be the singular form, the second the plural + form. For example, +

+
foot   = " foot",   " feet"
+...
+day    = " day",    " days"
+hour   = " hour",   " hours"
+minute = " minute", " minutes"
+second = " second", " seconds"
+ +

[[TimeFormats]]

+ +

+ This sub-section specifies what time format to use for different time contexts. For example, you + might want to use a different format when displaying the time in a day, versus the time in a month. It uses + strftime() formats. + The default looks like this: +

+
+    [[TimeFormats]]
+        hour        = %H:%M
+        day         = %X
+        week        = %X (%A)
+        month       = %x %X
+        year        = %x %X
+        rainyear    = %x %X
+        current     = %x %X
+        ephem_day   = %X
+        ephem_year  = %x %X
+
+ +

+ The specifiers %x, %X, and %A + code locale dependent date, time, and weekday names, respectively. Hence, if you set an appropriate + environment variable LANG, then the date and times should follow local conventions + (see section Environment variable LANG for details on how to do + this). However, the results may not look particularly nice, and you may want to change them. For example, + I use this in the U.S.: +

+
+    [[TimeFormats]]
+        #
+        # More attractive formats that work in most Western countries.
+        #
+        day        = %H:%M
+        week       = %H:%M on %A
+        month      = %d-%b-%Y %H:%M
+        year       = %d-%b-%Y %H:%M
+        rainyear   = %d-%b-%Y %H:%M
+        current    = %d-%b-%Y %H:%M
+        ephem_day  = %H:%M
+        ephem_year = %d-%b-%Y %H:%M
+ +

+ The last two formats, ephem_day and ephem_year allow the + formatting to be set for almanac times The first, ephem_day, is used for almanac + times within the day, such as sunrise or sunset. The second, ephem_year, is used + for almanac times within the year, such as the next equinox or full moon. +

+ +

[[Ordinates]]

+ +

directions

+ +

+ Set to the abbreviations to be used for ordinal directions. By default, this is N, NNE, NE, ENE, E, + ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N. +

+ +

[[DegreeDays]]

+ +

+ heating_base
cooling_base
growing_base +

+ +

Set to the base temperature for calculating heating, cooling, and growing degree-days, along with the unit to + be used. Examples: +

+
heating_base = 65.0, degree_F
+cooling_base = 20.0, degree_C
+growing_base = 50.0, degree_F
+

[[Trend]]

+ +

time_delta

+ +

Set to the time difference over which you want trends to be calculated. The default is 3 hours.

+ +

time_grace

+ +

+ When searching for a previous record to be used in calculating a trend, a record within this amount of time_delta will be accepted. Default is 300 seconds. +

+ +

[Texts]

+ +

+ The section [Texts] holds static texts that are used in the templates. Generally + there are multiple language files, one for each supported language, named by the language codes defined in + ISO 639-1. The + entries give the translation of the texts to the target language. For example, +

+
[Texts]
+    "Current Conditions" = "Aktuelle Werte"
+

+ would cause "Aktuelle Werte" to be used whereever $gettext("Current Conditions" + appeared. See the section on $gettext. +

+

+ Note!
+ Strings that include commas must be included in single or + double quotes. +

+ +

[CheetahGenerator]

+ +

This section contains the options for the Cheetah generator. + It applies to skin.conf only.

+ +

search_list

+ +

+ This is the list of search list objects that will be scanned by the template engine, looking for tags. See + the section Defining new tags and the Cheetah documentation for details on search lists. If + no search_list is specified, a default list will be used. + +

search_list_extensions

+

+ This defines one or more search list objects that will be appended to the search_list. + For example, if you are using the "seven day" and "forecast" search list extensions, the option would + look like +

+
search_list_extensions = user.seven_day.SevenDay, user.forecast.ForecastVariables
+

encoding

+ +

+ As Cheetah goes through the template, it substitutes strings for all tag values. This option controls which + encoding to use for the new strings. The encoding can be chosen on a per file basis. All of the encodings + listed in the Python documentation + Standard Encodings + are available, as well as these WeeWX-specific encodings: +

+ + + + + + + + + + + + + + + + + + + +
EncodingComments
html_entitiesNon 7-bit characters will be represented as HTML entities (e.g., the degree sign will be + represented as &#176;) +
strict_asciiNon 7-bit characters will be ignored.
normalized_asciiReplace accented characters with non-accented analogs (e.g., 'ö' will be replaced with 'o').
+

+ The encoding html_entities is the default. Other common choices are utf8, cp1252 (a.k.a. Windows-1252), and latin1. +

+ +

template

+ +

+ The name of a template file. A template filename must end with .tmpl. Filenames + are case-sensitive. If the template filename has the letters YYYY, MM, WW or DD in its name, these will + be substituted for the year, month, week and day of month, respectively. So, a template with the name summary-YYYY-MM.html.tmpl would have name summary-2010-03.html + for the month of March, 2010. +

+ +

generate_once + +

+ When set to True, the template is processed only on the first invocation of the + report engine service. This feature is useful for files that do not change when data values change, such as + HTML files that define a layout. The default is False. +

+ +

stale_age

+ +

+ File staleness age, in seconds. If the file is older than this age it will be generated from the template. + If no stale_age is specified, then the file will be generated every time the + generator runs. +

+ +

+ Note
Precise control over when a report + is run is available through use of the report_timing option in weewx.conf. + The report_timing option uses a CRON-like setting to control precisely when a + report is run. See the Scheduling reports section for details + on the report_timing option. +

+ +

[[SummaryByDay]]

+ +

+ The SummaryByDay section defines some special behavior. Each template in this + section will be used multiple times, each time with a different per-day timespan. Be sure to include YYYY, MM, and DD in the filename of + any template in this section. +

+ +

[[SummaryByMonth]]

+ +

+ The SummaryByMonth section defines some special behavior. Each template in this + section will be used multiple times, each time with a different per-month timespan. Be sure to include YYYY and MM in the filename of any template in this section. +

+ +

[[SummaryByYear]]

+ +

+ The SummaryByYear section defines some special behavior. Each template in this + section will be used multiple times, each time with a different per-year timespan. Be sure to include YYYY in the filename of any template in this section. +

+ +

[ImageGenerator]

+ +

This section describes the various options available to the image generator.

+ +
+ Part names in a WeeWX image + +
Part names in a WeeWX image
+
+ + +

Overall options

+ +

These are options that affect the overall image.

+ +

anti_alias

+ +

+ Setting to 2 or more might give a sharper image, with fewer jagged edges. Experimentation is in order. + Default is 1. +

+ +

chart_background_color

+ +

+ The background color of the chart itself. Optional. Default is #d8d8d8. +

+ +

chart_gridline_color

+ +

+ The color of the chart grid lines. Optional. Default is #a0a0a0 +

+ +
+
+ Effect of anti_alias option + +
+ A GIF showing the same image with anti_alias=1, 2, and 4. +
+
+
+ Example of day/night bands + +
Example of day/night bands in a one week image +
+
+
+

daynight_day_color

+ +

+ The color to be used for the daylight band. Optional. Default is #ffffff. +

+ +

daynight_edge_color

+ +

+ The color to be used in the transition zone between night and day. Optional. Default is #efefef, + a mid-gray. +

+ +

daynight_night_color

+ +

+ The color to be used for the nighttime band. Optional. Default is #f0f0f0, a dark + gray. +

+ +

image_background_color

+ +

+ The background color of the whole image. Optional. Default is #f5f5f5 + ("SmokeGray") +

+ +

+ image_width
image_height +

+ +

The width and height of the image in pixels. Optional. Default is 300 x 180 pixels.

+ +

show_daynight

+ +

+ Set to true to show day/night bands in an image. Otherwise, set to false. This + only looks good with day or week plots. Optional. Default is false. +

+ +

skip_if_empty

+ +

+ If set to true, then skip the generation of the image if all data in it are null. + If set to a time period, such as month or year, then + skip the generation of the image if all data in that period are null. Default is false. +

+ +

stale_age

+ +

+ Image file staleness age, in seconds. If the image file is older than this age it will be generated. If no + stale_age is specified, then the image file will be generated every time the + generator runs. +

+ +

unit

+ +

+ Normally, the unit used in a plot is set by whatever unit group the types are in. + However, this option allows overriding the unit used in a specific plot. +

+ +

Various label options

+ +

These are options for the various labels used in the image.

+ +

axis_label_font_color

+ +

+ The color of the x- and y-axis label font. Optional. Default is black. +

+ +

axis_label_font_path

+ +

+ The path to the font to be use for the x- and y-axis labels. Optional. If not given, or if WeeWX cannot find + the font, then the default PIL font will be used. +

+ +

axis_label_font_size

+ +

+ The size of the x- and y-axis labels in pixels. Optional. The default is 10. +

+ +

bottom_label_font_color

+ +

+ The color of the bottom label font. Optional. Default is black. +

+ +

bottom_label_font_path

+ +

+ The path to the font to be use for the bottom label. Optional. If not given, or if WeeWX cannot find the + font, then the default PIL font will be used. +

+ +

bottom_label_font_size

+ +

+ The size of the bottom label in pixels. Optional. The default is 10. +

+ +

bottom_label_format

+ +

+ The format to be used for the bottom label. It should be a strftime format. + Optional. Default is '%m/%d/%y + %H:%M'. +

+ +

bottom_label_offset

+ +

The margin of the bottom label from the bottom of the plot. Default is 3.

+ +

top_label_font_path

+ +

+ The path to the font to be use for the top label. Optional. If not given, or if WeeWX cannot find the font, + then the default PIL font will be used. +

+ +

top_label_font_size

+ +

+ The size of the top label in pixels. Optional. The default is 10. +

+ +

unit_label_font_color

+ +

+ The color of the unit label font. Optional. Default is black. +

+ +

unit_label_font_path

+ +

+ The path to the font to be use for the unit label. Optional. If not given, or if WeeWX cannot find the font, + then the default PIL font will be used. +

+ +

unit_label_font_size

+ +

+ The size of the unit label in pixels. Optional. The default is 10. +

+ +

x_interval

+ +

+ The time interval in seconds between x-axis tick marks. Optional. If not given, a suitable default will be + chosen. +

+ +

x_label_format

+ +

+ The format to be used for the time labels on the x-axis. It should be a strftime format. + Optional. If not given, a sensible format will be chosen automatically. +

+ +

x_label_spacing

+ +

+ Specifies the ordinal increment between labels on the x-axis: For example, 3 means a label every 3rd tick + mark. Optional. The default is 2. +

+ +

y_label_side

+ +

+ Specifies if the y-axis labels should be on the left, right, or both sides of the graph. + Valid values are left, right or + both. Optional. Default is left. +

+ +

y_label_spacing

+ +

+ Specifies the ordinal increment between labels on the y-axis: For example, 3 means a label every 3rd tick + mark. Optional. The default is 2. +

+ +

y_nticks

+ +

+ The nominal number of ticks along the y-axis. The default is 10. +

+ + +

Plot scaling options

+ +

time_length

+ +

+ The nominal length of the time period to be covered in seconds. The exact length of the x-axis is chosen by + the plotting engine to cover this period. Optional. Default is 86400 (one day). +

+ +

yscale

+ +

+ A 3-way tuple (ylow, yhigh, min_interval), + where ylow and yhigh are the minimum and maximum y-axis + values, respectively, and min_interval is the minimum tick interval. If set to + None, the corresponding value will be automatically chosen. Optional. Default is + None, None, None. (Choose the y-axis minimum, maximum, and minimum increment + automatically.) +

+ + +

Compass rose options

+ +
+ Example of a progressive vector plot + +
Example of a vector plot with a compass rose in the lower-left +
+
+ +

rose_label

+ +

+ The label to be used in the compass rose to indicate due North. Optional. Default is N. +

+ +

rose_label_font_path

+ +

+ The path to the font to be use for the rose label (the letter "N," indicating North). Optional. If not + given, or if WeeWX cannot find the font, then the default PIL font will be used. +

+ +

rose_label_font_size

+ +

+ The size of the compass rose label in pixels. Optional. The default is 10. +

+ +

rose_label_font_color

+ +

The color of the compass rose label. Optional. Default is the same color as the rose itself.

+ +

vector_rotate

+ +

+ Causes the vectors to be rotated by this many degrees. Positive is clockwise. If westerly winds dominate at + your location (as they do at mine), then you may want to specify +90 for this + option. This will cause the average vector to point straight up, rather than lie flat against the x-axis. + Optional. The default is 0. +

+ + +

Shared plot line options

+ +

These are options shared by all the plot lines.

+ +

chart_line_colors

+ +

+ Each chart line is drawn in a different color. This option is a list of those colors. If the number of lines + exceeds the length of the list, then the colors wrap around to the beginning of the list. Optional. In the + case of bar charts, this is the color of the outline of the bar. Default is #0000ff, #00ff00, #ff0000. +

Individual line color can be overridden by using option color. +

+ +

chart_fill_colors

+ +

+ A list of the color to be used as the fill of the bar charts. Optional. The default is to use the same color + as the outline color (option chart_line_colors). +

+ +

chart_line_width

+ +

+ Each chart line can be drawn using a different line width. This option is a list of these widths. If the + number of lines exceeds the length of the list, then the widths wrap around to the beginning of the list. + Optional. Default is 1, 1, 1.

Individual line widths can be overridden + by using option width. +

+ + +

Individual line options

+ +

These are options that are set for individual lines.

+ +

aggregate_interval

+ +

+ The time period over which the data should be aggregated, in seconds. Required if + aggregate_type has been set. Alternatively, the time can be specified by using + one of the "shortcuts" (that is, hour, day, + week, month, or year). +

+ +

aggregate_type

+ +

+ The default is to plot every data point, but this is probably not a good idea for any plot longer than a + day. By setting this option, you can aggregate data by a set time interval. Available aggregation + types include avg, count, cumulative, diff, last, max, min, sum, and tderiv. +

+ +

color

+ +

+ This option is to override the color for an individual line. Optional. Default is to use the color in chart_line_colors. +

+ +

data_type

+ +

+ The SQL data type to be used for this plot line. For more information, see the section Including a type more than once in a plot. Optional. The default + is to use the section name. +

+ +

fill_color

+ +

+ This option is to override the fill color for a bar chart. Optional. Default is to use the color in chart_fill_colors. +

+ +

label

+ +

The label to be used for this plot line in the top label. Optional. The default is to use the SQL variable + name. +

+ +

line_gap_fraction

+ +

+ If there is a gap between data points bigger than this fractional amount of the x-axis, then a gap will be + drawn, rather than a connecting line. See Section Line gaps. Optional. The + default is to always draw the line. +

+ +

line_type

+ +

+ The type of line to be used. Choices are solid or none. + Optional. Default is solid. +

+ +

marker_size

+ +

+ The size of the marker. Optional. Default is 8. +

+ +

marker_type

+ +

+ The type of marker to be used to mark each data point. Choices are cross, x, circle, box, or none. Optional. Default is none. +

+ +

plot_type

+ +

+ The type of plot for this line. Choices are line, bar, + or vector. Optional. Default is line. +

+ +

width

+ +

+ This option is to override the line widthfor an individual line. Optional. Default is to use the width in + chart_line_width. +

+ + +

[CopyGenerator]

+ +

+ This section is used by generator weewx.reportengine.CopyGenerator and controls + which files are to be copied over from the skin directory to the destination directory. Think of it as "file + generation," except that rather than going through the template engine, the files are simply copied over. +

+ +

copy_once

+ +

This option controls which files get copied over on the first invocation of the report engine service. + Typically, this is things such as style sheets or background GIFs. Wildcards can be used. +

+ +

copy_always

+ +

This is a list of files that should be copied on every invocation. Wildcards can be used.

+ +

+ Here is the [CopyGenerator] section from the Standard skin.conf +

+
[CopyGenerator]
+    # This section is used by the generator CopyGenerator
+
+    # List of files to be copied only the first time the generator runs
+    copy_once = backgrounds/*, weewx.css, mobile.css, favicon.ico
+
+    # List of files to be copied each time the generator runs
+    # copy_always = 
+

The Standard skin includes some background images, CSS files, and icons that need to be copied once. There + are no files that need to be copied every time the generator runs. +

+ + +

[Generators]

+ +

This section defines the list of generators that should be run.

+ +

generator_list

+ +

This option controls which generators get run for this skin. It is a comma separated list. The generators + will be run in this order. +

+ +

+ Here is the [Generators] section from the Standard skin.conf +

+
[Generators]
+    generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator
+

The Standard skin uses three generators: CheetahGenerator, ImageGenerator, and CopyGenerator.

+ + + + +

Appendix

+ +

Aggregation types

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aggregation types
Aggregation typeMeaning
avgThe average value in the aggregation period.
avg_ge(val)The number of days where the average value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
avg_le(val)The number of days where the average value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
countThe number of non-null values in the aggregation period. +
diffThe difference between the last and first value in the aggregation period. +
existsReturns True if the observation type exists in the database.
firstThe first non-null value in the aggregation period.
firsttimeThe time of the first non-null value in the aggregation period. +
gustdirThe direction of the max gust in the aggregation period. +
has_dataReturns True if the observation type exists in the database and is + non-null. +
lastThe last non-null value in the aggregation period.
lasttimeThe time of the last non-null value in the aggregation period. +
maxThe maximum value in the aggregation period.
maxminThe maximum daily minimum in the aggregation period. Aggregation period must be one day or longer. +
maxmintimeThe time of the maximum daily minimum.
maxsumThe maximum daily sum in the aggregation period. Aggregation period must be one day or longer. +
maxsumtimeThe time of the maximum daily sum.
maxtimeThe time of the maximum value.
max_ge(val)The number of days where the maximum value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
max_le(val)The number of days where the maximum value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
meanmaxThe average daily maximum in the aggregation period. Aggregation period must be one day or longer. +
meanminThe average daily minimum in the aggregation period. Aggregation period must be one day or longer. +
minThe minimum value in the aggregation period.
minmaxThe minimum daily maximum in the aggregation period. Aggregation period must be one day or longer. +
minmaxtimeThe time of the minimum daily maximum.
minsumThe minimum daily sum in the aggregation period. Aggregation period must be one day or longer. +
minsumtimeThe time of the minimum daily sum.
mintimeThe time of the minimum value.
min_ge(val)The number of days where the minimum value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
min_le(val)The number of days where the minimum value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
not_null + Returns truthy if any value over the aggregation period is non-null. +
rmsThe root mean square value in the aggregation period. +
sumThe sum of values in the aggregation period.
sum_ge(val)The number of days where the sum of value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
sum_le(val)The number of days where the sum of value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
tderiv + The time derivative between the last and first value in the aggregation period. This is the + difference in value divided by the difference in time. +
vecavgThe vector average speed in the aggregation period.
vecdirThe vector averaged direction during the aggregation period. +
+ + + +

Units

+ +

+ WeeWX offers three different unit systems: +

+ + + + + + + + + + + + + + + + + + + + + + + +
The standard unit systems used within WeeWX
NameEncoded valueNote
US0x01U.S. Customary
METRICWX0x11Metric, with rain related measurements in mm and speeds in m/s +
METRIC0x10Metric, with rain related measurements in cm and speeds in km/hr +
+ +

The table below lists all the unit groups, their members, which units are options for the group, and what the + defaults are for each standard unit system. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Unit groups, members and options
GroupMembersUnit optionsUSMETRICWXMETRIC
group_altitudealtitude
cloudbase +
foot
meter +
footmetermeter
group_ampampampampamp
group_booleanbooleanbooleanbooleanboolean
group_concentrationno2
pm1_0
pm2_5
pm10_0
microgram_per_meter_cubedmicrogram_per_meter_cubedmicrogram_per_meter_cubedmicrogram_per_meter_cubed
group_countleafWet1
leafWet2
lightning_disturber_count
lightning_noise_count
lightning_strike_count
countcountcountcount
group_databyte
bit
bytebytebyte
group_dbnoisedBdBdBdB
group_delta_timedaySunshineDur
rainDur
sunshineDurDoc
second
minute
hour
day
secondsecondsecond
group_degree_daycooldeg
heatdeg
growdeg +
degree_F_day
degree_C_day
degree_F_daydegree_C_daydegree_C_day
group_directiongustdir
vecdir
windDir
windGustDir
degree_compassdegree_compassdegree_compassdegree_compass
group_distancewindrun
lightning_distance
mile
km
milekmkm
group_energykilowatt_hour
mega_joule
watt_hour
watt_second
watt_hourwatt_hourwatt_hour
group_energy2kilowatt_hour
watt_hour
watt_second
watt_secondwatt_secondwatt_second
group_fractionco
co2
nh3
o3
pb
so2
ppmppmppmppm
group_frequencyhertzhertzhertzhertz
group_illuminanceilluminanceluxluxluxlux
group_intervalintervalminuteminuteminuteminute
group_lengthinch
cm +
inchcmcm
group_moisturesoilMoist1
soilMoist2
soilMoist3
soilMoist4 +
centibarcentibarcentibarcentibar
group_percent + cloudcover
extraHumid1
extraHumid2
inHumidity
outHumidity
pop
+ rxCheckPercent
snowMoisture +
percentpercentpercentpercent
group_powerkilowatt
watt
wattwattwatt
group_pressure + barometer
altimeter
pressure +
+ inHg
mbar
hPa
kPa +
inHgmbarmbar
group_pressurerate + barometerRate
altimeterRate
pressureRate +
+ inHg_per_hour
mbar_per_hour
hPa_per_hour
kPa_per_hour +
inHg_per_hourmbar_per_hourmbar_per_hour
group_radiationmaxSolarRad
radiation
watt_per_meter_squaredwatt_per_meter_squaredwatt_per_meter_squaredwatt_per_meter_squared
group_rainrain
ET
hail
snowDepth
snowRate
inch
cm
mm +
inchmmcm
group_rainraterainRate
hailRate +
inch_per_hour
cm_per_hour
mm_per_hour +
inch_per_hourmm_per_hourcm_per_hour
group_speedwind
windGust
windSpeed
windgustvec
windvec +
mile_per_hour
km_per_hour
knot
meter_per_second
beaufort +
mile_per_hourmeter_per_secondkm_per_hour
group_speed2rms
vecavg +
mile_per_hour2
km_per_hour2
knot2
meter_per_second2 +
mile_per_hour2meter_per_second2km_per_hour2
group_temperatureappTemp
dewpoint
extraTemp1
extraTemp2
extraTemp3
heatindex
+ heatingTemp
humidex
inTemp
leafTemp1
leafTemp2
outTemp
soilTemp1 +
soilTemp2
soilTemp3
soilTemp4
windchill
THSW +
degree_C
degree_F
degree_E
degree_K +
degree_Fdegree_Cdegree_C
group_timedateTimeunix_epoch
dublin_jd +
unix_epochunix_epochunix_epoch
group_uvUVuv_indexuv_indexuv_indexuv_index
group_voltconsBatteryVoltage
heatingVoltage
referenceVoltage
supplyVoltage +
voltvoltvoltvolt
group_volumecubic_foot
gallon
liter +
gallonliterliter
group_NONENONENONENONENONENONE
+ + + +

Class ValueTuple

+

+ A value, along with the unit it is in, can be represented by a 3-way tuple called a "value tuple". They are + used throughout WeeWX. All WeeWX routines can accept a simple unadorned 3-way tuple as a value tuple, but + they return the type ValueTuple. It is useful because its contents can be accessed + using named attributes. You can think of it as a unit-aware value, useful for converting to and from other + units. +

+

+ The following attributes, and their index, are present: +

+ + + + + + + + + + + + + + + + + + + + + +
IndexAttributeMeaning
0valueThe data value(s). Can be a series (e.g., [20.2, 23.2, ...]) or a scalar + (e.g., 20.2) +
1unitThe unit it is in ("degree_C")
2groupThe unit group ("group_temperature")
+ +

+ It is valid to have a datum value of None. +

+ +

+ It is also valid to have a unit type of None (meaning there is no information about the unit the value is + in). In this case, you won't be able to convert it to another unit. +

+

Here are some examples:

+
+from weewx.units import ValueTuple
+
+freezing_vt = ValueTuple(0.0, "degree_C", "group_temperature")
+body_temperature_vt = ValueTuple(98.6, "degree_F", group_temperature")
+station_altitude_vt = ValueTuple(120.0, "meter", "group_altitude")
+        
+ +

Class ValueHelper

+

+ Class ValueHelper contains all the information necessary to do the proper + formatting of a value, including a unit label. +

+ +

Instance attribute

+

ValueHelper.value_t

+

Returns the ValueTuple instance held internally.

+ +

Instance methods

+

ValueHelper.__str__()

+

Formats the value as a string, including a unit label, and returns it.

+ +

+ ValueHelper.format(format_string=None, None_string=None, add_label=True, localize=True) +

+
+

+ Format the value as a string, using various specified options, and return it. Unless otherwise + specified, a label is included. +

+

+ format_string A string to be used for formatting. It must include one, and + only one, format specifier. +

+

+ None_string In the event of a value of Python None, this string will be + substituted. If None, then a default string from skin.conf will be used. +

+

+ add_label If truthy, then an appropriate unit label will be attached. + Otherwise, no label is attached. +

+

+ localize If truthy, then the results will be localized. For example, in some + locales, a comma will be used as the decimal specifier. +

+
+ +
+ + + + +
+ + + + diff --git a/dist/weewx-4.10.1/docs/debian.htm b/dist/weewx-4.10.1/docs/debian.htm new file mode 100644 index 0000000..601e9c5 --- /dev/null +++ b/dist/weewx-4.10.1/docs/debian.htm @@ -0,0 +1,150 @@ + + + + weewx: Installation on Debian systems + + + + + + + +

WeeWX: Installation on Debian-based systems + + + + + + +

+ +

This is a guide to installing WeeWX from a DEB package on Debian-based systems, including Ubuntu, Mint, and Raspbian. +

+ +

Configure apt

+

+ The first time you install WeeWX, you must configure apt so that it knows to trust + weewx.com, and knows where to find the WeeWX releases. +

+ +

+ Step one: tell your system to trust weewx.com. If you see any errors, please consult the + FAQ. +

+ +
wget -qO - https://weewx.com/keys.html | sudo gpg --dearmor --output /etc/apt/trusted.gpg.d/weewx.gpg
+ + + + + +

+ Step two: run one of the following two commands to tell apt where to find the appropriate WeeWX + repository. +

+ +
    +
  • +

    For Debian10 and later, use Python 3:

    + +
    wget -qO - https://weewx.com/apt/weewx-python3.list | sudo tee /etc/apt/sources.list.d/weewx.list
    +
  • + +
  • +

    or, for Debian9 and earlier, use Python 2:

    + +
    wget -qO - https://weewx.com/apt/weewx-python2.list | sudo tee /etc/apt/sources.list.d/weewx.list
    +
  • + +
+ +

Install

+

+ Having configured apt, you can now use apt-get to install WeeWX. + The installer will prompt for a location, latitude/longitude, altitude, station type, and parameters specific to + your station hardware. +

+ +
sudo apt-get update
+sudo apt-get install weewx
+ +

When you are done, WeeWX will be running in the background as a daemon. +

+ +

Status

+

To make sure things are running properly look in the system log for messages from WeeWX. +

+
sudo tail -f /var/log/syslog
+ +

Verify

+

After about 5 minutes, open the station web page in a web browser. You should see your station information and data. +If your hardware supports hardware archiving, then how long you wait will depend on the archive interval set in your hardware. +

+
file:///var/www/html/weewx/index.html
+ +

Customize

+

To enable uploads such as Weather Underground or to customize reports, modify the configuration file /etc/weewx/weewx.conf. See the User Guide and Customization Guide for details. +

+ +

WeeWX must be restarted for configuration file changes to take effect. +

+ +

Start/Stop

+

To start/stop WeeWX:

+
sudo /etc/init.d/weewx start
+sudo /etc/init.d/weewx stop
+ +

Uninstall

+

To uninstall WeeWX but retain configuration files and data: +

+
sudo apt-get remove weewx
+

To uninstall WeeWX, removing configuration files but retaining data: +

+
sudo apt-get purge weewx
+

To remove data:

+
sudo rm -r /var/lib/weewx
+sudo rm -r /var/www/html/weewx
+ +

Layout

+

The installation will result in the following layout:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
executable:/usr/bin/weewxd
configuration file:/etc/weewx/weewx.conf
skins and templates:/etc/weewx/skins
sqlite databases:/var/lib/weewx/
generated web pages and images:/var/www/html/weewx/
documentation:/usr/share/doc/weewx/
examples:/usr/share/doc/weewx/examples/
utilities:/usr/bin/wee_*
+ + + + + diff --git a/dist/weewx-4.10.1/docs/devnotes.htm b/dist/weewx-4.10.1/docs/devnotes.htm new file mode 100644 index 0000000..86a83d5 --- /dev/null +++ b/dist/weewx-4.10.1/docs/devnotes.htm @@ -0,0 +1,806 @@ + + + + weewx: Developer's Notes + + + + + + + + + + + + + + + +
+ +
+
+ +
+Version: 4.10 + +
+
Notes for Developers of the WeeWX Weather System
+
+ +
+ +

This guide is intended for developers contributing to the open source project WeeWX. +

+ + +

Goals

+ +

The primary design goals of WeeWX are:

+
    +
  • + Architectural simplicity. No semaphores, no named pipes, no inter-process communications, no complex + multi-threading to manage. +
  • +
  • + Extensibility. Make it easy for the user to add new features or to modify existing features. +
  • +
  • + "Fast enough" In any design decision, architectural simplicity and elegance trump speed. +
  • +
  • + One code base. A single code base should be used for all platforms, all weather stations, all reports, + and any combination of features. Ample configuration and customization options should be provided so the + user does not feel tempted to start hacking code. At worse, the user may have to subclass, which is much + easier to port to newer versions of the code base, than customizing the base code. +
  • +
  • + Minimal dependencies. The code should rely on a minimal number of external packages, so the user does + not have to go chase them down all over the Web before getting started. +
  • +
  • + Simple data model. The implementation should use a very simple data model that is likely to support many + different types of hardware. +
  • +
  • + A "pythonic" code base. The code should be written in a style that others will recognize. +
  • +
+

Strategies

+ +

To meet these goals, the following strategies were used:

+
    +
  • + A "micro-kernel" design. The WeeWX engine actually does very little. Its primary job is to + load and run services at runtime, making it easy for users to add or subtract features. +
  • +
  • + A largely stateless design style. For example, many of the processing routines read the data they need + directly from the database, rather than caching it and sharing with other routines. While this means the + same data may be read multiple times, it also means the only point of possible cache incoherence is + through the database, where transactions are easily controlled. This greatly reduces the chances of + corrupting the data, making it much easier to understand and modify the code base. +
  • +
  • + Isolated data collection and archiving. The code for collecting and archiving data run in a single + thread that is simple enough that it is unlikely to crash. The report processing is where most mistakes + are likely to happen, so isolate that in a separate thread. If it crashes, it will not affect the main + data thread. +
  • +
  • + A powerful configuration parser. The ConfigObj module, by + Michael Foord and Nicola Larosa, was chosen to read the configuration file. This allows many options + that might otherwise have to go in the code, to be in a configuration file. +
  • +
  • + A powerful templating engine. The Cheetah module was chosen + for generating html and other types of files from templates. Cheetah allows search list + extensions to be defined, making it easy to extend WeeWX with new template tags. +
  • +
  • + Pure Python. The code base is 100% Python — no underlying C libraries need be built to install + WeeWX. This also means no Makefiles are needed. +
  • +
+

+ While WeeWX is nowhere near as fast at generating images and HTML as its predecessor, wview + (this is partially because WeeWX uses fancier fonts and a much more powerful templating engine), it is fast + enough for all platforms but the slowest. I run it regularly on a 500 MHz machine where generating the 9 + images used in the "Current Conditions" page takes just under 2 seconds (compared with 0.4 seconds + for wview). +

+ +

+ All writes to the databases are protected by transactions. You can kill the program at any time (either + Control-C if run directly or "/etc/init.d/weewx + stop" if run as a daemon) without fear of corrupting the databases. +

+ +

+ The code makes ample use of exceptions to insure graceful recovery from problems such as network outages. It + also monitors socket and console timeouts, restarting whatever it was working on several times before giving + up. In the case of an unrecoverable console error (such as the console not responding at all), the program + waits 60 seconds then restarts the program from the top. +

+ +

+ Any "hard" exceptions, that is those that do not involve network and console timeouts and are most + likely due to a logic error, are logged, reraised, and ultimately cause thread termination. If this happens + in the main thread (not likely due to its simplicity), then this causes program termination. If it happens + in the report processing thread (much more likely), then only the generation of reports will be affected + — the main thread will continue downloading data off the instrument and putting them in the database. +

+ + +

Units

+ +

+ In general, there are three different areas where the unit system makes a difference: +

+
    +
  1. + On the weather station hardware. Different manufacturers use different unit systems for their hardware. + The Davis Vantage series use U.S. Customary units exclusively, Fine Offset and LaCrosse stations use + metric, while Oregon Scientific, Peet Bros, and Hideki stations use a mishmash of US and metric. +
  2. +
  3. In the database. Either US or Metric can be used.
  4. +
  5. In the presentation (i.e., html and image files).
  6. +
+

+ The general strategy is that measurements are converted by service StdConvert as + they come off the weather station into a target unit system, then stored internally in the database in that + unit system. Then, as they come off the database to be used for a report, they are converted into a target + unit, specified by a combination of the configuration file weewx.conf and + the skin configuration file skin.conf. +

+ +

Value "None"

+ +

+ The Python special value None is used throughout to signal an invalid or bad data + point. All functions must be written to expect it. +

+ +

+ Device drivers should be written to emit None if a data value is bad (perhaps + because of a failed checksum). If the hardware simply doesn't support a data type, then the driver should + not emit a value at all. +

+ +

+ The same rule applies to derived values. If the input data for a derived value are missing, then no derived + value should be emitted. However, if the input values are present, but have value None, then the derived value should be set to None. +

+ +

+ However, the time value must never be None. This is because it is used as the + primary key in the SQL database. +

+ +

Time

+ +

+ WeeWX stores all data in UTC (roughly, "Greenwich" or "Zulu") time. However, usually one + is interested in weather events in local time and want image and HTML generation to reflect that. + Furthermore, most weather stations are configured in local time. This requires that many data times be + converted back and forth between UTC and local time. To avoid tripping up over time zones and daylight + savings time, WeeWX generally uses Python routines to do this conversion. Nowhere in the code base is there + any explicit recognition of DST. Instead, its presence is implicit in the conversions. At times, this can + cause the code to be relatively inefficient. +

+ +

+ For example, if one wanted to plot something every 3 hours in UTC time, it would be very simple: to get the + next plot point, just add 10,800 to the epoch time: +

+
next_ts = last_ts + 10800 
+

+ But, if one wanted to plot something for every 3 hours in local time (that is, at 0000, 0300, 0600, + etc.), despite a possible DST change in the middle, then things get a bit more complicated. One could modify + the above to recognize whether a DST transition occurs sometime between last_ts + and the next three hours and, if so, make the necessary adjustments. This is generally what wview does. WeeWX takes a different approach and converts from UTC to local, does + the arithmetic, then converts back. This is inefficient, but bulletproof against changes in DST algorithms, + etc: +

+
time_dt = datetime.datetime.fromtimestamp(last_ts)
+delta = datetime.timedelta(seconds=10800)
+next_dt = time_dt + delta
+next_ts = int(time.mktime(next_dt.timetuple()))
+

Other time conversion problems are handled in a similar manner.

+

+ For astronomical calculations, WeeWX uses the latitude and longitude specified in the configuration file. If + that location does not correspond to the computer's local time, reports with astronomical times will + probably be incorrect. +

+ +

Archive records

+

+ An archive record's timestamp, whether in software or in the database, represents the end time of + the record. For example, a record timestamped 05-Feb-2016 09:35, includes data from an instant after 09:30, + through 09:35. Another way to think of it is that it is exclusive on the left, inclusive on the right. + Schematically: +

+
09:30 < dateTime <= 09:35
+

+ Database queries should reflect this. For example, to find the maximum temperature for the hour between + timestamps 1454691600 and 1454695200, the query would be: +

+
SELECT MAX(outTemp) FROM archive WHERE dateTime > 1454691600 and dateTime <= 1454695200;
+

+ This ensures that the record at the beginning of the hour (1454691600) does not get included (it belongs to + the previous hour), while the record at the end of the hour (1454695200) does. +

+

+ One must be constantly be aware of this convention when working with timestamped data records. +

+ +

+ Better yet, if you need this kind of information, use an + xtypes call: +

+
+max_temp = weewx.xtypes.get_aggregate('outTemp',
+                                      (1454691600, 1454695200),
+                                      'max',
+                                      db_manager)
+
+

+ It will not only make sure the limits of the query are correct, but will also decide whether or not + the daily summary optimization can be used (details below). If not, it will + use the regular archive table. +

+ +

Internationalization

+

+ Generally, WeeWX is locale aware. It will emit reports using the local formatting conventions for date, + times, and values. +

+ +

Exceptions

+

In general, your code should not simply swallow an exception. For example, this is bad form:

+
+    try:
+        os.rename(oldname, newname)
+    except:
+        pass
+

+ While the odds are that if an exception happens it will be because the file oldname + does not exist, that is not guaranteed. It could be because of a keyboard interrupt, or a corrupted file + system, or something else. Instead, you should test explicitly for any expected exception, and let the rest + go by: +

+
+    try:
+        os.rename(oldname, newname)
+    except OSError:
+        pass
+

+ WeeWX has a few specialized exception types, used to rationalized all the different types of exceptions that + could be thrown by the underlying libraries. In particular, low-level I/O code can raise a myriad of + exceptions, such as USB errors, serial errors, network connectivity errors, etc. All device drivers + should catch these exceptions and convert them into an exception of type WeeWxIOError or one of its subclasses. +

+ +

Naming conventions

+

+ How you name variables makes a big difference in code readability. In general, long names are preferable to + short names. Instead of this, +

+
p = 990.1
+

use this,

+
pressure = 990.1
+

or, even better, this:

+
pressure_mbar = 990.1
+ +

+ WeeWX uses a number of conventions to signal the variable type, although they are not used consistently. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Variable suffix conventions
SuffixExampleDescription
_tsfirst_tsVariable is a timestamp in unix epoch time. +
_dtstart_dtVariable is an instance of datetime.datetime, + usually in local time. +
_dend_dVariable is an instance of datetime.date, usually in local time. +
_ttsod_ttVariable is an instance of time.struct_time (a time tuple), + usually in local time. +
_vhpressure_vhVariable is an instance of weewx.units.ValueHelper.
_vtspeed_vtVariable is an instance of weewx.units.ValueTuple.
+ + +

Code style

+

+ Generally, we try to follow the PEP 8 style guide, + but there are many exceptions. In particular, many older WeeWX function names use camelCase, but + PEP 8 calls for snake_case. Please use snake_case for new code. +

+

+ Most modern code editors, such as Eclipse, or PyCharm, have the ability to automatically format code. Resist + the temptation and don't use this feature! Two reasons: +

+
    +
  • + Unless all developers use the same tool, using the same settings, we will just thrash back and forth + between slightly different versions. +
  • +
  • + Automatic formatters play a useful role, but some of what they do are really trivial changes, such as + removing spaces in otherwise blank lines. Now if someone is trying to figure out what real, syntactic, + changes you have made, s/he will have to wade through all those extraneous "changed lines," trying to + find the important stuff. +
  • +
+

+ If you are working with a file where the formatting is so ragged that you really must do a reformat, then do + it as a separate commit. This allows the formatting changes to be clearly distinguished from more functional + changes. +

+ +

+ When invoking functions or instantiating classes, use the fully qualified name. Don't do this: +

+
from datetime import datetime
+now = datetime()
+

Instead, do this:

+
import datetime
+now = datetime.datetime()
+ +

Git work flow

+ +

+ We use Git as the source control system. If Git is still mysterious to you, bookmark this: Pro Git, then read the chapter Git Basics. Also + recommended is the article How to Write a Git Commit Message. +

+ +

+ The code is hosted on GitHub. + Their documentation is + very extensive and helpful. +

+ +

+ We generally follow Vincent Driessen's branching + model. Ignore the complicated diagram at the beginning of the article, and just focus on the text. In + this model, there are two key branches: +

+ +
    +
  • + master. Fixes go into this branch. We tend to use fewer "hot fix" branches + and, instead, just incorporate any fixes directly into the branch. Releases are tagged relative to this + branch. +
  • +
  • + development (called develop in Vince's article). + This is where new features go. Before a release, they will be merged into the master branch. +
  • +
+ +

+ What this means to you is that if you submit a pull request that includes a new feature, make sure you + commit your changes relative to the development branch. If it is just a + bug fix, it should be committed against the master branch. +

+ +

Tools

+

Python

+

+ JetBrain's PyCharm is exellent, and now there's a free + Community Edition. It has many advanced features, yet is structured that you need not be exposed to them + until you need them. Highly recommended. +

+ +

HTML and Javascript

+

+ For Javascript, JetBrain's WebStorm is excellent, + particularly if you will be using a framework such as NodeJS or ExpressJS. +

+ +

Daily summaries

+ +

+ This section builds on the discussion The database in + the Customizing Guide. Read it first. +

+

+ The big flat table in the database (usually called table archive) is the + definitive table of record. While it includes a lot of information, querying it can be slow. For example, to + find the maximum temperature of the year would require scanning the whole thing, which might include 100,000 + or more records. To speed things up, WeeWX includes daily summaries in the database as an + optimization. +

+

+ In the daily summaries, each observation type gets its own table, which holds a statistical summary for the + day. For example, for outside temperature (observation type outTemp), this table + would be named archive_day_outTemp. Here's what it would look like: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Structure of the archive_day_outTemp daily summary +
dateTimeminmintimemaxmaxtimesumcountwsumsumtime
165242520044.7165251160056.0165247764038297.07632297820.045780
165251160044.1165253128066.7165257250076674.414334600464.085980
165259800050.3165261522059.8165267432032903.06111974180.036660
...........................
+ +

+ Here's what the table columns mean: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameMeaning
dateTimeThe time of the start of day in unix epoch + time. This is the primary key in the database. It must be unique, and it cannot be + null. +
min + The minimum temperature seen for the day. The unit is whatever unit system the main archive table + uses (generally given by the first record in the table). +
mintime + The time in unix epoch time of the minimum temperature. +
max + The maximum temperature seen for the day. The unit is whatever unit system the main archive table + uses (generally given by the first record in the table). +
maxtime + The time in unix epoch time of the maximum temperature. +
sum + The sum of all the temperatures for the day. +
count + The number of records in the day. +
wsum + The weighted sum of all the temperatures for the day. The weight is the archive interval. That is, + for each record, the temperature is multiplied by the length of the archive record, then summed up. +
sumtime + The sum of all the archive intervals for the day. If the archive interval didn't change during the + day, then this number would be interval * count. +
+ +

+ Note how the average temperature for the day can be calculated as wsum / sumtime. + This will be true even if the archive interval changes during the day. +

+ +

+ Now consider an extensive variable such as rain. The total rainfall for the day + will be given by the field sum. So, calculating the total rainfall for the year + can be done by scanning and summing only 365 records, instead of potentially tens, or even hundreds, of + thousands of records. This results in a dramatic speed up for report generation, particularly on slower + machines such as the Raspberry Pi, working off an SD card. +

+ +

Wind

+

+ The daily summary for wind includes six additional fields. Here's what they mean: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameMeaning
max_dirThe direction of the maximum wind seen for the day.
xsumThe sum of the x-component (east-west) of the wind for the day.
ysumThe sum of the y-component (north-south) of the wind for the day.
dirsumtime + The sum of all the archive intervals for the day, which contributed to xsum and ysum. +
squaresum + The sum of the wind speed squared for the day. +
wsquaresum + The sum of the weighted wind speed squared for the day. That is the wind speed is squared, then + multiplied by the archive interval, then summed for the day. This is useful for calculating RMS wind + speed. +
+

+ Note that the RMS wind speed can be calculated as +

+
math.sqrt(wsquaresum / sumtime)
+ + +

Glossary

+ +

This is a glossary of terminology used throughout the code.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Terminology used in WeeWX
NameDescription
archive intervalWeeWX does not store the raw data that comes off a weather station. Instead, it aggregates the data + over a length of time, the archive interval, and then stores that. +
archive recordWhile packets are raw data that comes off the weather station, records are data + aggregated by time. For example, temperature may be the average temperature over an archive + interval. These are the data stored in the SQL database +
config_dictAll configuration information used by WeeWX is stored in the configuration file, usually + with the name weewx.conf. By convention, when this file is read into the + program, it is called config_dict, an instance of the class configobj.ConfigObj. +
datetimeAn instance of the Python object datetime.datetime. Variables of type + datetime usually have a suffix _dt. +
db_dictA dictionary with all the data necessary to bind to a database. An example for SQLite would be + {'driver':'db.sqlite', + 'root':'/home/weewx', + 'database_name':'archive/weewx.sdb'}, an example for MySQL would be { + 'driver':'db.mysql', + 'host':'localhost', + 'user':'weewx', + 'password':'mypassword', + 'database_name':'weewx'}. +
epoch timeSometimes referred to as "unix time," or "unix epoch time." The number of + seconds since the epoch, which is 1 Jan 1970 00:00:00 UTC. Hence, it always represents UTC (well... + after adding a few leap seconds. But, close enough). This is the time used in the databases and + appears as type + dateTime in the SQL schema, perhaps an unfortunate name because of the similarity to the completely + unrelated Python type datetime. Very easy to manipulate, but it is a big + opaque number. +
LOOP packetThe real-time data coming off the weather station. The terminology "LOOP" comes from the Davis + series. A LOOP packet can contain all observation types, or it may contain only some of them + ("Partial packet"). +
observation typeA physical quantity measured by a weather station (e.g., outTemp) + or something derived from it (e.g., dewpoint). +
skin_dictAll configuration information used by a particular skin is stored in the skin configuration + file, usually with the name skin.conf. By convention, when this file + is read into the program, it is called skin_dict, an instance of the class + configobj.ConfigObj. +
SQL typeA type that appears in the SQL database. This usually looks something like outTemp, + barometer, extraTemp1, and so on. +
standard unit systemA complete set of units used together. Either US, METRIC, + or METRICWX. +
time stampA variable in unix epoch time. Always in UTC. Variables carrying a time stamp usually have a suffix + _ts. +
tuple-timeAn instance of the Python object + + time.struct_time. This is a 9-wise tuple that represent a time. It could be in either local + time or UTC, though usually the former. See module + time for more information. Variables + carrying tuple time usually have a suffix _tt. +
value tupleA 3-way tuple. First element is a value, second element the unit type the value is in, the third the + unit group. An example would be (21.2, + 'degree_C', 'group_temperature'). +
+ +
+ + + +
+ + + diff --git a/dist/weewx-4.10.1/docs/examples/tag.htm b/dist/weewx-4.10.1/docs/examples/tag.htm new file mode 100644 index 0000000..477762c --- /dev/null +++ b/dist/weewx-4.10.1/docs/examples/tag.htm @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Time intervalMax temperatureTime
00:00-01:0022.9°F (-5.1°C)00:55
01:00-02:0023.5°F (-4.7°C)01:25
02:00-03:0024.1°F (-4.4°C)03:00
03:00-04:0025.5°F (-3.6°C)03:55
04:00-05:0025.7°F (-3.5°C)04:20
05:00-06:0025.4°F (-3.7°C)05:05
06:00-07:0025.1°F (-3.8°C)06:05
07:00-08:0025.1°F (-3.8°C)07:05
08:00-09:0025.6°F (-3.6°C)09:00
09:00-10:0026.5°F (-3.1°C)10:00
10:00-11:0029.2°F (-1.6°C)10:45
11:00-12:0030.6°F (-0.8°C)11:20
12:00-13:0031.4°F (-0.3°C)12:30
13:00-14:0030.6°F (-0.8°C)13:05
14:00-15:0030.3°F (-0.9°C)14:20
15:00-16:0030.0°F (-1.1°C)15:10
16:00-17:0029.7°F (-1.3°C)16:50
17:00-18:0029.7°F (-1.3°C)17:05
18:00-19:0029.0°F (-1.7°C)18:05
19:00-20:0029.4°F (-1.4°C)19:45
20:00-21:0030.4°F (-0.9°C)21:00
21:00-22:0031.1°F (-0.5°C)21:40
22:00-23:0031.3°F (-0.4°C)22:55
23:00-00:0032.1°F (0.1°C)23:50
Hourly max temperatures yesterday
+ 18-Jan-2017 +
+ + + diff --git a/dist/weewx-4.10.1/docs/hardware.htm b/dist/weewx-4.10.1/docs/hardware.htm new file mode 100644 index 0000000..5e5a6b6 --- /dev/null +++ b/dist/weewx-4.10.1/docs/hardware.htm @@ -0,0 +1,4992 @@ + + + + weewx: Hardware Guide + + + + + + + + + + + + + + + + +
+ +
+
+ +
+Version: 4.10 + +
+
WeeWX Hardware Guide
+
+ +
+ + +

Driver status

+ +

The following table enumerates many of the weather stations that + are known to work with WeeWX. If your + station is not in the table, check the pictures at the + supported hardware page — + it could be a variation of one of the supported models. You can also + check the station comparison + table — sometimes new models use the same communication protocols + as older hardware. +

+ +

The maturity column indicates the degree of confidence in the + driver. For stations marked Tested, + the station is routinely tested as part of the release process + and should work as documented. For stations not marked at all, + they are "known to work" using the indicated driver, but are not + routinely tested. For stations marked + Experimental, we are still working + on the driver. There can be problems. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Weather hardware supported by WeeWX
VendorModelHardware
Interface
Required
Package
Station
Driver
Maturity
AcuRite01025USBpyusbAcuRite12
01035USBpyusbAcuRite12Tested
01036USBpyusbAcuRite12
01525USBpyusbAcuRite12
02032USBpyusbAcuRite12
02064USBpyusbAcuRite12
06037USBpyusbAcuRite12
06039USBpyusbAcuRite12
Argent Data SystemsWS1SerialpyusbWS19
AercusWS2083USBpyusbFineOffsetUSB5
WS3083USBpyusbFineOffsetUSB5
Ambient WeatherWS1090USBpyusbFineOffsetUSB5Tested
WS2080USBpyusbFineOffsetUSB5Tested
WS2080AUSBpyusbFineOffsetUSB5Tested
WS2090USBpyusbFineOffsetUSB5
WS2095USBpyusbFineOffsetUSB5
CrestaWRX815USBpyusbTE9238
PWS720USBpyusbTE9238
DAZADZ-WH1080USBpyusbFineOffsetUSB5
DZ-WS3101USBpyusbFineOffsetUSB5
DZ-WS3104USBpyusbFineOffsetUSB5
DavisVantagePro2Serial or USBpyserialVantage1Tested
VantagePro2WeatherLink IP Vantage1Tested
VantageVueSerial or USBpyserialVantage1Tested
Elecsa6975USBpyusbFineOffsetUSB5
6976USBpyusbFineOffsetUSB5
ExcelvanExcelvanUSBpyusbFineOffsetUSB5
Fine OffsetWH1080USBpyusbFineOffsetUSB5
WH1081USBpyusbFineOffsetUSB5
WH1091USBpyusbFineOffsetUSB5
WH1090USBpyusbFineOffsetUSB5
WS1080USBpyusbFineOffsetUSB5
WA2080USBpyusbFineOffsetUSB5
WA2081USBpyusbFineOffsetUSB5
WH2080USBpyusbFineOffsetUSB5
WH2081USBpyusbFineOffsetUSB5
WH3080USBpyusbFineOffsetUSB5
WH3081USBpyusbFineOffsetUSB5
FroggitWS8700USBpyusbTE9238
WH1080USBpyusbFineOffsetUSB5
WH3080USBpyusbFineOffsetUSB5
General ToolsWS831DLUSBpyusbTE9238
HidekiDV928USBpyusbTE9238
TE821USBpyusbTE9238
TE827USBpyusbTE9238
TE831USBpyusbTE9238
TE838USBpyusbTE9238
TE923USBpyusbTE9238
HugerWM918SerialpyserialWMR9x84
IROXPro XUSBpyusbTE9238
La CrosseC86234USBpyusbWS28xx7Tested
WS-1640USBpyusbTE9238
WS-23XXSerialfcntl/selectWS23xx6Tested
WS-28XXUSBpyusbWS28xx7
MaplinN96GYUSBpyusbFineOffsetUSB5
N96FYUSBpyusbFineOffsetUSB5
MeadeTE923WUSBpyusbTE9238Tested
TE923W-MUSBpyusbTE9238
TE924WUSBpyusbTE9238
MebusTE923USBpyusbTE9238
National Geographic265USBpyusbFineOffsetUSB5
Oregon ScientificWMR88USBpyusbWMR1002
WMR88AUSBpyusbWMR1002
WMR100USBpyusbWMR1002
WMR100NUSBpyusbWMR1002Tested
WMR180USBpyusbWMR1002
WMR180AUSBpyusbWMR1002
WMRS200USBpyusbWMR1002
WMR300USBpyusbWMR3003Experimental
WMR300AUSBpyusbWMR3003Experimental
WMR918SerialpyserialWMR9x84
WMR928NSerialpyserialWMR9x84Tested
WMR968SerialpyserialWMR9x84Tested
PeetBrosUltimeter 100SerialpyserialUltimeter10
Ultimeter 800SerialpyserialUltimeter10
Ultimeter 2000SerialpyserialUltimeter10
Ultimeter 2100SerialpyserialUltimeter10
RainWiseMark IIISerialpyserialCC300011
CC3000SerialpyserialCC300011Tested
Radio Shack63-256USBpyusbWMR1002
63-1016SerialpyserialWMR9x84
SinometerWS1080 / WS1081USBpyusbFineOffsetUSB5
WS3100 / WS3101USBpyusbFineOffsetUSB5
TechnoLineWS-2300Serialfcntl/selectWS23xx6
WS-2350Serialfcntl/selectWS23xx6
TFAMatrixSerialfcntl/selectWS23xx6
NexusUSBpyusbTE9238
OpusUSBpyusbWS28xx7
PrimusUSBpyusbWS28xx7
SinusUSBpyusbTE9238
TyconTP1080WCUSBpyusbFineOffsetUSB5
WatsonW-8681USBpyusbFineOffsetUSB5
WX-2008USBpyusbFineOffsetUSB5
VellemanWS3080USBpyusbFineOffsetUSB5
VentusW831USBpyusbTE9238
W928USBpyusbTE9238
+ +
    +
  1. Davis "Vantage" series of weather + stations, including the + VantagePro2™ + and + VantageVue™, + using serial, USB, or + WeatherLinkIP™ + connections. Both the "Rev A" (firmware dated before + 22 April 2002) and "Rev B" versions are supported. +
  2. +
  3. Oregon Scientific WMR-100 stations. Tested on the + Oregon + Scientific WMR100N. +
  4. +
  5. Oregon Scientific WMR-300 stations. Tested on the + Oregon + Scientific WMR300A. +
  6. +
  7. Oregon Scientific WMR-9x8 stations. Tested on the + Oregon + Scientific + WMR968. +
  8. +
  9. Fine Offset 10xx, 20xx, and 30xx stations. + Tested on the + Ambient Weather WS2080. +
  10. +
  11. La Crosse WS-23xx stations. Tested on the + La Crosse 2317. +
  12. +
  13. La Crosse WS-28xx stations. Tested on the + La Crosse C86234. +
  14. +
  15. Hideki Professional Weather Stations. Tested on the + Meade TE923. +
  16. +
  17. ADS WS1 Stations. Tested on the + WS1. +
  18. +
  19. PeetBros Ultimeter Stations. Tested on the + Ultimeter 2000. +
  20. +
  21. RainWise Mark III Stations. Tested on the + CC3000 + (firmware "Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016"). +
  22. +
  23. AcuRite Weather Stations. Tested on the + 01036RX. +
  24. +
+ + + + + + +

AcuRite

+ +

According to Acurite, the wind speed updates every 18 seconds. + The wind direction updates every 30 seconds. Other sensors update + every 60 seconds.

+ +

In fact, because of the message structure and the data logging + design, these are the actual update frequencies:

+ + + + + + + + + + + +
AcuRite transmission periods
sensorperiod
Wind speed18 seconds
Outdoor temperature, outdoor humidity36 seconds
Wind direction, rain total36 seconds
Indoor temperature, pressure60 seconds
Indoor humidity12 minutes (only when in USB mode 3)
+ +

The station emits partial packets, which may confuse some online + services.

+ +

The AcuRite stations do not record wind gusts.

+ +

Some consoles have a small internal logger. Data in the logger + are erased when power is removed from the station.

+ +

The console has a sensor for inside humidity, but the values from + that sensor are available only by reading from the console logger. + Due to instability of the console firmware, the + WeeWX driver does not read the console + logger.

+ +

USB Mode

+ +

Some AcuRite consoles have a setting called "USB Mode" that controls + how data are saved and communicated:

+ + + + + + + + + + + + + + + +
AcuRite USB mode
ModeShow data
in display
Store data
in logger
Send data
over USB
1yesyes
2yes
3yesyesyes
4yesyes
+ +

If the AcuRite console has multiple USB modes, it must be set to + USB mode 3 or 4 in order to work with the WeeWX driver.

+ +

Communication + via USB is disabled in modes 1 and 2. Mode 4 is more reliable than + mode 3; mode 3 enables logging of data, mode 4 does not. When the + console is logging it frequently causes USB communication + problems.

+ +

The default mode is 2, so after a power failure one must use the + console controls to change the mode before + WeeWX can resume data collection.

+ +

The 01025, 01035, 01036, 01525, and 02032 consoles have a USB mode + setting.

+ +

The 02064 and 01536 consoles do not have a mode setting; these + consoles are always in USB mode 4.

+ +

Configuring with wee_device

+ +

The wee_device utility + cannot be used to configure AcuRite stations.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AcuRite station data
Database FieldObservationLoopArchive
barometerS
pressurepressureH
altimeterS
inTemptemperature_inH
outTemptemperature_outH
inHumidityhumidity_in
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
rainrainD
rain_totalH
rainRateS
dewpointS
windchillS
heatindexS
rxCheckPercentrssiH
outTempBatteryStatusbatteryH
+

+ Each packet contains a subset of all possible readings. For example, one type of packet contains windSpeed, windDir and rain. A + different type of packet contains windSpeed, outTemp and + outHumidity. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

CC3000

+ +

The CC3000 data logger stores 2MB of records.

+ +

When the logger fills up, it stops recording.

+ +

When WeeWX starts up it will attempt to + download all records from the logger since the last record in the + archive database.

+ +

The driver does not support hardware record generation.

+ +

The CC3000 data logger may be configured to return data in METRIC + or ENGLISH units. These are then mapped to the WeeWX unit groups + METRICWX or US, respectively.

+ +

Configuring with wee_device

+

+ Make sure you stop weewxd before running + wee_device. +

+ +

+ Action --help

+ +

Invoking wee_device with the + --help option

+ +
wee_device --help
+ +

will produce something like this:

+ +
+Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+Usage: wee_device [config_file] [options] [-y] [--debug] [--help]
+
+Configuration utility for weewx devices.
+
+Options:
+  -h, --help            show this help message and exit
+  --debug               display diagnostic information while running
+  -y                    answer yes to every prompt
+  --info                display weather station configuration
+  --current             display current weather readings
+  --history=N           display N records (0 for all records)
+  --history-since=N     display records since N minutes ago
+  --clear-memory        clear station memory
+  --get-header          display data header
+  --get-rain            get the rain counter
+  --reset-rain          reset the rain counter
+  --get-max             get the max values observed
+  --reset-max           reset the max counters
+  --get-min             get the min values observed
+  --reset-min           reset the min counters
+  --get-clock           display station clock
+  --set-clock           set station clock to computer time
+  --get-interval        display logger archive interval, in seconds
+  --set-interval=N      set logging interval to N seconds
+  --get-units           show units of logger
+  --set-units=UNITS     set units to METRIC or ENGLISH
+  --get-dst             display daylight savings settings
+  --set-dst=mm/dd HH:MM,mm/dd HH:MM,[MM]M
+                        set daylight savings start, end, and amount
+  --get-channel         display the station channel
+  --set-channel=CHANNEL
+                        set the station channel
+ +

Action --info

+ +

Display the station settings with + --info

+
wee_device --info
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+Firmware: Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016
+Time: 2020/01/04 15:38:14
+DST: 03/08 02:00,11/01 02:00,060
+Units: ENGLISH
+Memory: 252242 bytes, 4349 records, 12%
+Interval: 300
+Channel: 0
+Charger: CHARGER=OFF,BATTERIES=5.26V
+Baro: 30.37
+Rain: 3.31
+HEADER: ['HDR', '"TIMESTAMP"', '"TEMP OUT"', '"HUMIDITY"', '"PRESSURE"', '"WIND DIRECTION"', '"WIND SPEED"',~
+MAX: ['MAX', '11:59  61.1', '02:47  99', '09:51 30.42', '13:32 337', '13:32  13.3', '00:00   0.00', '09:20  ~
+MIN: ['MIN', '02:19  40.6', '14:42  66', '00:34 30.24', '00:00  67', '00:00   0.0', '00:00   0.00', '06:48  ~
+ +

+ Action --current

+ +

Returns the current values in a comma-delimited format. The order of + the data values corresponds to the output of the + --get-header command. + NO DATA is returned if the unit has not + received a transmission from the weather station.

+ +
wee_device --current
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+{'dateTime': 1578175453.0, 'outTemp': 58.6, 'outHumidity': 71.0, 'pressure': 30.36, 'windDir': 315.0, ~
+ +

Action --history=N

+ +

Display the latest N records from the + CC3000 logger memory. Use a value of 0 + to display all records. Note: because there may be other records + mixed in with the archive records, this command will display an + extra seven records per day (or partial day).

+ +
wee_device --history=2
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+['REC', '2020/01/04 13:25', ' 58.9', ' 71', '30.36', '344', '  4.9', ' 10.7', '  0.00', ' 7.44', ' 5.26', ~
+['REC', '2020/01/04 13:30', ' 59.0', ' 71', '30.36', '327', '  3.6', ' 10.0', '  0.00', ' 7.32', ' 5.26', ~
+['REC', '2020/01/04 13:35', ' 59.1', ' 70', '30.36', '305', '  5.5', ' 13.3', '  0.00', ' 7.44', ' 5.26', ~
+['REC', '2020/01/04 13:40', ' 59.1', ' 70', '30.36', '330', '  3.4', '  8.9', '  0.00', ' 7.08', ' 5.26', ~
+['REC', '2020/01/04 13:45', ' 58.9', ' 70', '30.36', '318', '  2.6', '  7.2', '  0.00', ' 7.17', ' 5.26', ~
+['REC', '2020/01/04 13:50', ' 58.8', ' 71', '30.36', '312', '  3.6', '  7.9', '  0.00', ' 7.14', ' 5.26', ~
+['REC', '2020/01/04 13:55', ' 58.9', ' 71', '30.36', '330', '  4.5', ' 10.0', '  0.00', ' 7.20', ' 5.26', ~
+['REC', '2020/01/04 14:00', ' 58.8', ' 71', '30.36', '331', '  4.6', '  9.6', '  0.00', ' 7.38', ' 5.26', ~
+['REC', '2020/01/04 14:05', ' 58.6', ' 71', '30.36', '331', '  4.0', '  9.3', '  0.00', ' 7.29', ' 5.26', ~
+ +

+ Action --history-since=N

+ +

Display all CC3000 logger memory records created in + the last N minutes.

+ +
wee_device --history-since=10
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+{'dateTime': 1578175800.0, 'outTemp': 58.6, 'outHumidity': 70.0, 'pressure': 30.36, 'windDir': 316.0, ~
+{'dateTime': 1578176100.0, 'outTemp': 58.7, 'outHumidity': 70.0, 'pressure': 30.36, 'windDir': 317.0, ~
+ +

Action --clear-memory

+ +

Use --clear-memory to erase all records + from the logger memory.

+ +
wee_device --clear-memory
+ +

+ Action --get-header

+ +

Returns a series of comma delimited text descriptions. These + descriptions are used to identify the type and order of the returned + data in both --get-current, + --download=N and + --download-since=N commands.

+ +
wee_device --get-header
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+['HDR', '"TIMESTAMP"', '"TEMP OUT"', '"HUMIDITY"', '"PRESSURE"', '"WIND DIRECTION"', '"WIND SPEED"', ~
+ +

+ Action --get-rain

+ +

Display the raing counter.

+

The CC-3000 maintains a rainfall counter that is only reset by a + reboot or by issuing the reset command. The counter counts in 0.01” + in- crements and rolls over at 65536 counts. Issuing the rainfall + reset command will clear all rainfall counters including the current + daily rainfall.

+ +
wee_device --get-rain
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+3.31
+ +

Action --reset-rain

+ +

Reset the rain counter to zero.

+ +
wee_device --reset-rain
+ +

+ Action --get-max

+ +

Get the maximum values observed since the last time + +

Output parameter order: Outside temperature, humidity, pressure, + wind direction, wind speed, rainfall (daily total), station + voltage, inside temperature. If any optional sensors have been + enabled they will also be displayed.

+ +
wee_device --get-max
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+['MAX', '11:59  61.1', '02:47  99', '09:51 30.42', '13:32 337', '13:32  13.3', '00:00   0.00', '09:20 ~
+ +

Action --reset-max

+ +

Reset the maximum values.

+ +
wee_device --reset-max
+ +

+ Action --get-min

+ +

Get the minimum values observed since the last time +

Output parameter order: Outside temperature, humidity, pressure, + wind direction, wind speed, rainfall (ignore), station + voltage, inside temperature. If any optional sensors have been + enabled they will also be displayed.

+ +
wee_device --get-min
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+['MIN', '02:19  40.6', '14:42  66', '00:34 30.24', '00:00  67', '00:00   0.0', '00:00   0.00', '06:48 ~
+ +

Action --reset-min

+ +

Reset the minimum values.

+ +
wee_device --reset-min
+ +

+ Action --get-clock

+ +

Get the time.

+ +
wee_device --get-clock
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+2020/01/04 15:01:34
+ +

Action --set-clock

+ +

Set the station clock to match the date/time of the computer.

+ +
wee_device --set-clock
+ +

+ Action --get-interval

+ +

Returns the current logging interval (in seconds).

+ +
wee_device --get-interval
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+300
+ +

+ Action --set-interval=N

+ +

Set the archive interval. + CC3000 loggers ship from the factory with an archive interval of + 1 minutes (60 seconds). To change the + station's interval to 300 seconds (5 minutes), do the following:

+ +
wee_device --set-interval=5
+ +

+ Action --get-units

+ +

Returns the current measurement units.

+ +
wee_device --get-units
+

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+ENGLISH
+ +

Action --set-units

+ +

The CC3000 can display data in either ENGLISH or METRIC unit + systems. Use --set-units to specify + one or the other.

+ +

The CC3000 driver automatically converts the units to maintain + consistency with the units in WeeWX. +

+ +
wee_device --set-units=ENGLISH
+ +

+ Action --get-dst

+ +

Return the dates and times when the clock will change due to daylight + saving and the number of minutes that the clock will change.

+ +

This will result in something like:

+
Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+03/08 02:00,11/01 02:00,060
+ +

Action --set-dst

+ +

Set the station start, end, and amount of daylight savings.

+

The schedule can be set by adding the three parameters, + forward date and time, back date and time and number of minutes to + change (120 max). Daylight saving can be disabled by setting the + daylight-saving to zero.

+ +
wee_device --set-dst="03/08 02:00,11/01 02:00,060"
+
wee_device --set-dst=0
+ +

+ Action --get-channel

+ +

Displays the station channel (0-3).

+ +

This will result in something like:

+
Using configuration file /home/weewx/weewx.conf
+Using CC3000 driver version 0.30 (weewx.drivers.cc3000)
+0
+ +

Action --set-channel=N

+ +

Rainwise stations transmit on one of four channels. If you have + multiple instrument clusters within a kilometer or so of each + other, you should configure each to use a different channel. + In the instrument cluster, remove the cover and set the DIP + switches 0 and 1. Use --set-channel + to a value of 0-3 to match that of the instrument cluster.

+ +
wee_device --set-channel=0
+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CC3000 station data
Database FieldObservationLoopArchive
barometerSS
pressurePRESSUREHH
altimeterSS
inTempTEMP INHH
outTempTEMP OUTHH
outHumidityHUMIDITYHH
windSpeedWIND SPEEDHH
windDirWIND DIRECTIONHH
windGustWIND GUSTHH
rainrain_deltaDD
RAINHH
rainRateSS
dewpointSS
windchillSS
heatindexSS
radiation1SOLAR RADIATIONHH
UV1UV INDEXHH
supplyVoltageSTATION BATTERYH
consBatteryVoltageBATTERY BACKUPH
extraTemp12TEMP 1HH
extraTemp22TEMP 2HH
+

+ 1 The radiation and + UV + data are available only with the optional solar radiation sensor. +

+

+ 2 The extraTemp1 and + extraTemp2 + data are available only with the optional additional temperature + sensors. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

FineOffsetUSB

+ +

The station clock can only be set manually via buttons on the console, or (if the station supports it) by + WWVB radio. The FineOffsetUSB driver ignores the station clock since it cannot be trusted.

+ +

The station reads data from the sensors every 48 seconds. The 30xx stations read UV data every 60 + seconds.

+ +

The 10xx and 20xx stations can save up to 4080 historical readings. That is about 85 days of data with the + default recording interval of 30 minutes, or about 14 days with a recording interval of 5 minutes. The 30xx + stations can save up to 3264 historical readings.

+ +

When WeeWX starts up it will attempt to + download all records from the console since + the last record in the archive database.

+ +

Polling mode and + interval

+ +

When reading 'live' data, WeeWX can read + as fast as possible, or at a user-defined + period. This is controlled by the option + polling_mode in + weewx.conf.

+ + + + + + + + + + + + + + + + + +
Polling modes for Fine Offset stations
ModeConfigurationNotes
ADAPTIVE +
[FineOffsetUSB]
+    polling_mode = ADAPTIVE
+
+

In this mode, WeeWX reads data from the station as often as possible, + but at intervals that avoid communication between the console and the sensors. Nominally this + results in reading data every 48 seconds.

+
PERIODIC +
[FineOffsetUSB]
+    polling_mode = PERIODIC
+    polling_interval = 60
+
+

In this mode, WeeWX reads data from the station every polling_interval seconds.

+ +

The console reads the sensors every 48 seconds (60 seconds for UV), so setting the polling_interval to a value less than 48 will result in duplicate + readings.

+
+ + +

Data format

+ +

The 10xx/20xx consoles have a data format that is different from + the 30xx consoles. All of the consoles recognize wind, rain, + temperature, and humidity from the same instrument clusters. + However, some instrument clusters also include a luminosity sensor. + Only the 30xx consoles recognize the luminosity and UV output + from these sensors. As a consequence, the 30xx consoles also have + a different data format. +

+

Since WeeWX cannot reliably determine + the data format by communicating with the station, the + data_format configuration option + indicates the station type. Possible values are + 1080 and 3080. + Use 1080 for the 10xx and 20xx consoles. + The default value is 1080. +

+

For example, this would indicate that the station is a 30xx + console:

+
[FineOffsetUSB]
+    ...
+    data_format = 3080
+
+ +

Configuring with wee_device

+ +

+ Make sure you stop weewxd before running + wee_device. +

+ +

+ Action --help

+ +

Invoking wee_device with the + --help option

+ +
wee_device /home/weewx/weewx.conf --help
+ +

will produce something like this:

+ +
+FineOffsetUSB driver version 1.7
+Usage: wee_device [config_file] [options] [--debug] [--help]
+
+Configuration utility for weewx devices.
+
+Options:
+-h, --help           show this help message and exit
+--debug              display diagnostic information while running
+-y                   answer yes to every prompt
+--info               display weather station configuration
+--current            get the current weather conditions
+--history=N          display N records
+--history-since=N    display records since N minutes ago
+--clear-memory       clear station memory
+--set-time           set station clock to computer time
+--set-interval=N     set logging interval to N minutes
+--live               display live readings from the station
+--logged             display logged readings from the station
+--fixed-block        display the contents of the fixed block
+--check-usb          test the quality of the USB connection
+--check-fixed-block  monitor the contents of the fixed block
+--format=FORMAT      format for output, one of raw, table, or dict
+
+Mutating actions will request confirmation before proceeding.
+ +

Action --info

+ +

Display the station settings with the + --info option.

+
wee_device --info
+

This will result in something like:

+
Fine Offset station settings:
+                    local time: 2013.02.11 18:34:28 CET
+                  polling_mode: ADAPTIVE
+
+                  abs_pressure: 933.3
+                   current_pos: 592
+                  data_changed: 0
+                    data_count: 22
+                     date_time: 2007-01-01 22:49
+                 hum_in_offset: 18722
+                hum_out_offset: 257
+                            id: None
+                 lux_wm2_coeff: 0
+                       magic_1: 0x55
+                       magic_2: 0xaa
+                         model: None
+                     rain_coef: None
+                   read_period: 30
+                  rel_pressure: 1014.8
+                temp_in_offset: 1792
+               temp_out_offset: 0
+                      timezone: 0
+                    unknown_01: 0
+                    unknown_18: 0
+                       version: 255
+                     wind_coef: None
+                     wind_mult: 0
+ +

Highlighted values can be modified. +

+ +

Action --set-interval

+ +

Set the archive interval. Fine Offset stations ship from the + factory with an archive interval (read_period) of 30 minutes (1800 + seconds). To change the station's interval to 5 minutes, do the + following:

+ +

wee_device --set-interval=5

+ +

Action --history

+ +

Fine Offset stations store records in a circular buffer — once the buffer fills, the oldest records are + replaced by newer records. The 1080 and 2080 consoles store up to 4080 records. The 3080 consoles store up + to 3264 records. The data_count indicates how many records are in memory. The + read_period indicates the number of minutes between records. wee_device + can display these records in space-delimited, raw bytes, or dictionary format.

+ +

For example, to display the most recent 30 records from the console memory:

+
wee_device --history=30
+ +

+ Action --clear-memory

+

To clear the console memory:

+
wee_device --clear-memory
+ +

Action --check-usb

+ +

This command can test the quality of the USB connection between the computer and console. Poor quality USB + cables, under-powered USB hubs, and other devices on the bus can interfere with communication.

+ +

To test the quality of the USB connection to the console:

+
wee_device --check-usb
+

Let the utility run for at least a few minutes, or possibly an hour or two. It is not unusual to see a few + bad reads in an hour, but if you see many bad reads within a few minutes, consider replacing the USB cable, + USB hub, or removing other devices from the bus.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fine Offset station data
Database FieldObservationLoopArchive
barometerSS
pressurepressureHH
altimeterSS
inTemptemperature_inHH
outTemptemperature_outHH
inHumidityhumidity_inHH
outHumidityhumidity_outHH
windSpeedwind_speedHH
windDirwind_dirHH
windGustwind_gustHH
rainrainDD
rain_totalHH
rainRateSS
dewpointdewpointHS
windchillwindchillHS
heatindexheatindexHS
radiation1radiationDD
luminosity1luminosityHH
rxCheckPercentsignalH
outTempBatteryStatusbatteryH
+

+ 1 The radiation data are available + only from 30xx stations. + These stations include a luminosity sensor, from which the radiation is + approximated. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

TE923

+

Some station models will recognize up to 5 remote + temperature/humidity sensor units. Use the hardware switch + in each sensor unit to identify sensors. Use the + sensor_map in + weewx.conf + to associate each sensor with a database field.

+ +

The station has either 208 or 3442 history records, depending on + the model. That is just over a day (208 records) or about 23 days + (3442 records) with an archive interval of 5 minutes.

+ +

The TE923 driver will read history records from the station when + WeeWX starts up, but it does not support hardware record + generation.

+ +

Configuring with wee_device

+ +

+ Make sure you stop weewxd before running + wee_device. +

+ +

+ Action --help

+ +

Invoking wee_device with the + --help option

+ +
wee_device /home/weewx/weewx.conf --help
+ +

will produce something like this:

+ +
+Using configuration file /home/weewx/weewx.conf
+Using TE923 driver version 0.21 (weewx.drivers.te923)
+Usage: wee_device [config_file] [options] [--debug] [--help]
+
+Configuration utility for weewx devices.
+
+Options:
+  -h, --help            show this help message and exit
+  --debug               display diagnostic information while running
+  -y                    answer yes to every prompt
+  --info                display weather station configuration
+  --current             get the current weather conditions
+  --history=N           display N history records
+  --history-since=N     display history records since N minutes ago
+  --minmax              display historical min/max data
+  --get-date            display station date
+  --set-date=YEAR,MONTH,DAY
+                        set station date
+  --sync-date           set station date using system clock
+  --get-location-local  display local location and timezone
+  --set-location-local=CITY|USR,LONG_DEG,LONG_MIN,E|W,LAT_DEG,LAT_MIN,N|S,TZ,DST
+                        set local location and timezone
+  --get-location-alt    display alternate location and timezone
+  --set-location-alt=CITY|USR,LONG_DEG,LONG_MIN,E|W,LAT_DEG,LAT_MIN,N|S,TZ,DST
+                        set alternate location and timezone
+  --get-altitude        display altitude
+  --set-altitude=ALT    set altitude (meters)
+  --get-alarms          display alarms
+  --set-alarms=WEEKDAY,SINGLE,PRE_ALARM,SNOOZE,MAXTEMP,MINTEMP,RAIN,WIND,GUST
+                        set alarm state
+  --get-interval        display archive interval
+  --set-interval=INTERVAL
+                        set archive interval (minutes)
+  --format=FORMAT       formats include: table, dict
+
+Be sure to stop weewx first before using. Mutating actions will request
+confirmation before proceeding.
+
+ +

+ Action --info

+ +

Use --info to display the station + configuration:

+
wee_device --info
+

This will result in something like:

+
Querying the station for the configuration...
+        altitude: 16
+           bat_1: True
+           bat_2: True
+           bat_3: True
+           bat_4: True
+           bat_5: True
+        bat_rain: True
+          bat_uv: False
+        bat_wind: True
+        latitude: 43.35
+       longitude: -72.0
+     version_bar: 23
+     version_rcc: 16
+     version_sys: 41
+      version_uv: 20
+    version_wind: 38
+ +

+ Action --current

+ +

Use --current to display the + current status of each sensor:

+
wee_device --current
+

This will result in something like:

+
Querying the station for current weather data...
+        dateTime: 1454615168
+        forecast: 5
+             h_1: 41
+       h_1_state: ok
+             h_2: 48
+       h_2_state: ok
+             h_3: None
+       h_3_state: no_link
+             h_4: None
+       h_4_state: no_link
+             h_5: None
+       h_5_state: no_link
+            h_in: 44
+      h_in_state: ok
+            rain: 2723
+      rain_state: ok
+             slp: 1012.4375
+       slp_state: ok
+           storm: 0
+             t_1: 13.9
+       t_1_state: ok
+             t_2: 21.5
+       t_2_state: ok
+             t_3: None
+       t_3_state: no_link
+             t_4: None
+       t_4_state: no_link
+             t_5: None
+       t_5_state: no_link
+            t_in: 22.85
+      t_in_state: ok
+              uv: None
+        uv_state: no_link
+       windchill: None
+ windchill_state: invalid
+         winddir: 12
+   winddir_state: invalid
+        windgust: None
+  windgust_state: invalid
+       windspeed: None
+ windspeed_state: invalid
+ +

+ Action --set-interval

+ +

TE923 stations ship from the factory with an archive interval of 1 + hour (3600 seconds). To change the station's interval to 5 minutes + (300 seconds), do the following:

+ +

wee_device --set-interval=300

+ +

+ Action --history

+ +

Use the --history action to display + records from the logger in tabular or dictionary format. +

+ +

For example, to display the most recent 30 records in dictionary + format:

+
wee_device --history=30 --format=dict
+ +

Action --clear-memory

+ +

Use --clear-memory to erase all records + from the logger memory.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TE923 station data
Database FieldObservationLoopArchive
barometerbarometerHH
pressureSS
altimeterSS
inTempt_inHH
inHumidityh_inHH
outTempt_1HH
outHumidityh_1HH
outTempBatteryStatusbat_1H
outLinkStatuslink_1H
windSpeedwindspeedHH
windDirwinddirHH
windGustwindgustHH
windBatteryStatusbat_windH
windLinkStatuslink_windH
rainrainDD
rain_totalHH
rainBatteryStatusbat_rainH
rainLinkStatuslink_rainH
rainRateSS
dewpointSS
windchillwindchillHH
heatindexSS
UV1uvHH
uvBatteryStatusbat_uvH
uvLinkStatuslink_uvH
extraTemp1t_2HH
extraHumid1h_2HH
extraBatteryStatus1bat_2H
extraLinkStatus1link_2H
extraTemp2t_3HH
extraHumid2h_3HH
extraBatteryStatus2bat_3H
extraLinkStatus2link_3H
extraTemp3t_4HH
extraHumid3h_4HH
extraBatteryStatus3bat_4H
extraLinkStatus3link_4H
extraTemp4t_5HH
extraHumid4h_5HH
extraBatteryStatus4bat_5H
extraLinkStatus4link_5H
+

+ Some stations support up to 5 remote temperature/humidity sensors. +

+

+ 1 The UV data are available + only with the optional solar radiation sensor. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

Ultimeter

+

The Ultimeter driver operates the Ultimeter in Data Logger Mode, + which results in sensor readings every 1/2 second or so.

+ +

The Ultimeter driver ignores the maximum, minimum, and average + values recorded by the station.

+ +

Configuring with wee_device

+ +

The wee_device utility + cannot be used to configure Ultimeter stations.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Ultimeter station data
Database FieldObservationLoopArchive
barometer2barometerH
pressure2S
altimeter2S
inTemp2temperature_inH
outTemptemperature_outH
inHumidity2humidity_inH
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
rain1rainD
rain_totalH
rainRate1S
dewpointS
windchillS
heatindexS
+

+ 1 The rain and + rainRate are + available only on stations with the optional rain sensor. +

+ +

+ 2 Pressure, inside temperature, and inside humidity + data are not available on all types of Ultimeter stations. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

Vantage

+ +

The Davis Vantage stations include a variety of models and + configurations. The WeeWX driver + can communicate with a console or envoy using serial, USB, + or TCP/IP interface.

+ +

Configuring with wee_device

+ +

+ Make sure you stop weewxd before running + wee_device. +

+ +

+ Action --help

+ +

Invoking wee_device with the + --help option

+ +
wee_device /home/weewx/weewx.conf --help
+ +

will produce something like this:

+ +
+Using configuration file /home/weewx/weewx.conf
+Using Vantage driver version 3.2.3 (weewx.drivers.vantage)
+Usage: wee_device --help
+       wee_device --info [config_file]
+       wee_device --current [config_file]
+       wee_device --clear-memory [config_file] [-y]
+       wee_device --set-interval=MINUTES [config_file] [-y]
+       wee_device --set-latitude=DEGREE [config_file] [-y]
+       wee_device --set-longitude=DEGREE [config_file] [-y]
+       wee_device --set-altitude=FEET [config_file] [-y]
+       wee_device --set-barometer=inHg [config_file] [-y]
+       wee_device --set-wind-cup=CODE [config_file] [-y]
+       wee_device --set-bucket=CODE [config_file] [-y]
+       wee_device --set-rain-year-start=MM [config_file] [-y]
+       wee_device --set-offset=VARIABLE,OFFSET [config_file] [-y]
+       wee_device --set-transmitter-type=CHANNEL,TYPE,TEMP,HUM,REPEATER_ID [config_file] [-y]
+       wee_device --set-retransmit=[OFF|ON|ON,CHANNEL] [config_file] [-y]
+       wee_device --set-temperature-logging=[LAST|AVERAGE] [config_file] [-y]
+       wee_device --set-time [config_file] [-y]
+       wee_device --set-dst=[AUTO|ON|OFF] [config_file] [-y]
+       wee_device --set-tz-code=TZCODE [config_file] [-y]
+       wee_device --set-tz-offset=HHMM [config_file] [-y]
+       wee_device --set-lamp=[ON|OFF] [config_file]
+       wee_device --dump [--batch-size=BATCH_SIZE] [config_file] [-y]
+       wee_device --logger-summary=FILE [config_file] [-y]
+       wee_device [--start | --stop] [config_file]
+
+Configures the Davis Vantage weather station.
+
+Options:
+  -h, --help            show this help message and exit
+  --debug               display diagnostic information while running
+  -y                    answer yes to every prompt
+  --info                To print configuration, reception, and barometer
+                        calibration information about your weather station.
+  --current             To print current LOOP information.
+  --clear-memory        To clear the memory of your weather station.
+  --set-interval=MINUTES
+                        Sets the archive interval to the specified number of
+                        minutes. Valid values are 1, 5, 10, 15, 30, 60, or
+                        120.
+  --set-latitude=DEGREE
+                        Sets the latitude of the station to the specified
+                        number of tenth degree.
+  --set-longitude=DEGREE
+                        Sets the longitude of the station to the specified
+                        number of tenth degree.
+  --set-altitude=FEET   Sets the altitude of the station to the specified
+                        number of feet.
+  --set-barometer=inHg  Sets the barometer reading of the station to a known
+                        correct value in inches of mercury. Specify 0 (zero)
+                        to have the console pick a sensible value.
+  --set-wind-cup=CODE   Set the type of wind cup. Specify '0' for small size;
+                        '1' for large size
+  --set-bucket=CODE     Set the type of rain bucket. Specify '0' for 0.01
+                        inches; '1' for 0.2 mm; '2' for 0.1 mm
+  --set-rain-year-start=MM
+                        Set the rain year start (1=Jan, 2=Feb, etc.).
+  --set-offset=VARIABLE,OFFSET
+                        Set the onboard offset for VARIABLE inTemp, outTemp,
+                        extraTemp[1-7], inHumid, outHumid, extraHumid[1-7],
+                        soilTemp[1-4], leafTemp[1-4], windDir) to OFFSET
+                        (Fahrenheit, %, degrees)
+  --set-transmitter-type=CHANNEL,TYPE,TEMP,HUM,REPEATER_ID
+                        Set the transmitter type for CHANNEL (1-8), TYPE
+                        (0=iss, 1=temp, 2=hum, 3=temp_hum, 4=wind, 5=rain,
+                        6=leaf, 7=soil, 8=leaf_soil, 9=sensorlink, 10=none),
+                        as extra TEMP station and extra HUM station (both 1-7,
+                        if applicable), REPEATER_ID ('A'-'H', if used)
+  --set-retransmit=OFF|ON|ON,CHANNEL
+                        Turn console retransmit function 'ON' or 'OFF'.
+  --set-temperature-logging=LAST|AVERAGE
+                        Set console temperature logging to either 'LAST' or
+                        'AVERAGE'.
+  --set-time            Set the onboard clock to the current time.
+  --set-dst=AUTO|ON|OFF
+                        Set DST to 'ON', 'OFF', or 'AUTO'
+  --set-tz-code=TZCODE  Set timezone code to TZCODE. See your Vantage manual
+                        for valid codes.
+  --set-tz-offset=HHMM  Set timezone offset to HHMM. E.g. '-0800' for U.S.
+                        Pacific Time.
+  --set-lamp=ON|OFF     Turn the console lamp 'ON' or 'OFF'.
+  --dump                Dump all data to the archive. NB: This may result in
+                        many duplicate primary key errors.
+  --batch-size=BATCH_SIZE
+                        Use with option --dump. Pages are read off the console
+                        in batches of BATCH_SIZE. A BATCH_SIZE of zero means
+                        dump all data first, then put it in the database. This
+                        can improve performance in high-latency environments,
+                        but requires sufficient memory to hold all station
+                        data. Default is 1 (one).
+  --logger-summary=FILE
+                        Save diagnostic summary to FILE (for debugging the
+                        logger).
+  --start               Start the logger.
+  --stop                Stop the logger.
+
+Be sure to stop weewx first before using. Mutating actions will request
+confirmation before proceeding.
+
+ +

+ Action --info

+ +

Use the --info option to display the current EEPROM settings:

+
wee_device --info
+

This will result in something like:

+
Davis Vantage EEPROM settings:
+
+    CONSOLE TYPE:                   Vantage Pro2
+
+    CONSOLE FIRMWARE:
+      Date:                         Jun  3 2013
+      Version:                      3.15
+
+    CONSOLE SETTINGS:
+      Archive interval:             300 (seconds)
+      Altitude:                     700 (foot)
+      Wind cup type:                large
+      Rain bucket type:             0.01 inches
+      Rain year start:              10
+      Onboard time:                 2014-09-25 07:41:14
+
+    CONSOLE DISPLAY UNITS:
+      Barometer:                    inHg
+      Temperature:                  degree_F
+      Rain:                         inch
+      Wind:                         mile_per_hour
+
+    CONSOLE STATION INFO:
+      Latitude (onboard):           45.7°
+      Longitude (onboard):          -121.6°
+      Use manual or auto DST?       AUTO
+      DST setting:                  N/A
+      Use GMT offset or zone code?  ZONE_CODE
+      Time zone code:               4
+      GMT offset:                   N/A
+      Retransmit channel:           0 (OFF)
+
+    TRANSMITTERS:
+      Channel   Receive   Repeater  Type
+         1      active      A       iss
+         2      active      none    temp_hum (as extra temperature 1 and extra humidity 1)
+         3      active      none    leaf_soil
+         4      inactive    none    (N/A)
+         5      inactive    none    (N/A)
+         6      inactive    none    (N/A)
+         7      inactive    none    (N/A)
+         8      inactive    none    (N/A)
+
+    RECEPTION STATS:
+      Total packets received:       10670
+      Total packets missed:         128
+      Number of resynchronizations: 0
+      Longest good stretch:         934
+      Number of CRC errors:         651
+
+    BAROMETER CALIBRATION DATA:
+      Current barometer reading:    29.834 inHg
+      Altitude:                     700 feet
+      Dew point:                    55 F
+      Virtual temperature:          59 F
+      Humidity correction factor:   2.7
+      Correction ratio:             1.025
+      Correction constant:          +0.000 inHg
+      Gain:                         0.000
+      Offset:                       -47.000
+
+    OFFSETS:
+      Wind direction:               +0 deg
+      Inside Temperature:           +0.0 F
+      Inside Humidity:              +0 %
+      Outside Temperature:          +0.0 F
+      Outside Humidity:             +0 %
+      Extra Temperature 1:          +0.0 F
+

The console version number is available only on consoles with firmware dates after about 2006.

+ +

Highlighted values can be modified using the + various wee_device + commands below.

+ +

+ Action --current

+

This command will print a single LOOP packet.

+ +

+ Action --clear-memory

+

This command will clear the logger memory after asking for confirmation.

+ +

+ Action --set-interval

+ +

Use this command to change the archive interval of the internal logger. Valid intervals are 1, 5, 10, + 15, 30, 60, or 120 minutes. However, if you are ftp'ing lots of files to a server, setting it to + one minute may not give enough time to have them all uploaded before the next archive record is due. If this + is the case, you should pick a longer archive interval, or trim the number of files you + are using.

+ +

An archive interval of five minutes works well for the Vantage stations. Because of the large + amount of onboard memory they carry, going to a larger interval does not have any real advantages.

+ +

Example: to change the archive interval to 10 minutes:

+
wee_device --set-interval=10
+ +

Action --set-altitude

+ +

Use this command to set the console's stored altitude. The altitude must be in feet. Example:

+
wee_device --set-altitude=700
+ +

Action --set-barometer

+ +

Use this command to calibrate the barometer in your Vantage weather station. To use it, you must have a known + correct barometer reading for your altitude. In practice, you will either have to move your console + to a known-correct station (perhaps a nearby airport) and perform the calibration there, or reduce the + barometer reading to your altitude. Otherwise, specify the value zero and the station will pick a sensible + value.

+ +

+ Action --set-bucket

+ +

Normally, this is set by Davis, but if you have replaced your bucket with a different kind, you might want to + reconfigure. For example, to change to a 0.1 mm bucket (bucket code "2"), use the following:

+
wee_device --set-bucket=2
+ +

Action --set-rain-year-start

+ +

The Davis Vantage series allows the start of the rain year to be something other than 1 January. For example, + to set it to 1 October:

+
wee_device --set-rain-year-start=10
+ +

+ Action --set-offset

+ +

The Davis instruments can correct sensor errors by adding an offset to their emitted values. This is + particularly useful for Southern Hemisphere users. Davis fits the wind vane to the Integrated Sensor Suite + (ISS) in a position optimized for Northern Hemisphere users, who face the solar panel to the south. Users + south of the equator must orient the ISS's solar panel to the north to get maximal insolation, resulting in + a 180° error in the wind direction. The solution is to add a 180° offset correction. You can do this + with the following command:

+ +
wee_device --set-offset=windDir,180
+ +

+ Action --set-transmitter-type

+ +

If you have additional sensors and/or repeaters for your Vantage station, you can configure them using your console. + However, if you have a + Davis Weather Envoy receiver, + it will not have a console! As an alternative, + wee_device can do this using the command --set-transmitter-type. +

+ +

For example, to add an extra temperature sensor to channel 3 and no repeater is used, do the following:

+
wee_device --set-transmitter-type=3,1,2
+

This says to turn on channel 3, set its type to 1 ("Temperature only"), without repeater use and it will show up in the database + as extraTemp2.

+

If you omit the repeater id, repeater id will be: 'no repeater'.

+ +

Here's another example, this time for a combined temperature / + humidity sensor retransmitted via repeater A:

+
wee_device --set-transmitter-type=5,3,2,4,a
+

This will add the combined sensor to channel 5, set its type to 3 ("Temperature and humidity"), via Repeater A and it will + show up in the database as extraTemp2 and extraHumid4. +

+ +

The --help option will give you the code for each sensor type and repeater id.

+

If you have to use repeaters with your Vantage Pro2 station, please take a look at + + Installing Repeater Networks for Vantage Pro2 how to setup.

+ +

You can only use channels not actively used for retransmission. The command checks for this and will not + accept channel numbers actively used for retransmission.

+ +

+ Action --set-retransmit

+ +

Use this command to tell your console whether or not to act as a retransmitter.

+

Example: Tell your console to turn retransmission 'ON' and let the software select the first available channel:

+
wee_device --set-retransmit=on
+ +

Another example: Tell your console to turn retransmission 'OFF':

+
wee_device --set-retransmit=off
+ +

Last example: Tell your console to turn retransmission 'ON' at channel 4:

+
wee_device --set-retransmit=on,4
+

You only can use channels not actively used for reception. The command checks for this and will not accept channel numbers actively used for reception of senor stations.

+ +

Action --set-dst

+ +

Use the command to tell your console whether or not daylight savings time is in effect, or to have it set + automatically based on the time zone.

+ +

+ Action --set-tz-code

+ +

This command can be used to change the time zone. Consult the + Vantage manual for the code that corresponds to + your time zone.

+ +

You can set either the time zone code or + the time zone offset, but not both.

+ +

For example, to set the time zone code to Central European Time + (code 20):

+
wee_device --set-tz-code=20
+ +

Action --set-tz-offset

+ +

If you live in an odd time zone that is perhaps not covered by the + "canned" Davis time zones, you can set the + offset from UTC using this command.

+ +

You can set either the time zone code or + the time zone offset, but not both.

+ +

For example, to set the time zone offset for Newfoundland Standard + Time (UTC-03:30), use the following:

+
wee_device --set-tz-offset=-0330
+ +

Action --set-lamp

+ +

Use this command to turn the console lamp on or off.

+ +

+ Action --dump

+ +

+ Generally, WeeWX downloads only new archive records from the on-board logger in the Vantage. However, + occasionally the memory in the Vantage will get corrupted, making this impossible. See the section WeeWX + generates HTML pages, but it does not update them in the Wiki. The fix involves clearing the memory + but, unfortunately, this means you may lose any data which might have accumulated in the logger memory, but + not yet downloaded. By using the --dump command before clearing the memory, you + might be able to save these data. Stop WeeWX first, then +

+
wee_device --dump
+

This will dump all data archived in the Vantage memory directly to the database, without regard to whether or + not they have been seen before. Because the command dumps all data, it may result in many duplicate + primary key errors. These can be ignored.

+ +

Action --logger-summary FILE

+

This command is useful for debugging the console logger. It will scan the logger memory, recording the + timestamp in each page and index slot to the file FILE.

+

Example:

+
wee_device --logger-summary=/var/tmp/summary.txt
+ +

Action --start

+ +

Use this command to start the logger. There are occasions when an + out-of-the-box logger needs this command.

+ +

Action --stop

+ +

Use this command to stop the logger. I can't think of a good reason + why you would need to do this, but the + command is included for completeness.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Vantage station data
Database FieldObservationLoopArchive
barometerbarometerHH
pressureSS
altimeterSS
inTempinTempHH
outTempoutTempHH
inHumidityinHumidityHH
outHumidityoutHumidityHH
windSpeedwindSpeedHH
windDirwindDirHH
windGustwindGustHH
windGustDirwindGustDirHH
rainrainDH
monthRainHH
rainRaterainRateHH
dewpointSS
windchillSS
heatindexSS
radiationradiationHH
UVUVHH
extraTemp1extraTemp1HH
extraTemp2extraTemp2HH
extraTemp3extraTemp3HH
extraTemp4extraTemp4H
extraTemp5extraTemp5H
extraTemp6extraTemp6H
extraTemp7extraTemp7H
soilTemp1soilTemp1HH
soilTemp2soilTemp2HH
soilTemp3soilTemp3HH
soilTemp4soilTemp4HH
leafTemp1leafTemp1HH
leafTemp2leafTemp2HH
leafTemp3leafTemp3HH
leafTemp4leafTemp4HH
extraHumid1extraHumid1HH
extraHumid2extraHumid2HH
extraHumid3extraHumid3H
extraHumid4extraHumid4H
extraHumid5extraHumid5H
extraHumid6extraHumid6H
extraHumid7extraHumid7H
soilMoist1soilMoist1HH
soilMoist2soilMoist2HH
soilMoist3soilMoist3HH
soilMoist4soilMoist4HH
leafWet1leafWet1HH
leafWet2leafWet2HH
leafWet3leafWet3HH
leafWet4leafWet4HH
txBatteryStatustxBatteryStatusHH
consBatteryVoltageconsBatteryVoltageHH
wind_samplesH
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

WMR100

+ +

The station emits partial packets, which may confuse some online + services.

+ +

Configuring with wee_device

+ +

The wee_device utility + cannot be used to configure WMR100 stations.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WMR100 station data
Database FieldObservationLoopArchive
barometerS
pressurepressureH
altimeterS
inTemptemperature_0H
outTemptemperature_1H
inHumidityhumidity_0H
outHumidityhumidity_1H
windSpeedwind_speedH
windDirwind_dirH
windGustwind_gustH
rainrainD
rain_totalH
rainRaterain_rateH
dewpointS
windchillS
heatindexS
extraTemp1temperature_2H
extraTemp2temperature_3H
extraTemp3temperature_4H
extraTemp4temperature_5H
extraTemp5temperature_6H
extraTemp6temperature_7H
extraTemp7temperature_8H
extraHumid1humidity_2H
extraHumid2humidity_3H
extraHumid3humidity_4H
extraHumid4humidity_5H
extraHumid5humidity_6H
extraHumid6humidity_7H
extraHumid7humidity_8H
UVuvH
inTempBatteryStatusbattery_status_0H
outTempBatteryStatusbattery_status_1H
rainBatteryStatusrain_battery_statusH
windBatteryStatuswind_battery_statusH
uvBatteryStatusuv_battery_statusH
extraBatteryStatus1battery_status_2H
extraBatteryStatus2battery_status_3H
extraBatteryStatus3battery_status_4H
extraBatteryStatus4battery_status_5H
extraBatteryStatus5battery_status_6H
extraBatteryStatus6battery_status_7H
extraBatteryStatus7battery_status_8H
+

+ Each packet contains a subset of all possible readings. For + example, a temperature packet contains + temperature_N and + battery_status_N, a rain packet contains + rain_total and + rain_rate. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

WMR300

+ +

A single WMR300 console supports 1 wind, 1 rain, 1 UV, and up to 8 + temperature/humidity sensors.

+ +

The WMR300 sensors send at different rates:

+ + + + + + + + + +
WMR300 transmission periods
sensorperiod
Wind2.5 to 3 seconds
T/H10 to 12 seconds
Rain20 to 24 seconds
+ +

The console contains the pressure sensor. The console reports + pressure every 15 minutes.

+ +

The station emits partial packets, which may confuse some online + services.

+ +

The rain counter has a limit of 400 inches (10160 mm). The + counter does not wrap around; it must be reset when it hits + the limit, otherwise additional rain data will not be recorded. +

+ +

The logger stores about 50,000 records. When the logger fills + up, it stops recording data.

+ +

When WeeWX starts up it will attempt to + download all records from the console since the last record in the + archive database. This can take as much as couple of hours, + depending on the number of records in the logger and the speed of + the computer and disk.

+ +

Configuring with wee_device

+ +

The wee_device utility + cannot be used to configure WMR300 stations.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WMR300 station data
Database FieldObservationLoopArchive
barometerbarometerHH
pressurepressureHS
altimeterSS
inTemptemperature_0HH
inHumidityhumidity_0HH
windSpeedwind_avgHH
windDirwind_dirHH
windGustwind_gustHH
windGustDirwind_gust_dirHH
rainrainDD
rain_totalHH
rainRaterain_rateHH
outTemptemperature_1HH
outHumidityhumidity_1HH
dewpointdewpoint_1HH
heatindexheatindex_1HH
windchillwindchillHH
extraTemp1temperature_2HH
extraHumid1humidity_2HH
extraDewpoint1dewpoint_2HH
extraHeatindex1heatindex_2HH
extraTemp2temperature_3HH
extraHumid2humidity_3HH
extraDewpoint2dewpoint_3HH
extraHeatindex2heatindex_3HH
extraTemp3temperature_4HH
extraHumid3humidity_4HH
extraDewpoint3dewpoint_4HH
extraHeatindex3heatindex_4HH
extraTemp4temperature_5HH
extraHumid4humidity_5HH
extraDewpoint4dewpoint_5HH
extraHeatindex4heatindex_5HH
extraTemp5temperature_6HH
extraHumid5humidity_6HH
extraDewpoint5dewpoint_6HH
extraHeatindex5heatindex_6HH
extraTemp6temperature_7HH
extraHumid6humidity_7HH
extraDewpoint6dewpoint_7HH
extraHeatindex6heatindex_7HH
extraTemp7temperature_8HH
extraHumid7humidity_8HH
extraDewpoint7dewpoint_8HH
extraHeatindex7heatindex_8HH
+

+ Each packet contains a subset of all possible readings. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

WMR9x8

+ +

The station includes a data logger, but the driver does not read + records from the station.

+ +

The station emits partial packets, which may confuse some online + services.

+ +

Configuring with wee_device

+ +

The wee_device utility + cannot be used to configure WMR9x8 stations.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WMR9x8 station data
Database FieldObservationLoopArchive
barometerbarometerH
pressurepressureH
altimeterS
inTemptemperature_inH
outTemptemperature_outH
inHumidityhumidity_inH
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
windGustwind_gustH
windGustDirwind_gust_dirH
rainrainD
rain_totalH
rainRaterain_rateH
inDewpointdewpoint_inH
dewpointdewpoint_outH
windchillwindchillH
heatindexS
UVuvH
extraTemp1temperature_1H
extraTemp2temperature_2H
extraTemp3temperature_3H
extraTemp4temperature_4H
extraTemp5temperature_5H
extraTemp6temperature_6H
extraTemp7temperature_7H
extraTemp8temperature_8H
extraHumid1humidity_1H
extraHumid2humidity_3H
extraHumid3humidity_3H
extraHumid4humidity_4H
extraHumid5humidity_5H
extraHumid6humidity_6H
extraHumid7humidity_7H
extraHumid8humidity_8H
inTempBatteryStatusbattery_status_inH
outTempBatteryStatusbattery_status_outH
rainBatteryStatusrain_battery_statusH
windBatteryStatuswind_battery_statusH
extraBatteryStatus1battery_status_1H
extraBatteryStatus2battery_status_2H
extraBatteryStatus3battery_status_3H
extraBatteryStatus4battery_status_4H
extraBatteryStatus5battery_status_5H
extraBatteryStatus6battery_status_6H
extraBatteryStatus7battery_status_7H
extraBatteryStatus8battery_status_8H
+

+ Each packet contains a subset of all possible readings. For + example, a temperature packet contains + temperature_N and + battery_status_N, a rain packet contains + rain_total and + rain_rate. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

WS1

+ +

The WS1 stations produce data every 1/2 second or so.

+ +

Configuring with wee_device

+ +

The wee_device utility + cannot be used to configure WS1 stations.

+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WS1 station data
Database FieldObservationLoopArchive
barometerS
pressurepressureH
altimeterS
inTemptemperature_inH
outTemptemperature_outH
inHumidityhumidity_inH
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
rainrainD
rain_totalH
rainRateS
dewpointS
windchillS
heatindexS
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

WS23xx

+ +

The hardware interface is a serial port, but USB-serial converters + can be used with computers that have no serial port. Beware that + not every type of USB-serial converter will work. Converters based + on ATEN UC-232A chipset are known to work.

+ +

The station does not record wind gust or wind gust direction.

+ +

The hardware calculates windchill and dewpoint.

+ +

Sensors can be connected to the console with a wire. If not wired, + the sensors will communicate via a wireless interface. When + connected by wire, some sensor data are transmitted every 8 + seconds. With wireless, data are transmitted every 16 to 128 + seconds, depending on wind speed and rain activity. +

+ + + + + + + + + + + +
WS23xx transmission periods
sensorperiod
Wind32 seconds when wind > 22.36 mph (wireless)
+ 128 seconds when wind > 22.36 mph (wireless)
+ 10 minutes (wireless after 5 failed attempts)
+ 8 seconds (wired) +
Temperature15 seconds
Humidity20 seconds
Pressure15 seconds
+ +

The station has 175 history records. That is just over 7 days of + data with the factory default history recording interval of 60 + minutes, or about 14 hours with a recording interval of 5 + minutes.

+ +

When WeeWX starts up it will attempt to + download all records from the console since the last record in the + archive database.

+ +

Configuring with wee_device

+ +

+ Make sure you stop weewxd before running + wee_device. +

+ +

+ Action --help

+ +

Invoking wee_device with the + --help option

+ +
wee_device /home/weewx/weewx.conf --help
+ +

will produce something like this:

+ +
+WS23xx driver version 0.21
+Usage: wee_device [config_file] [options] [--debug] [--help]
+
+Configuration utility for weewx devices.
+
+Options:
+  -h, --help         show this help message and exit
+  --debug            display diagnostic information while running
+  -y                 answer yes to every prompt
+  --info             display weather station configuration
+  --current          get the current weather conditions
+  --history=N        display N history records
+  --history-since=N  display history records since N minutes ago
+  --clear-memory     clear station memory
+  --set-time         set the station clock to the current time
+  --set-interval=N   set the station archive interval to N minutes
+
+Mutating actions will request confirmation before proceeding.
+ +

Action --info

+ +

Display the station settings with the + --info option.

+
wee_device --info 
+

This will result in something like:

+
buzzer: 0
+connection time till connect: 1.5
+connection type: 15
+dew point: 8.88
+dew point max: 18.26
+dew point max alarm: 20.0
+dew point max alarm active: 0
+dew point max alarm set: 0
+dew point max when: 978565200.0
+dew point min: -2.88
+dew point min alarm: 0.0
+dew point min alarm active: 0
+dew point min alarm set: 0
+dew point min when: 978757260.0
+forecast: 0
+history interval: 5.0
+history last record pointer: 8.0
+history last sample when: 1385564760.0
+history number of records: 175.0
+history time till sample: 5.0
+icon alarm active: 0
+in humidity: 48.0
+...
+

+ The line history number of records indicates how many records are in memory. The + line history interval indicates the number of minutes between records. +

+ +

Action --set-interval

+ +

WS23xx stations ship from the factory with an archive interval of + 60 minutes (3600 seconds). To change the + station's interval to 5 minutes, do the following:

+ +

wee_device --set-interval=5

+ +

Warning!
Changing the archive + interval will clear the station memory. +

+ +

Action --history

+ +

WS23xx stations store records in a circular buffer — once the + buffer fills, the oldest records are replaced by newer records. + The console stores up to 175 records.

+ +

For example, to display the latest 30 records from the console + memory:

+
wee_device --history=30
+ +

Action --clear-memory

+ +

To clear the console memory:

+
wee_device --clear-memory
+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WS23xx station data
Database FieldObservationLoopArchive
barometerSS
pressurepressureHH
altimeterSS
inTemptemperature_inHH
outTemptemperature_outHH
inHumidityhumidity_inHH
outHumidityhumidity_outHH
windSpeedwind_speedHH
windDirwind_dirHH
rainrainDD
rain_totalHH
rainRaterain_rateHH
dewpointdewpointHH
windchillwindchillHH
heatindexSS
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ + + + + +

WS28xx

+ +

WeeWX communicates with a USB transceiver, + which communicates with the station console, which in turn + communicates with the sensors. The transceiver and console must be + paired and synchronized.

+ +

The sensors send data at different rates:

+ + + + + + + + + + +
WS28xx transmission periods
sensorperiod
Wind17 seconds
T/H13 seconds
Rain19 seconds
Pressure15 seconds
+ +

The wind and rain sensors transmit to the temperature/humidity + device, then the temperature/humidity device retransmits to the + weather station console. Pressure is measured by a sensor in the + console. +

+ +

The station has 1797 history records. That is just over 6 days of + data with an archive interval of 5 minutes.

+ +

When WeeWX starts up it will attempt to + download all records from the console since + the last record in the archive database.

+ +

The WS28xx driver sets the station archive interval to 5 + minutes.

+ +

The WS28xx driver does not support hardware archive record + generation.

+ +

Pairing

+ +

The console and transceiver must be paired. Pairing ensures that + your transceiver is talking to your console, not your neighbor's + console. Pairing should only have to be done once, although you + might have to pair again after power cycling the console, for + example after you replace the batteries.

+ +

There are two ways to pair the console and the transceiver:

+
    +
  1. The WeeWX way. Be sure that + WeeWX is not running. Run the + configuration utility, press and hold the [v] button on the + console until you see 'PC' in the display, then release the + button. If the console pairs with the transceiver, 'PC' will + go away within a second or two. +
    wee_device --pair
    +
    +Pairing transceiver with console...
    +Press and hold the [v] key until "PC" appears (attempt 1 of 3)
    +Transceiver is paired to console
    +
  2. +
  3. The HeavyWeather way. Follow the pairing + instructions that came with the station. You will have to run + HeavyWeather on a windows computer with the USB transceiver. + After HeavyWeather indicates the devices are paired, put the + USB transceiver in your WeeWX + computer and start WeeWX. Do not power cycle the station + console or you will have to start over. +
  4. +
+ +

If the console does not pair, you will see messages in the log such + as this:

+
ws28xx: RFComm: message from console contains unknown device ID (id=165a resp=80 req=6)
+

Either approach to pairing may require multiple attempts.

+ +

Synchronizing

+ +

After pairing, the transceiver and console must be synchronized in + order to communicate. Synchronization will happen automatically at + the top of each hour, or you can force synchronization by pressing + the [SET] button momentarily. Do not press and hold the [SET] + button — that modifies the console alarms.

+ +

When the transceiver and console are synchronized, you will see + lots of 'ws28xx: RFComm' messages in the + log when debug=1. When the devices are + not synchronized, you will see messages like this about every 10 + minutes:

+
Nov  7 19:12:17 raspi weewx[2335]: ws28xx: MainThread: no contact with console
+

If you see this, or if you see an extended gap in the weather data + in the WeeWX plots, press momentarily + the [SET] button, or wait until the top of the hour.

+ +

When the transceiver has not received new data for awhile, you will + see messages like this in the log:

+
Nov  7 19:12:17 raspi weewx[2335]: ws28xx: MainThread: no new weather data
+

If you see 'no new weather data' messages with the 'no contact with + console' messages, it simply means that the transceiver has not + been able to synchronize with the console. If you see only the + 'no new weather data' messages, then the sensors are not + communicating with the console, or the console may be defective. +

+ +

Alarms

+ +

When an alarm goes off, communication with the transceiver stops. + The WS28xx driver clears all alarms in the station. It is better + to create alarms in WeeWX, and the WeeWX + alarms can do much more than the console alarms anyway.

+ +

Configuring with wee_device

+ +

+ Make sure you stop weewxd before running + wee_device. +

+ +

+ Action --help

+ +

Invoking wee_device with the + --help option

+ +
wee_device /home/weewx/weewx.conf --help
+ +

will produce something like this:

+ +
+WS28xx driver version 0.33
+Usage: wee_device [config_file] [options] [--debug] [--help]
+
+Configuration utility for weewx devices.
+
+Options:
+  -h, --help           show this help message and exit
+  --debug              display diagnostic information while running
+  -y                   answer yes to every prompt
+  --check-transceiver  check USB transceiver
+  --pair               pair the USB transceiver with station console
+  --info               display weather station configuration
+  --set-interval=N     set logging interval to N minutes
+  --current            get the current weather conditions
+  --history=N          display N history records
+  --history-since=N    display history records since N minutes ago
+  --maxtries=MAXTRIES  maximum number of retries, 0 indicates no max
+
+Mutating actions will request confirmation before proceeding.
+ +

Action --pair

+

The console and transceiver must be paired. This can be done either + by using this command, or by running the program HeavyWeather on a + Windows PC.

+ +

Be sure that WeeWX is not running. Run + the command:

+
wee_device --pair
+
+Pairing transceiver with console...
+Press and hold the [v] key until "PC" appears (attempt 1 of 3)
+Transceiver is paired to console
+ +

Press and hold the [v] button on the console until you see 'PC' in + the display, then release the button. If the console pairs with + the transceiver, 'PC' will go away within a second or two.

+

If the console does not pair, you will see messages in the log such + as this:

+
ws28xx: RFComm: message from console contains unknown device ID (id=165a resp=80 req=6)
+

Pairing may require multiple attempts.

+

After pairing, the transceiver and console must be synchronized in + order to communicate. This should happen automatically.

+ +

Action --info

+ +

Display the station settings with the + --info option.

+ +

wee_device --info

+ +

This will result in something like:

+ +
alarm_flags_other: 0
+alarm_flags_wind_dir: 0
+checksum_in: 1327
+checksum_out: 1327
+format_clock: 1
+format_pressure: 0
+format_rain: 1
+format_temperature: 0
+format_windspeed: 4
+history_interval: 1
+indoor_humidity_max: 70
+indoor_humidity_max_time: None
+indoor_humidity_min: 45
+indoor_humidity_min_time: None
+indoor_temp_max: 40.0
+indoor_temp_max_time: None
+indoor_temp_min: 0.0
+indoor_temp_min_time: None
+lcd_contrast: 4
+low_battery_flags: 0
+outdoor_humidity_max: 70
+outdoor_humidity_max_time: None
+outdoor_humidity_min: 45
+outdoor_humidity_min_time: None
+outdoor_temp_max: 40.0
+outdoor_temp_max_time: None
+outdoor_temp_min: 0.0
+outdoor_temp_min_time: None
+pressure_max: 1040.0
+pressure_max_time: None
+pressure_min: 960.0
+pressure_min_time: None
+rain_24h_max: 50.0
+rain_24h_max_time: None
+threshold_storm: 5
+threshold_weather: 3
+wind_gust_max: 12.874765625
+wind_gust_max_time: None
+ +

Station data

+ +

The following table shows which data are provided by the station + hardware and which are calculated by WeeWX. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WS28xx station data
Database FieldObservationLoopArchive
barometerSS
pressurepressureHH
altimeterSS
inTemptemperature_inHH
outTemptemperature_outHH
inHumidityhumidity_inHH
outHumidityhumidity_outHH
windSpeedwind_speedHH
windDirwind_dirHH
windGustwind_gustHH
windGustDirwind_gust_dirHH
rainrainDD
rain_totalHH
rainRaterain_rateH
dewpointSS
windchillwindchillHH
heatindexheatindexHH
rxCheckPercentrssiH
windBatteryStatuswind_battery_statusH
rainBatteryStatusrain_battery_statusH
outTempBatteryStatusbattery_status_outH
inTempBatteryStatusbattery_status_inH
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ +
+ + + +
+ + diff --git a/dist/weewx-4.10.1/docs/images/antialias.gif b/dist/weewx-4.10.1/docs/images/antialias.gif new file mode 100644 index 0000000..29f412f Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/antialias.gif differ diff --git a/dist/weewx-4.10.1/docs/images/day-gap-not-shown.png b/dist/weewx-4.10.1/docs/images/day-gap-not-shown.png new file mode 100644 index 0000000..dfba9ae Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/day-gap-not-shown.png differ diff --git a/dist/weewx-4.10.1/docs/images/day-gap-showing.png b/dist/weewx-4.10.1/docs/images/day-gap-showing.png new file mode 100644 index 0000000..5868a49 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/day-gap-showing.png differ diff --git a/dist/weewx-4.10.1/docs/images/daycompare.png b/dist/weewx-4.10.1/docs/images/daycompare.png new file mode 100644 index 0000000..effbe13 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/daycompare.png differ diff --git a/dist/weewx-4.10.1/docs/images/daytemp_with_avg.png b/dist/weewx-4.10.1/docs/images/daytemp_with_avg.png new file mode 100644 index 0000000..530e29a Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/daytemp_with_avg.png differ diff --git a/dist/weewx-4.10.1/docs/images/dayvaporp.png b/dist/weewx-4.10.1/docs/images/dayvaporp.png new file mode 100644 index 0000000..104b6d8 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/dayvaporp.png differ diff --git a/dist/weewx-4.10.1/docs/images/daywindvec.png b/dist/weewx-4.10.1/docs/images/daywindvec.png new file mode 100644 index 0000000..caefe92 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/daywindvec.png differ diff --git a/dist/weewx-4.10.1/docs/images/favicon.png b/dist/weewx-4.10.1/docs/images/favicon.png new file mode 100644 index 0000000..4026396 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/favicon.png differ diff --git a/dist/weewx-4.10.1/docs/images/ferrites.jpg b/dist/weewx-4.10.1/docs/images/ferrites.jpg new file mode 100644 index 0000000..d6626ae Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/ferrites.jpg differ diff --git a/dist/weewx-4.10.1/docs/images/funky_degree.png b/dist/weewx-4.10.1/docs/images/funky_degree.png new file mode 100644 index 0000000..d22b2ee Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/funky_degree.png differ diff --git a/dist/weewx-4.10.1/docs/images/image_parts.png b/dist/weewx-4.10.1/docs/images/image_parts.png new file mode 100644 index 0000000..79d5a68 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/image_parts.png differ diff --git a/dist/weewx-4.10.1/docs/images/image_parts.xcf b/dist/weewx-4.10.1/docs/images/image_parts.xcf new file mode 100644 index 0000000..3b8708a Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/image_parts.xcf differ diff --git a/dist/weewx-4.10.1/docs/images/logo-apple.png b/dist/weewx-4.10.1/docs/images/logo-apple.png new file mode 100644 index 0000000..a9c1617 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-apple.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-centos.png b/dist/weewx-4.10.1/docs/images/logo-centos.png new file mode 100644 index 0000000..5afb7b4 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-centos.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-debian.png b/dist/weewx-4.10.1/docs/images/logo-debian.png new file mode 100644 index 0000000..7897b80 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-debian.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-fedora.png b/dist/weewx-4.10.1/docs/images/logo-fedora.png new file mode 100644 index 0000000..d8c5094 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-fedora.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-linux.png b/dist/weewx-4.10.1/docs/images/logo-linux.png new file mode 100644 index 0000000..d86f9dc Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-linux.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-mint.png b/dist/weewx-4.10.1/docs/images/logo-mint.png new file mode 100644 index 0000000..d4a9513 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-mint.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-opensuse.png b/dist/weewx-4.10.1/docs/images/logo-opensuse.png new file mode 100644 index 0000000..b14d575 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-opensuse.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-pypi.svg b/dist/weewx-4.10.1/docs/images/logo-pypi.svg new file mode 100644 index 0000000..6b8b45d --- /dev/null +++ b/dist/weewx-4.10.1/docs/images/logo-pypi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/weewx-4.10.1/docs/images/logo-redhat.png b/dist/weewx-4.10.1/docs/images/logo-redhat.png new file mode 100644 index 0000000..4ff65f4 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-redhat.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-rpi.png b/dist/weewx-4.10.1/docs/images/logo-rpi.png new file mode 100644 index 0000000..738cf3d Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-rpi.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-suse.png b/dist/weewx-4.10.1/docs/images/logo-suse.png new file mode 100644 index 0000000..7fb9047 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-suse.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-ubuntu.png b/dist/weewx-4.10.1/docs/images/logo-ubuntu.png new file mode 100644 index 0000000..0c5b4e7 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-ubuntu.png differ diff --git a/dist/weewx-4.10.1/docs/images/logo-weewx.png b/dist/weewx-4.10.1/docs/images/logo-weewx.png new file mode 100644 index 0000000..0221ca9 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/logo-weewx.png differ diff --git a/dist/weewx-4.10.1/docs/images/pipeline.png b/dist/weewx-4.10.1/docs/images/pipeline.png new file mode 100644 index 0000000..c9e20c0 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/pipeline.png differ diff --git a/dist/weewx-4.10.1/docs/images/sample_monthrain.png b/dist/weewx-4.10.1/docs/images/sample_monthrain.png new file mode 100644 index 0000000..0282144 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/sample_monthrain.png differ diff --git a/dist/weewx-4.10.1/docs/images/sample_monthtempdew.png b/dist/weewx-4.10.1/docs/images/sample_monthtempdew.png new file mode 100644 index 0000000..c6a4287 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/sample_monthtempdew.png differ diff --git a/dist/weewx-4.10.1/docs/images/weekgustoverlay.png b/dist/weewx-4.10.1/docs/images/weekgustoverlay.png new file mode 100644 index 0000000..cb9a652 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/weekgustoverlay.png differ diff --git a/dist/weewx-4.10.1/docs/images/weektempdew.png b/dist/weewx-4.10.1/docs/images/weektempdew.png new file mode 100644 index 0000000..00041c8 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/weektempdew.png differ diff --git a/dist/weewx-4.10.1/docs/images/yeardiff.png b/dist/weewx-4.10.1/docs/images/yeardiff.png new file mode 100644 index 0000000..aa0c377 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/yeardiff.png differ diff --git a/dist/weewx-4.10.1/docs/images/yearhilow.png b/dist/weewx-4.10.1/docs/images/yearhilow.png new file mode 100644 index 0000000..0776a87 Binary files /dev/null and b/dist/weewx-4.10.1/docs/images/yearhilow.png differ diff --git a/dist/weewx-4.10.1/docs/js/cash.js b/dist/weewx-4.10.1/docs/js/cash.js new file mode 100644 index 0000000..a235b22 --- /dev/null +++ b/dist/weewx-4.10.1/docs/js/cash.js @@ -0,0 +1,1299 @@ +/* MIT https://github.com/kenwheeler/cash */ +(function(){ +"use strict"; + +var doc = document, + win = window, + div = doc.createElement('div'), + _a = Array.prototype, + filter = _a.filter, + indexOf = _a.indexOf, + map = _a.map, + push = _a.push, + reverse = _a.reverse, + slice = _a.slice, + some = _a.some, + splice = _a.splice; +var idRe = /^#[\w-]*$/, + classRe = /^\.[\w-]*$/, + htmlRe = /<.+>/, + tagRe = /^\w+$/; // @require ./variables.ts + +function find(selector, context) { + if (context === void 0) { + context = doc; + } + + return context !== doc && context.nodeType !== 1 && context.nodeType !== 9 ? [] : classRe.test(selector) ? context.getElementsByClassName(selector.slice(1)) : tagRe.test(selector) ? context.getElementsByTagName(selector) : context.querySelectorAll(selector); +} // @require ./find.ts +// @require ./variables.ts + + +var Cash = +/** @class */ +function () { + function Cash(selector, context) { + if (context === void 0) { + context = doc; + } + + if (!selector) return; + if (isCash(selector)) return selector; + var eles = selector; + + if (isString(selector)) { + var ctx = isCash(context) ? context[0] : context; + eles = idRe.test(selector) ? ctx.getElementById(selector.slice(1)) : htmlRe.test(selector) ? parseHTML(selector) : find(selector, ctx); + if (!eles) return; + } else if (isFunction(selector)) { + return this.ready(selector); //FIXME: `fn.ready` is not included in `core`, but it's actually a core functionality + } + + if (eles.nodeType || eles === win) eles = [eles]; + this.length = eles.length; + + for (var i = 0, l = this.length; i < l; i++) { + this[i] = eles[i]; + } + } + + Cash.prototype.init = function (selector, context) { + return new Cash(selector, context); + }; + + return Cash; +}(); + +var cash = Cash.prototype.init; +cash.fn = cash.prototype = Cash.prototype; // Ensuring that `cash () instanceof cash` + +Cash.prototype.length = 0; +Cash.prototype.splice = splice; // Ensuring a cash collection gets printed as array-like in Chrome + +if (typeof Symbol === 'function') { + Cash.prototype[Symbol['iterator']] = Array.prototype[Symbol['iterator']]; +} + +Cash.prototype.get = function (index) { + if (index === undefined) return slice.call(this); + return this[index < 0 ? index + this.length : index]; +}; + +Cash.prototype.eq = function (index) { + return cash(this.get(index)); +}; + +Cash.prototype.first = function () { + return this.eq(0); +}; + +Cash.prototype.last = function () { + return this.eq(-1); +}; + +Cash.prototype.map = function (callback) { + return cash(map.call(this, function (ele, i) { + return callback.call(ele, i, ele); + })); +}; + +Cash.prototype.slice = function () { + return cash(slice.apply(this, arguments)); +}; // @require ./cash.ts + + +var dashAlphaRe = /-([a-z])/g; + +function camelCaseReplace(all, letter) { + return letter.toUpperCase(); +} + +function camelCase(str) { + return str.replace(dashAlphaRe, camelCaseReplace); +} + +cash.camelCase = camelCase; // @require ./cash.ts + +function each(arr, callback) { + for (var i = 0, l = arr.length; i < l; i++) { + if (callback.call(arr[i], i, arr[i]) === false) break; + } +} + +cash.each = each; + +Cash.prototype.each = function (callback) { + each(this, callback); + return this; +}; + +Cash.prototype.removeProp = function (prop) { + return this.each(function (i, ele) { + delete ele[prop]; + }); +}; // @require ./cash.ts + + +function extend(target) { + var objs = []; + + for (var _i = 1; _i < arguments.length; _i++) { + objs[_i - 1] = arguments[_i]; + } + + var args = arguments, + length = args.length; + + for (var i = length < 2 ? 0 : 1; i < length; i++) { + for (var key in args[i]) { + target[key] = args[i][key]; + } + } + + return target; +} + +Cash.prototype.extend = function (plugins) { + return extend(cash.fn, plugins); +}; + +cash.extend = extend; // @require ./cash.ts + +var guid = 1; +cash.guid = guid; // @require ./cash.ts + +function matches(ele, selector) { + var matches = ele && (ele.matches || ele['webkitMatchesSelector'] || ele['mozMatchesSelector'] || ele['msMatchesSelector'] || ele['oMatchesSelector']); + return !!matches && matches.call(ele, selector); +} + +cash.matches = matches; // @require ./variables.ts + +function pluck(arr, prop, deep) { + var plucked = []; + + for (var i = 0, l = arr.length; i < l; i++) { + var val_1 = arr[i][prop]; + + while (val_1 != null) { + plucked.push(val_1); + if (!deep) break; + val_1 = val_1[prop]; + } + } + + return plucked; +} // @require ./cash.ts + + +function isCash(x) { + return x instanceof Cash; +} + +function isFunction(x) { + return typeof x === 'function'; +} + +function isString(x) { + return typeof x === 'string'; +} + +function isNumeric(x) { + return !isNaN(parseFloat(x)) && isFinite(x); +} + +var isArray = Array.isArray; +cash.isFunction = isFunction; +cash.isString = isString; +cash.isNumeric = isNumeric; +cash.isArray = isArray; + +Cash.prototype.prop = function (prop, value) { + if (!prop) return; + + if (isString(prop)) { + if (arguments.length < 2) return this[0] && this[0][prop]; + return this.each(function (i, ele) { + ele[prop] = value; + }); + } + + for (var key in prop) { + this.prop(key, prop[key]); + } + + return this; +}; // @require ./matches.ts +// @require ./type_checking.ts + + +function getCompareFunction(comparator) { + return isString(comparator) ? function (i, ele) { + return matches(ele, comparator); + } : isFunction(comparator) ? comparator : isCash(comparator) ? function (i, ele) { + return comparator.is(ele); + } : function (i, ele) { + return ele === comparator; + }; +} + +Cash.prototype.filter = function (comparator) { + if (!comparator) return cash(); + var compare = getCompareFunction(comparator); + return cash(filter.call(this, function (ele, i) { + return compare.call(ele, i, ele); + })); +}; // @require collection/filter.ts + + +function filtered(collection, comparator) { + return !comparator || !collection.length ? collection : collection.filter(comparator); +} // @require ./type_checking.ts + + +var splitValuesRe = /\S+/g; + +function getSplitValues(str) { + return isString(str) ? str.match(splitValuesRe) || [] : []; +} + +Cash.prototype.hasClass = function (cls) { + return cls && some.call(this, function (ele) { + return ele.classList.contains(cls); + }); +}; + +Cash.prototype.removeAttr = function (attr) { + var attrs = getSplitValues(attr); + if (!attrs.length) return this; + return this.each(function (i, ele) { + each(attrs, function (i, a) { + ele.removeAttribute(a); + }); + }); +}; + +function attr(attr, value) { + if (!attr) return; + + if (isString(attr)) { + if (arguments.length < 2) { + if (!this[0]) return; + var value_1 = this[0].getAttribute(attr); + return value_1 === null ? undefined : value_1; + } + + if (value === null) return this.removeAttr(attr); + return this.each(function (i, ele) { + ele.setAttribute(attr, value); + }); + } + + for (var key in attr) { + this.attr(key, attr[key]); + } + + return this; +} + +Cash.prototype.attr = attr; + +Cash.prototype.toggleClass = function (cls, force) { + var classes = getSplitValues(cls), + isForce = force !== undefined; + if (!classes.length) return this; + return this.each(function (i, ele) { + each(classes, function (i, c) { + if (isForce) { + force ? ele.classList.add(c) : ele.classList.remove(c); + } else { + ele.classList.toggle(c); + } + }); + }); +}; + +Cash.prototype.addClass = function (cls) { + return this.toggleClass(cls, true); +}; + +Cash.prototype.removeClass = function (cls) { + return !arguments.length ? this.attr('class', '') : this.toggleClass(cls, false); +}; // @optional ./add_class.ts +// @optional ./attr.ts +// @optional ./has_class.ts +// @optional ./prop.ts +// @optional ./remove_attr.ts +// @optional ./remove_class.ts +// @optional ./remove_prop.ts +// @optional ./toggle_class.ts +// @require ./cash.ts +// @require ./variables + + +function unique(arr) { + return arr.length > 1 ? filter.call(arr, function (item, index, self) { + return indexOf.call(self, item) === index; + }) : arr; +} + +cash.unique = unique; + +Cash.prototype.add = function (selector, context) { + return cash(unique(this.get().concat(cash(selector, context).get()))); +}; // @require core/variables.ts + + +function computeStyle(ele, prop, isVariable) { + if (ele.nodeType !== 1 || !prop) return; + var style = win.getComputedStyle(ele, null); + return prop ? isVariable ? style.getPropertyValue(prop) || undefined : style[prop] : style; +} // @require ./compute_style.ts + + +function computeStyleInt(ele, prop) { + return parseInt(computeStyle(ele, prop), 10) || 0; +} + +var cssVariableRe = /^--/; // @require ./variables.ts + +function isCSSVariable(prop) { + return cssVariableRe.test(prop); +} // @require core/camel_case.ts +// @require core/cash.ts +// @require core/each.ts +// @require core/variables.ts +// @require ./is_css_variable.ts + + +var prefixedProps = {}, + style = div.style, + vendorsPrefixes = ['webkit', 'moz', 'ms', 'o']; + +function getPrefixedProp(prop, isVariable) { + if (isVariable === void 0) { + isVariable = isCSSVariable(prop); + } + + if (isVariable) return prop; + + if (!prefixedProps[prop]) { + var propCC = camelCase(prop), + propUC = "" + propCC.charAt(0).toUpperCase() + propCC.slice(1), + props = (propCC + " " + vendorsPrefixes.join(propUC + " ") + propUC).split(' '); + each(props, function (i, p) { + if (p in style) { + prefixedProps[prop] = p; + return false; + } + }); + } + + return prefixedProps[prop]; +} + +; +cash.prefixedProp = getPrefixedProp; // @require core/type_checking.ts +// @require ./is_css_variable.ts + +var numericProps = { + animationIterationCount: true, + columnCount: true, + flexGrow: true, + flexShrink: true, + fontWeight: true, + lineHeight: true, + opacity: true, + order: true, + orphans: true, + widows: true, + zIndex: true +}; + +function getSuffixedValue(prop, value, isVariable) { + if (isVariable === void 0) { + isVariable = isCSSVariable(prop); + } + + return !isVariable && !numericProps[prop] && isNumeric(value) ? value + "px" : value; +} + +function css(prop, value) { + if (isString(prop)) { + var isVariable_1 = isCSSVariable(prop); + prop = getPrefixedProp(prop, isVariable_1); + if (arguments.length < 2) return this[0] && computeStyle(this[0], prop, isVariable_1); + if (!prop) return this; + value = getSuffixedValue(prop, value, isVariable_1); + return this.each(function (i, ele) { + if (ele.nodeType !== 1) return; + + if (isVariable_1) { + ele.style.setProperty(prop, value); + } else { + ele.style[prop] = value; //TSC + } + }); + } + + for (var key in prop) { + this.css(key, prop[key]); + } + + return this; +} + +; +Cash.prototype.css = css; // @optional ./css.ts + +var dataNamespace = '__cashData', + dataAttributeRe = /^data-(.*)/; // @require core/cash.ts +// @require ./helpers/variables.ts + +function hasData(ele) { + return dataNamespace in ele; +} + +cash.hasData = hasData; // @require ./variables.ts + +function getDataCache(ele) { + return ele[dataNamespace] = ele[dataNamespace] || {}; +} // @require attributes/attr.ts +// @require ./get_data_cache.ts + + +function getData(ele, key) { + var cache = getDataCache(ele); + + if (key) { + if (!(key in cache)) { + var value = ele.dataset ? ele.dataset[key] || ele.dataset[camelCase(key)] : cash(ele).attr("data-" + key); + + if (value !== undefined) { + try { + value = JSON.parse(value); + } catch (e) {} + + cache[key] = value; + } + } + + return cache[key]; + } + + return cache; +} // @require ./variables.ts +// @require ./get_data_cache.ts + + +function removeData(ele, key) { + if (key === undefined) { + delete ele[dataNamespace]; + } else { + delete getDataCache(ele)[key]; + } +} // @require ./get_data_cache.ts + + +function setData(ele, key, value) { + getDataCache(ele)[key] = value; +} + +function data(name, value) { + var _this = this; + + if (!name) { + if (!this[0]) return; + each(this[0].attributes, function (i, attr) { + var match = attr.name.match(dataAttributeRe); + if (!match) return; + + _this.data(match[1]); + }); + return getData(this[0]); + } + + if (isString(name)) { + if (value === undefined) return this[0] && getData(this[0], name); + return this.each(function (i, ele) { + return setData(ele, name, value); + }); + } + + for (var key in name) { + this.data(key, name[key]); + } + + return this; +} + +Cash.prototype.data = data; + +Cash.prototype.removeData = function (key) { + return this.each(function (i, ele) { + return removeData(ele, key); + }); +}; // @optional ./data.ts +// @optional ./remove_data.ts +// @require css/helpers/compute_style_int.ts + + +function getExtraSpace(ele, xAxis) { + return computeStyleInt(ele, "border" + (xAxis ? 'Left' : 'Top') + "Width") + computeStyleInt(ele, "padding" + (xAxis ? 'Left' : 'Top')) + computeStyleInt(ele, "padding" + (xAxis ? 'Right' : 'Bottom')) + computeStyleInt(ele, "border" + (xAxis ? 'Right' : 'Bottom') + "Width"); +} + +each(['Width', 'Height'], function (i, prop) { + Cash.prototype["inner" + prop] = function () { + if (!this[0]) return; + if (this[0] === win) return win["inner" + prop]; + return this[0]["client" + prop]; + }; +}); +each(['width', 'height'], function (index, prop) { + Cash.prototype[prop] = function (value) { + if (!this[0]) return value === undefined ? undefined : this; + + if (!arguments.length) { + if (this[0] === win) return this[0][camelCase("outer-" + prop)]; + return this[0].getBoundingClientRect()[prop] - getExtraSpace(this[0], !index); + } + + var valueNumber = parseInt(value, 10); + return this.each(function (i, ele) { + if (ele.nodeType !== 1) return; + var boxSizing = computeStyle(ele, 'boxSizing'); + ele.style[prop] = getSuffixedValue(prop, valueNumber + (boxSizing === 'border-box' ? getExtraSpace(ele, !index) : 0)); + }); + }; +}); +each(['Width', 'Height'], function (index, prop) { + Cash.prototype["outer" + prop] = function (includeMargins) { + if (!this[0]) return; + if (this[0] === win) return win["outer" + prop]; + return this[0]["offset" + prop] + (includeMargins ? computeStyleInt(this[0], "margin" + (!index ? 'Left' : 'Top')) + computeStyleInt(this[0], "margin" + (!index ? 'Right' : 'Bottom')) : 0); + }; +}); // @optional ./inner.ts +// @optional ./normal.ts +// @optional ./outer.ts +// @require css/helpers/compute_style.ts + +var defaultDisplay = {}; + +function getDefaultDisplay(tagName) { + if (defaultDisplay[tagName]) return defaultDisplay[tagName]; + var ele = doc.createElement(tagName); + doc.body.appendChild(ele); + var display = computeStyle(ele, 'display'); + doc.body.removeChild(ele); + return defaultDisplay[tagName] = display !== 'none' ? display : 'block'; +} // @require css/helpers/compute_style.ts + + +function isHidden(ele) { + return computeStyle(ele, 'display') === 'none'; +} + +Cash.prototype.toggle = function (force) { + return this.each(function (i, ele) { + force = force !== undefined ? force : isHidden(ele); + + if (force) { + ele.style.display = ''; + + if (isHidden(ele)) { + ele.style.display = getDefaultDisplay(ele.tagName); + } + } else { + ele.style.display = 'none'; + } + }); +}; + +Cash.prototype.hide = function () { + return this.toggle(false); +}; + +Cash.prototype.show = function () { + return this.toggle(true); +}; // @optional ./hide.ts +// @optional ./show.ts +// @optional ./toggle.ts + + +function hasNamespaces(ns1, ns2) { + return !ns2 || !some.call(ns2, function (ns) { + return ns1.indexOf(ns) < 0; + }); +} + +var eventsNamespace = '__cashEvents', + eventsNamespacesSeparator = '.', + eventsFocus = { + focus: 'focusin', + blur: 'focusout' +}, + eventsHover = { + mouseenter: 'mouseover', + mouseleave: 'mouseout' +}, + eventsMouseRe = /^(?:mouse|pointer|contextmenu|drag|drop|click|dblclick)/i; // @require ./variables.ts + +function getEventNameBubbling(name) { + return eventsHover[name] || eventsFocus[name] || name; +} // @require ./variables.ts + + +function getEventsCache(ele) { + return ele[eventsNamespace] = ele[eventsNamespace] || {}; +} // @require core/guid.ts +// @require events/helpers/get_events_cache.ts + + +function addEvent(ele, name, namespaces, callback) { + callback['guid'] = callback['guid'] || guid++; + var eventCache = getEventsCache(ele); + eventCache[name] = eventCache[name] || []; + eventCache[name].push([namespaces, callback]); + ele.addEventListener(name, callback); //TSC +} // @require ./variables.ts + + +function parseEventName(eventName) { + var parts = eventName.split(eventsNamespacesSeparator); + return [parts[0], parts.slice(1).sort()]; // [name, namespace[]] +} // @require ./get_events_cache.ts +// @require ./has_namespaces.ts +// @require ./parse_event_name.ts + + +function removeEvent(ele, name, namespaces, callback) { + var cache = getEventsCache(ele); + + if (!name) { + for (name in cache) { + removeEvent(ele, name, namespaces, callback); + } + + delete ele[eventsNamespace]; + } else if (cache[name]) { + cache[name] = cache[name].filter(function (_a) { + var ns = _a[0], + cb = _a[1]; + if (callback && cb['guid'] !== callback['guid'] || !hasNamespaces(ns, namespaces)) return true; + ele.removeEventListener(name, cb); + }); + } +} + +Cash.prototype.off = function (eventFullName, callback) { + var _this = this; + + if (eventFullName === undefined) { + this.each(function (i, ele) { + return removeEvent(ele); + }); + } else { + each(getSplitValues(eventFullName), function (i, eventFullName) { + var _a = parseEventName(getEventNameBubbling(eventFullName)), + name = _a[0], + namespaces = _a[1]; + + _this.each(function (i, ele) { + return removeEvent(ele, name, namespaces, callback); + }); + }); + } + + return this; +}; + +function on(eventFullName, selector, callback, _one) { + var _this = this; + + if (!isString(eventFullName)) { + for (var key in eventFullName) { + this.on(key, selector, eventFullName[key]); + } + + return this; + } + + if (isFunction(selector)) { + callback = selector; + selector = ''; + } + + each(getSplitValues(eventFullName), function (i, eventFullName) { + var _a = parseEventName(getEventNameBubbling(eventFullName)), + name = _a[0], + namespaces = _a[1]; + + _this.each(function (i, ele) { + var finalCallback = function finalCallback(event) { + if (event.namespace && !hasNamespaces(namespaces, event.namespace.split(eventsNamespacesSeparator))) return; + var thisArg = ele; + + if (selector) { + var target = event.target; + + while (!matches(target, selector)) { + //TSC + if (target === ele) return; + target = target.parentNode; + if (!target) return; + } + + thisArg = target; + event.__delegate = true; + } + + if (event.__delegate) { + Object.defineProperty(event, 'currentTarget', { + configurable: true, + get: function get() { + return thisArg; + } + }); + } + + var returnValue = callback.call(thisArg, event, event.data); //TSC + + if (_one) { + removeEvent(ele, name, namespaces, finalCallback); + } + + if (returnValue === false) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + finalCallback['guid'] = callback['guid'] = callback['guid'] || guid++; + addEvent(ele, name, namespaces, finalCallback); + }); + }); + return this; +} + +Cash.prototype.on = on; + +function one(eventFullName, selector, callback) { + return this.on(eventFullName, selector, callback, true); //TSC +} + +; +Cash.prototype.one = one; + +Cash.prototype.ready = function (callback) { + var finalCallback = function finalCallback() { + return callback(cash); + }; + + if (doc.readyState !== 'loading') { + setTimeout(finalCallback); + } else { + doc.addEventListener('DOMContentLoaded', finalCallback); + } + + return this; +}; + +Cash.prototype.trigger = function (eventFullName, data) { + var evt = eventFullName; + + if (isString(eventFullName)) { + var _a = parseEventName(eventFullName), + name_1 = _a[0], + namespaces = _a[1], + type = eventsMouseRe.test(name_1) ? 'MouseEvents' : 'HTMLEvents'; + + evt = doc.createEvent(type); + evt.initEvent(name_1, true, true); + evt['namespace'] = namespaces.join(eventsNamespacesSeparator); + } + + evt['data'] = data; + var isEventFocus = evt['type'] in eventsFocus; + return this.each(function (i, ele) { + if (isEventFocus && isFunction(ele[evt['type']])) { + ele[evt['type']](); + } else { + ele.dispatchEvent(evt); + } + }); +}; // @optional ./off.ts +// @optional ./on.ts +// @optional ./one.ts +// @optional ./ready.ts +// @optional ./trigger.ts +// @require core/pluck.ts +// @require core/variables.ts + + +function getValue(ele) { + if (ele.multiple) return pluck(filter.call(ele.options, function (option) { + return option.selected && !option.disabled && !option.parentNode.disabled; + }), 'value'); + return ele.value || ''; +} + +var queryEncodeSpaceRe = /%20/g; + +function queryEncode(prop, value) { + return "&" + encodeURIComponent(prop) + "=" + encodeURIComponent(value).replace(queryEncodeSpaceRe, '+'); +} // @require core/cash.ts +// @require core/each.ts +// @require core/type_checking.ts +// @require ./helpers/get_value.ts +// @require ./helpers/query_encode.ts + + +var skippableRe = /file|reset|submit|button|image/i, + checkableRe = /radio|checkbox/i; + +Cash.prototype.serialize = function () { + var query = ''; + this.each(function (i, ele) { + each(ele.elements || [ele], function (i, ele) { + if (ele.disabled || !ele.name || ele.tagName === 'FIELDSET' || skippableRe.test(ele.type) || checkableRe.test(ele.type) && !ele.checked) return; + var value = getValue(ele); + if (value === undefined) return; + var values = isArray(value) ? value : [value]; + each(values, function (i, value) { + query += queryEncode(ele.name, value); + }); + }); + }); + return query.substr(1); +}; + +function val(value) { + if (value === undefined) return this[0] && getValue(this[0]); + return this.each(function (i, ele) { + if (ele.tagName === 'SELECT') { + var eleValue_1 = isArray(value) ? value : value === null ? [] : [value]; + each(ele.options, function (i, option) { + option.selected = eleValue_1.indexOf(option.value) >= 0; + }); + } else { + ele.value = value === null ? '' : value; + } + }); +} + +Cash.prototype.val = val; + +Cash.prototype.clone = function () { + return this.map(function (i, ele) { + return ele.cloneNode(true); + }); +}; + +Cash.prototype.detach = function () { + return this.each(function (i, ele) { + if (ele.parentNode) { + ele.parentNode.removeChild(ele); + } + }); +}; // @require ./cash.ts +// @require ./variables.ts +// @require ./type_checking.ts +// @require collection/get.ts +// @require manipulation/detach.ts + + +var fragmentRe = /^\s*<(\w+)[^>]*>/, + singleTagRe = /^\s*<(\w+)\s*\/?>(?:<\/\1>)?\s*$/; +var containers; + +function initContainers() { + if (containers) return; + var table = doc.createElement('table'), + tr = doc.createElement('tr'); + containers = { + '*': div, + tr: doc.createElement('tbody'), + td: tr, + th: tr, + thead: table, + tbody: table, + tfoot: table + }; +} + +function parseHTML(html) { + initContainers(); + if (!isString(html)) return []; + if (singleTagRe.test(html)) return [doc.createElement(RegExp.$1)]; + var fragment = fragmentRe.test(html) && RegExp.$1, + container = containers[fragment] || containers['*']; + container.innerHTML = html; + return cash(container.childNodes).detach().get(); +} + +cash.parseHTML = parseHTML; + +Cash.prototype.empty = function () { + var ele = this[0]; + + if (ele) { + while (ele.firstChild) { + ele.removeChild(ele.firstChild); + } + } + + return this; +}; + +function html(html) { + if (html === undefined) return this[0] && this[0].innerHTML; + return this.each(function (i, ele) { + ele.innerHTML = html; + }); +} + +Cash.prototype.html = html; + +Cash.prototype.remove = function () { + return this.detach().off(); +}; + +function text(text) { + if (text === undefined) return this[0] ? this[0].textContent : ''; + return this.each(function (i, ele) { + ele.textContent = text; + }); +} + +; +Cash.prototype.text = text; + +Cash.prototype.unwrap = function () { + this.parent().each(function (i, ele) { + var $ele = cash(ele); + $ele.replaceWith($ele.children()); + }); + return this; +}; // @require core/cash.ts +// @require core/variables.ts + + +var docEle = doc.documentElement; + +Cash.prototype.offset = function () { + var ele = this[0]; + if (!ele) return; + var rect = ele.getBoundingClientRect(); + return { + top: rect.top + win.pageYOffset - docEle.clientTop, + left: rect.left + win.pageXOffset - docEle.clientLeft + }; +}; + +Cash.prototype.offsetParent = function () { + return cash(this[0] && this[0].offsetParent); +}; + +Cash.prototype.position = function () { + var ele = this[0]; + if (!ele) return; + return { + left: ele.offsetLeft, + top: ele.offsetTop + }; +}; + +Cash.prototype.children = function (comparator) { + var result = []; + this.each(function (i, ele) { + push.apply(result, ele.children); + }); + return filtered(cash(unique(result)), comparator); +}; + +Cash.prototype.contents = function () { + var result = []; + this.each(function (i, ele) { + push.apply(result, ele.tagName === 'IFRAME' ? [ele.contentDocument] : ele.childNodes); + }); + return cash(unique(result)); +}; + +Cash.prototype.find = function (selector) { + var result = []; + + for (var i = 0, l = this.length; i < l; i++) { + var found = find(selector, this[i]); + + if (found.length) { + push.apply(result, found); + } + } + + return cash(unique(result)); +}; // @require collection/filter.ts +// @require collection/filter.ts +// @require traversal/find.ts + + +var scriptTypeRe = /^$|^module$|\/(?:java|ecma)script/i, + HTMLCDATARe = /^\s*\s*$/g; + +function evalScripts(node) { + var collection = cash(node); + collection.filter('script').add(collection.find('script')).each(function (i, ele) { + if (!ele.src && scriptTypeRe.test(ele.type)) { + // The script type is supported + if (ele.ownerDocument.documentElement.contains(ele)) { + // The element is attached to the DOM // Using `documentElement` for broader browser support + eval(ele.textContent.replace(HTMLCDATARe, '')); + } + } + }); +} // @require ./eval_scripts.ts + + +function insertElement(anchor, child, prepend, prependTarget) { + if (prepend) { + anchor.insertBefore(child, prependTarget); + } else { + anchor.appendChild(child); + } + + evalScripts(child); +} // @require core/each.ts +// @require core/type_checking.ts +// @require ./insert_element.ts + + +function insertContent(parent, child, prepend) { + each(parent, function (index, parentEle) { + each(child, function (i, childEle) { + insertElement(parentEle, !index ? childEle : childEle.cloneNode(true), prepend, prepend && parentEle.firstChild); + }); + }); +} + +Cash.prototype.append = function () { + var _this = this; + + each(arguments, function (i, selector) { + insertContent(_this, cash(selector)); + }); + return this; +}; + +Cash.prototype.appendTo = function (selector) { + insertContent(cash(selector), this); + return this; +}; + +Cash.prototype.insertAfter = function (selector) { + var _this = this; + + cash(selector).each(function (index, ele) { + var parent = ele.parentNode; + + if (parent) { + _this.each(function (i, e) { + insertElement(parent, !index ? e : e.cloneNode(true), true, ele.nextSibling); + }); + } + }); + return this; +}; + +Cash.prototype.after = function () { + var _this = this; + + each(reverse.apply(arguments), function (i, selector) { + reverse.apply(cash(selector).slice()).insertAfter(_this); + }); + return this; +}; + +Cash.prototype.insertBefore = function (selector) { + var _this = this; + + cash(selector).each(function (index, ele) { + var parent = ele.parentNode; + + if (parent) { + _this.each(function (i, e) { + insertElement(parent, !index ? e : e.cloneNode(true), true, ele); + }); + } + }); + return this; +}; + +Cash.prototype.before = function () { + var _this = this; + + each(arguments, function (i, selector) { + cash(selector).insertBefore(_this); + }); + return this; +}; + +Cash.prototype.prepend = function () { + var _this = this; + + each(arguments, function (i, selector) { + insertContent(_this, cash(selector), true); + }); + return this; +}; + +Cash.prototype.prependTo = function (selector) { + insertContent(cash(selector), reverse.apply(this.slice()), true); + return this; +}; + +Cash.prototype.replaceWith = function (selector) { + return this.before(selector).remove(); +}; + +Cash.prototype.replaceAll = function (selector) { + cash(selector).replaceWith(this); + return this; +}; + +Cash.prototype.wrapAll = function (selector) { + if (this[0]) { + var structure = cash(selector); + this.first().before(structure); + var wrapper = structure[0]; + + while (wrapper.children.length) { + wrapper = wrapper.firstElementChild; + } + + this.appendTo(wrapper); + } + + return this; +}; + +Cash.prototype.wrap = function (selector) { + return this.each(function (index, ele) { + var wrapper = cash(selector)[0]; + cash(ele).wrapAll(!index ? wrapper : wrapper.cloneNode(true)); + }); +}; + +Cash.prototype.wrapInner = function (selector) { + return this.each(function (i, ele) { + var $ele = cash(ele), + contents = $ele.contents(); + contents.length ? contents.wrapAll(selector) : $ele.append(selector); + }); +}; + +Cash.prototype.has = function (selector) { + var comparator = isString(selector) ? function (i, ele) { + return !!find(selector, ele).length; + } : function (i, ele) { + return ele.contains(selector); + }; + return this.filter(comparator); +}; + +Cash.prototype.is = function (comparator) { + if (!comparator || !this[0]) return false; + var compare = getCompareFunction(comparator); + var check = false; + this.each(function (i, ele) { + check = compare.call(ele, i, ele); + return !check; + }); + return check; +}; + +Cash.prototype.next = function (comparator, _all) { + return filtered(cash(unique(pluck(this, 'nextElementSibling', _all))), comparator); +}; + +Cash.prototype.nextAll = function (comparator) { + return this.next(comparator, true); +}; + +Cash.prototype.not = function (comparator) { + if (!comparator || !this[0]) return this; + var compare = getCompareFunction(comparator); + return this.filter(function (i, ele) { + return !compare.call(ele, i, ele); + }); +}; + +Cash.prototype.parent = function (comparator) { + return filtered(cash(unique(pluck(this, 'parentNode'))), comparator); +}; + +Cash.prototype.index = function (selector) { + var child = selector ? cash(selector)[0] : this[0], + collection = selector ? this : cash(child).parent().children(); + return indexOf.call(collection, child); +}; + +Cash.prototype.closest = function (comparator) { + if (!comparator || !this[0]) return cash(); + var filtered = this.filter(comparator); + if (filtered.length) return filtered; + return this.parent().closest(comparator); +}; + +Cash.prototype.parents = function (comparator) { + return filtered(cash(unique(pluck(this, 'parentElement', true))), comparator); +}; + +Cash.prototype.prev = function (comparator, _all) { + return filtered(cash(unique(pluck(this, 'previousElementSibling', _all))), comparator); +}; + +Cash.prototype.prevAll = function (comparator) { + return this.prev(comparator, true); +}; + +Cash.prototype.siblings = function (comparator) { + var ele = this[0]; + return filtered(this.parent().children().filter(function (i, child) { + return child !== ele; + }), comparator); +}; // @optional ./children.ts +// @optional ./closest.ts +// @optional ./contents.ts +// @optional ./find.ts +// @optional ./has.ts +// @optional ./is.ts +// @optional ./next.ts +// @optional ./not.ts +// @optional ./parent.ts +// @optional ./parents.ts +// @optional ./prev.ts +// @optional ./siblings.ts +// @optional attributes/index.ts +// @optional collection/index.ts +// @optional css/index.ts +// @optional data/index.ts +// @optional dimensions/index.ts +// @optional effects/index.ts +// @optional events/index.ts +// @optional forms/index.ts +// @optional manipulation/index.ts +// @optional offset/index.ts +// @optional traversal/index.ts +// @require core/index.ts +// @priority -100 +// @require ./cash.ts +// @require ./variables.ts + + +if (typeof exports !== 'undefined') { + // Node.js + module.exports = cash; +} else { + // Browser + win['cash'] = win['$'] = cash; +} +})(); \ No newline at end of file diff --git a/dist/weewx-4.10.1/docs/js/cash.min.js b/dist/weewx-4.10.1/docs/js/cash.min.js new file mode 100644 index 0000000..568455d --- /dev/null +++ b/dist/weewx-4.10.1/docs/js/cash.min.js @@ -0,0 +1,37 @@ +/* MIT https://github.com/kenwheeler/cash */ +(function(){ +'use strict';var e=document,g=window,k=e.createElement("div"),l=Array.prototype,m=l.filter,n=l.indexOf,aa=l.map,q=l.push,r=l.reverse,u=l.slice,v=l.some,ba=l.splice,ca=/^#[\w-]*$/,da=/^\.[\w-]*$/,ea=/<.+>/,fa=/^\w+$/;function w(a,b){void 0===b&&(b=e);return b!==e&&1!==b.nodeType&&9!==b.nodeType?[]:da.test(a)?b.getElementsByClassName(a.slice(1)):fa.test(a)?b.getElementsByTagName(a):b.querySelectorAll(a)} +var x=function(){function a(a,c){void 0===c&&(c=e);if(a){if(a instanceof x)return a;var b=a;if(y(a)){if(b=c instanceof x?c[0]:c,b=ca.test(a)?b.getElementById(a.slice(1)):ea.test(a)?z(a):w(a,b),!b)return}else if(A(a))return this.ready(a);if(b.nodeType||b===g)b=[b];this.length=b.length;a=0;for(c=this.length;aa?a+this.length:a]};x.prototype.eq=function(a){return B(this.get(a))};x.prototype.first=function(){return this.eq(0)};x.prototype.last=function(){return this.eq(-1)};x.prototype.map=function(a){return B(aa.call(this,function(b,c){return a.call(b,c,b)}))};x.prototype.slice=function(){return B(u.apply(this,arguments))};var ha=/-([a-z])/g; +function ia(a,b){return b.toUpperCase()}function C(a){return a.replace(ha,ia)}B.camelCase=C;function D(a,b){for(var c=0,d=a.length;cc?0:1;darguments.length?this[0]&&this[0][a]:this.each(function(c,f){f[a]=b});for(var c in a)this.prop(c,a[c]);return this}};function J(a){return y(a)?function(b,c){return G(c,a)}:A(a)?a:a instanceof x?function(b,c){return a.is(c)}:function(b,c){return c===a}}x.prototype.filter=function(a){if(!a)return B();var b=J(a);return B(m.call(this,function(a,d){return b.call(a,d,a)}))}; +function K(a,b){return b&&a.length?a.filter(b):a}var ka=/\S+/g;function L(a){return y(a)?a.match(ka)||[]:[]}x.prototype.hasClass=function(a){return a&&v.call(this,function(b){return b.classList.contains(a)})};x.prototype.removeAttr=function(a){var b=L(a);return b.length?this.each(function(a,d){D(b,function(a,b){d.removeAttribute(b)})}):this}; +x.prototype.attr=function(a,b){if(a){if(y(a)){if(2>arguments.length){if(!this[0])return;var c=this[0].getAttribute(a);return null===c?void 0:c}return null===b?this.removeAttr(a):this.each(function(c,f){f.setAttribute(a,b)})}for(c in a)this.attr(c,a[c]);return this}};x.prototype.toggleClass=function(a,b){var c=L(a),d=void 0!==b;return c.length?this.each(function(a,h){D(c,function(a,c){d?b?h.classList.add(c):h.classList.remove(c):h.classList.toggle(c)})}):this}; +x.prototype.addClass=function(a){return this.toggleClass(a,!0)};x.prototype.removeClass=function(a){return arguments.length?this.toggleClass(a,!1):this.attr("class","")};function N(a){return 1arguments.length)return this[0]&&O(this[0],a,c);if(!a)return this;b=pa(a,b,c);return this.each(function(d,h){1===h.nodeType&&(c?h.style.setProperty(a,b):h.style[a]=b)})}for(var d in a)this.css(d,a[d]);return this};var qa=/^data-(.*)/;B.hasData=function(a){return"__cashData"in a};function S(a){return a.__cashData=a.__cashData||{}} +function ra(a,b){var c=S(a);if(b){if(!(b in c)&&(a=a.dataset?a.dataset[b]||a.dataset[C(b)]:B(a).attr("data-"+b),void 0!==a)){try{a=JSON.parse(a)}catch(d){}c[b]=a}return c[b]}return c}x.prototype.data=function(a,b){var c=this;if(!a){if(!this[0])return;D(this[0].attributes,function(a,b){(a=b.name.match(qa))&&c.data(a[1])});return ra(this[0])}if(y(a))return void 0===b?this[0]&&ra(this[0],a):this.each(function(c,d){S(d)[a]=b});for(var d in a)this.data(d,a[d]);return this}; +x.prototype.removeData=function(a){return this.each(function(b,c){void 0===a?delete c.__cashData:delete S(c)[a]})};function sa(a,b){return P(a,"border"+(b?"Left":"Top")+"Width")+P(a,"padding"+(b?"Left":"Top"))+P(a,"padding"+(b?"Right":"Bottom"))+P(a,"border"+(b?"Right":"Bottom")+"Width")}D(["Width","Height"],function(a,b){x.prototype["inner"+b]=function(){if(this[0])return this[0]===g?g["inner"+b]:this[0]["client"+b]}}); +D(["width","height"],function(a,b){x.prototype[b]=function(c){if(!this[0])return void 0===c?void 0:this;if(!arguments.length)return this[0]===g?this[0][C("outer-"+b)]:this[0].getBoundingClientRect()[b]-sa(this[0],!a);var d=parseInt(c,10);return this.each(function(c,h){1===h.nodeType&&(c=O(h,"boxSizing"),h.style[b]=pa(b,d+("border-box"===c?sa(h,!a):0)))})}}); +D(["Width","Height"],function(a,b){x.prototype["outer"+b]=function(c){if(this[0])return this[0]===g?g["outer"+b]:this[0]["offset"+b]+(c?P(this[0],"margin"+(a?"Top":"Left"))+P(this[0],"margin"+(a?"Bottom":"Right")):0)}});var T={}; +x.prototype.toggle=function(a){return this.each(function(b,c){if(a=void 0!==a?a:"none"===O(c,"display")){if(c.style.display="","none"===O(c,"display")){b=c.style;c=c.tagName;if(T[c])c=T[c];else{var d=e.createElement(c);e.body.appendChild(d);var f=O(d,"display");e.body.removeChild(d);c=T[c]="none"!==f?f:"block"}b.display=c}}else c.style.display="none"})};x.prototype.hide=function(){return this.toggle(!1)};x.prototype.show=function(){return this.toggle(!0)}; +function ta(a,b){return!b||!v.call(b,function(b){return 0>a.indexOf(b)})}var U={focus:"focusin",blur:"focusout"},ua={mouseenter:"mouseover",mouseleave:"mouseout"},va=/^(?:mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function wa(a,b,c,d){d.guid=d.guid||F++;var f=a.__cashEvents=a.__cashEvents||{};f[b]=f[b]||[];f[b].push([c,d]);a.addEventListener(b,d)}function V(a){a=a.split(".");return[a[0],a.slice(1).sort()]} +function W(a,b,c,d){var f=a.__cashEvents=a.__cashEvents||{};if(b)f[b]&&(f[b]=f[b].filter(function(f){var h=f[0];f=f[1];if(d&&f.guid!==d.guid||!ta(h,c))return!0;a.removeEventListener(b,f)}));else{for(b in f)W(a,b,c,d);delete a.__cashEvents}}x.prototype.off=function(a,b){var c=this;void 0===a?this.each(function(a,b){return W(b)}):D(L(a),function(a,f){a=V(ua[f]||U[f]||f);var d=a[0],p=a[1];c.each(function(a,c){return W(c,d,p,b)})});return this}; +x.prototype.on=function(a,b,c,d){var f=this;if(!y(a)){for(var h in a)this.on(h,b,a[h]);return this}A(b)&&(c=b,b="");D(L(a),function(a,h){a=V(ua[h]||U[h]||h);var p=a[0],M=a[1];f.each(function(a,f){a=function za(a){if(!a.namespace||ta(M,a.namespace.split("."))){var h=f;if(b){for(var t=a.target;!G(t,b);){if(t===f)return;t=t.parentNode;if(!t)return}h=t;a.__delegate=!0}a.__delegate&&Object.defineProperty(a,"currentTarget",{configurable:!0,get:function(){return h}});t=c.call(h,a,a.data);d&&W(f,p,M,za); +!1===t&&(a.preventDefault(),a.stopPropagation())}};a.guid=c.guid=c.guid||F++;wa(f,p,M,a)})});return this};x.prototype.one=function(a,b,c){return this.on(a,b,c,!0)};x.prototype.ready=function(a){function b(){return a(B)}"loading"!==e.readyState?setTimeout(b):e.addEventListener("DOMContentLoaded",b);return this}; +x.prototype.trigger=function(a,b){var c=a;if(y(a)){var d=V(a);a=d[0];d=d[1];var f=va.test(a)?"MouseEvents":"HTMLEvents";c=e.createEvent(f);c.initEvent(a,!0,!0);c.namespace=d.join(".")}c.data=b;var h=c.type in U;return this.each(function(a,b){if(h&&A(b[c.type]))b[c.type]();else b.dispatchEvent(c)})};function xa(a){return a.multiple?H(m.call(a.options,function(a){return a.selected&&!a.disabled&&!a.parentNode.disabled}),"value"):a.value||""}var ya=/%20/g,Aa=/file|reset|submit|button|image/i,Ba=/radio|checkbox/i; +x.prototype.serialize=function(){var a="";this.each(function(b,c){D(c.elements||[c],function(b,c){c.disabled||!c.name||"FIELDSET"===c.tagName||Aa.test(c.type)||Ba.test(c.type)&&!c.checked||(b=xa(c),void 0!==b&&(b=I(b)?b:[b],D(b,function(b,d){b=a;d="&"+encodeURIComponent(c.name)+"="+encodeURIComponent(d).replace(ya,"+");a=b+d})))})});return a.substr(1)}; +x.prototype.val=function(a){return void 0===a?this[0]&&xa(this[0]):this.each(function(b,c){if("SELECT"===c.tagName){var d=I(a)?a:null===a?[]:[a];D(c.options,function(a,b){b.selected=0<=d.indexOf(b.value)})}else c.value=null===a?"":a})};x.prototype.clone=function(){return this.map(function(a,b){return b.cloneNode(!0)})};x.prototype.detach=function(){return this.each(function(a,b){b.parentNode&&b.parentNode.removeChild(b)})};var Ca=/^\s*<(\w+)[^>]*>/,Da=/^\s*<(\w+)\s*\/?>(?:<\/\1>)?\s*$/,X; +function z(a){if(!X){var b=e.createElement("table"),c=e.createElement("tr");X={"*":k,tr:e.createElement("tbody"),td:c,th:c,thead:b,tbody:b,tfoot:b}}if(!y(a))return[];if(Da.test(a))return[e.createElement(RegExp.$1)];b=Ca.test(a)&&RegExp.$1;b=X[b]||X["*"];b.innerHTML=a;return B(b.childNodes).detach().get()}B.parseHTML=z;x.prototype.empty=function(){var a=this[0];if(a)for(;a.firstChild;)a.removeChild(a.firstChild);return this}; +x.prototype.html=function(a){return void 0===a?this[0]&&this[0].innerHTML:this.each(function(b,c){c.innerHTML=a})};x.prototype.remove=function(){return this.detach().off()};x.prototype.text=function(a){return void 0===a?this[0]?this[0].textContent:"":this.each(function(b,c){c.textContent=a})};x.prototype.unwrap=function(){this.parent().each(function(a,b){a=B(b);a.replaceWith(a.children())});return this};var Ea=e.documentElement; +x.prototype.offset=function(){var a=this[0];if(a)return a=a.getBoundingClientRect(),{top:a.top+g.pageYOffset-Ea.clientTop,left:a.left+g.pageXOffset-Ea.clientLeft}};x.prototype.offsetParent=function(){return B(this[0]&&this[0].offsetParent)};x.prototype.position=function(){var a=this[0];if(a)return{left:a.offsetLeft,top:a.offsetTop}};x.prototype.children=function(a){var b=[];this.each(function(a,d){q.apply(b,d.children)});return K(B(N(b)),a)}; +x.prototype.contents=function(){var a=[];this.each(function(b,c){q.apply(a,"IFRAME"===c.tagName?[c.contentDocument]:c.childNodes)});return B(N(a))};x.prototype.find=function(a){for(var b=[],c=0,d=this.length;c\s*$/g; +function Y(a){a=B(a);a.filter("script").add(a.find("script")).each(function(a,c){!c.src&&Fa.test(c.type)&&c.ownerDocument.documentElement.contains(c)&&eval(c.textContent.replace(Ga,""))})}function Z(a,b,c){D(a,function(a,f){D(b,function(b,d){b=a?d.cloneNode(!0):d;c?f.insertBefore(b,c&&f.firstChild):f.appendChild(b);Y(b)})})}x.prototype.append=function(){var a=this;D(arguments,function(b,c){Z(a,B(c))});return this};x.prototype.appendTo=function(a){Z(B(a),this);return this}; +x.prototype.insertAfter=function(a){var b=this;B(a).each(function(a,d){var c=d.parentNode;c&&b.each(function(b,f){b=a?f.cloneNode(!0):f;c.insertBefore(b,d.nextSibling);Y(b)})});return this};x.prototype.after=function(){var a=this;D(r.apply(arguments),function(b,c){r.apply(B(c).slice()).insertAfter(a)});return this};x.prototype.insertBefore=function(a){var b=this;B(a).each(function(a,d){var c=d.parentNode;c&&b.each(function(b,f){b=a?f.cloneNode(!0):f;c.insertBefore(b,d);Y(b)})});return this}; +x.prototype.before=function(){var a=this;D(arguments,function(b,c){B(c).insertBefore(a)});return this};x.prototype.prepend=function(){var a=this;D(arguments,function(b,c){Z(a,B(c),!0)});return this};x.prototype.prependTo=function(a){Z(B(a),r.apply(this.slice()),!0);return this};x.prototype.replaceWith=function(a){return this.before(a).remove()};x.prototype.replaceAll=function(a){B(a).replaceWith(this);return this}; +x.prototype.wrapAll=function(a){if(this[0]){a=B(a);this.first().before(a);for(a=a[0];a.children.length;)a=a.firstElementChild;this.appendTo(a)}return this};x.prototype.wrap=function(a){return this.each(function(b,c){var d=B(a)[0];B(c).wrapAll(b?d.cloneNode(!0):d)})};x.prototype.wrapInner=function(a){return this.each(function(b,c){b=B(c);c=b.contents();c.length?c.wrapAll(a):b.append(a)})}; +x.prototype.has=function(a){var b=y(a)?function(b,d){return!!w(a,d).length}:function(b,d){return d.contains(a)};return this.filter(b)};x.prototype.is=function(a){if(!a||!this[0])return!1;var b=J(a),c=!1;this.each(function(a,f){c=b.call(f,a,f);return!c});return c};x.prototype.next=function(a,b){return K(B(N(H(this,"nextElementSibling",b))),a)};x.prototype.nextAll=function(a){return this.next(a,!0)}; +x.prototype.not=function(a){if(!a||!this[0])return this;var b=J(a);return this.filter(function(a,d){return!b.call(d,a,d)})};x.prototype.parent=function(a){return K(B(N(H(this,"parentNode"))),a)};x.prototype.index=function(a){var b=a?B(a)[0]:this[0];a=a?this:B(b).parent().children();return n.call(a,b)};x.prototype.closest=function(a){if(!a||!this[0])return B();var b=this.filter(a);return b.length?b:this.parent().closest(a)}; +x.prototype.parents=function(a){return K(B(N(H(this,"parentElement",!0))),a)};x.prototype.prev=function(a,b){return K(B(N(H(this,"previousElementSibling",b))),a)};x.prototype.prevAll=function(a){return this.prev(a,!0)};x.prototype.siblings=function(a){var b=this[0];return K(this.parent().children().filter(function(a,d){return d!==b}),a)};"undefined"!==typeof exports?module.exports=B:g.cash=g.$=B; +})(); \ No newline at end of file diff --git a/dist/weewx-4.10.1/docs/js/tocbot-4.12.0.js b/dist/weewx-4.10.1/docs/js/tocbot-4.12.0.js new file mode 100644 index 0000000..c4971e5 --- /dev/null +++ b/dist/weewx-4.10.1/docs/js/tocbot-4.12.0.js @@ -0,0 +1 @@ +!function(e){var t={};function n(o){if(t[o])return t[o].exports;var l=t[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,n),l.l=!0,l.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)n.d(o,l,function(t){return e[t]}.bind(null,l));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(o){var l,r,i;!function(o,s){r=[],l=function(e){"use strict";var t,o,l,r=n(2),i={},s={},c=n(3),a=n(4),u=n(5),d=!!(e&&e.document&&e.document.querySelector&&e.addEventListener);if("undefined"==typeof window&&!d)return;var f=Object.prototype.hasOwnProperty;function m(e,t,n){var o,l;return t||(t=250),function(){var r=n||this,i=+new Date,s=arguments;o&&ie.fixedSidebarOffset?-1===n.className.indexOf(e.positionFixedClass)&&(n.className+=r+e.positionFixedClass):n.className=n.className.split(r+e.positionFixedClass).join("")}();var c,a=i;if(l&&null!==document.querySelector(e.tocSelector)&&a.length>0){n.call(a,function(t,n){return function t(n){var o=0;return n!==document.querySelector(e.contentSelector&&null!=n)&&(o=n.offsetTop,e.hasInnerContainers&&(o+=t(n.offsetParent))),o}(t)>s+e.headingsOffset+10?(c=a[0===n?n:n-1],!0):n===a.length-1?(c=a[a.length-1],!0):void 0});var u=document.querySelector(e.tocSelector).querySelectorAll("."+e.linkClass);t.call(u,function(t){t.className=t.className.split(r+e.activeLinkClass).join("")});var d=document.querySelector(e.tocSelector).querySelectorAll("."+e.listItemClass);t.call(d,function(t){t.className=t.className.split(r+e.activeListItemClass).join("")});var f=document.querySelector(e.tocSelector).querySelector("."+e.linkClass+".node-name--"+c.nodeName+'[href="'+e.basePath+"#"+c.id.replace(/([ #;&,.+*~':"!^$[\]()=>|/@])/g,"\\$1")+'"]');-1===f.className.indexOf(e.activeLinkClass)&&(f.className+=r+e.activeLinkClass);var m=f.parentNode;m&&-1===m.className.indexOf(e.activeListItemClass)&&(m.className+=r+e.activeListItemClass);var h=document.querySelector(e.tocSelector).querySelectorAll("."+e.listClass+"."+e.collapsibleClass);t.call(h,function(t){-1===t.className.indexOf(e.isCollapsedClass)&&(t.className+=r+e.isCollapsedClass)}),f.nextSibling&&-1!==f.nextSibling.className.indexOf(e.isCollapsedClass)&&(f.nextSibling.className=f.nextSibling.className.split(r+e.isCollapsedClass).join("")),function t(n){return-1!==n.className.indexOf(e.collapsibleClass)&&-1!==n.className.indexOf(e.isCollapsedClass)?(n.className=n.className.split(r+e.isCollapsedClass).join(""),t(n.parentNode.parentNode)):n}(f.parentNode.parentNode)}}}}},function(e,t){e.exports=function(e){var t=[].reduce;function n(e){return e[e.length-1]}function o(t){if(!(t instanceof window.HTMLElement))return t;if(e.ignoreHiddenElements&&(!t.offsetHeight||!t.offsetParent))return null;var n={id:t.id,children:[],nodeName:t.nodeName,headingLevel:function(e){return+e.nodeName.split("H").join("")}(t),textContent:e.headingLabelCallback?String(e.headingLabelCallback(t.textContent)):t.textContent.trim()};return e.includeHtml&&(n.childNodes=t.childNodes),e.headingObjectCallback?e.headingObjectCallback(n,t):n}return{nestHeadingsArray:function(l){return t.call(l,function(t,l){var r=o(l);return r&&function(t,l){for(var r=o(t),i=r.headingLevel,s=l,c=n(s),a=i-(c?c.headingLevel:0);a>0;)(c=n(s))&&void 0!==c.children&&(s=c.children),a--;i>=e.collapseDepth&&(r.isCollapsed=!0),s.push(r)}(r,t.nest),t},{nest:[]})},selectHeadings:function(t,n){var o=n;e.ignoreSelector&&(o=n.split(",").map(function(t){return t.trim()+":not("+e.ignoreSelector+")"}));try{return document.querySelector(t).querySelectorAll(o)}catch(e){return console.warn("Element not found: "+t),null}}}}},function(e,t){e.exports=function(e){var t=document.querySelector(e.tocSelector);if(t&&t.scrollHeight>t.clientHeight){var n=t.querySelector("."+e.activeListItemClass);n&&(t.scrollTop=n.offsetTop)}}},function(e,t){function n(e,t){var n=window.pageYOffset,o={duration:t.duration,offset:t.offset||0,callback:t.callback,easing:t.easing||d},l=document.querySelector('[id="'+decodeURI(e).split("#").join("")+'"]'),r=typeof e==="string"?o.offset+(e?l&&l.getBoundingClientRect().top||0:-(document.documentElement.scrollTop||document.body.scrollTop)):e,i=typeof o.duration==="function"?o.duration(r):o.duration,s,c;function a(e){c=e-s;window.scrollTo(0,o.easing(c,n,r,i));if(c0||"#"===e.href.charAt(e.href.length-1))&&(r(e.href)===l||r(e.href)+"#"===l)}(i.target)||i.target.className.indexOf("no-smooth-scroll")>-1||"#"===i.target.href.charAt(i.target.href.length-2)&&"!"===i.target.href.charAt(i.target.href.length-1)||-1===i.target.className.indexOf(e.linkClass))return;n(i.target.hash,{duration:t,offset:o,callback:function(){!function(e){var t=document.getElementById(e.substring(1));t&&(/^(?:a|select|input|button|textarea)$/i.test(t.tagName)||(t.tabIndex=-1),t.focus())}(i.target.hash)}})},!1)}()}}]); \ No newline at end of file diff --git a/dist/weewx-4.10.1/docs/js/tocbot-4.12.0.min.js b/dist/weewx-4.10.1/docs/js/tocbot-4.12.0.min.js new file mode 100644 index 0000000..c4971e5 --- /dev/null +++ b/dist/weewx-4.10.1/docs/js/tocbot-4.12.0.min.js @@ -0,0 +1 @@ +!function(e){var t={};function n(o){if(t[o])return t[o].exports;var l=t[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,n),l.l=!0,l.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)n.d(o,l,function(t){return e[t]}.bind(null,l));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(o){var l,r,i;!function(o,s){r=[],l=function(e){"use strict";var t,o,l,r=n(2),i={},s={},c=n(3),a=n(4),u=n(5),d=!!(e&&e.document&&e.document.querySelector&&e.addEventListener);if("undefined"==typeof window&&!d)return;var f=Object.prototype.hasOwnProperty;function m(e,t,n){var o,l;return t||(t=250),function(){var r=n||this,i=+new Date,s=arguments;o&&ie.fixedSidebarOffset?-1===n.className.indexOf(e.positionFixedClass)&&(n.className+=r+e.positionFixedClass):n.className=n.className.split(r+e.positionFixedClass).join("")}();var c,a=i;if(l&&null!==document.querySelector(e.tocSelector)&&a.length>0){n.call(a,function(t,n){return function t(n){var o=0;return n!==document.querySelector(e.contentSelector&&null!=n)&&(o=n.offsetTop,e.hasInnerContainers&&(o+=t(n.offsetParent))),o}(t)>s+e.headingsOffset+10?(c=a[0===n?n:n-1],!0):n===a.length-1?(c=a[a.length-1],!0):void 0});var u=document.querySelector(e.tocSelector).querySelectorAll("."+e.linkClass);t.call(u,function(t){t.className=t.className.split(r+e.activeLinkClass).join("")});var d=document.querySelector(e.tocSelector).querySelectorAll("."+e.listItemClass);t.call(d,function(t){t.className=t.className.split(r+e.activeListItemClass).join("")});var f=document.querySelector(e.tocSelector).querySelector("."+e.linkClass+".node-name--"+c.nodeName+'[href="'+e.basePath+"#"+c.id.replace(/([ #;&,.+*~':"!^$[\]()=>|/@])/g,"\\$1")+'"]');-1===f.className.indexOf(e.activeLinkClass)&&(f.className+=r+e.activeLinkClass);var m=f.parentNode;m&&-1===m.className.indexOf(e.activeListItemClass)&&(m.className+=r+e.activeListItemClass);var h=document.querySelector(e.tocSelector).querySelectorAll("."+e.listClass+"."+e.collapsibleClass);t.call(h,function(t){-1===t.className.indexOf(e.isCollapsedClass)&&(t.className+=r+e.isCollapsedClass)}),f.nextSibling&&-1!==f.nextSibling.className.indexOf(e.isCollapsedClass)&&(f.nextSibling.className=f.nextSibling.className.split(r+e.isCollapsedClass).join("")),function t(n){return-1!==n.className.indexOf(e.collapsibleClass)&&-1!==n.className.indexOf(e.isCollapsedClass)?(n.className=n.className.split(r+e.isCollapsedClass).join(""),t(n.parentNode.parentNode)):n}(f.parentNode.parentNode)}}}}},function(e,t){e.exports=function(e){var t=[].reduce;function n(e){return e[e.length-1]}function o(t){if(!(t instanceof window.HTMLElement))return t;if(e.ignoreHiddenElements&&(!t.offsetHeight||!t.offsetParent))return null;var n={id:t.id,children:[],nodeName:t.nodeName,headingLevel:function(e){return+e.nodeName.split("H").join("")}(t),textContent:e.headingLabelCallback?String(e.headingLabelCallback(t.textContent)):t.textContent.trim()};return e.includeHtml&&(n.childNodes=t.childNodes),e.headingObjectCallback?e.headingObjectCallback(n,t):n}return{nestHeadingsArray:function(l){return t.call(l,function(t,l){var r=o(l);return r&&function(t,l){for(var r=o(t),i=r.headingLevel,s=l,c=n(s),a=i-(c?c.headingLevel:0);a>0;)(c=n(s))&&void 0!==c.children&&(s=c.children),a--;i>=e.collapseDepth&&(r.isCollapsed=!0),s.push(r)}(r,t.nest),t},{nest:[]})},selectHeadings:function(t,n){var o=n;e.ignoreSelector&&(o=n.split(",").map(function(t){return t.trim()+":not("+e.ignoreSelector+")"}));try{return document.querySelector(t).querySelectorAll(o)}catch(e){return console.warn("Element not found: "+t),null}}}}},function(e,t){e.exports=function(e){var t=document.querySelector(e.tocSelector);if(t&&t.scrollHeight>t.clientHeight){var n=t.querySelector("."+e.activeListItemClass);n&&(t.scrollTop=n.offsetTop)}}},function(e,t){function n(e,t){var n=window.pageYOffset,o={duration:t.duration,offset:t.offset||0,callback:t.callback,easing:t.easing||d},l=document.querySelector('[id="'+decodeURI(e).split("#").join("")+'"]'),r=typeof e==="string"?o.offset+(e?l&&l.getBoundingClientRect().top||0:-(document.documentElement.scrollTop||document.body.scrollTop)):e,i=typeof o.duration==="function"?o.duration(r):o.duration,s,c;function a(e){c=e-s;window.scrollTo(0,o.easing(c,n,r,i));if(c0||"#"===e.href.charAt(e.href.length-1))&&(r(e.href)===l||r(e.href)+"#"===l)}(i.target)||i.target.className.indexOf("no-smooth-scroll")>-1||"#"===i.target.href.charAt(i.target.href.length-2)&&"!"===i.target.href.charAt(i.target.href.length-1)||-1===i.target.className.indexOf(e.linkClass))return;n(i.target.hash,{duration:t,offset:o,callback:function(){!function(e){var t=document.getElementById(e.substring(1));t&&(/^(?:a|select|input|button|textarea)$/i.test(t.tagName)||(t.tabIndex=-1),t.focus())}(i.target.hash)}})},!1)}()}}]); \ No newline at end of file diff --git a/dist/weewx-4.10.1/docs/js/tocbot-4.3.1.js b/dist/weewx-4.10.1/docs/js/tocbot-4.3.1.js new file mode 100644 index 0000000..bc5a978 --- /dev/null +++ b/dist/weewx-4.10.1/docs/js/tocbot-4.3.1.js @@ -0,0 +1,133 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { +/******/ configurable: false, +/******/ enumerable: true, +/******/ get: getter +/******/ }); +/******/ } +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/*!*************************!*\ + !*** ./src/js/index.js ***! + \*************************/ +/*! dynamic exports provided */ +/*! all exports used */ +/***/ (function(module, exports, __webpack_require__) { + +eval("/* WEBPACK VAR INJECTION */(function(global) {var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/**\n * Tocbot\n * Tocbot creates a toble of contents based on HTML headings on a page,\n * this allows users to easily jump to different sections of the document.\n * Tocbot was inspired by tocify (http://gregfranko.com/jquery.tocify.js/).\n * The main differences are that it works natively without any need for jquery or jquery UI).\n *\n * @author Tim Scanlin\n */\n\n/* globals define */\n\n(function (root, factory) {\n if (true) {\n !(__WEBPACK_AMD_DEFINE_ARRAY__ = [], __WEBPACK_AMD_DEFINE_FACTORY__ = (factory(root)),\n\t\t\t\t__WEBPACK_AMD_DEFINE_RESULT__ = (typeof __WEBPACK_AMD_DEFINE_FACTORY__ === 'function' ?\n\t\t\t\t(__WEBPACK_AMD_DEFINE_FACTORY__.apply(exports, __WEBPACK_AMD_DEFINE_ARRAY__)) : __WEBPACK_AMD_DEFINE_FACTORY__),\n\t\t\t\t__WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__))\n } else if (typeof exports === 'object') {\n module.exports = factory(root)\n } else {\n root.tocbot = factory(root)\n }\n})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {\n 'use strict'\n\n // Default options.\n var defaultOptions = __webpack_require__(/*! ./default-options.js */ 2)\n // Object to store current options.\n var options = {}\n // Object for public APIs.\n var tocbot = {}\n\n var BuildHtml = __webpack_require__(/*! ./build-html.js */ 3)\n var ParseContent = __webpack_require__(/*! ./parse-content.js */ 4)\n // Keep these variables at top scope once options are passed in.\n var buildHtml\n var parseContent\n\n // Just return if its not a browser.\n if (typeof window === 'undefined') {\n return\n }\n var supports = !!root.document.querySelector && !!root.addEventListener // Feature test\n var headingsArray\n\n // From: https://github.com/Raynos/xtend\n var hasOwnProperty = Object.prototype.hasOwnProperty\n function extend () {\n var target = {}\n for (var i = 0; i < arguments.length; i++) {\n var source = arguments[i]\n for (var key in source) {\n if (hasOwnProperty.call(source, key)) {\n target[key] = source[key]\n }\n }\n }\n return target\n }\n\n // From: https://remysharp.com/2010/07/21/throttling-function-calls\n function throttle (fn, threshhold, scope) {\n threshhold || (threshhold = 250)\n var last\n var deferTimer\n return function () {\n var context = scope || this\n var now = +new Date()\n var args = arguments\n if (last && now < last + threshhold) {\n // hold on to it\n clearTimeout(deferTimer)\n deferTimer = setTimeout(function () {\n last = now\n fn.apply(context, args)\n }, threshhold)\n } else {\n last = now\n fn.apply(context, args)\n }\n }\n }\n\n /**\n * Destroy tocbot.\n */\n tocbot.destroy = function () {\n // Clear HTML.\n try {\n document.querySelector(options.tocSelector).innerHTML = ''\n } catch (e) {\n console.warn('Element not found: ' + options.tocSelector); // eslint-disable-line\n }\n\n // Remove event listeners.\n document.removeEventListener('scroll', this._scrollListener, false)\n document.removeEventListener('resize', this._scrollListener, false)\n if (buildHtml) {\n document.removeEventListener('click', this._clickListener, false)\n }\n }\n\n /**\n * Initialize tocbot.\n * @param {object} customOptions\n */\n tocbot.init = function (customOptions) {\n // feature test\n if (!supports) {\n return\n }\n\n // Merge defaults with user options.\n // Set to options variable at the top.\n options = extend(defaultOptions, customOptions || {})\n this.options = options\n this.state = {}\n\n // Init smooth scroll if enabled (default).\n if (options.scrollSmooth) {\n options.duration = options.scrollSmoothDuration\n tocbot.scrollSmooth = __webpack_require__(/*! ./scroll-smooth */ 5).initSmoothScrolling(options)\n }\n\n // Pass options to these modules.\n buildHtml = BuildHtml(options)\n parseContent = ParseContent(options)\n\n // For testing purposes.\n this._buildHtml = buildHtml\n this._parseContent = parseContent\n\n // Destroy it if it exists first.\n tocbot.destroy()\n\n // Get headings array.\n headingsArray = parseContent.selectHeadings(options.contentSelector, options.headingSelector)\n // Return if no headings are found.\n if (headingsArray === null) {\n return\n }\n\n // Build nested headings array.\n var nestedHeadingsObj = parseContent.nestHeadingsArray(headingsArray)\n var nestedHeadings = nestedHeadingsObj.nest\n\n // Render.\n buildHtml.render(options.tocSelector, nestedHeadings)\n\n // Update Sidebar and bind listeners.\n this._scrollListener = throttle(function (e) {\n buildHtml.updateToc(headingsArray)\n var isTop = e && e.target && e.target.scrollingElement && e.target.scrollingElement.scrollTop === 0\n if ((e && (e.eventPhase === 0 || e.currentTarget === null)) || isTop) {\n buildHtml.enableTocAnimation()\n buildHtml.updateToc(headingsArray)\n if (options.scrollEndCallback) {\n options.scrollEndCallback(e)\n }\n }\n }, options.throttleTimeout)\n this._scrollListener()\n document.addEventListener('scroll', this._scrollListener, false)\n document.addEventListener('resize', this._scrollListener, false)\n\n // Bind click listeners to disable animation.\n this._clickListener = throttle(function (event) {\n if (options.scrollSmooth) {\n buildHtml.disableTocAnimation(event)\n }\n buildHtml.updateToc(headingsArray)\n }, options.throttleTimeout)\n document.addEventListener('click', this._clickListener, false)\n\n return this\n }\n\n /**\n * Refresh tocbot.\n */\n tocbot.refresh = function (customOptions) {\n tocbot.destroy()\n tocbot.init(customOptions || this.options)\n }\n\n // Make tocbot available globally.\n root.tocbot = tocbot\n\n return tocbot\n})\n\n/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(/*! ./../../node_modules/webpack/buildin/global.js */ 1)))//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMC5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy8uL3NyYy9qcy9pbmRleC5qcz9iYzY2Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogVG9jYm90XG4gKiBUb2Nib3QgY3JlYXRlcyBhIHRvYmxlIG9mIGNvbnRlbnRzIGJhc2VkIG9uIEhUTUwgaGVhZGluZ3Mgb24gYSBwYWdlLFxuICogdGhpcyBhbGxvd3MgdXNlcnMgdG8gZWFzaWx5IGp1bXAgdG8gZGlmZmVyZW50IHNlY3Rpb25zIG9mIHRoZSBkb2N1bWVudC5cbiAqIFRvY2JvdCB3YXMgaW5zcGlyZWQgYnkgdG9jaWZ5IChodHRwOi8vZ3JlZ2ZyYW5rby5jb20vanF1ZXJ5LnRvY2lmeS5qcy8pLlxuICogVGhlIG1haW4gZGlmZmVyZW5jZXMgYXJlIHRoYXQgaXQgd29ya3MgbmF0aXZlbHkgd2l0aG91dCBhbnkgbmVlZCBmb3IganF1ZXJ5IG9yIGpxdWVyeSBVSSkuXG4gKlxuICogQGF1dGhvciBUaW0gU2NhbmxpblxuICovXG5cbi8qIGdsb2JhbHMgZGVmaW5lICovXG5cbihmdW5jdGlvbiAocm9vdCwgZmFjdG9yeSkge1xuICBpZiAodHlwZW9mIGRlZmluZSA9PT0gJ2Z1bmN0aW9uJyAmJiBkZWZpbmUuYW1kKSB7XG4gICAgZGVmaW5lKFtdLCBmYWN0b3J5KHJvb3QpKVxuICB9IGVsc2UgaWYgKHR5cGVvZiBleHBvcnRzID09PSAnb2JqZWN0Jykge1xuICAgIG1vZHVsZS5leHBvcnRzID0gZmFjdG9yeShyb290KVxuICB9IGVsc2Uge1xuICAgIHJvb3QudG9jYm90ID0gZmFjdG9yeShyb290KVxuICB9XG59KSh0eXBlb2YgZ2xvYmFsICE9PSAndW5kZWZpbmVkJyA/IGdsb2JhbCA6IHRoaXMud2luZG93IHx8IHRoaXMuZ2xvYmFsLCBmdW5jdGlvbiAocm9vdCkge1xuICAndXNlIHN0cmljdCdcblxuICAvLyBEZWZhdWx0IG9wdGlvbnMuXG4gIHZhciBkZWZhdWx0T3B0aW9ucyA9IHJlcXVpcmUoJy4vZGVmYXVsdC1vcHRpb25zLmpzJylcbiAgLy8gT2JqZWN0IHRvIHN0b3JlIGN1cnJlbnQgb3B0aW9ucy5cbiAgdmFyIG9wdGlvbnMgPSB7fVxuICAvLyBPYmplY3QgZm9yIHB1YmxpYyBBUElzLlxuICB2YXIgdG9jYm90ID0ge31cblxuICB2YXIgQnVpbGRIdG1sID0gcmVxdWlyZSgnLi9idWlsZC1odG1sLmpzJylcbiAgdmFyIFBhcnNlQ29udGVudCA9IHJlcXVpcmUoJy4vcGFyc2UtY29udGVudC5qcycpXG4gIC8vIEtlZXAgdGhlc2UgdmFyaWFibGVzIGF0IHRvcCBzY29wZSBvbmNlIG9wdGlvbnMgYXJlIHBhc3NlZCBpbi5cbiAgdmFyIGJ1aWxkSHRtbFxuICB2YXIgcGFyc2VDb250ZW50XG5cbiAgLy8gSnVzdCByZXR1cm4gaWYgaXRzIG5vdCBhIGJyb3dzZXIuXG4gIGlmICh0eXBlb2Ygd2luZG93ID09PSAndW5kZWZpbmVkJykge1xuICAgIHJldHVyblxuICB9XG4gIHZhciBzdXBwb3J0cyA9ICEhcm9vdC5kb2N1bWVudC5xdWVyeVNlbGVjdG9yICYmICEhcm9vdC5hZGRFdmVudExpc3RlbmVyIC8vIEZlYXR1cmUgdGVzdFxuICB2YXIgaGVhZGluZ3NBcnJheVxuXG4gIC8vIEZyb206IGh0dHBzOi8vZ2l0aHViLmNvbS9SYXlub3MveHRlbmRcbiAgdmFyIGhhc093blByb3BlcnR5ID0gT2JqZWN0LnByb3RvdHlwZS5oYXNPd25Qcm9wZXJ0eVxuICBmdW5jdGlvbiBleHRlbmQgKCkge1xuICAgIHZhciB0YXJnZXQgPSB7fVxuICAgIGZvciAodmFyIGkgPSAwOyBpIDwgYXJndW1lbnRzLmxlbmd0aDsgaSsrKSB7XG4gICAgICB2YXIgc291cmNlID0gYXJndW1lbnRzW2ldXG4gICAgICBmb3IgKHZhciBrZXkgaW4gc291cmNlKSB7XG4gICAgICAgIGlmIChoYXNPd25Qcm9wZXJ0eS5jYWxsKHNvdXJjZSwga2V5KSkge1xuICAgICAgICAgIHRhcmdldFtrZXldID0gc291cmNlW2tleV1cbiAgICAgICAgfVxuICAgICAgfVxuICAgIH1cbiAgICByZXR1cm4gdGFyZ2V0XG4gIH1cblxuICAvLyBGcm9tOiBodHRwczovL3JlbXlzaGFycC5jb20vMjAxMC8wNy8yMS90aHJvdHRsaW5nLWZ1bmN0aW9uLWNhbGxzXG4gIGZ1bmN0aW9uIHRocm90dGxlIChmbiwgdGhyZXNoaG9sZCwgc2NvcGUpIHtcbiAgICB0aHJlc2hob2xkIHx8ICh0aHJlc2hob2xkID0gMjUwKVxuICAgIHZhciBsYXN0XG4gICAgdmFyIGRlZmVyVGltZXJcbiAgICByZXR1cm4gZnVuY3Rpb24gKCkge1xuICAgICAgdmFyIGNvbnRleHQgPSBzY29wZSB8fCB0aGlzXG4gICAgICB2YXIgbm93ID0gK25ldyBEYXRlKClcbiAgICAgIHZhciBhcmdzID0gYXJndW1lbnRzXG4gICAgICBpZiAobGFzdCAmJiBub3cgPCBsYXN0ICsgdGhyZXNoaG9sZCkge1xuICAgICAgICAvLyBob2xkIG9uIHRvIGl0XG4gICAgICAgIGNsZWFyVGltZW91dChkZWZlclRpbWVyKVxuICAgICAgICBkZWZlclRpbWVyID0gc2V0VGltZW91dChmdW5jdGlvbiAoKSB7XG4gICAgICAgICAgbGFzdCA9IG5vd1xuICAgICAgICAgIGZuLmFwcGx5KGNvbnRleHQsIGFyZ3MpXG4gICAgICAgIH0sIHRocmVzaGhvbGQpXG4gICAgICB9IGVsc2Uge1xuICAgICAgICBsYXN0ID0gbm93XG4gICAgICAgIGZuLmFwcGx5KGNvbnRleHQsIGFyZ3MpXG4gICAgICB9XG4gICAgfVxuICB9XG5cbiAgLyoqXG4gICAqIERlc3Ryb3kgdG9jYm90LlxuICAgKi9cbiAgdG9jYm90LmRlc3Ryb3kgPSBmdW5jdGlvbiAoKSB7XG4gICAgLy8gQ2xlYXIgSFRNTC5cbiAgICB0cnkge1xuICAgICAgZG9jdW1lbnQucXVlcnlTZWxlY3RvcihvcHRpb25zLnRvY1NlbGVjdG9yKS5pbm5lckhUTUwgPSAnJ1xuICAgIH0gY2F0Y2ggKGUpIHtcbiAgICAgIGNvbnNvbGUud2FybignRWxlbWVudCBub3QgZm91bmQ6ICcgKyBvcHRpb25zLnRvY1NlbGVjdG9yKTsgLy8gZXNsaW50LWRpc2FibGUtbGluZVxuICAgIH1cblxuICAgIC8vIFJlbW92ZSBldmVudCBsaXN0ZW5lcnMuXG4gICAgZG9jdW1lbnQucmVtb3ZlRXZlbnRMaXN0ZW5lcignc2Nyb2xsJywgdGhpcy5fc2Nyb2xsTGlzdGVuZXIsIGZhbHNlKVxuICAgIGRvY3VtZW50LnJlbW92ZUV2ZW50TGlzdGVuZXIoJ3Jlc2l6ZScsIHRoaXMuX3Njcm9sbExpc3RlbmVyLCBmYWxzZSlcbiAgICBpZiAoYnVpbGRIdG1sKSB7XG4gICAgICBkb2N1bWVudC5yZW1vdmVFdmVudExpc3RlbmVyKCdjbGljaycsIHRoaXMuX2NsaWNrTGlzdGVuZXIsIGZhbHNlKVxuICAgIH1cbiAgfVxuXG4gIC8qKlxuICAgKiBJbml0aWFsaXplIHRvY2JvdC5cbiAgICogQHBhcmFtIHtvYmplY3R9IGN1c3RvbU9wdGlvbnNcbiAgICovXG4gIHRvY2JvdC5pbml0ID0gZnVuY3Rpb24gKGN1c3RvbU9wdGlvbnMpIHtcbiAgICAvLyBmZWF0dXJlIHRlc3RcbiAgICBpZiAoIXN1cHBvcnRzKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICAvLyBNZXJnZSBkZWZhdWx0cyB3aXRoIHVzZXIgb3B0aW9ucy5cbiAgICAvLyBTZXQgdG8gb3B0aW9ucyB2YXJpYWJsZSBhdCB0aGUgdG9wLlxuICAgIG9wdGlvbnMgPSBleHRlbmQoZGVmYXVsdE9wdGlvbnMsIGN1c3RvbU9wdGlvbnMgfHwge30pXG4gICAgdGhpcy5vcHRpb25zID0gb3B0aW9uc1xuICAgIHRoaXMuc3RhdGUgPSB7fVxuXG4gICAgLy8gSW5pdCBzbW9vdGggc2Nyb2xsIGlmIGVuYWJsZWQgKGRlZmF1bHQpLlxuICAgIGlmIChvcHRpb25zLnNjcm9sbFNtb290aCkge1xuICAgICAgb3B0aW9ucy5kdXJhdGlvbiA9IG9wdGlvbnMuc2Nyb2xsU21vb3RoRHVyYXRpb25cbiAgICAgIHRvY2JvdC5zY3JvbGxTbW9vdGggPSByZXF1aXJlKCcuL3Njcm9sbC1zbW9vdGgnKS5pbml0U21vb3RoU2Nyb2xsaW5nKG9wdGlvbnMpXG4gICAgfVxuXG4gICAgLy8gUGFzcyBvcHRpb25zIHRvIHRoZXNlIG1vZHVsZXMuXG4gICAgYnVpbGRIdG1sID0gQnVpbGRIdG1sKG9wdGlvbnMpXG4gICAgcGFyc2VDb250ZW50ID0gUGFyc2VDb250ZW50KG9wdGlvbnMpXG5cbiAgICAvLyBGb3IgdGVzdGluZyBwdXJwb3Nlcy5cbiAgICB0aGlzLl9idWlsZEh0bWwgPSBidWlsZEh0bWxcbiAgICB0aGlzLl9wYXJzZUNvbnRlbnQgPSBwYXJzZUNvbnRlbnRcblxuICAgIC8vIERlc3Ryb3kgaXQgaWYgaXQgZXhpc3RzIGZpcnN0LlxuICAgIHRvY2JvdC5kZXN0cm95KClcblxuICAgIC8vIEdldCBoZWFkaW5ncyBhcnJheS5cbiAgICBoZWFkaW5nc0FycmF5ID0gcGFyc2VDb250ZW50LnNlbGVjdEhlYWRpbmdzKG9wdGlvbnMuY29udGVudFNlbGVjdG9yLCBvcHRpb25zLmhlYWRpbmdTZWxlY3RvcilcbiAgICAvLyBSZXR1cm4gaWYgbm8gaGVhZGluZ3MgYXJlIGZvdW5kLlxuICAgIGlmIChoZWFkaW5nc0FycmF5ID09PSBudWxsKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICAvLyBCdWlsZCBuZXN0ZWQgaGVhZGluZ3MgYXJyYXkuXG4gICAgdmFyIG5lc3RlZEhlYWRpbmdzT2JqID0gcGFyc2VDb250ZW50Lm5lc3RIZWFkaW5nc0FycmF5KGhlYWRpbmdzQXJyYXkpXG4gICAgdmFyIG5lc3RlZEhlYWRpbmdzID0gbmVzdGVkSGVhZGluZ3NPYmoubmVzdFxuXG4gICAgLy8gUmVuZGVyLlxuICAgIGJ1aWxkSHRtbC5yZW5kZXIob3B0aW9ucy50b2NTZWxlY3RvciwgbmVzdGVkSGVhZGluZ3MpXG5cbiAgICAvLyBVcGRhdGUgU2lkZWJhciBhbmQgYmluZCBsaXN0ZW5lcnMuXG4gICAgdGhpcy5fc2Nyb2xsTGlzdGVuZXIgPSB0aHJvdHRsZShmdW5jdGlvbiAoZSkge1xuICAgICAgYnVpbGRIdG1sLnVwZGF0ZVRvYyhoZWFkaW5nc0FycmF5KVxuICAgICAgdmFyIGlzVG9wID0gZSAmJiBlLnRhcmdldCAmJiBlLnRhcmdldC5zY3JvbGxpbmdFbGVtZW50ICYmIGUudGFyZ2V0LnNjcm9sbGluZ0VsZW1lbnQuc2Nyb2xsVG9wID09PSAwXG4gICAgICBpZiAoKGUgJiYgKGUuZXZlbnRQaGFzZSA9PT0gMCB8fCBlLmN1cnJlbnRUYXJnZXQgPT09IG51bGwpKSB8fCBpc1RvcCkge1xuICAgICAgICBidWlsZEh0bWwuZW5hYmxlVG9jQW5pbWF0aW9uKClcbiAgICAgICAgYnVpbGRIdG1sLnVwZGF0ZVRvYyhoZWFkaW5nc0FycmF5KVxuICAgICAgICBpZiAob3B0aW9ucy5zY3JvbGxFbmRDYWxsYmFjaykge1xuICAgICAgICAgIG9wdGlvbnMuc2Nyb2xsRW5kQ2FsbGJhY2soZSlcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH0sIG9wdGlvbnMudGhyb3R0bGVUaW1lb3V0KVxuICAgIHRoaXMuX3Njcm9sbExpc3RlbmVyKClcbiAgICBkb2N1bWVudC5hZGRFdmVudExpc3RlbmVyKCdzY3JvbGwnLCB0aGlzLl9zY3JvbGxMaXN0ZW5lciwgZmFsc2UpXG4gICAgZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcigncmVzaXplJywgdGhpcy5fc2Nyb2xsTGlzdGVuZXIsIGZhbHNlKVxuXG4gICAgLy8gQmluZCBjbGljayBsaXN0ZW5lcnMgdG8gZGlzYWJsZSBhbmltYXRpb24uXG4gICAgdGhpcy5fY2xpY2tMaXN0ZW5lciA9IHRocm90dGxlKGZ1bmN0aW9uIChldmVudCkge1xuICAgICAgaWYgKG9wdGlvbnMuc2Nyb2xsU21vb3RoKSB7XG4gICAgICAgIGJ1aWxkSHRtbC5kaXNhYmxlVG9jQW5pbWF0aW9uKGV2ZW50KVxuICAgICAgfVxuICAgICAgYnVpbGRIdG1sLnVwZGF0ZVRvYyhoZWFkaW5nc0FycmF5KVxuICAgIH0sIG9wdGlvbnMudGhyb3R0bGVUaW1lb3V0KVxuICAgIGRvY3VtZW50LmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgdGhpcy5fY2xpY2tMaXN0ZW5lciwgZmFsc2UpXG5cbiAgICByZXR1cm4gdGhpc1xuICB9XG5cbiAgLyoqXG4gICAqIFJlZnJlc2ggdG9jYm90LlxuICAgKi9cbiAgdG9jYm90LnJlZnJlc2ggPSBmdW5jdGlvbiAoY3VzdG9tT3B0aW9ucykge1xuICAgIHRvY2JvdC5kZXN0cm95KClcbiAgICB0b2Nib3QuaW5pdChjdXN0b21PcHRpb25zIHx8IHRoaXMub3B0aW9ucylcbiAgfVxuXG4gIC8vIE1ha2UgdG9jYm90IGF2YWlsYWJsZSBnbG9iYWxseS5cbiAgcm9vdC50b2Nib3QgPSB0b2Nib3RcblxuICByZXR1cm4gdG9jYm90XG59KVxuXG5cblxuLy8vLy8vLy8vLy8vLy8vLy8vXG4vLyBXRUJQQUNLIEZPT1RFUlxuLy8gLi9zcmMvanMvaW5kZXguanNcbi8vIG1vZHVsZSBpZCA9IDBcbi8vIG1vZHVsZSBjaHVua3MgPSAwIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUFBO0FBQUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBIiwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///0\n"); + +/***/ }), +/* 1 */ +/*!***********************************!*\ + !*** (webpack)/buildin/global.js ***! + \***********************************/ +/*! dynamic exports provided */ +/*! all exports used */ +/***/ (function(module, exports) { + +eval("var g;\r\n\r\n// This works in non-strict mode\r\ng = (function() {\r\n\treturn this;\r\n})();\r\n\r\ntry {\r\n\t// This works if eval is allowed (see CSP)\r\n\tg = g || Function(\"return this\")() || (1,eval)(\"this\");\r\n} catch(e) {\r\n\t// This works if the window reference is available\r\n\tif(typeof window === \"object\")\r\n\t\tg = window;\r\n}\r\n\r\n// g can still be undefined, but nothing to do about it...\r\n// We return undefined, instead of nothing here, so it's\r\n// easier to handle this case. if(!global) { ...}\r\n\r\nmodule.exports = g;\r\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMS5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy8od2VicGFjaykvYnVpbGRpbi9nbG9iYWwuanM/MzY5OCJdLCJzb3VyY2VzQ29udGVudCI6WyJ2YXIgZztcclxuXHJcbi8vIFRoaXMgd29ya3MgaW4gbm9uLXN0cmljdCBtb2RlXHJcbmcgPSAoZnVuY3Rpb24oKSB7XHJcblx0cmV0dXJuIHRoaXM7XHJcbn0pKCk7XHJcblxyXG50cnkge1xyXG5cdC8vIFRoaXMgd29ya3MgaWYgZXZhbCBpcyBhbGxvd2VkIChzZWUgQ1NQKVxyXG5cdGcgPSBnIHx8IEZ1bmN0aW9uKFwicmV0dXJuIHRoaXNcIikoKSB8fCAoMSxldmFsKShcInRoaXNcIik7XHJcbn0gY2F0Y2goZSkge1xyXG5cdC8vIFRoaXMgd29ya3MgaWYgdGhlIHdpbmRvdyByZWZlcmVuY2UgaXMgYXZhaWxhYmxlXHJcblx0aWYodHlwZW9mIHdpbmRvdyA9PT0gXCJvYmplY3RcIilcclxuXHRcdGcgPSB3aW5kb3c7XHJcbn1cclxuXHJcbi8vIGcgY2FuIHN0aWxsIGJlIHVuZGVmaW5lZCwgYnV0IG5vdGhpbmcgdG8gZG8gYWJvdXQgaXQuLi5cclxuLy8gV2UgcmV0dXJuIHVuZGVmaW5lZCwgaW5zdGVhZCBvZiBub3RoaW5nIGhlcmUsIHNvIGl0J3NcclxuLy8gZWFzaWVyIHRvIGhhbmRsZSB0aGlzIGNhc2UuIGlmKCFnbG9iYWwpIHsgLi4ufVxyXG5cclxubW9kdWxlLmV4cG9ydHMgPSBnO1xyXG5cblxuXG4vLy8vLy8vLy8vLy8vLy8vLy9cbi8vIFdFQlBBQ0sgRk9PVEVSXG4vLyAod2VicGFjaykvYnVpbGRpbi9nbG9iYWwuanNcbi8vIG1vZHVsZSBpZCA9IDFcbi8vIG1vZHVsZSBjaHVua3MgPSAwIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7Iiwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///1\n"); + +/***/ }), +/* 2 */ +/*!***********************************!*\ + !*** ./src/js/default-options.js ***! + \***********************************/ +/*! dynamic exports provided */ +/*! all exports used */ +/***/ (function(module, exports) { + +eval("module.exports = {\n // Where to render the table of contents.\n tocSelector: '.js-toc',\n // Where to grab the headings to build the table of contents.\n contentSelector: '.js-toc-content',\n // Which headings to grab inside of the contentSelector element.\n headingSelector: 'h1, h2, h3',\n // Headings that match the ignoreSelector will be skipped.\n ignoreSelector: '.js-toc-ignore',\n // Main class to add to links.\n linkClass: 'toc-link',\n // Extra classes to add to links.\n extraLinkClasses: '',\n // Class to add to active links,\n // the link corresponding to the top most heading on the page.\n activeLinkClass: 'is-active-link',\n // Main class to add to lists.\n listClass: 'toc-list',\n // Extra classes to add to lists.\n extraListClasses: '',\n // Class that gets added when a list should be collapsed.\n isCollapsedClass: 'is-collapsed',\n // Class that gets added when a list should be able\n // to be collapsed but isn't necessarily collpased.\n collapsibleClass: 'is-collapsible',\n // Class to add to list items.\n listItemClass: 'toc-list-item',\n // Class to add to active list items.\n activeListItemClass: 'is-active-li',\n // How many heading levels should not be collpased.\n // For example, number 6 will show everything since\n // there are only 6 heading levels and number 0 will collpase them all.\n // The sections that are hidden will open\n // and close as you scroll to headings within them.\n collapseDepth: 0,\n // Smooth scrolling enabled.\n scrollSmooth: true,\n // Smooth scroll duration.\n scrollSmoothDuration: 420,\n // Callback for scroll end.\n scrollEndCallback: function (e) {},\n // Headings offset between the headings and the top of the document (this is meant for minor adjustments).\n headingsOffset: 1,\n // Timeout between events firing to make sure it's\n // not too rapid (for performance reasons).\n throttleTimeout: 50,\n // Element to add the positionFixedClass to.\n positionFixedSelector: null,\n // Fixed position class to add to make sidebar fixed after scrolling\n // down past the fixedSidebarOffset.\n positionFixedClass: 'is-position-fixed',\n // fixedSidebarOffset can be any number but by default is set\n // to auto which sets the fixedSidebarOffset to the sidebar\n // element's offsetTop from the top of the document on init.\n fixedSidebarOffset: 'auto',\n // includeHtml can be set to true to include the HTML markup from the\n // heading node instead of just including the textContent.\n includeHtml: false,\n // onclick function to apply to all links in toc. will be called with\n // the event as the first parameter, and this can be used to stop,\n // propagation, prevent default or perform action\n onClick: false\n}\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMi5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy8uL3NyYy9qcy9kZWZhdWx0LW9wdGlvbnMuanM/MTg1MSJdLCJzb3VyY2VzQ29udGVudCI6WyJtb2R1bGUuZXhwb3J0cyA9IHtcbiAgLy8gV2hlcmUgdG8gcmVuZGVyIHRoZSB0YWJsZSBvZiBjb250ZW50cy5cbiAgdG9jU2VsZWN0b3I6ICcuanMtdG9jJyxcbiAgLy8gV2hlcmUgdG8gZ3JhYiB0aGUgaGVhZGluZ3MgdG8gYnVpbGQgdGhlIHRhYmxlIG9mIGNvbnRlbnRzLlxuICBjb250ZW50U2VsZWN0b3I6ICcuanMtdG9jLWNvbnRlbnQnLFxuICAvLyBXaGljaCBoZWFkaW5ncyB0byBncmFiIGluc2lkZSBvZiB0aGUgY29udGVudFNlbGVjdG9yIGVsZW1lbnQuXG4gIGhlYWRpbmdTZWxlY3RvcjogJ2gxLCBoMiwgaDMnLFxuICAvLyBIZWFkaW5ncyB0aGF0IG1hdGNoIHRoZSBpZ25vcmVTZWxlY3RvciB3aWxsIGJlIHNraXBwZWQuXG4gIGlnbm9yZVNlbGVjdG9yOiAnLmpzLXRvYy1pZ25vcmUnLFxuICAvLyBNYWluIGNsYXNzIHRvIGFkZCB0byBsaW5rcy5cbiAgbGlua0NsYXNzOiAndG9jLWxpbmsnLFxuICAvLyBFeHRyYSBjbGFzc2VzIHRvIGFkZCB0byBsaW5rcy5cbiAgZXh0cmFMaW5rQ2xhc3NlczogJycsXG4gIC8vIENsYXNzIHRvIGFkZCB0byBhY3RpdmUgbGlua3MsXG4gIC8vIHRoZSBsaW5rIGNvcnJlc3BvbmRpbmcgdG8gdGhlIHRvcCBtb3N0IGhlYWRpbmcgb24gdGhlIHBhZ2UuXG4gIGFjdGl2ZUxpbmtDbGFzczogJ2lzLWFjdGl2ZS1saW5rJyxcbiAgLy8gTWFpbiBjbGFzcyB0byBhZGQgdG8gbGlzdHMuXG4gIGxpc3RDbGFzczogJ3RvYy1saXN0JyxcbiAgLy8gRXh0cmEgY2xhc3NlcyB0byBhZGQgdG8gbGlzdHMuXG4gIGV4dHJhTGlzdENsYXNzZXM6ICcnLFxuICAvLyBDbGFzcyB0aGF0IGdldHMgYWRkZWQgd2hlbiBhIGxpc3Qgc2hvdWxkIGJlIGNvbGxhcHNlZC5cbiAgaXNDb2xsYXBzZWRDbGFzczogJ2lzLWNvbGxhcHNlZCcsXG4gIC8vIENsYXNzIHRoYXQgZ2V0cyBhZGRlZCB3aGVuIGEgbGlzdCBzaG91bGQgYmUgYWJsZVxuICAvLyB0byBiZSBjb2xsYXBzZWQgYnV0IGlzbid0IG5lY2Vzc2FyaWx5IGNvbGxwYXNlZC5cbiAgY29sbGFwc2libGVDbGFzczogJ2lzLWNvbGxhcHNpYmxlJyxcbiAgLy8gQ2xhc3MgdG8gYWRkIHRvIGxpc3QgaXRlbXMuXG4gIGxpc3RJdGVtQ2xhc3M6ICd0b2MtbGlzdC1pdGVtJyxcbiAgLy8gQ2xhc3MgdG8gYWRkIHRvIGFjdGl2ZSBsaXN0IGl0ZW1zLlxuICBhY3RpdmVMaXN0SXRlbUNsYXNzOiAnaXMtYWN0aXZlLWxpJyxcbiAgLy8gSG93IG1hbnkgaGVhZGluZyBsZXZlbHMgc2hvdWxkIG5vdCBiZSBjb2xscGFzZWQuXG4gIC8vIEZvciBleGFtcGxlLCBudW1iZXIgNiB3aWxsIHNob3cgZXZlcnl0aGluZyBzaW5jZVxuICAvLyB0aGVyZSBhcmUgb25seSA2IGhlYWRpbmcgbGV2ZWxzIGFuZCBudW1iZXIgMCB3aWxsIGNvbGxwYXNlIHRoZW0gYWxsLlxuICAvLyBUaGUgc2VjdGlvbnMgdGhhdCBhcmUgaGlkZGVuIHdpbGwgb3BlblxuICAvLyBhbmQgY2xvc2UgYXMgeW91IHNjcm9sbCB0byBoZWFkaW5ncyB3aXRoaW4gdGhlbS5cbiAgY29sbGFwc2VEZXB0aDogMCxcbiAgLy8gU21vb3RoIHNjcm9sbGluZyBlbmFibGVkLlxuICBzY3JvbGxTbW9vdGg6IHRydWUsXG4gIC8vIFNtb290aCBzY3JvbGwgZHVyYXRpb24uXG4gIHNjcm9sbFNtb290aER1cmF0aW9uOiA0MjAsXG4gIC8vIENhbGxiYWNrIGZvciBzY3JvbGwgZW5kLlxuICBzY3JvbGxFbmRDYWxsYmFjazogZnVuY3Rpb24gKGUpIHt9LFxuICAvLyBIZWFkaW5ncyBvZmZzZXQgYmV0d2VlbiB0aGUgaGVhZGluZ3MgYW5kIHRoZSB0b3Agb2YgdGhlIGRvY3VtZW50ICh0aGlzIGlzIG1lYW50IGZvciBtaW5vciBhZGp1c3RtZW50cykuXG4gIGhlYWRpbmdzT2Zmc2V0OiAxLFxuICAvLyBUaW1lb3V0IGJldHdlZW4gZXZlbnRzIGZpcmluZyB0byBtYWtlIHN1cmUgaXQnc1xuICAvLyBub3QgdG9vIHJhcGlkIChmb3IgcGVyZm9ybWFuY2UgcmVhc29ucykuXG4gIHRocm90dGxlVGltZW91dDogNTAsXG4gIC8vIEVsZW1lbnQgdG8gYWRkIHRoZSBwb3NpdGlvbkZpeGVkQ2xhc3MgdG8uXG4gIHBvc2l0aW9uRml4ZWRTZWxlY3RvcjogbnVsbCxcbiAgLy8gRml4ZWQgcG9zaXRpb24gY2xhc3MgdG8gYWRkIHRvIG1ha2Ugc2lkZWJhciBmaXhlZCBhZnRlciBzY3JvbGxpbmdcbiAgLy8gZG93biBwYXN0IHRoZSBmaXhlZFNpZGViYXJPZmZzZXQuXG4gIHBvc2l0aW9uRml4ZWRDbGFzczogJ2lzLXBvc2l0aW9uLWZpeGVkJyxcbiAgLy8gZml4ZWRTaWRlYmFyT2Zmc2V0IGNhbiBiZSBhbnkgbnVtYmVyIGJ1dCBieSBkZWZhdWx0IGlzIHNldFxuICAvLyB0byBhdXRvIHdoaWNoIHNldHMgdGhlIGZpeGVkU2lkZWJhck9mZnNldCB0byB0aGUgc2lkZWJhclxuICAvLyBlbGVtZW50J3Mgb2Zmc2V0VG9wIGZyb20gdGhlIHRvcCBvZiB0aGUgZG9jdW1lbnQgb24gaW5pdC5cbiAgZml4ZWRTaWRlYmFyT2Zmc2V0OiAnYXV0bycsXG4gIC8vIGluY2x1ZGVIdG1sIGNhbiBiZSBzZXQgdG8gdHJ1ZSB0byBpbmNsdWRlIHRoZSBIVE1MIG1hcmt1cCBmcm9tIHRoZVxuICAvLyBoZWFkaW5nIG5vZGUgaW5zdGVhZCBvZiBqdXN0IGluY2x1ZGluZyB0aGUgdGV4dENvbnRlbnQuXG4gIGluY2x1ZGVIdG1sOiBmYWxzZSxcbiAgLy8gb25jbGljayBmdW5jdGlvbiB0byBhcHBseSB0byBhbGwgbGlua3MgaW4gdG9jLiB3aWxsIGJlIGNhbGxlZCB3aXRoXG4gIC8vIHRoZSBldmVudCBhcyB0aGUgZmlyc3QgcGFyYW1ldGVyLCBhbmQgdGhpcyBjYW4gYmUgdXNlZCB0byBzdG9wLFxuICAvLyBwcm9wYWdhdGlvbiwgcHJldmVudCBkZWZhdWx0IG9yIHBlcmZvcm0gYWN0aW9uXG4gIG9uQ2xpY2s6IGZhbHNlXG59XG5cblxuXG4vLy8vLy8vLy8vLy8vLy8vLy9cbi8vIFdFQlBBQ0sgRk9PVEVSXG4vLyAuL3NyYy9qcy9kZWZhdWx0LW9wdGlvbnMuanNcbi8vIG1vZHVsZSBpZCA9IDJcbi8vIG1vZHVsZSBjaHVua3MgPSAwIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7Iiwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///2\n"); + +/***/ }), +/* 3 */ +/*!******************************!*\ + !*** ./src/js/build-html.js ***! + \******************************/ +/*! dynamic exports provided */ +/*! all exports used */ +/***/ (function(module, exports) { + +eval("/**\n * This file is responsible for building the DOM and updating DOM state.\n *\n * @author Tim Scanlin\n */\n\nmodule.exports = function (options) {\n var forEach = [].forEach\n var some = [].some\n var body = document.body\n var currentlyHighlighting = true\n var SPACE_CHAR = ' '\n\n /**\n * Create link and list elements.\n * @param {Object} d\n * @param {HTMLElement} container\n * @return {HTMLElement}\n */\n function createEl (d, container) {\n var link = container.appendChild(createLink(d))\n if (d.children.length) {\n var list = createList(d.isCollapsed)\n d.children.forEach(function (child) {\n createEl(child, list)\n })\n link.appendChild(list)\n }\n }\n\n /**\n * Render nested heading array data into a given selector.\n * @param {String} selector\n * @param {Array} data\n * @return {HTMLElement}\n */\n function render (selector, data) {\n var collapsed = false\n var container = createList(collapsed)\n\n data.forEach(function (d) {\n createEl(d, container)\n })\n\n var parent = document.querySelector(selector)\n\n // Return if no parent is found.\n if (parent === null) {\n return\n }\n\n // Remove existing child if it exists.\n if (parent.firstChild) {\n parent.removeChild(parent.firstChild)\n }\n\n // Just return the parent and don't append the list if no links are found.\n if (data.length === 0) {\n return parent\n }\n\n // Append the Elements that have been created\n return parent.appendChild(container)\n }\n\n /**\n * Create link element.\n * @param {Object} data\n * @return {HTMLElement}\n */\n function createLink (data) {\n var item = document.createElement('li')\n var a = document.createElement('a')\n if (options.listItemClass) {\n item.setAttribute('class', options.listItemClass)\n }\n\n if (options.onClick) {\n a.onclick = options.onClick\n }\n\n if (options.includeHtml && data.childNodes.length) {\n forEach.call(data.childNodes, function (node) {\n a.appendChild(node.cloneNode(true))\n })\n } else {\n // Default behavior.\n a.textContent = data.textContent\n }\n a.setAttribute('href', '#' + data.id)\n a.setAttribute('class', options.linkClass +\n SPACE_CHAR + 'node-name--' + data.nodeName +\n SPACE_CHAR + options.extraLinkClasses)\n item.appendChild(a)\n return item\n }\n\n /**\n * Create list element.\n * @param {Boolean} isCollapsed\n * @return {HTMLElement}\n */\n function createList (isCollapsed) {\n var list = document.createElement('ol')\n var classes = options.listClass +\n SPACE_CHAR + options.extraListClasses\n if (isCollapsed) {\n classes += SPACE_CHAR + options.collapsibleClass\n classes += SPACE_CHAR + options.isCollapsedClass\n }\n list.setAttribute('class', classes)\n return list\n }\n\n /**\n * Update fixed sidebar class.\n * @return {HTMLElement}\n */\n function updateFixedSidebarClass () {\n var top = document.documentElement.scrollTop || body.scrollTop\n var posFixedEl = document.querySelector(options.positionFixedSelector)\n\n if (options.fixedSidebarOffset === 'auto') {\n options.fixedSidebarOffset = document.querySelector(options.tocSelector).offsetTop\n }\n\n if (top > options.fixedSidebarOffset) {\n if (posFixedEl.className.indexOf(options.positionFixedClass) === -1) {\n posFixedEl.className += SPACE_CHAR + options.positionFixedClass\n }\n } else {\n posFixedEl.className = posFixedEl.className.split(SPACE_CHAR + options.positionFixedClass).join('')\n }\n }\n\n /**\n * Update TOC highlighting and collpased groupings.\n */\n function updateToc (headingsArray) {\n var top = document.documentElement.scrollTop || body.scrollTop\n\n // Add fixed class at offset\n if (options.positionFixedSelector) {\n updateFixedSidebarClass()\n }\n\n // Get the top most heading currently visible on the page so we know what to highlight.\n var headings = headingsArray\n var topHeader\n // Using some instead of each so that we can escape early.\n if (currentlyHighlighting &&\n document.querySelector(options.tocSelector) !== null &&\n headings.length > 0) {\n some.call(headings, function (heading, i) {\n if (heading.offsetTop > top + options.headingsOffset + 10) {\n // Don't allow negative index value.\n var index = (i === 0) ? i : i - 1\n topHeader = headings[index]\n return true\n } else if (i === headings.length - 1) {\n // This allows scrolling for the last heading on the page.\n topHeader = headings[headings.length - 1]\n return true\n }\n })\n\n // Remove the active class from the other tocLinks.\n var tocLinks = document.querySelector(options.tocSelector)\n .querySelectorAll('.' + options.linkClass)\n forEach.call(tocLinks, function (tocLink) {\n tocLink.className = tocLink.className.split(SPACE_CHAR + options.activeLinkClass).join('')\n })\n var tocLis = document.querySelector(options.tocSelector)\n .querySelectorAll('.' + options.listItemClass)\n forEach.call(tocLis, function (tocLi) {\n tocLi.className = tocLi.className.split(SPACE_CHAR + options.activeListItemClass).join('')\n })\n\n // Add the active class to the active tocLink.\n var activeTocLink = document.querySelector(options.tocSelector)\n .querySelector('.' + options.linkClass +\n '.node-name--' + topHeader.nodeName +\n '[href=\"#' + topHeader.id + '\"]')\n if (activeTocLink.className.indexOf(options.activeLinkClass) === -1) {\n activeTocLink.className += SPACE_CHAR + options.activeLinkClass\n }\n var li = activeTocLink.parentNode\n if (li && li.className.indexOf(options.activeListItemClass) === -1) {\n li.className += SPACE_CHAR + options.activeListItemClass\n }\n\n var tocLists = document.querySelector(options.tocSelector)\n .querySelectorAll('.' + options.listClass + '.' + options.collapsibleClass)\n\n // Collapse the other collapsible lists.\n forEach.call(tocLists, function (list) {\n if (list.className.indexOf(options.isCollapsedClass) === -1) {\n list.className += SPACE_CHAR + options.isCollapsedClass\n }\n })\n\n // Expand the active link's collapsible list and its sibling if applicable.\n if (activeTocLink.nextSibling && activeTocLink.nextSibling.className.indexOf(options.isCollapsedClass) !== -1) {\n activeTocLink.nextSibling.className = activeTocLink.nextSibling.className.split(SPACE_CHAR + options.isCollapsedClass).join('')\n }\n removeCollapsedFromParents(activeTocLink.parentNode.parentNode)\n }\n }\n\n /**\n * Remove collpased class from parent elements.\n * @param {HTMLElement} element\n * @return {HTMLElement}\n */\n function removeCollapsedFromParents (element) {\n if (element.className.indexOf(options.collapsibleClass) !== -1 && element.className.indexOf(options.isCollapsedClass) !== -1) {\n element.className = element.className.split(SPACE_CHAR + options.isCollapsedClass).join('')\n return removeCollapsedFromParents(element.parentNode.parentNode)\n }\n return element\n }\n\n /**\n * Disable TOC Animation when a link is clicked.\n * @param {Event} event\n */\n function disableTocAnimation (event) {\n var target = event.target || event.srcElement\n if (typeof target.className !== 'string' || target.className.indexOf(options.linkClass) === -1) {\n return\n }\n // Bind to tocLink clicks to temporarily disable highlighting\n // while smoothScroll is animating.\n currentlyHighlighting = false\n }\n\n /**\n * Enable TOC Animation.\n */\n function enableTocAnimation () {\n currentlyHighlighting = true\n }\n\n return {\n enableTocAnimation: enableTocAnimation,\n disableTocAnimation: disableTocAnimation,\n render: render,\n updateToc: updateToc\n }\n}\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMy5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy8uL3NyYy9qcy9idWlsZC1odG1sLmpzPzdkMDEiXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBUaGlzIGZpbGUgaXMgcmVzcG9uc2libGUgZm9yIGJ1aWxkaW5nIHRoZSBET00gYW5kIHVwZGF0aW5nIERPTSBzdGF0ZS5cbiAqXG4gKiBAYXV0aG9yIFRpbSBTY2FubGluXG4gKi9cblxubW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiAob3B0aW9ucykge1xuICB2YXIgZm9yRWFjaCA9IFtdLmZvckVhY2hcbiAgdmFyIHNvbWUgPSBbXS5zb21lXG4gIHZhciBib2R5ID0gZG9jdW1lbnQuYm9keVxuICB2YXIgY3VycmVudGx5SGlnaGxpZ2h0aW5nID0gdHJ1ZVxuICB2YXIgU1BBQ0VfQ0hBUiA9ICcgJ1xuXG4gIC8qKlxuICAgKiBDcmVhdGUgbGluayBhbmQgbGlzdCBlbGVtZW50cy5cbiAgICogQHBhcmFtIHtPYmplY3R9IGRcbiAgICogQHBhcmFtIHtIVE1MRWxlbWVudH0gY29udGFpbmVyXG4gICAqIEByZXR1cm4ge0hUTUxFbGVtZW50fVxuICAgKi9cbiAgZnVuY3Rpb24gY3JlYXRlRWwgKGQsIGNvbnRhaW5lcikge1xuICAgIHZhciBsaW5rID0gY29udGFpbmVyLmFwcGVuZENoaWxkKGNyZWF0ZUxpbmsoZCkpXG4gICAgaWYgKGQuY2hpbGRyZW4ubGVuZ3RoKSB7XG4gICAgICB2YXIgbGlzdCA9IGNyZWF0ZUxpc3QoZC5pc0NvbGxhcHNlZClcbiAgICAgIGQuY2hpbGRyZW4uZm9yRWFjaChmdW5jdGlvbiAoY2hpbGQpIHtcbiAgICAgICAgY3JlYXRlRWwoY2hpbGQsIGxpc3QpXG4gICAgICB9KVxuICAgICAgbGluay5hcHBlbmRDaGlsZChsaXN0KVxuICAgIH1cbiAgfVxuXG4gIC8qKlxuICAgKiBSZW5kZXIgbmVzdGVkIGhlYWRpbmcgYXJyYXkgZGF0YSBpbnRvIGEgZ2l2ZW4gc2VsZWN0b3IuXG4gICAqIEBwYXJhbSB7U3RyaW5nfSBzZWxlY3RvclxuICAgKiBAcGFyYW0ge0FycmF5fSBkYXRhXG4gICAqIEByZXR1cm4ge0hUTUxFbGVtZW50fVxuICAgKi9cbiAgZnVuY3Rpb24gcmVuZGVyIChzZWxlY3RvciwgZGF0YSkge1xuICAgIHZhciBjb2xsYXBzZWQgPSBmYWxzZVxuICAgIHZhciBjb250YWluZXIgPSBjcmVhdGVMaXN0KGNvbGxhcHNlZClcblxuICAgIGRhdGEuZm9yRWFjaChmdW5jdGlvbiAoZCkge1xuICAgICAgY3JlYXRlRWwoZCwgY29udGFpbmVyKVxuICAgIH0pXG5cbiAgICB2YXIgcGFyZW50ID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcihzZWxlY3RvcilcblxuICAgIC8vIFJldHVybiBpZiBubyBwYXJlbnQgaXMgZm91bmQuXG4gICAgaWYgKHBhcmVudCA9PT0gbnVsbCkge1xuICAgICAgcmV0dXJuXG4gICAgfVxuXG4gICAgLy8gUmVtb3ZlIGV4aXN0aW5nIGNoaWxkIGlmIGl0IGV4aXN0cy5cbiAgICBpZiAocGFyZW50LmZpcnN0Q2hpbGQpIHtcbiAgICAgIHBhcmVudC5yZW1vdmVDaGlsZChwYXJlbnQuZmlyc3RDaGlsZClcbiAgICB9XG5cbiAgICAvLyBKdXN0IHJldHVybiB0aGUgcGFyZW50IGFuZCBkb24ndCBhcHBlbmQgdGhlIGxpc3QgaWYgbm8gbGlua3MgYXJlIGZvdW5kLlxuICAgIGlmIChkYXRhLmxlbmd0aCA9PT0gMCkge1xuICAgICAgcmV0dXJuIHBhcmVudFxuICAgIH1cblxuICAgIC8vIEFwcGVuZCB0aGUgRWxlbWVudHMgdGhhdCBoYXZlIGJlZW4gY3JlYXRlZFxuICAgIHJldHVybiBwYXJlbnQuYXBwZW5kQ2hpbGQoY29udGFpbmVyKVxuICB9XG5cbiAgLyoqXG4gICAqIENyZWF0ZSBsaW5rIGVsZW1lbnQuXG4gICAqIEBwYXJhbSB7T2JqZWN0fSBkYXRhXG4gICAqIEByZXR1cm4ge0hUTUxFbGVtZW50fVxuICAgKi9cbiAgZnVuY3Rpb24gY3JlYXRlTGluayAoZGF0YSkge1xuICAgIHZhciBpdGVtID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnbGknKVxuICAgIHZhciBhID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnYScpXG4gICAgaWYgKG9wdGlvbnMubGlzdEl0ZW1DbGFzcykge1xuICAgICAgaXRlbS5zZXRBdHRyaWJ1dGUoJ2NsYXNzJywgb3B0aW9ucy5saXN0SXRlbUNsYXNzKVxuICAgIH1cblxuICAgIGlmIChvcHRpb25zLm9uQ2xpY2spIHtcbiAgICAgIGEub25jbGljayA9IG9wdGlvbnMub25DbGlja1xuICAgIH1cblxuICAgIGlmIChvcHRpb25zLmluY2x1ZGVIdG1sICYmIGRhdGEuY2hpbGROb2Rlcy5sZW5ndGgpIHtcbiAgICAgIGZvckVhY2guY2FsbChkYXRhLmNoaWxkTm9kZXMsIGZ1bmN0aW9uIChub2RlKSB7XG4gICAgICAgIGEuYXBwZW5kQ2hpbGQobm9kZS5jbG9uZU5vZGUodHJ1ZSkpXG4gICAgICB9KVxuICAgIH0gZWxzZSB7XG4gICAgICAvLyBEZWZhdWx0IGJlaGF2aW9yLlxuICAgICAgYS50ZXh0Q29udGVudCA9IGRhdGEudGV4dENvbnRlbnRcbiAgICB9XG4gICAgYS5zZXRBdHRyaWJ1dGUoJ2hyZWYnLCAnIycgKyBkYXRhLmlkKVxuICAgIGEuc2V0QXR0cmlidXRlKCdjbGFzcycsIG9wdGlvbnMubGlua0NsYXNzICtcbiAgICAgIFNQQUNFX0NIQVIgKyAnbm9kZS1uYW1lLS0nICsgZGF0YS5ub2RlTmFtZSArXG4gICAgICBTUEFDRV9DSEFSICsgb3B0aW9ucy5leHRyYUxpbmtDbGFzc2VzKVxuICAgIGl0ZW0uYXBwZW5kQ2hpbGQoYSlcbiAgICByZXR1cm4gaXRlbVxuICB9XG5cbiAgLyoqXG4gICAqIENyZWF0ZSBsaXN0IGVsZW1lbnQuXG4gICAqIEBwYXJhbSB7Qm9vbGVhbn0gaXNDb2xsYXBzZWRcbiAgICogQHJldHVybiB7SFRNTEVsZW1lbnR9XG4gICAqL1xuICBmdW5jdGlvbiBjcmVhdGVMaXN0IChpc0NvbGxhcHNlZCkge1xuICAgIHZhciBsaXN0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnb2wnKVxuICAgIHZhciBjbGFzc2VzID0gb3B0aW9ucy5saXN0Q2xhc3MgK1xuICAgICAgU1BBQ0VfQ0hBUiArIG9wdGlvbnMuZXh0cmFMaXN0Q2xhc3Nlc1xuICAgIGlmIChpc0NvbGxhcHNlZCkge1xuICAgICAgY2xhc3NlcyArPSBTUEFDRV9DSEFSICsgb3B0aW9ucy5jb2xsYXBzaWJsZUNsYXNzXG4gICAgICBjbGFzc2VzICs9IFNQQUNFX0NIQVIgKyBvcHRpb25zLmlzQ29sbGFwc2VkQ2xhc3NcbiAgICB9XG4gICAgbGlzdC5zZXRBdHRyaWJ1dGUoJ2NsYXNzJywgY2xhc3NlcylcbiAgICByZXR1cm4gbGlzdFxuICB9XG5cbiAgLyoqXG4gICAqIFVwZGF0ZSBmaXhlZCBzaWRlYmFyIGNsYXNzLlxuICAgKiBAcmV0dXJuIHtIVE1MRWxlbWVudH1cbiAgICovXG4gIGZ1bmN0aW9uIHVwZGF0ZUZpeGVkU2lkZWJhckNsYXNzICgpIHtcbiAgICB2YXIgdG9wID0gZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50LnNjcm9sbFRvcCB8fCBib2R5LnNjcm9sbFRvcFxuICAgIHZhciBwb3NGaXhlZEVsID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcihvcHRpb25zLnBvc2l0aW9uRml4ZWRTZWxlY3RvcilcblxuICAgIGlmIChvcHRpb25zLmZpeGVkU2lkZWJhck9mZnNldCA9PT0gJ2F1dG8nKSB7XG4gICAgICBvcHRpb25zLmZpeGVkU2lkZWJhck9mZnNldCA9IGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3Iob3B0aW9ucy50b2NTZWxlY3Rvcikub2Zmc2V0VG9wXG4gICAgfVxuXG4gICAgaWYgKHRvcCA+IG9wdGlvbnMuZml4ZWRTaWRlYmFyT2Zmc2V0KSB7XG4gICAgICBpZiAocG9zRml4ZWRFbC5jbGFzc05hbWUuaW5kZXhPZihvcHRpb25zLnBvc2l0aW9uRml4ZWRDbGFzcykgPT09IC0xKSB7XG4gICAgICAgIHBvc0ZpeGVkRWwuY2xhc3NOYW1lICs9IFNQQUNFX0NIQVIgKyBvcHRpb25zLnBvc2l0aW9uRml4ZWRDbGFzc1xuICAgICAgfVxuICAgIH0gZWxzZSB7XG4gICAgICBwb3NGaXhlZEVsLmNsYXNzTmFtZSA9IHBvc0ZpeGVkRWwuY2xhc3NOYW1lLnNwbGl0KFNQQUNFX0NIQVIgKyBvcHRpb25zLnBvc2l0aW9uRml4ZWRDbGFzcykuam9pbignJylcbiAgICB9XG4gIH1cblxuICAvKipcbiAgICogVXBkYXRlIFRPQyBoaWdobGlnaHRpbmcgYW5kIGNvbGxwYXNlZCBncm91cGluZ3MuXG4gICAqL1xuICBmdW5jdGlvbiB1cGRhdGVUb2MgKGhlYWRpbmdzQXJyYXkpIHtcbiAgICB2YXIgdG9wID0gZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50LnNjcm9sbFRvcCB8fCBib2R5LnNjcm9sbFRvcFxuXG4gICAgLy8gQWRkIGZpeGVkIGNsYXNzIGF0IG9mZnNldFxuICAgIGlmIChvcHRpb25zLnBvc2l0aW9uRml4ZWRTZWxlY3Rvcikge1xuICAgICAgdXBkYXRlRml4ZWRTaWRlYmFyQ2xhc3MoKVxuICAgIH1cblxuICAgIC8vIEdldCB0aGUgdG9wIG1vc3QgaGVhZGluZyBjdXJyZW50bHkgdmlzaWJsZSBvbiB0aGUgcGFnZSBzbyB3ZSBrbm93IHdoYXQgdG8gaGlnaGxpZ2h0LlxuICAgIHZhciBoZWFkaW5ncyA9IGhlYWRpbmdzQXJyYXlcbiAgICB2YXIgdG9wSGVhZGVyXG4gICAgLy8gVXNpbmcgc29tZSBpbnN0ZWFkIG9mIGVhY2ggc28gdGhhdCB3ZSBjYW4gZXNjYXBlIGVhcmx5LlxuICAgIGlmIChjdXJyZW50bHlIaWdobGlnaHRpbmcgJiZcbiAgICAgIGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3Iob3B0aW9ucy50b2NTZWxlY3RvcikgIT09IG51bGwgJiZcbiAgICAgIGhlYWRpbmdzLmxlbmd0aCA+IDApIHtcbiAgICAgIHNvbWUuY2FsbChoZWFkaW5ncywgZnVuY3Rpb24gKGhlYWRpbmcsIGkpIHtcbiAgICAgICAgaWYgKGhlYWRpbmcub2Zmc2V0VG9wID4gdG9wICsgb3B0aW9ucy5oZWFkaW5nc09mZnNldCArIDEwKSB7XG4gICAgICAgICAgLy8gRG9uJ3QgYWxsb3cgbmVnYXRpdmUgaW5kZXggdmFsdWUuXG4gICAgICAgICAgdmFyIGluZGV4ID0gKGkgPT09IDApID8gaSA6IGkgLSAxXG4gICAgICAgICAgdG9wSGVhZGVyID0gaGVhZGluZ3NbaW5kZXhdXG4gICAgICAgICAgcmV0dXJuIHRydWVcbiAgICAgICAgfSBlbHNlIGlmIChpID09PSBoZWFkaW5ncy5sZW5ndGggLSAxKSB7XG4gICAgICAgICAgLy8gVGhpcyBhbGxvd3Mgc2Nyb2xsaW5nIGZvciB0aGUgbGFzdCBoZWFkaW5nIG9uIHRoZSBwYWdlLlxuICAgICAgICAgIHRvcEhlYWRlciA9IGhlYWRpbmdzW2hlYWRpbmdzLmxlbmd0aCAtIDFdXG4gICAgICAgICAgcmV0dXJuIHRydWVcbiAgICAgICAgfVxuICAgICAgfSlcblxuICAgICAgLy8gUmVtb3ZlIHRoZSBhY3RpdmUgY2xhc3MgZnJvbSB0aGUgb3RoZXIgdG9jTGlua3MuXG4gICAgICB2YXIgdG9jTGlua3MgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKG9wdGlvbnMudG9jU2VsZWN0b3IpXG4gICAgICAgIC5xdWVyeVNlbGVjdG9yQWxsKCcuJyArIG9wdGlvbnMubGlua0NsYXNzKVxuICAgICAgZm9yRWFjaC5jYWxsKHRvY0xpbmtzLCBmdW5jdGlvbiAodG9jTGluaykge1xuICAgICAgICB0b2NMaW5rLmNsYXNzTmFtZSA9IHRvY0xpbmsuY2xhc3NOYW1lLnNwbGl0KFNQQUNFX0NIQVIgKyBvcHRpb25zLmFjdGl2ZUxpbmtDbGFzcykuam9pbignJylcbiAgICAgIH0pXG4gICAgICB2YXIgdG9jTGlzID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcihvcHRpb25zLnRvY1NlbGVjdG9yKVxuICAgICAgICAucXVlcnlTZWxlY3RvckFsbCgnLicgKyBvcHRpb25zLmxpc3RJdGVtQ2xhc3MpXG4gICAgICBmb3JFYWNoLmNhbGwodG9jTGlzLCBmdW5jdGlvbiAodG9jTGkpIHtcbiAgICAgICAgdG9jTGkuY2xhc3NOYW1lID0gdG9jTGkuY2xhc3NOYW1lLnNwbGl0KFNQQUNFX0NIQVIgKyBvcHRpb25zLmFjdGl2ZUxpc3RJdGVtQ2xhc3MpLmpvaW4oJycpXG4gICAgICB9KVxuXG4gICAgICAvLyBBZGQgdGhlIGFjdGl2ZSBjbGFzcyB0byB0aGUgYWN0aXZlIHRvY0xpbmsuXG4gICAgICB2YXIgYWN0aXZlVG9jTGluayA9IGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3Iob3B0aW9ucy50b2NTZWxlY3RvcilcbiAgICAgICAgLnF1ZXJ5U2VsZWN0b3IoJy4nICsgb3B0aW9ucy5saW5rQ2xhc3MgK1xuICAgICAgICAgICcubm9kZS1uYW1lLS0nICsgdG9wSGVhZGVyLm5vZGVOYW1lICtcbiAgICAgICAgICAnW2hyZWY9XCIjJyArIHRvcEhlYWRlci5pZCArICdcIl0nKVxuICAgICAgaWYgKGFjdGl2ZVRvY0xpbmsuY2xhc3NOYW1lLmluZGV4T2Yob3B0aW9ucy5hY3RpdmVMaW5rQ2xhc3MpID09PSAtMSkge1xuICAgICAgICBhY3RpdmVUb2NMaW5rLmNsYXNzTmFtZSArPSBTUEFDRV9DSEFSICsgb3B0aW9ucy5hY3RpdmVMaW5rQ2xhc3NcbiAgICAgIH1cbiAgICAgIHZhciBsaSA9IGFjdGl2ZVRvY0xpbmsucGFyZW50Tm9kZVxuICAgICAgaWYgKGxpICYmIGxpLmNsYXNzTmFtZS5pbmRleE9mKG9wdGlvbnMuYWN0aXZlTGlzdEl0ZW1DbGFzcykgPT09IC0xKSB7XG4gICAgICAgIGxpLmNsYXNzTmFtZSArPSBTUEFDRV9DSEFSICsgb3B0aW9ucy5hY3RpdmVMaXN0SXRlbUNsYXNzXG4gICAgICB9XG5cbiAgICAgIHZhciB0b2NMaXN0cyA9IGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3Iob3B0aW9ucy50b2NTZWxlY3RvcilcbiAgICAgICAgLnF1ZXJ5U2VsZWN0b3JBbGwoJy4nICsgb3B0aW9ucy5saXN0Q2xhc3MgKyAnLicgKyBvcHRpb25zLmNvbGxhcHNpYmxlQ2xhc3MpXG5cbiAgICAgIC8vIENvbGxhcHNlIHRoZSBvdGhlciBjb2xsYXBzaWJsZSBsaXN0cy5cbiAgICAgIGZvckVhY2guY2FsbCh0b2NMaXN0cywgZnVuY3Rpb24gKGxpc3QpIHtcbiAgICAgICAgaWYgKGxpc3QuY2xhc3NOYW1lLmluZGV4T2Yob3B0aW9ucy5pc0NvbGxhcHNlZENsYXNzKSA9PT0gLTEpIHtcbiAgICAgICAgICBsaXN0LmNsYXNzTmFtZSArPSBTUEFDRV9DSEFSICsgb3B0aW9ucy5pc0NvbGxhcHNlZENsYXNzXG4gICAgICAgIH1cbiAgICAgIH0pXG5cbiAgICAgIC8vIEV4cGFuZCB0aGUgYWN0aXZlIGxpbmsncyBjb2xsYXBzaWJsZSBsaXN0IGFuZCBpdHMgc2libGluZyBpZiBhcHBsaWNhYmxlLlxuICAgICAgaWYgKGFjdGl2ZVRvY0xpbmsubmV4dFNpYmxpbmcgJiYgYWN0aXZlVG9jTGluay5uZXh0U2libGluZy5jbGFzc05hbWUuaW5kZXhPZihvcHRpb25zLmlzQ29sbGFwc2VkQ2xhc3MpICE9PSAtMSkge1xuICAgICAgICBhY3RpdmVUb2NMaW5rLm5leHRTaWJsaW5nLmNsYXNzTmFtZSA9IGFjdGl2ZVRvY0xpbmsubmV4dFNpYmxpbmcuY2xhc3NOYW1lLnNwbGl0KFNQQUNFX0NIQVIgKyBvcHRpb25zLmlzQ29sbGFwc2VkQ2xhc3MpLmpvaW4oJycpXG4gICAgICB9XG4gICAgICByZW1vdmVDb2xsYXBzZWRGcm9tUGFyZW50cyhhY3RpdmVUb2NMaW5rLnBhcmVudE5vZGUucGFyZW50Tm9kZSlcbiAgICB9XG4gIH1cblxuICAvKipcbiAgICogUmVtb3ZlIGNvbGxwYXNlZCBjbGFzcyBmcm9tIHBhcmVudCBlbGVtZW50cy5cbiAgICogQHBhcmFtIHtIVE1MRWxlbWVudH0gZWxlbWVudFxuICAgKiBAcmV0dXJuIHtIVE1MRWxlbWVudH1cbiAgICovXG4gIGZ1bmN0aW9uIHJlbW92ZUNvbGxhcHNlZEZyb21QYXJlbnRzIChlbGVtZW50KSB7XG4gICAgaWYgKGVsZW1lbnQuY2xhc3NOYW1lLmluZGV4T2Yob3B0aW9ucy5jb2xsYXBzaWJsZUNsYXNzKSAhPT0gLTEgJiYgZWxlbWVudC5jbGFzc05hbWUuaW5kZXhPZihvcHRpb25zLmlzQ29sbGFwc2VkQ2xhc3MpICE9PSAtMSkge1xuICAgICAgZWxlbWVudC5jbGFzc05hbWUgPSBlbGVtZW50LmNsYXNzTmFtZS5zcGxpdChTUEFDRV9DSEFSICsgb3B0aW9ucy5pc0NvbGxhcHNlZENsYXNzKS5qb2luKCcnKVxuICAgICAgcmV0dXJuIHJlbW92ZUNvbGxhcHNlZEZyb21QYXJlbnRzKGVsZW1lbnQucGFyZW50Tm9kZS5wYXJlbnROb2RlKVxuICAgIH1cbiAgICByZXR1cm4gZWxlbWVudFxuICB9XG5cbiAgLyoqXG4gICAqIERpc2FibGUgVE9DIEFuaW1hdGlvbiB3aGVuIGEgbGluayBpcyBjbGlja2VkLlxuICAgKiBAcGFyYW0ge0V2ZW50fSBldmVudFxuICAgKi9cbiAgZnVuY3Rpb24gZGlzYWJsZVRvY0FuaW1hdGlvbiAoZXZlbnQpIHtcbiAgICB2YXIgdGFyZ2V0ID0gZXZlbnQudGFyZ2V0IHx8IGV2ZW50LnNyY0VsZW1lbnRcbiAgICBpZiAodHlwZW9mIHRhcmdldC5jbGFzc05hbWUgIT09ICdzdHJpbmcnIHx8IHRhcmdldC5jbGFzc05hbWUuaW5kZXhPZihvcHRpb25zLmxpbmtDbGFzcykgPT09IC0xKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG4gICAgLy8gQmluZCB0byB0b2NMaW5rIGNsaWNrcyB0byB0ZW1wb3JhcmlseSBkaXNhYmxlIGhpZ2hsaWdodGluZ1xuICAgIC8vIHdoaWxlIHNtb290aFNjcm9sbCBpcyBhbmltYXRpbmcuXG4gICAgY3VycmVudGx5SGlnaGxpZ2h0aW5nID0gZmFsc2VcbiAgfVxuXG4gIC8qKlxuICAgKiBFbmFibGUgVE9DIEFuaW1hdGlvbi5cbiAgICovXG4gIGZ1bmN0aW9uIGVuYWJsZVRvY0FuaW1hdGlvbiAoKSB7XG4gICAgY3VycmVudGx5SGlnaGxpZ2h0aW5nID0gdHJ1ZVxuICB9XG5cbiAgcmV0dXJuIHtcbiAgICBlbmFibGVUb2NBbmltYXRpb246IGVuYWJsZVRvY0FuaW1hdGlvbixcbiAgICBkaXNhYmxlVG9jQW5pbWF0aW9uOiBkaXNhYmxlVG9jQW5pbWF0aW9uLFxuICAgIHJlbmRlcjogcmVuZGVyLFxuICAgIHVwZGF0ZVRvYzogdXBkYXRlVG9jXG4gIH1cbn1cblxuXG5cbi8vLy8vLy8vLy8vLy8vLy8vL1xuLy8gV0VCUEFDSyBGT09URVJcbi8vIC4vc3JjL2pzL2J1aWxkLWh0bWwuanNcbi8vIG1vZHVsZSBpZCA9IDNcbi8vIG1vZHVsZSBjaHVua3MgPSAwIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTsiLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///3\n"); + +/***/ }), +/* 4 */ +/*!*********************************!*\ + !*** ./src/js/parse-content.js ***! + \*********************************/ +/*! dynamic exports provided */ +/*! all exports used */ +/***/ (function(module, exports) { + +eval("/**\n * This file is responsible for parsing the content from the DOM and making\n * sure data is nested properly.\n *\n * @author Tim Scanlin\n */\n\nmodule.exports = function parseContent (options) {\n var reduce = [].reduce\n\n /**\n * Get the last item in an array and return a reference to it.\n * @param {Array} array\n * @return {Object}\n */\n function getLastItem (array) {\n return array[array.length - 1]\n }\n\n /**\n * Get heading level for a heading dom node.\n * @param {HTMLElement} heading\n * @return {Number}\n */\n function getHeadingLevel (heading) {\n return +heading.nodeName.split('H').join('')\n }\n\n /**\n * Get important properties from a heading element and store in a plain object.\n * @param {HTMLElement} heading\n * @return {Object}\n */\n function getHeadingObject (heading) {\n var obj = {\n id: heading.id,\n children: [],\n nodeName: heading.nodeName,\n headingLevel: getHeadingLevel(heading),\n textContent: heading.textContent.trim()\n }\n\n if (options.includeHtml) {\n obj.childNodes = heading.childNodes\n }\n\n return obj\n }\n\n /**\n * Add a node to the nested array.\n * @param {Object} node\n * @param {Array} nest\n * @return {Array}\n */\n function addNode (node, nest) {\n var obj = getHeadingObject(node)\n var level = getHeadingLevel(node)\n var array = nest\n var lastItem = getLastItem(array)\n var lastItemLevel = lastItem\n ? lastItem.headingLevel\n : 0\n var counter = level - lastItemLevel\n\n while (counter > 0) {\n lastItem = getLastItem(array)\n if (lastItem && lastItem.children !== undefined) {\n array = lastItem.children\n }\n counter--\n }\n\n if (level >= options.collapseDepth) {\n obj.isCollapsed = true\n }\n\n array.push(obj)\n return array\n }\n\n /**\n * Select headings in content area, exclude any selector in options.ignoreSelector\n * @param {String} contentSelector\n * @param {Array} headingSelector\n * @return {Array}\n */\n function selectHeadings (contentSelector, headingSelector) {\n var selectors = headingSelector\n if (options.ignoreSelector) {\n selectors = headingSelector.split(',')\n .map(function mapSelectors (selector) {\n return selector.trim() + ':not(' + options.ignoreSelector + ')'\n })\n }\n try {\n return document.querySelector(contentSelector)\n .querySelectorAll(selectors)\n } catch (e) {\n console.warn('Element not found: ' + contentSelector); // eslint-disable-line\n return null\n }\n }\n\n /**\n * Nest headings array into nested arrays with 'children' property.\n * @param {Array} headingsArray\n * @return {Object}\n */\n function nestHeadingsArray (headingsArray) {\n return reduce.call(headingsArray, function reducer (prev, curr) {\n var currentHeading = getHeadingObject(curr)\n\n addNode(currentHeading, prev.nest)\n return prev\n }, {\n nest: []\n })\n }\n\n return {\n nestHeadingsArray: nestHeadingsArray,\n selectHeadings: selectHeadings\n }\n}\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiNC5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy8uL3NyYy9qcy9wYXJzZS1jb250ZW50LmpzPzg3ZjAiXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBUaGlzIGZpbGUgaXMgcmVzcG9uc2libGUgZm9yIHBhcnNpbmcgdGhlIGNvbnRlbnQgZnJvbSB0aGUgRE9NIGFuZCBtYWtpbmdcbiAqIHN1cmUgZGF0YSBpcyBuZXN0ZWQgcHJvcGVybHkuXG4gKlxuICogQGF1dGhvciBUaW0gU2NhbmxpblxuICovXG5cbm1vZHVsZS5leHBvcnRzID0gZnVuY3Rpb24gcGFyc2VDb250ZW50IChvcHRpb25zKSB7XG4gIHZhciByZWR1Y2UgPSBbXS5yZWR1Y2VcblxuICAvKipcbiAgICogR2V0IHRoZSBsYXN0IGl0ZW0gaW4gYW4gYXJyYXkgYW5kIHJldHVybiBhIHJlZmVyZW5jZSB0byBpdC5cbiAgICogQHBhcmFtIHtBcnJheX0gYXJyYXlcbiAgICogQHJldHVybiB7T2JqZWN0fVxuICAgKi9cbiAgZnVuY3Rpb24gZ2V0TGFzdEl0ZW0gKGFycmF5KSB7XG4gICAgcmV0dXJuIGFycmF5W2FycmF5Lmxlbmd0aCAtIDFdXG4gIH1cblxuICAvKipcbiAgICogR2V0IGhlYWRpbmcgbGV2ZWwgZm9yIGEgaGVhZGluZyBkb20gbm9kZS5cbiAgICogQHBhcmFtIHtIVE1MRWxlbWVudH0gaGVhZGluZ1xuICAgKiBAcmV0dXJuIHtOdW1iZXJ9XG4gICAqL1xuICBmdW5jdGlvbiBnZXRIZWFkaW5nTGV2ZWwgKGhlYWRpbmcpIHtcbiAgICByZXR1cm4gK2hlYWRpbmcubm9kZU5hbWUuc3BsaXQoJ0gnKS5qb2luKCcnKVxuICB9XG5cbiAgLyoqXG4gICAqIEdldCBpbXBvcnRhbnQgcHJvcGVydGllcyBmcm9tIGEgaGVhZGluZyBlbGVtZW50IGFuZCBzdG9yZSBpbiBhIHBsYWluIG9iamVjdC5cbiAgICogQHBhcmFtIHtIVE1MRWxlbWVudH0gaGVhZGluZ1xuICAgKiBAcmV0dXJuIHtPYmplY3R9XG4gICAqL1xuICBmdW5jdGlvbiBnZXRIZWFkaW5nT2JqZWN0IChoZWFkaW5nKSB7XG4gICAgdmFyIG9iaiA9IHtcbiAgICAgIGlkOiBoZWFkaW5nLmlkLFxuICAgICAgY2hpbGRyZW46IFtdLFxuICAgICAgbm9kZU5hbWU6IGhlYWRpbmcubm9kZU5hbWUsXG4gICAgICBoZWFkaW5nTGV2ZWw6IGdldEhlYWRpbmdMZXZlbChoZWFkaW5nKSxcbiAgICAgIHRleHRDb250ZW50OiBoZWFkaW5nLnRleHRDb250ZW50LnRyaW0oKVxuICAgIH1cblxuICAgIGlmIChvcHRpb25zLmluY2x1ZGVIdG1sKSB7XG4gICAgICBvYmouY2hpbGROb2RlcyA9IGhlYWRpbmcuY2hpbGROb2Rlc1xuICAgIH1cblxuICAgIHJldHVybiBvYmpcbiAgfVxuXG4gIC8qKlxuICAgKiBBZGQgYSBub2RlIHRvIHRoZSBuZXN0ZWQgYXJyYXkuXG4gICAqIEBwYXJhbSB7T2JqZWN0fSBub2RlXG4gICAqIEBwYXJhbSB7QXJyYXl9IG5lc3RcbiAgICogQHJldHVybiB7QXJyYXl9XG4gICAqL1xuICBmdW5jdGlvbiBhZGROb2RlIChub2RlLCBuZXN0KSB7XG4gICAgdmFyIG9iaiA9IGdldEhlYWRpbmdPYmplY3Qobm9kZSlcbiAgICB2YXIgbGV2ZWwgPSBnZXRIZWFkaW5nTGV2ZWwobm9kZSlcbiAgICB2YXIgYXJyYXkgPSBuZXN0XG4gICAgdmFyIGxhc3RJdGVtID0gZ2V0TGFzdEl0ZW0oYXJyYXkpXG4gICAgdmFyIGxhc3RJdGVtTGV2ZWwgPSBsYXN0SXRlbVxuICAgICAgPyBsYXN0SXRlbS5oZWFkaW5nTGV2ZWxcbiAgICAgIDogMFxuICAgIHZhciBjb3VudGVyID0gbGV2ZWwgLSBsYXN0SXRlbUxldmVsXG5cbiAgICB3aGlsZSAoY291bnRlciA+IDApIHtcbiAgICAgIGxhc3RJdGVtID0gZ2V0TGFzdEl0ZW0oYXJyYXkpXG4gICAgICBpZiAobGFzdEl0ZW0gJiYgbGFzdEl0ZW0uY2hpbGRyZW4gIT09IHVuZGVmaW5lZCkge1xuICAgICAgICBhcnJheSA9IGxhc3RJdGVtLmNoaWxkcmVuXG4gICAgICB9XG4gICAgICBjb3VudGVyLS1cbiAgICB9XG5cbiAgICBpZiAobGV2ZWwgPj0gb3B0aW9ucy5jb2xsYXBzZURlcHRoKSB7XG4gICAgICBvYmouaXNDb2xsYXBzZWQgPSB0cnVlXG4gICAgfVxuXG4gICAgYXJyYXkucHVzaChvYmopXG4gICAgcmV0dXJuIGFycmF5XG4gIH1cblxuICAvKipcbiAgICogU2VsZWN0IGhlYWRpbmdzIGluIGNvbnRlbnQgYXJlYSwgZXhjbHVkZSBhbnkgc2VsZWN0b3IgaW4gb3B0aW9ucy5pZ25vcmVTZWxlY3RvclxuICAgKiBAcGFyYW0ge1N0cmluZ30gY29udGVudFNlbGVjdG9yXG4gICAqIEBwYXJhbSB7QXJyYXl9IGhlYWRpbmdTZWxlY3RvclxuICAgKiBAcmV0dXJuIHtBcnJheX1cbiAgICovXG4gIGZ1bmN0aW9uIHNlbGVjdEhlYWRpbmdzIChjb250ZW50U2VsZWN0b3IsIGhlYWRpbmdTZWxlY3Rvcikge1xuICAgIHZhciBzZWxlY3RvcnMgPSBoZWFkaW5nU2VsZWN0b3JcbiAgICBpZiAob3B0aW9ucy5pZ25vcmVTZWxlY3Rvcikge1xuICAgICAgc2VsZWN0b3JzID0gaGVhZGluZ1NlbGVjdG9yLnNwbGl0KCcsJylcbiAgICAgICAgLm1hcChmdW5jdGlvbiBtYXBTZWxlY3RvcnMgKHNlbGVjdG9yKSB7XG4gICAgICAgICAgcmV0dXJuIHNlbGVjdG9yLnRyaW0oKSArICc6bm90KCcgKyBvcHRpb25zLmlnbm9yZVNlbGVjdG9yICsgJyknXG4gICAgICAgIH0pXG4gICAgfVxuICAgIHRyeSB7XG4gICAgICByZXR1cm4gZG9jdW1lbnQucXVlcnlTZWxlY3Rvcihjb250ZW50U2VsZWN0b3IpXG4gICAgICAgIC5xdWVyeVNlbGVjdG9yQWxsKHNlbGVjdG9ycylcbiAgICB9IGNhdGNoIChlKSB7XG4gICAgICBjb25zb2xlLndhcm4oJ0VsZW1lbnQgbm90IGZvdW5kOiAnICsgY29udGVudFNlbGVjdG9yKTsgLy8gZXNsaW50LWRpc2FibGUtbGluZVxuICAgICAgcmV0dXJuIG51bGxcbiAgICB9XG4gIH1cblxuICAvKipcbiAgICogTmVzdCBoZWFkaW5ncyBhcnJheSBpbnRvIG5lc3RlZCBhcnJheXMgd2l0aCAnY2hpbGRyZW4nIHByb3BlcnR5LlxuICAgKiBAcGFyYW0ge0FycmF5fSBoZWFkaW5nc0FycmF5XG4gICAqIEByZXR1cm4ge09iamVjdH1cbiAgICovXG4gIGZ1bmN0aW9uIG5lc3RIZWFkaW5nc0FycmF5IChoZWFkaW5nc0FycmF5KSB7XG4gICAgcmV0dXJuIHJlZHVjZS5jYWxsKGhlYWRpbmdzQXJyYXksIGZ1bmN0aW9uIHJlZHVjZXIgKHByZXYsIGN1cnIpIHtcbiAgICAgIHZhciBjdXJyZW50SGVhZGluZyA9IGdldEhlYWRpbmdPYmplY3QoY3VycilcblxuICAgICAgYWRkTm9kZShjdXJyZW50SGVhZGluZywgcHJldi5uZXN0KVxuICAgICAgcmV0dXJuIHByZXZcbiAgICB9LCB7XG4gICAgICBuZXN0OiBbXVxuICAgIH0pXG4gIH1cblxuICByZXR1cm4ge1xuICAgIG5lc3RIZWFkaW5nc0FycmF5OiBuZXN0SGVhZGluZ3NBcnJheSxcbiAgICBzZWxlY3RIZWFkaW5nczogc2VsZWN0SGVhZGluZ3NcbiAgfVxufVxuXG5cblxuLy8vLy8vLy8vLy8vLy8vLy8vXG4vLyBXRUJQQUNLIEZPT1RFUlxuLy8gLi9zcmMvanMvcGFyc2UtY29udGVudC5qc1xuLy8gbW9kdWxlIGlkID0gNFxuLy8gbW9kdWxlIGNodW5rcyA9IDAiXSwibWFwcGluZ3MiOiJBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7Iiwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///4\n"); + +/***/ }), +/* 5 */ +/*!***************************************!*\ + !*** ./src/js/scroll-smooth/index.js ***! + \***************************************/ +/*! dynamic exports provided */ +/*! all exports used */ +/***/ (function(module, exports) { + +eval("/* globals location, requestAnimationFrame */\n\nexports.initSmoothScrolling = initSmoothScrolling\n\nfunction initSmoothScrolling (options) {\n if (isCssSmoothSCrollSupported()) { }\n\n var duration = options.duration\n\n var pageUrl = location.hash\n ? stripHash(location.href)\n : location.href\n\n delegatedLinkHijacking()\n\n function delegatedLinkHijacking () {\n document.body.addEventListener('click', onClick, false)\n\n function onClick (e) {\n if (\n !isInPageLink(e.target) ||\n e.target.className.indexOf('no-smooth-scroll') > -1 ||\n (e.target.href.charAt(e.target.href.length - 2) === '#' &&\n e.target.href.charAt(e.target.href.length - 1) === '!') ||\n e.target.className.indexOf(options.linkClass) === -1) {\n return\n }\n\n // Don't prevent default or hash doesn't change.\n // e.preventDefault()\n\n jump(e.target.hash, {\n duration: duration,\n callback: function () {\n setFocus(e.target.hash)\n }\n })\n }\n }\n\n function isInPageLink (n) {\n return n.tagName.toLowerCase() === 'a' &&\n (n.hash.length > 0 || n.href.charAt(n.href.length - 1) === '#') &&\n (stripHash(n.href) === pageUrl || stripHash(n.href) + '#' === pageUrl)\n }\n\n function stripHash (url) {\n return url.slice(0, url.lastIndexOf('#'))\n }\n\n function isCssSmoothSCrollSupported () {\n return 'scrollBehavior' in document.documentElement.style\n }\n\n // Adapted from:\n // https://www.nczonline.net/blog/2013/01/15/fixing-skip-to-content-links/\n function setFocus (hash) {\n var element = document.getElementById(hash.substring(1))\n\n if (element) {\n if (!/^(?:a|select|input|button|textarea)$/i.test(element.tagName)) {\n element.tabIndex = -1\n }\n\n element.focus()\n }\n }\n}\n\nfunction jump (target, options) {\n var start = window.pageYOffset\n var opt = {\n duration: options.duration,\n offset: options.offset || 0,\n callback: options.callback,\n easing: options.easing || easeInOutQuad\n }\n // This makes ids that start with a number work: ('[id=\"' + decodeURI(target).split('#').join('') + '\"]')\n // DecodeURI for nonASCII hashes, they was encoded, but id was not encoded, it lead to not finding the tgt element by id.\n // And this is for IE: document.body.scrollTop\n var tgt = document.querySelector('[id=\"' + decodeURI(target).split('#').join('') + '\"]')\n var distance = typeof target === 'string'\n ? opt.offset + (\n target\n ? (tgt && tgt.getBoundingClientRect().top) || 0 // handle non-existent links better.\n : -(document.documentElement.scrollTop || document.body.scrollTop))\n : target\n var duration = typeof opt.duration === 'function'\n ? opt.duration(distance)\n : opt.duration\n var timeStart\n var timeElapsed\n\n requestAnimationFrame(function (time) { timeStart = time; loop(time) })\n function loop (time) {\n timeElapsed = time - timeStart\n\n window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration))\n\n if (timeElapsed < duration) { requestAnimationFrame(loop) } else { end() }\n }\n\n function end () {\n window.scrollTo(0, start + distance)\n\n if (typeof opt.callback === 'function') { opt.callback() }\n }\n\n // Robert Penner's easeInOutQuad - http://robertpenner.com/easing/\n function easeInOutQuad (t, b, c, d) {\n t /= d / 2\n if (t < 1) return c / 2 * t * t + b\n t--\n return -c / 2 * (t * (t - 2) - 1) + b\n }\n}\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiNS5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy8uL3NyYy9qcy9zY3JvbGwtc21vb3RoL2luZGV4LmpzP2EzMGUiXSwic291cmNlc0NvbnRlbnQiOlsiLyogZ2xvYmFscyBsb2NhdGlvbiwgcmVxdWVzdEFuaW1hdGlvbkZyYW1lICovXG5cbmV4cG9ydHMuaW5pdFNtb290aFNjcm9sbGluZyA9IGluaXRTbW9vdGhTY3JvbGxpbmdcblxuZnVuY3Rpb24gaW5pdFNtb290aFNjcm9sbGluZyAob3B0aW9ucykge1xuICBpZiAoaXNDc3NTbW9vdGhTQ3JvbGxTdXBwb3J0ZWQoKSkgeyB9XG5cbiAgdmFyIGR1cmF0aW9uID0gb3B0aW9ucy5kdXJhdGlvblxuXG4gIHZhciBwYWdlVXJsID0gbG9jYXRpb24uaGFzaFxuICAgID8gc3RyaXBIYXNoKGxvY2F0aW9uLmhyZWYpXG4gICAgOiBsb2NhdGlvbi5ocmVmXG5cbiAgZGVsZWdhdGVkTGlua0hpamFja2luZygpXG5cbiAgZnVuY3Rpb24gZGVsZWdhdGVkTGlua0hpamFja2luZyAoKSB7XG4gICAgZG9jdW1lbnQuYm9keS5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIG9uQ2xpY2ssIGZhbHNlKVxuXG4gICAgZnVuY3Rpb24gb25DbGljayAoZSkge1xuICAgICAgaWYgKFxuICAgICAgICAhaXNJblBhZ2VMaW5rKGUudGFyZ2V0KSB8fFxuICAgICAgICBlLnRhcmdldC5jbGFzc05hbWUuaW5kZXhPZignbm8tc21vb3RoLXNjcm9sbCcpID4gLTEgfHxcbiAgICAgICAgKGUudGFyZ2V0LmhyZWYuY2hhckF0KGUudGFyZ2V0LmhyZWYubGVuZ3RoIC0gMikgPT09ICcjJyAmJlxuICAgICAgICBlLnRhcmdldC5ocmVmLmNoYXJBdChlLnRhcmdldC5ocmVmLmxlbmd0aCAtIDEpID09PSAnIScpIHx8XG4gICAgICAgIGUudGFyZ2V0LmNsYXNzTmFtZS5pbmRleE9mKG9wdGlvbnMubGlua0NsYXNzKSA9PT0gLTEpIHtcbiAgICAgICAgcmV0dXJuXG4gICAgICB9XG5cbiAgICAgIC8vIERvbid0IHByZXZlbnQgZGVmYXVsdCBvciBoYXNoIGRvZXNuJ3QgY2hhbmdlLlxuICAgICAgLy8gZS5wcmV2ZW50RGVmYXVsdCgpXG5cbiAgICAgIGp1bXAoZS50YXJnZXQuaGFzaCwge1xuICAgICAgICBkdXJhdGlvbjogZHVyYXRpb24sXG4gICAgICAgIGNhbGxiYWNrOiBmdW5jdGlvbiAoKSB7XG4gICAgICAgICAgc2V0Rm9jdXMoZS50YXJnZXQuaGFzaClcbiAgICAgICAgfVxuICAgICAgfSlcbiAgICB9XG4gIH1cblxuICBmdW5jdGlvbiBpc0luUGFnZUxpbmsgKG4pIHtcbiAgICByZXR1cm4gbi50YWdOYW1lLnRvTG93ZXJDYXNlKCkgPT09ICdhJyAmJlxuICAgICAgKG4uaGFzaC5sZW5ndGggPiAwIHx8IG4uaHJlZi5jaGFyQXQobi5ocmVmLmxlbmd0aCAtIDEpID09PSAnIycpICYmXG4gICAgICAoc3RyaXBIYXNoKG4uaHJlZikgPT09IHBhZ2VVcmwgfHwgc3RyaXBIYXNoKG4uaHJlZikgKyAnIycgPT09IHBhZ2VVcmwpXG4gIH1cblxuICBmdW5jdGlvbiBzdHJpcEhhc2ggKHVybCkge1xuICAgIHJldHVybiB1cmwuc2xpY2UoMCwgdXJsLmxhc3RJbmRleE9mKCcjJykpXG4gIH1cblxuICBmdW5jdGlvbiBpc0Nzc1Ntb290aFNDcm9sbFN1cHBvcnRlZCAoKSB7XG4gICAgcmV0dXJuICdzY3JvbGxCZWhhdmlvcicgaW4gZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50LnN0eWxlXG4gIH1cblxuICAvLyBBZGFwdGVkIGZyb206XG4gIC8vIGh0dHBzOi8vd3d3Lm5jem9ubGluZS5uZXQvYmxvZy8yMDEzLzAxLzE1L2ZpeGluZy1za2lwLXRvLWNvbnRlbnQtbGlua3MvXG4gIGZ1bmN0aW9uIHNldEZvY3VzIChoYXNoKSB7XG4gICAgdmFyIGVsZW1lbnQgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZChoYXNoLnN1YnN0cmluZygxKSlcblxuICAgIGlmIChlbGVtZW50KSB7XG4gICAgICBpZiAoIS9eKD86YXxzZWxlY3R8aW5wdXR8YnV0dG9ufHRleHRhcmVhKSQvaS50ZXN0KGVsZW1lbnQudGFnTmFtZSkpIHtcbiAgICAgICAgZWxlbWVudC50YWJJbmRleCA9IC0xXG4gICAgICB9XG5cbiAgICAgIGVsZW1lbnQuZm9jdXMoKVxuICAgIH1cbiAgfVxufVxuXG5mdW5jdGlvbiBqdW1wICh0YXJnZXQsIG9wdGlvbnMpIHtcbiAgdmFyIHN0YXJ0ID0gd2luZG93LnBhZ2VZT2Zmc2V0XG4gIHZhciBvcHQgPSB7XG4gICAgZHVyYXRpb246IG9wdGlvbnMuZHVyYXRpb24sXG4gICAgb2Zmc2V0OiBvcHRpb25zLm9mZnNldCB8fCAwLFxuICAgIGNhbGxiYWNrOiBvcHRpb25zLmNhbGxiYWNrLFxuICAgIGVhc2luZzogb3B0aW9ucy5lYXNpbmcgfHwgZWFzZUluT3V0UXVhZFxuICB9XG4gIC8vIFRoaXMgbWFrZXMgaWRzIHRoYXQgc3RhcnQgd2l0aCBhIG51bWJlciB3b3JrOiAoJ1tpZD1cIicgKyBkZWNvZGVVUkkodGFyZ2V0KS5zcGxpdCgnIycpLmpvaW4oJycpICsgJ1wiXScpXG4gIC8vIERlY29kZVVSSSBmb3Igbm9uQVNDSUkgaGFzaGVzLCB0aGV5IHdhcyBlbmNvZGVkLCBidXQgaWQgd2FzIG5vdCBlbmNvZGVkLCBpdCBsZWFkIHRvIG5vdCBmaW5kaW5nIHRoZSB0Z3QgZWxlbWVudCBieSBpZC5cbiAgLy8gQW5kIHRoaXMgaXMgZm9yIElFOiBkb2N1bWVudC5ib2R5LnNjcm9sbFRvcFxuICB2YXIgdGd0ID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcignW2lkPVwiJyArIGRlY29kZVVSSSh0YXJnZXQpLnNwbGl0KCcjJykuam9pbignJykgKyAnXCJdJylcbiAgdmFyIGRpc3RhbmNlID0gdHlwZW9mIHRhcmdldCA9PT0gJ3N0cmluZydcbiAgICA/IG9wdC5vZmZzZXQgKyAoXG4gICAgICB0YXJnZXRcbiAgICAgID8gKHRndCAmJiB0Z3QuZ2V0Qm91bmRpbmdDbGllbnRSZWN0KCkudG9wKSB8fCAwIC8vIGhhbmRsZSBub24tZXhpc3RlbnQgbGlua3MgYmV0dGVyLlxuICAgICAgOiAtKGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5zY3JvbGxUb3AgfHwgZG9jdW1lbnQuYm9keS5zY3JvbGxUb3ApKVxuICAgIDogdGFyZ2V0XG4gIHZhciBkdXJhdGlvbiA9IHR5cGVvZiBvcHQuZHVyYXRpb24gPT09ICdmdW5jdGlvbidcbiAgICA/IG9wdC5kdXJhdGlvbihkaXN0YW5jZSlcbiAgICA6IG9wdC5kdXJhdGlvblxuICB2YXIgdGltZVN0YXJ0XG4gIHZhciB0aW1lRWxhcHNlZFxuXG4gIHJlcXVlc3RBbmltYXRpb25GcmFtZShmdW5jdGlvbiAodGltZSkgeyB0aW1lU3RhcnQgPSB0aW1lOyBsb29wKHRpbWUpIH0pXG4gIGZ1bmN0aW9uIGxvb3AgKHRpbWUpIHtcbiAgICB0aW1lRWxhcHNlZCA9IHRpbWUgLSB0aW1lU3RhcnRcblxuICAgIHdpbmRvdy5zY3JvbGxUbygwLCBvcHQuZWFzaW5nKHRpbWVFbGFwc2VkLCBzdGFydCwgZGlzdGFuY2UsIGR1cmF0aW9uKSlcblxuICAgIGlmICh0aW1lRWxhcHNlZCA8IGR1cmF0aW9uKSB7IHJlcXVlc3RBbmltYXRpb25GcmFtZShsb29wKSB9IGVsc2UgeyBlbmQoKSB9XG4gIH1cblxuICBmdW5jdGlvbiBlbmQgKCkge1xuICAgIHdpbmRvdy5zY3JvbGxUbygwLCBzdGFydCArIGRpc3RhbmNlKVxuXG4gICAgaWYgKHR5cGVvZiBvcHQuY2FsbGJhY2sgPT09ICdmdW5jdGlvbicpIHsgb3B0LmNhbGxiYWNrKCkgfVxuICB9XG5cbiAgLy8gUm9iZXJ0IFBlbm5lcidzIGVhc2VJbk91dFF1YWQgLSBodHRwOi8vcm9iZXJ0cGVubmVyLmNvbS9lYXNpbmcvXG4gIGZ1bmN0aW9uIGVhc2VJbk91dFF1YWQgKHQsIGIsIGMsIGQpIHtcbiAgICB0IC89IGQgLyAyXG4gICAgaWYgKHQgPCAxKSByZXR1cm4gYyAvIDIgKiB0ICogdCArIGJcbiAgICB0LS1cbiAgICByZXR1cm4gLWMgLyAyICogKHQgKiAodCAtIDIpIC0gMSkgKyBiXG4gIH1cbn1cblxuXG5cbi8vLy8vLy8vLy8vLy8vLy8vL1xuLy8gV0VCUEFDSyBGT09URVJcbi8vIC4vc3JjL2pzL3Njcm9sbC1zbW9vdGgvaW5kZXguanNcbi8vIG1vZHVsZSBpZCA9IDVcbi8vIG1vZHVsZSBjaHVua3MgPSAwIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOyIsInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///5\n"); + +/***/ }) +/******/ ]); \ No newline at end of file diff --git a/dist/weewx-4.10.1/docs/js/tocbot-4.3.1.min.js b/dist/weewx-4.10.1/docs/js/tocbot-4.3.1.min.js new file mode 100644 index 0000000..40c2adf --- /dev/null +++ b/dist/weewx-4.10.1/docs/js/tocbot-4.3.1.min.js @@ -0,0 +1 @@ +!function(e){function t(o){if(n[o])return n[o].exports;var l=n[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,t),l.l=!0,l.exports}var n={};t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=0)}([function(e,t,n){(function(o){var l,i,s;!function(n,o){i=[],l=o(n),void 0!==(s="function"==typeof l?l.apply(t,i):l)&&(e.exports=s)}(void 0!==o?o:this.window||this.global,function(e){"use strict";function t(){for(var e={},t=0;te.fixedSidebarOffset?-1===n.className.indexOf(e.positionFixedClass)&&(n.className+=h+e.positionFixedClass):n.className=n.className.split(h+e.positionFixedClass).join("")}function s(t){var n=document.documentElement.scrollTop||f.scrollTop;e.positionFixedSelector&&i();var o,l=t;if(m&&null!==document.querySelector(e.tocSelector)&&l.length>0){d.call(l,function(t,i){if(t.offsetTop>n+e.headingsOffset+10){return o=l[0===i?i:i-1],!0}if(i===l.length-1)return o=l[l.length-1],!0});var s=document.querySelector(e.tocSelector).querySelectorAll("."+e.linkClass);u.call(s,function(t){t.className=t.className.split(h+e.activeLinkClass).join("")});var c=document.querySelector(e.tocSelector).querySelectorAll("."+e.listItemClass);u.call(c,function(t){t.className=t.className.split(h+e.activeListItemClass).join("")});var a=document.querySelector(e.tocSelector).querySelector("."+e.linkClass+".node-name--"+o.nodeName+'[href="#'+o.id+'"]');-1===a.className.indexOf(e.activeLinkClass)&&(a.className+=h+e.activeLinkClass);var p=a.parentNode;p&&-1===p.className.indexOf(e.activeListItemClass)&&(p.className+=h+e.activeListItemClass);var C=document.querySelector(e.tocSelector).querySelectorAll("."+e.listClass+"."+e.collapsibleClass);u.call(C,function(t){-1===t.className.indexOf(e.isCollapsedClass)&&(t.className+=h+e.isCollapsedClass)}),a.nextSibling&&-1!==a.nextSibling.className.indexOf(e.isCollapsedClass)&&(a.nextSibling.className=a.nextSibling.className.split(h+e.isCollapsedClass).join("")),r(a.parentNode.parentNode)}}function r(t){return-1!==t.className.indexOf(e.collapsibleClass)&&-1!==t.className.indexOf(e.isCollapsedClass)?(t.className=t.className.split(h+e.isCollapsedClass).join(""),r(t.parentNode.parentNode)):t}function c(t){var n=t.target||t.srcElement;"string"==typeof n.className&&-1!==n.className.indexOf(e.linkClass)&&(m=!1)}function a(){m=!0}var u=[].forEach,d=[].some,f=document.body,m=!0,h=" ";return{enableTocAnimation:a,disableTocAnimation:c,render:n,updateToc:s}}},function(e,t){e.exports=function(e){function t(e){return e[e.length-1]}function n(e){return+e.nodeName.split("H").join("")}function o(t){var o={id:t.id,children:[],nodeName:t.nodeName,headingLevel:n(t),textContent:t.textContent.trim()};return e.includeHtml&&(o.childNodes=t.childNodes),o}function l(l,i){for(var s=o(l),r=n(l),c=i,a=t(c),u=a?a.headingLevel:0,d=r-u;d>0;)a=t(c),a&&void 0!==a.children&&(c=a.children),d--;return r>=e.collapseDepth&&(s.isCollapsed=!0),c.push(s),c}function i(t,n){var o=n;e.ignoreSelector&&(o=n.split(",").map(function(t){return t.trim()+":not("+e.ignoreSelector+")"}));try{return document.querySelector(t).querySelectorAll(o)}catch(e){return console.warn("Element not found: "+t),null}}function s(e){return r.call(e,function(e,t){return l(o(t),e.nest),e},{nest:[]})}var r=[].reduce;return{nestHeadingsArray:s,selectHeadings:i}}},function(e,t){function n(e){function t(e){return"a"===e.tagName.toLowerCase()&&(e.hash.length>0||"#"===e.href.charAt(e.href.length-1))&&(n(e.href)===s||n(e.href)+"#"===s)}function n(e){return e.slice(0,e.lastIndexOf("#"))}function l(e){var t=document.getElementById(e.substring(1));t&&(/^(?:a|select|input|button|textarea)$/i.test(t.tagName)||(t.tabIndex=-1),t.focus())}!function(){document.documentElement.style}();var i=e.duration,s=location.hash?n(location.href):location.href;!function(){function n(n){!t(n.target)||n.target.className.indexOf("no-smooth-scroll")>-1||"#"===n.target.href.charAt(n.target.href.length-2)&&"!"===n.target.href.charAt(n.target.href.length-1)||-1===n.target.className.indexOf(e.linkClass)||o(n.target.hash,{duration:i,callback:function(){l(n.target.hash)}})}document.body.addEventListener("click",n,!1)}()}function o(e,t){function n(e){s=e-i,window.scrollTo(0,c.easing(s,r,u,d)),s + * + * See the file LICENSE.txt for your rights. + */ + +function make_ids(contentSelector, headingSelector) { + // Tocbot does not automatically add ids to all headings. We must do that. + if (!headingSelector) headingSelector = 'h1, h2, h3, h4'; + const content = document.querySelector(contentSelector); + const headings = content.querySelectorAll(headingSelector); + const headingMap = {}; + + Array.prototype.forEach.call(headings, function (heading) { + // Use a hashing similar to the old tocify, to maintain backwards compatiblity with old links + const id = heading.id ? heading.id : heading.textContent.trim().replace(/[ <>#\/\\?&\n]/g, '_'); + headingMap[id] = !isNaN(headingMap[id]) ? ++headingMap[id] : 0; + if (headingMap[id]) { + heading.id = id + '-' + headingMap[id] + } else { + heading.id = id + } + }) + +} + +function set_cookie(name, value, days) { + // Default duration of 30 days + if (!days) days = 30; + const expire = new Date(Date.now() + 24 * 3600000 * days); + document.cookie = name + "=" + value + ";path=/;expires=" + expire.toUTCString(); +} + +function get_cookie(name) { + if (!name) return ""; + const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)'); + return v ? v[2] : null; +} + +function get_level_from_cookie() { + let level = 3; + const level_str = get_cookie("toc_level"); + if (level_str) { + level = parseInt(level_str, 10) + } + return level; +} + +function create_toc(level) { + let headingSelector = "h1"; + for (let i = 2; i <= level; i++) { + headingSelector += ", h" + i; + } + tocbot.init({ + // Where to render the table of contents. + tocSelector: '#toc-location', + // Where to grab the headings to build the table of contents. + contentSelector: '#technical_content', + // Which headings to grab inside of the contentSelector element. + headingSelector: headingSelector, + // At what depth should we start collapsing things? A depth of 6 shows everything. + collapseDepth: 6, + }); +} + +function create_toc_level_control(level) { + // Create the TOC "select" control + const c = document.getElementById('toc_controls'); + if (c) { + let txt = " + #for $monthYear in $SummaryByMonth + + #end for + + +
+ $gettext("Yearly Reports"): + +
+ +#end if + diff --git a/dist/weewx-4.10.1/skins/Smartphone/barometer.html.tmpl b/dist/weewx-4.10.1/skins/Smartphone/barometer.html.tmpl new file mode 100644 index 0000000..fd97d5d --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/barometer.html.tmpl @@ -0,0 +1,43 @@ +#encoding UTF-8 + + + + + $obs.label.barometer $gettext("in") $station.location + + + + + + +
+
+

$obs.label.barometer

+
+
+

$gettext("24h barometer")

+ +

+ $gettext("min"): $day.barometer.min $gettext("at") $day.barometer.mintime
+ $gettext("max"): $day.barometer.max $gettext("at") $day.barometer.maxtime +

+ +

$gettext("7-day barometer")

+ +

+ $gettext("min"): $week.barometer.min $gettext("at") $week.barometer.mintime
+ $gettext("max"): $week.barometer.max $gettext("at") $week.barometer.maxtime +

+ +

$gettext("30-day barometer")

+ +

+ $gettext("min"): $month.barometer.min $gettext("at") $month.barometer.mintime
+ $gettext("max"): $month.barometer.max $gettext("at") $month.barometer.maxtime +

+
## End 'content' +
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Smartphone/custom.js b/dist/weewx-4.10.1/skins/Smartphone/custom.js new file mode 100644 index 0000000..83de7c8 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/custom.js @@ -0,0 +1,4 @@ +$(document).bind("mobileinit", function(){ + $.mobile.defaultPageTransition = 'slide'; + $.mobile.page.prototype.options.addBackBtn = true; +}); \ No newline at end of file diff --git a/dist/weewx-4.10.1/skins/Smartphone/favicon.ico b/dist/weewx-4.10.1/skins/Smartphone/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-4.10.1/skins/Smartphone/favicon.ico differ diff --git a/dist/weewx-4.10.1/skins/Smartphone/humidity.html.tmpl b/dist/weewx-4.10.1/skins/Smartphone/humidity.html.tmpl new file mode 100644 index 0000000..b309a04 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/humidity.html.tmpl @@ -0,0 +1,29 @@ +#encoding UTF-8 + + + + + $obs.label.outHumidity $gettext("in") $station.location + + + + + + +
+
+

$obs.label.outHumidity

+
+
+

$gettext("24h outside humidity")

+ +

$gettext("Last 7 days")

+ +

$gettext("Last 30 days")

+ +
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Smartphone/icons/icon_ipad_x1.png b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_ipad_x1.png new file mode 100644 index 0000000..aec7f0d Binary files /dev/null and b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_ipad_x1.png differ diff --git a/dist/weewx-4.10.1/skins/Smartphone/icons/icon_ipad_x2.png b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_ipad_x2.png new file mode 100644 index 0000000..3dd7f78 Binary files /dev/null and b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_ipad_x2.png differ diff --git a/dist/weewx-4.10.1/skins/Smartphone/icons/icon_iphone_x1.png b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_iphone_x1.png new file mode 100644 index 0000000..985337f Binary files /dev/null and b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_iphone_x1.png differ diff --git a/dist/weewx-4.10.1/skins/Smartphone/icons/icon_iphone_x2.png b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_iphone_x2.png new file mode 100644 index 0000000..e2c54aa Binary files /dev/null and b/dist/weewx-4.10.1/skins/Smartphone/icons/icon_iphone_x2.png differ diff --git a/dist/weewx-4.10.1/skins/Smartphone/index.html.tmpl b/dist/weewx-4.10.1/skins/Smartphone/index.html.tmpl new file mode 100644 index 0000000..c6ba1f2 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/index.html.tmpl @@ -0,0 +1,59 @@ +#encoding UTF-8 + + + + + $station.location Weather Conditions + + + + + + + + + + + + + + #end if +
+

WeeWX v$station.version

+
+ + + diff --git a/dist/weewx-4.10.1/skins/Smartphone/lang/de.conf b/dist/weewx-4.10.1/skins/Smartphone/lang/de.conf new file mode 100644 index 0000000..855bc55 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/lang/de.conf @@ -0,0 +1,39 @@ +############################################################################### +# Localization File # +# Deutsch # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + barometer = Luftdruck + dewpoint = Taupunkt + outHumidity = Außenluftfeuchte + outTemp = Außentemperatur + rain = Regen + rainRate = Regenrate + wind = Wind + windDir = Windrichtung + windGust = Windböen + windSpeed = Windstärke + +[Texts] + "7-day barometer" = "Luftdruck in den letzten 7 Tagen" + "24h barometer" = "Luftdruck in den letzten 24 Stunden" + "24h outside humidity" = "Luftfeuchte in den letzten 24 Stunden" + "24h outside temperature" = "Außentemperatur in den letzten 24 Stunden" + "24h rain" = "Regen in den letzten 24 Stunden" + "24h wind" = "Wind in den letzten 24 Stunden" + "at" = "um" # Time context. E.g., 15.1C "at" 12:22 + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "7-Tage" + "Last 30 days" = "30-Tage" + "Last update" = "letzte Aktualisierung" + "max gust" = "max. Böen" + "Max rate" = "Max. Rate" + "max" = "max" + "min" = "min" + "Rain (daily total)" = "Regen (täglich gesamt)" + "Rain (hourly total)" = "Regen (stündlich gesamt)" + "Total" = "gesamt" diff --git a/dist/weewx-4.10.1/skins/Smartphone/lang/en.conf b/dist/weewx-4.10.1/skins/Smartphone/lang/en.conf new file mode 100644 index 0000000..7719ef0 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/lang/en.conf @@ -0,0 +1,40 @@ +############################################################################### +# Localization File # +# English # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + barometer = Barometer + dewpoint = Dew Point + outHumidity = Outside Humidity + outTemp = Outside Temperature + rain = Rain + rainRate = Rain Rate + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windSpeed = Wind Speed + +[Texts] + "7-day barometer" = "7-day barometer" + "24h barometer" = "24h barometer" + "24h outside humidity" = "24h outside humidity" + "24h outside temperature" = "24h outside temperature" + "24h rain" = "24h rain" + "24h wind" = "24h wind" + "at" = "at" # Time context. E.g., 15.1C "at" 12:22 + "from" = "from" # Direction context + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "Last 7 days" + "Last 30 days" = "Last 30 days" + "Last update" = "Last update" + "max gust" = "max gust" + "Max rate" = "Max rate" + "max" = "max" + "min" = "min" + "Rain (daily total)" = "Rain (daily total)" + "Rain (hourly total)" = "Rain (hourly total)" + "Total" = "Total" diff --git a/dist/weewx-4.10.1/skins/Smartphone/lang/nl.conf b/dist/weewx-4.10.1/skins/Smartphone/lang/nl.conf new file mode 100644 index 0000000..99c569f --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/lang/nl.conf @@ -0,0 +1,40 @@ +############################################################################### +# Localization File # +# Dutch # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# Translation by Eelco # +############################################################################### + +[Labels] + [[Generic]] + barometer = Barometer + dewpoint = Dauwpunt + outHumidity = Luchtvochtigheid Buiten + outTemp = Temperatuur Buiten + rain = Regen + rainRate = Regen Intensiteit + wind = Wind + windDir = Wind Richting + windGust = Windvlaag Snelheid + windSpeed = Wind Snelheid + +[Texts] + "7-day barometer" = "7-dagen barometer" + "24h barometer" = "24h barometer" + "24h outside humidity" = "24h luchtvochtigheid buiten" + "24h outside temperature" = "24h temperatuur buiten" + "24h rain" = "24h regen" + "24h wind" = "24h wind" + "at" = "om" # Time context. E.g., 15.1C "at" 12:22 + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "Laatste 7 dagen" + "Last 30 days" = "Laatste 30 dagen" + "Last update" = "Laatste update" + "max gust" = "max windvlaag" + "Max rate" = "Max rate" + "max" = "max" + "min" = "min" + "Rain (daily total)" = "Regen (dag totaal)" + "Rain (hourly total)" = "Regen (uur totaal)" + "Total" = "Totaal" diff --git a/dist/weewx-4.10.1/skins/Smartphone/lang/no.conf b/dist/weewx-4.10.1/skins/Smartphone/lang/no.conf new file mode 100644 index 0000000..0833396 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/lang/no.conf @@ -0,0 +1,82 @@ +############################################################################### +# Localization File # +# Norwegian # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### +unit_system = metricwx + +[Units] + + # [[Groups]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_degree_day = degree_C_day # Options are 'degree_F_day' or 'degree_C_day' + # group_distance = km # Options are 'mile' or 'km' + # group_pressure = mBar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_speed = meter_per_second # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + # group_speed2 = meter_per_second2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + # group_temperature = degree_C # Options are 'degree_F' or 'degree_C' + + [[Labels]] + + # These are singular, plural + meter = " meter", " meter" + day = " dag", " dager" + hour = " time", " timer" + minute = " minutt", " minutter" + second = " sekund", " sekunder" + + cm_per_hour = " cm/t" + hPa_per_hour = " hPa/t" + inch_per_hour = " in/t" + inHg_per_hour = " inHg/t" + km_per_hour = " km/t" + km_per_hour2 = " km/t" + kPa_per_hour = " kPa/t", + mbar_per_hour = " mbar/t" + mm_per_hour = " mm/t" + mmHg_per_hour = " mmHg/t" + meter_per_second = " m/s" + meter_per_second2 = " m/s" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNØ, NØ, ØNØ, Ø, ØSØ, SØ, SSØ, S, SSV, SV, VSV, V, VNV, NV, NNV, N/A + +[Labels] + [[Generic]] + barometer = Lufttrykk + dewpoint = Doggpunkt + outHumidity = Fuktighet ute + outTemp = Utetemperatur + rain = Regn + rainRate = Regnintensitet + wind = Vind + windDir = Vindretning + windGust = Vindkast + windSpeed = Vindhastighet + +[Texts] + "30-day barometer" = "30-dagers lufttrykk" + "7-day barometer" = "7-dagers lufttrykk" + "24h barometer" = "24-timers lufttrykk" + "24h outside humidity" = "24-timers fuktighet ute" + "24h outside temperature" = "24-timers utetemperatur" + "24h rain" = "24-timers regn" + "24h wind" = "24-timers vind" + "at" = "" # Time context. E.g., 15.1C "at" 12:22 + "from" = "fra" # Direction context + "in" = "i" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "Siste 7 dager" + "Last 30 days" = "Siste 30 dager" + "Last update" = "Sist oppdatert" + "max gust" = "Maks vindkast" + "Max rate" = "Maks regnmengde" + "max" = "Maks" + "min" = "Min" + "Rain (daily total)" = "Regn (pr. dag)" + "Rain (hourly total)" = "Regn (pr. time)" + "Total" = "Sum" diff --git a/dist/weewx-4.10.1/skins/Smartphone/rain.html.tmpl b/dist/weewx-4.10.1/skins/Smartphone/rain.html.tmpl new file mode 100644 index 0000000..f930743 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/rain.html.tmpl @@ -0,0 +1,41 @@ +#encoding UTF-8 + + + + + $obs.label.rain $gettext("in") $station.location + + + + + + +
+
+

$obs.label.rain

+
+
+

$gettext("24h rain")

+ +

+ $gettext("Total"): $day.rain.sum
+ $gettext("Max rate"): $day.rainRate.max $gettext("at") $day.rainRate.maxtime +

+

$obs.label.rain $gettext("Last 7 days")

+ +

+ $gettext("Total"): $week.rain.sum
+ $gettext("Max rate"): $week.rainRate.max $gettext("at") $week.rainRate.maxtime +

+

$obs.label.rain $gettext("Last 30 days")

+ +

+ $gettext("Total"): $month.rain.sum
+ $gettext("Max rate"): $month.rainRate.max $gettext("at") $month.rainRate.maxtime +

+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Smartphone/skin.conf b/dist/weewx-4.10.1/skins/Smartphone/skin.conf new file mode 100644 index 0000000..dc0c067 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/skin.conf @@ -0,0 +1,272 @@ +# configuration file for Smartphone skin + +SKIN_NAME = Smartphone +SKIN_VERSION = 4.10.1 + +[Extras] + # Set this URL to display a radar image + #radar_img = http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif + # Set this URL for the radar image link + #radar_url = http://radar.weather.gov/ridge/radar.php?product=NCR&rid=RTX&loop=yes + +############################################################################### + +[Units] + [[Groups]] + # group_altitude = foot + # group_degree_day = degree_F_day + # group_pressure = inHg + # group_rain = inch + # group_rainrate = inch_per_hour + # group_speed = mile_per_hour + # group_temperature = degree_F + + [[Labels]] + # day = " day", " days" + # hour = " hour", " hours" + # minute = " minute", " minutes" + # second = " second", " seconds" + # NONE = "" + + [[TimeFormats]] + # day = %X + # week = %X (%A) + # month = %x %X + # year = %x %X + # rainyear = %x %X + # current = %x %X + # ephem_day = %X + # ephem_year = %x %X + + [[Ordinates]] + # directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +############################################################################### + +[Labels] + # Set to hemisphere abbreviations suitable for your location: + # hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole degrees, + # and minutes: + # latlon_formats = "%02d", "%03d", "%05.2f" + + [[Generic]] + # barometer = Barometer + # dewpoint = Dew Point + # heatindex = Heat Index + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # radiation = Radiation + # rain = Rain + # rainRate = Rain Rate + # rxCheckPercent = ISS Signal Quality + # UV = UV Index + # windDir = Wind Direction + # windGust = Gust Speed + # windGustDir = Gust Direction + # windSpeed = Wind Speed + # windchill = Wind Chill + # windgustvec = Gust Vector + # windvec = Wind Vector + +############################################################################### + +[Almanac] + # moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################### + +[CheetahGenerator] + encoding = html_entities + + [[ToDate]] + [[[MobileSmartphone]]] + template = index.html.tmpl + + [[[MobileBarometer]]] + template = barometer.html.tmpl + + [[[MobileTemp]]] + template = temp.html.tmpl + + [[[MobileHumidity]]] + template = humidity.html.tmpl + + [[[MobileRain]]] + template = rain.html.tmpl + + [[[MobileWind]]] + template = wind.html.tmpl + +############################################################################### + +[CopyGenerator] + copy_once = favicon.ico, icons/*, custom.js + +############################################################################### + +[ImageGenerator] + + image_width = 300 + image_height = 180 + image_background_color = "#f5f5f5" + + chart_background_color = "#d8d8d8" + chart_gridline_color = "#a0a0a0" + + top_label_font_path = DejaVuSansCondensed-Bold.ttf + top_label_font_size = 10 + + unit_label_font_path = DejaVuSansCondensed-Bold.ttf + unit_label_font_size = 10 + unit_label_font_color = "#000000" + + bottom_label_font_path = DejaVuSansCondensed-Bold.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#000000" + bottom_label_offset = 3 + + axis_label_font_path = DejaVuSansCondensed-Bold.ttf + axis_label_font_size = 10 + axis_label_font_color = "#000000" + + rose_label = N + rose_label_font_path = DejaVuSansCondensed-Bold.ttf + rose_label_font_size = 10 + rose_label_font_color = "#000000" + + line_type = 'solid' + marker_size = 8 + marker_type ='none' + + chart_line_colors = "#4282b4", "#b44242", "#42b442" + chart_fill_colors = "#72b2c4", "#c47272", "#72c472" + + yscale = None, None, None + + vector_rotate = 90 + + line_gap_fraction = 0.05 + + show_daynight = true + daynight_day_color = "#dfdfdf" + daynight_night_color = "#bbbbbb" + daynight_edge_color = "#d0d0d0" + + plot_type = line + aggregate_type = none + width = 1 + time_length = 86400 # == 24 hours + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %x %X + time_length = 97200 # == 27 hours + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[dayhumidity]]] + [[[[outHumidity]]]] + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = hour + label = Rain (hourly total) + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[daywinddir]]] + # Hardwire in the y-axis scale for wind direction + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[week_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 604800 # == 7 days + aggregate_type = avg + aggregate_interval = hour + + [[[weekbarometer]]] + [[[[barometer]]]] + + [[[weekhumidity]]] + [[[[outHumidity]]]] + + [[[weektempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[weekrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = day + label = Rain (daily total) + + [[[weekwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[weekwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[month_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 2592000 # == 30 days + aggregate_type = avg + aggregate_interval = 10800 # == 3 hours + show_daynight = false + + [[[monthbarometer]]] + [[[[barometer]]]] + + [[[monthhumidity]]] + [[[[outHumidity]]]] + + [[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[monthrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = day + label = Rain (daily total) + + [[[monthwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[monthwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + +############################################################################### + +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator + + diff --git a/dist/weewx-4.10.1/skins/Smartphone/temp.html.tmpl b/dist/weewx-4.10.1/skins/Smartphone/temp.html.tmpl new file mode 100644 index 0000000..f2e223a --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/temp.html.tmpl @@ -0,0 +1,42 @@ +#encoding UTF-8 + + + + + $obs.label.outTemp $gettext("in") $station.location + + + + + + +
+
+

$obs.label.outTemp

+
+
+

$gettext("24h outside temperature")

+ +

+ $gettext("min"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
+ $gettext("max"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime +

+

$gettext("Last 7 days") $obs.label.outTemp

+ +

+ $gettext("min"): $week.outTemp.min $gettext("at") $week.outTemp.mintime
+ $gettext("max"): $week.outTemp.max $gettext("at") $week.outTemp.maxtime +

+

$gettext("Last 30 days") $obs.label.outTemp

+ +

+ $gettext("min"): $month.outTemp.min $gettext("at") $month.outTemp.mintime
+ $gettext("max"): $month.outTemp.max $gettext("at") $month.outTemp.maxtime +

+
+
+

WeeWX v$station.version

+
+
+ + diff --git a/dist/weewx-4.10.1/skins/Smartphone/wind.html.tmpl b/dist/weewx-4.10.1/skins/Smartphone/wind.html.tmpl new file mode 100644 index 0000000..9a999c3 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Smartphone/wind.html.tmpl @@ -0,0 +1,44 @@ +#encoding UTF-8 + + + + + $obs.label.wind $gettext("in") $station.location + + + + + + +
+
+

$obs.label.wind

+
+
+

$gettext("24h wind")

+ + +

+ $gettext("max"): $day.windSpeed.max $gettext("at") $day.windSpeed.maxtime + $gettext("max gust"): $day.windGust.max $gettext("at") $day.windGust.maxtime +

+

$gettext("Last 7 days") $obs.label.wind

+ + +

+ $gettext("max"): $week.windSpeed.max $gettext("at") $week.windSpeed.maxtime + $gettext("max gust"): $week.windGust.max $gettext("at") $week.windGust.maxtime +

+

$gettext("Last 30 days") $obs.label.wind

+ + +

+ $gettext("max"): $month.windSpeed.max $gettext("at") $month.windSpeed.maxtime + $gettext("max gust"): $month.windGust.max $gettext("at") $month.windGust.maxtime +

+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Standard/NOAA/NOAA-%Y-%m.txt.tmpl b/dist/weewx-4.10.1/skins/Standard/NOAA/NOAA-%Y-%m.txt.tmpl new file mode 100644 index 0000000..e9a751e --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/NOAA/NOAA-%Y-%m.txt.tmpl @@ -0,0 +1,39 @@ +#errorCatcher Echo +#set $YM="%Y %m" +#set $D=" %d" +#set $M=" %b" +#set $Time=" %H:%M" +#set $NODAY=" N/A" +#set $Temp="%6.1f" +#set $Wind="%6.1f" +#set $Dir="%6.0f" +#set $Count="%6d" +#set $NONE=" N/A" +#if $unit.unit_type_dict.group_rain == "mm" +#set $Rain="%6.1f" +#else +#set $Rain="%6.2f" +#end if + MONTHLY CLIMATOLOGICAL SUMMARY for $month_name $year_name + + +NAME: $station.location +ELEV: $station.altitude LAT: $station.latitude[0]-$station.latitude[1] $station.latitude[2] LONG: $station.longitude[0]-$station.longitude[1] $station.longitude[2] + + + TEMPERATURE ($unit.label.outTemp.strip()), RAIN ($unit.label.rain.strip()), WIND SPEED ($unit.label.windSpeed.strip()) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- +#for $day in $month.days +#if $day.outTemp.count.raw or $day.rain.count.raw or $day.wind.count.raw +$day.dateTime.format($D, add_label=False) $day.outTemp.avg.format($Temp,$NONE,add_label=False) $day.outTemp.max.format($Temp,$NONE,add_label=False) $day.outTemp.maxtime.format($Time,add_label=False) $day.outTemp.min.format($Temp,$NONE,add_label=False) $day.outTemp.mintime.format($Time,add_label=False) $day.heatdeg.sum.format($Temp,$NONE,add_label=False) $day.cooldeg.sum.format($Temp,$NONE,add_label=False) $day.rain.sum.format($Rain,$NONE,add_label=False) $day.wind.avg.format($Wind,$NONE,add_label=False) $day.wind.max.format($Wind,$NONE,add_label=False) $day.wind.maxtime.format($Time,add_label=False) $day.wind.vecdir.format($Dir,$NONE,add_label=False) +#else +$day.dateTime.format($D) +#end if +#end for +--------------------------------------------------------------------------------------- + $month.outTemp.avg.format($Temp,$NONE,add_label=False) $month.outTemp.max.format($Temp,$NONE,add_label=False) $month.outTemp.maxtime.format($D,add_label=False) $month.outTemp.min.format($Temp,$NONE,add_label=False) $month.outTemp.mintime.format($D,add_label=False) $month.heatdeg.sum.format($Temp,$NONE,add_label=False) $month.cooldeg.sum.format($Temp,$NONE,add_label=False) $month.rain.sum.format($Rain,$NONE,add_label=False) $month.wind.avg.format($Wind,$NONE,add_label=False) $month.wind.max.format($Wind,$NONE,add_label=False) $month.wind.maxtime.format($D,add_label=False) $month.wind.vecdir.format($Dir,add_label=False) + diff --git a/dist/weewx-4.10.1/skins/Standard/NOAA/NOAA-%Y.txt.tmpl b/dist/weewx-4.10.1/skins/Standard/NOAA/NOAA-%Y.txt.tmpl new file mode 100644 index 0000000..09baeae --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/NOAA/NOAA-%Y.txt.tmpl @@ -0,0 +1,96 @@ +#errorCatcher Echo +#set $YM="%Y %m" +#set $D=" %d" +#set $M=" %b" +#set $NODAY=" N/A" +#set $Temp="%6.1f" +#set $Wind="%6.1f" +#set $Dir="%6.0f" +#set $Count="%6d" +#set $NONE=" N/A" +#if $unit.unit_type_dict.group_temperature == "degree_F" +#set $Hot =(90.0,"degree_F") +#set $Cold =(32.0,"degree_F") +#set $VeryCold=(0.0, "degree_F") +#else +#set $Hot =(30.0,"degree_C") +#set $Cold =(0.0,"degree_C") +#set $VeryCold=(-20.0,"degree_C") +#end if +#if $unit.unit_type_dict.group_rain == "inch" +#set $Trace =(0.01,"inch") +#set $SomeRain =(0.1, "inch") +#set $Soak =(1.0, "inch") +#set $Rain="%6.2f" +#elif $unit.unit_type_dict.group_rain == "mm" +#set $Trace =(.3, "mm") +#set $SomeRain =(3, "mm") +#set $Soak =(30.0,"mm") +#set $Rain="%6.1f" +#else +#set $Trace =(.03,"cm") +#set $SomeRain =(.3, "cm") +#set $Soak =(3.0,"cm") +#set $Rain="%6.2f" +#end if +#def ShowInt($T) +$("%6d" % $T[0])#slurp +#end def +#def ShowFloat($R) +$("%6.2f" % $R[0])#slurp +#end def + CLIMATOLOGICAL SUMMARY for year $year_name + + +NAME: $station.location +ELEV: $station.altitude LAT: $station.latitude[0]-$station.latitude[1] $station.latitude[2] LONG: $station.longitude[0]-$station.longitude[1] $station.longitude[2] + + + TEMPERATURE ($unit.label.outTemp.strip()) + + HEAT COOL MAX MAX MIN MIN + MEAN MEAN DEG DEG >= <= <= <= + YR MO MAX MIN MEAN DAYS DAYS HI DAY LOW DAY $ShowInt($Hot) $ShowInt($Cold) $ShowInt($Cold) $ShowInt($VeryCold) +------------------------------------------------------------------------------------------------ +#for $month in $year.months +#if $month.outTemp.count.raw +$month.dateTime.format($YM) $month.outTemp.meanmax.format($Temp,$NONE,add_label=False) $month.outTemp.meanmin.format($Temp,$NONE,add_label=False) $month.outTemp.avg.format($Temp,$NONE,add_label=False) $month.heatdeg.sum.format($Temp,$NONE,add_label=False) $month.cooldeg.sum.format($Temp,$NONE,add_label=False) $month.outTemp.max.format($Temp,$NONE,add_label=False) $month.outTemp.maxtime.format($D,$NODAY) $month.outTemp.min.format($Temp,$NONE,add_label=False) $month.outTemp.mintime.format($D,$NODAY) $month.outTemp.max_ge($Hot).format($Count,$NONE,add_label=False) $month.outTemp.max_le($Cold).format($Count,$NONE,add_label=False) $month.outTemp.min_le($Cold).format($Count,$NONE,add_label=False) $month.outTemp.min_le($VeryCold).format($Count,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +------------------------------------------------------------------------------------------------ + $year.outTemp.meanmax.format($Temp,$NONE,add_label=False) $year.outTemp.meanmin.format($Temp,$NONE,add_label=False) $year.outTemp.avg.format($Temp,$NONE,add_label=False) $year.heatdeg.sum.format($Temp,$NONE,add_label=False) $year.cooldeg.sum.format($Temp,$NONE,add_label=False) $year.outTemp.max.format($Temp,$NONE,add_label=False) $year.outTemp.maxtime.format($M,$NODAY) $year.outTemp.min.format($Temp,$NONE,add_label=False) $year.outTemp.mintime.format($M,$NODAY) $year.outTemp.max_ge($Hot).format($Count,$NONE,add_label=False) $year.outTemp.max_le($Cold).format($Count,$NONE,add_label=False) $year.outTemp.min_le($Cold).format($Count,$NONE,add_label=False) $year.outTemp.min_le($VeryCold).format($Count,$NONE,add_label=False) + + + PRECIPITATION ($unit.label.rain.strip()) + + MAX ---DAYS OF RAIN--- + OBS. OVER + YR MO TOTAL DAY DATE $ShowFloat(Trace) $ShowFloat($SomeRain) $ShowFloat($Soak) +------------------------------------------------ +#for $month in $year.months +#if $month.rain.count.raw +$month.dateTime.format($YM) $month.rain.sum.format($Rain,$NONE,add_label=False) $month.rain.maxsum.format($Rain,$NONE,add_label=False) $month.rain.maxsumtime.format($D,$NODAY) $month.rain.sum_ge($Trace).format($Count,$NONE,add_label=False) $month.rain.sum_ge($SomeRain).format($Count,$NONE,add_label=False) $month.rain.sum_ge($Soak).format($Count,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +------------------------------------------------ + $year.rain.sum.format($Rain,$NONE,add_label=False) $year.rain.maxsum.format($Rain,$NONE,add_label=False) $year.rain.maxsumtime.format($M,$NODAY) $year.rain.sum_ge($Trace).format($Count,$NONE,add_label=False) $year.rain.sum_ge($SomeRain).format($Count,$NONE,add_label=False) $year.rain.sum_ge($Soak).format($Count,$NONE,add_label=False) + + + WIND SPEED ($unit.label.windSpeed.strip()) + + DOM + YR MO AVG HI DATE DIR +----------------------------------- +#for $month in $year.months +#if $month.wind.count.raw +$month.dateTime.format($YM) $month.wind.avg.format($Wind,$NONE,add_label=False) $month.wind.max.format($Wind,$NONE,add_label=False) $month.wind.maxtime.format($D,$NODAY) $month.wind.vecdir.format($Dir,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +----------------------------------- + $year.wind.avg.format($Wind,$NONE,add_label=False) $year.wind.max.format($Wind,$NONE,add_label=False) $year.wind.maxtime.format($M,$NODAY) $year.wind.vecdir.format($Dir,$NONE,add_label=False) diff --git a/dist/weewx-4.10.1/skins/Standard/RSS/weewx_rss.xml.tmpl b/dist/weewx-4.10.1/skins/Standard/RSS/weewx_rss.xml.tmpl new file mode 100644 index 0000000..6a24d66 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/RSS/weewx_rss.xml.tmpl @@ -0,0 +1,136 @@ + + + + $station.location, $gettext("Weather Conditions") + $station.station_url + $gettext("Current conditions, and daily, monthly, and yearly summaries") + "$lang" + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + http://blogs.law.harvard.edu/tech/rss + weewx $station.version + $current.interval.string('') + + + $gettext("Weather Conditions at") $current.dateTime + $station.station_url + + $obs.label.outTemp: $current.outTemp; + $obs.label.barometer: $current.barometer; + $obs.label.wind: $current.windSpeed $gettext("from") $current.windDir; + $obs.label.rainRate: $current.rainRate; + $obs.label.inTemp: $current.inTemp + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + $station.latitude_f + $station.longitude_f + + $obs.label.dateTime: $current.dateTime
+ $obs.label.outTemp: $current.outTemp
+ $obs.label.inTemp: $current.inTemp
+ $obs.label.windchill: $current.windchill
+ $obs.label.heatindex: $current.heatindex
+ $obs.label.dewpoint: $current.dewpoint
+ $obs.label.outHumidity: $current.outHumidity
+ $obs.label.barometer: $current.barometer
+ $obs.label.wind: $current.windSpeed $gettext("from") $current.windDir
+ $obs.label.rainRate: $current.rainRate
+

+ ]]>
+
+ + + $gettext("Daily Weather Summary as of") $current.dateTime + $station.station_url + + $gettext("Min outside temperature"): $day.outTemp.min $gettext("at") $day.outTemp.mintime; + $gettext("Max outside temperature"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime; + $gettext("Min inside temperature"): $day.inTemp.min $gettext("at") $day.inTemp.mintime; + $gettext("Max inside temperature"): $day.inTemp.max $gettext("at") $day.inTemp.maxtime; + $gettext("Min barometer"): $day.barometer.min $gettext("at") $day.barometer.mintime; + $gettext("Max barometer"): $day.barometer.max $gettext("at") $day.barometer.maxtime; + $gettext("Max wind") : $day.wind.max $gettext("from") $day.wind.gustdir $gettext("at") $day.wind.maxtime; + $gettext("Rain today"): $day.rain.sum + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + $station.latitude_f + $station.longitude_f + + $gettext("Day"): $day.dateTime.format("%d %b %Y")
+ $gettext("Min outside temperature"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
+ $gettext("Max outside temperature"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime
+ $gettext("Min inside temperature"): $day.inTemp.min $gettext("at") $day.inTemp.mintime
+ $gettext("Max inside temperature"): $day.inTemp.max $gettext("at") $day.inTemp.maxtime
+ $gettext("Min barometer"): $day.barometer.min $gettext("at") $day.barometer.mintime
+ $gettext("Max barometer"): $day.barometer.max $gettext("at") $day.barometer.maxtime
+ $gettext("Max wind") : $day.wind.max $gettext("from") $day.wind.gustdir $gettext("at") $day.wind.maxtime
+ $gettext("Rain today"): $day.rain.sum
+

+ ]]>
+
+ + + $gettext("Monthly Weather Summary as of") $current.dateTime + $station.station_url + + $gettext("Min outside temperature"): $month.outTemp.min $gettext("at") $month.outTemp.mintime; + $gettext("Max outside temperature"): $month.outTemp.max $gettext("at") $month.outTemp.maxtime; + $gettext("Min inside temperature"): $month.inTemp.min $gettext("at") $month.inTemp.mintime; + $gettext("Max inside temperature"): $month.inTemp.max $gettext("at") $month.inTemp.maxtime; + $gettext("Min barometer"): $month.barometer.min $gettext("at") $month.barometer.mintime; + $gettext("Max barometer"): $month.barometer.max $gettext("at") $month.barometer.maxtime; + $gettext("Max wind") : $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime; + $gettext("Rain total for month"): $month.rain.sum + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $gettext("Month"): $month.dateTime.format("%B %Y")
+ $gettext("Max outside temperature"): $month.outTemp.max $gettext("at") $month.outTemp.maxtime
+ $gettext("Min outside temperature"): $month.outTemp.min $gettext("at") $month.outTemp.mintime
+ $gettext("Max inside temperature"): $month.inTemp.max $gettext("at") $month.inTemp.maxtime
+ $gettext("Min inside temperature"): $month.inTemp.min $gettext("at") $month.inTemp.mintime
+ $gettext("Min barometer"): $month.barometer.min $gettext("at") $month.barometer.mintime
+ $gettext("Max barometer"): $month.barometer.max $gettext("at") $month.barometer.maxtime
+ $gettext("Max wind") : $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime
+ $gettext("Rain total for month"): $month.rain.sum
+

+ ]]>
+
+ + + $gettext("Yearly Weather Summary as of") $current.dateTime + $station.station_url + + $gettext("Min outside temperature"): $year.outTemp.min $gettext("at") $year.outTemp.mintime; + $gettext("Max outside temperature"): $year.outTemp.max $gettext("at") $year.outTemp.maxtime; + $gettext("Min inside temperature"): $year.inTemp.min $gettext("at") $year.inTemp.mintime; + $gettext("Max inside temperature"): $year.inTemp.max $gettext("at") $year.inTemp.maxtime; + $gettext("Min barometer"): $year.barometer.min $gettext("at") $year.barometer.mintime; + $gettext("Max barometer"): $year.barometer.max $gettext("at") $year.barometer.maxtime; + $gettext("Max wind") : $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime; + $gettext("Rain total for year"): $year.rain.sum + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $gettext("Year"): $year.dateTime.format("%Y")
+ $gettext("Max outside temperature"): $year.outTemp.max $gettext("at") $year.outTemp.maxtime
+ $gettext("Min outside temperature"): $year.outTemp.min $gettext("at") $year.outTemp.mintime
+ $gettext("Max inside temperature"): $year.inTemp.max $gettext("at") $year.inTemp.maxtime
+ $gettext("Min inside temperature"): $year.inTemp.min $gettext("at") $year.inTemp.mintime
+ $gettext("Min barometer"): $year.barometer.min $gettext("at") $year.barometer.mintime
+ $gettext("Max barometer"): $year.barometer.max $gettext("at") $year.barometer.maxtime
+ $gettext("Max wind") : $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime
+ $gettext("Rain total for year"): $year.rain.sum
+

+ ]]>
+
+ +
+
diff --git a/dist/weewx-4.10.1/skins/Standard/backgrounds/band.gif b/dist/weewx-4.10.1/skins/Standard/backgrounds/band.gif new file mode 100644 index 0000000..fdfd5cd Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/backgrounds/band.gif differ diff --git a/dist/weewx-4.10.1/skins/Standard/backgrounds/butterfly.jpg b/dist/weewx-4.10.1/skins/Standard/backgrounds/butterfly.jpg new file mode 100644 index 0000000..e33a26f Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/backgrounds/butterfly.jpg differ diff --git a/dist/weewx-4.10.1/skins/Standard/backgrounds/drops.gif b/dist/weewx-4.10.1/skins/Standard/backgrounds/drops.gif new file mode 100644 index 0000000..6d3814e Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/backgrounds/drops.gif differ diff --git a/dist/weewx-4.10.1/skins/Standard/backgrounds/flower.jpg b/dist/weewx-4.10.1/skins/Standard/backgrounds/flower.jpg new file mode 100644 index 0000000..4b33f6d Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/backgrounds/flower.jpg differ diff --git a/dist/weewx-4.10.1/skins/Standard/backgrounds/leaf.jpg b/dist/weewx-4.10.1/skins/Standard/backgrounds/leaf.jpg new file mode 100644 index 0000000..dd3b02c Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/backgrounds/leaf.jpg differ diff --git a/dist/weewx-4.10.1/skins/Standard/backgrounds/night.gif b/dist/weewx-4.10.1/skins/Standard/backgrounds/night.gif new file mode 100644 index 0000000..e6a1774 Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/backgrounds/night.gif differ diff --git a/dist/weewx-4.10.1/skins/Standard/favicon.ico b/dist/weewx-4.10.1/skins/Standard/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/favicon.ico differ diff --git a/dist/weewx-4.10.1/skins/Standard/index.html.tmpl b/dist/weewx-4.10.1/skins/Standard/index.html.tmpl new file mode 100644 index 0000000..a825106 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/index.html.tmpl @@ -0,0 +1,527 @@ +## Copyright 2009-2022 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Current Weather Conditions") + + + #if $station.station_url + + #end if + + + + +
+
+

$station.location

+

$gettext("Current Weather Conditions")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("Current Conditions") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $day.UV.has_data + + + + + #end if + #if $day.ET.has_data and $day.ET.sum.raw is not None and $day.ET.sum.raw > 0.0 + + + + + #end if + #if $day.radiation.has_data + + + + + #end if + +
$obs.label.outTemp$current.outTemp
$obs.label.windchill$current.windchill
$obs.label.heatindex$current.heatindex
$obs.label.dewpoint$current.dewpoint
$obs.label.outHumidity$current.outHumidity
$obs.label.barometer$current.barometer
$obs.label.barometerRate ($trend.time_delta.hour.format("%.0f"))$trend.barometer
$obs.label.wind$current.windSpeed $gettext("from") $current.windDir ($current.windDir.ordinal_compass)
$obs.label.rainRate$current.rainRate
$obs.label.inTemp$current.inTemp
$obs.label.UV$current.UV
$obs.label.ET$current.ET
$obs.label.radiation$current.radiation
+
+ +

 

+ +
+
+ $gettext("Since Midnight") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $day.UV.has_data + + + + + #end if + #if $day.ET.has_data and $day.ET.sum.raw is not None and $day.ET.sum.raw >0.0 + + + + + #end if + #if $day.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $day.outTemp.max $gettext("at") $day.outTemp.maxtime
+ $day.outTemp.min $gettext("at") $day.outTemp.mintime +
+ $gettext("High Heat Index")
+ $gettext("Low Wind Chill") +
+ $day.heatindex.max $gettext("at") $day.heatindex.maxtime
+ $day.windchill.min $gettext("at") $day.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $day.outHumidity.max $gettext("at") $day.outHumidity.maxtime
+ $day.outHumidity.min $gettext("at") $day.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $day.dewpoint.max $gettext("at") $day.dewpoint.maxtime
+ $day.dewpoint.min $gettext("at") $day.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $day.barometer.max $gettext("at") $day.barometer.maxtime
+ $day.barometer.min $gettext("at") $day.barometer.mintime +
$gettext("Today's Rain")$day.rain.sum
$gettext("High Rain Rate")$day.rainRate.max $gettext("at") $day.rainRate.maxtime
+ $gettext("High Wind") + + $day.wind.max $gettext("from") $day.wind.gustdir $gettext("at") $day.wind.maxtime +
+ $gettext("Average Wind") + + $day.wind.avg +
+ $gettext("RMS Wind") + + $day.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $day.wind.vecavg
+ $day.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $day.inTemp.max $gettext("at") $day.inTemp.maxtime
+ $day.inTemp.min $gettext("at") $day.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $day.UV.max $gettext("at") $day.UV.maxtime
+ $day.UV.min $gettext("at") $day.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $day.ET.max $gettext("at") $day.ET.maxtime
+ $day.ET.min $gettext("at") $day.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $day.radiation.max $gettext("at") $day.radiation.maxtime
+ $day.radiation.min $gettext("at") $day.radiation.mintime +
+
+ +

 

+ + #if 'radar_img' in $Extras +
+ #if 'radar_url' in $Extras + + #end if + Radar + #if 'radar_url' in $Extras + +

$gettext("Click image for expanded radar loop")

+ #end if +
+ #end if + +
+ +
+
+
+ $gettext("About this weather station"): +
+ + + + + + + + + + + + + + +
$gettext("Location")
$gettext("Latitude"):$station.latitude[0]° $station.latitude[1]' $station.latitude[2]
$gettext("Longitude"):$station.longitude[0]° $station.longitude[1]' $station.longitude[2]
$pgettext("Geographical", "Altitude"):$station.altitude
+

+ $gettext("This station uses a") + $station.hardware, + $gettext("controlled by 'WeeWX',") + $gettext("an experimental weather software system written in Python.") + $gettext("Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts.") +

+

$gettext("RSS feed")

+

$gettext("Smartphone formatted")

+

$gettext("WeeWX uptime"): $station.uptime.long_form
+ $gettext("Server uptime"): $station.os_uptime.long_form
+ weewx v$station.version

+
+ +
+
+ $gettext("Today's Almanac") +
+
+ #if $almanac.hasExtras + ## Extended almanac information is available. Do the full set of tables. + #set $sun_altitude = $almanac.sun.alt + #if $sun_altitude < 0 + #set $sun_None="(" + $gettext("Always down") + ")" + #else + #set $sun_None="(" + $gettext("Always up") + ")" + #end if +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $almanac.next_equinox.raw < $almanac.next_solstice.raw + ## The equinox is before the solstice. Display them in order. + + + + + + + + + #else + ## The solstice is before the equinox. Display them in order. + + + + + + + + + #end if +
$gettext("Sun")
$gettext("Start civil twilight"):$almanac(horizon=-6).sun(use_center=1).rise
$gettext("Sunrise"):$almanac.sun.rise.string($sun_None)
$gettext("Transit"):$almanac.sun.transit
$gettext("Sunset"):$almanac.sun.set.string($sun_None)
$gettext("End civil twilight"):$almanac(horizon=-6).sun(use_center=1).set
$gettext("Azimuth"):$("%.1f°" % $almanac.sun.az)
$pgettext("Astronomical", "Altitude"):$("%.1f°" % $sun_altitude)
$gettext("Right ascension"):$("%.1f°" % $almanac.sun.ra)
$gettext("Declination"):$("%.1f°" % $almanac.sun.dec)
$gettext("Equinox"):$almanac.next_equinox
$gettext("Solstice"):$almanac.next_solstice
$gettext("Solstice"):$almanac.next_solstice
$gettext("Equinox"):$almanac.next_equinox
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $almanac.next_full_moon.raw < $almanac.next_new_moon.raw + + + + + + + + + #else + + + + + + + + + #end if + + + + +
$gettext("Moon")
$gettext("Rise"):$almanac.moon.rise
$gettext("Transit"):$almanac.moon.transit
$gettext("Set"):$almanac.moon.set
$gettext("Azimuth"):$("%.1f°" % $almanac.moon.az)
$pgettext("Astronomical", "Altitude"):$("%.1f°" % $almanac.moon.alt)
$gettext("Right ascension"):$("%.1f°" % $almanac.moon.ra)
$gettext("Declination"):$("%.1f°" % $almanac.moon.dec)
$gettext("Full moon"):$almanac.next_full_moon
$gettext("New moon"):$almanac.next_new_moon
$gettext("New moon"):$almanac.next_new_moon
$gettext("Full moon"):$almanac.next_full_moon
$gettext("Phase"):$almanac.moon_phase
($almanac.moon_fullness% full)
+
+ #else + ## No extended almanac information available. Fall back to a simple table. + + + + + + + + + + + + + +
$gettext("Sunrise"):$almanac.sunrise
$gettext("Sunset"):$almanac.sunset
$gettext("Moon Phase"):$almanac.moon_phase
($almanac.moon_fullness% full)
+ #end if +
+
+ +
+ temperatures + heatchill + outside humidity + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $day.radiation.has_data + Radiation + #end if + #if $day.UV.has_data + UV Index + #end if + #if $day.rxCheckPercent.has_data + day rx percent + #end if +
+
+ + +
+ + ## Include the Google Analytics code if the user has supplied an ID: + #if 'googleAnalyticsId' in $Extras + + + #end if + + + + diff --git a/dist/weewx-4.10.1/skins/Standard/lang/de.conf b/dist/weewx-4.10.1/skins/Standard/lang/de.conf new file mode 100644 index 0000000..bb85ad5 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/lang/de.conf @@ -0,0 +1,220 @@ +############################################################################### +# Localization File --- Standard skin # +# German # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Karen" # +############################################################################### + +[Units] + + [[Labels]] + + day = " Tag", " Tage" + hour = " Stunde", " Stunden" + minute = " Minute", " Minuten" + second = " Sekunde", " Sekunden" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Luftdruck (QNH) # QNH + altimeterRate = Luftdruckänderung + appTemp = gefühlte Temperatur + appTemp1 = gefühlte Temperatur + barometer = Luftdruck # QFF + barometerRate = Luftdruckänderung + cloudbase = Wolkenuntergrenze + dateTime = "Datum/Zeit" + dewpoint = Taupunkt + ET = ET + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + heatindex = Hitzeindex + inDewpoint = Raumtaupunkt + inHumidity = Raumluftfeuchte + inTemp = Raumtemperatur + interval = Intervall + lightning_distance = Blitzentfernung + lightning_strike_count = Blitzanzahl + outHumidity = Luftfeuchte + outTemp = Außentemperatur + pressure = abs. Luftdruck # QFE + pressureRate = Luftdruckänderung + radiation = Sonnenstrahlung + rain = Regen + rainRate = Regen-Rate + THSW = THSW-Index + UV = UV-Index + wind = Wind + windchill = Windchill + windDir = Windrichtung + windGust = Böen Geschwindigkeit + windGustDir = Böen Richtung + windgustvec = Böen-Vektor + windrun = Windverlauf + windSpeed = Windgeschwindigkeit + windvec = Wind-Vektor + + # used in Seasons skin but not defined + feel = gefühlte Temperatur + + # Sensor status indicators + consBatteryVoltage = Konsolenbatterie + heatingVoltage = Heizungsspannung + inTempBatteryStatus = Innentemperatursensor + outTempBatteryStatus = Außentemperatursensor + rainBatteryStatus = Regenmesser + referenceVoltage = Referenz + rxCheckPercent = Signalqualität + supplyVoltage = Versorgung + txBatteryStatus = Übertrager + windBatteryStatus = Anemometer + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Neumond, zunehmend, Halbmond, zunehmend, Vollmond, abnehmend, Halbmond, abnehmend + +[Texts] + "7-day" = "7-Tage" + "24h" = "24h" + "About this weather station" = "Über diese Wetterstation" + "Always down" = "Polarnacht" + "Always up" = "Polartag" + "an experimental weather software system written in Python." = "einer experimentellen Wettersoftware geschrieben in Python." + "at" = "um" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Durchschn. Wind" + "Azimuth" = "Azimut" + "Big Page" = "für PC formatiert" + "Calendar Year" = "Kalenderjahr" + "controlled by 'WeeWX'," = "gesteuert von 'weewx'," + "Current Conditions" = "aktuelle Werte" + "Current conditions, and daily, monthly, and yearly summaries" = "Momentanwerte und Tages-, Monats- und Jahreszusammenfassung" + "Current Weather Conditions" = "aktuelle Wetterwerte" + "Daily Weather Summary as of" = "Tageszusammenfassung vom" + "Day" = "Tag" + "Declination" = "Deklination" + "End civil twilight" = "Ende der bürgerlichen Dämmerung" + "Equinox" = "Tagundnachtgleiche" + "from" = "aus" # Direction context. The wind is "from" the NE + "Full moon" = "Vollmond" + "High Barometer" = "Luftdruck max." + "High Dewpoint" = "Taupunkt max." + "High ET" = "ET max." + "High Heat Index" = "Hitzeindex max." + "High Humidity" = "Luftfeuchte max." + "High Inside Temperature" = "Innentemp. max." + "High Radiation" = "Sonnenstrahlg. max." + "High Rain Rate" = "Regenrate max." + "High Temperature" = "Temperatur max." + "High UV" = "UV max." + "High Wind" = "Wind max." + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "der letzten 30 Tage" + "Latitude" = "Breite" + "Location" = "Lage" + "Longitude" = "Länge" + "Low Barometer" = "Luftdruck min." + "Low Dewpoint" = "Taupunkt min." + "Low ET" = "ET min." + "Low Humidity" = "Luftfeuchte min." + "Low Inside Temperature" = "Innentemp. min." + "Low Radiation" = "Sonnenstrahlg. min." + "Low Temperature" = "Temperatur min." + "Low UV" = "UV min" + "Low Wind Chill" = "Wind-Chill min." + "Max barometer" = "Maximaler Luftdruck" + "Max inside temperature" = "Maximale Innentemperatur" + "Max outside temperature" = "Maximale Außentemperatur" + "Max rate" = "Maximale Rate" + "Max wind" = "Maximale Windstärke" + "Min barometer" = "Minimaler Luftdruck" + "Min inside temperature" = "Minimale Innentemperatur" + "Min outside temperature" = "Minimale Außentemperatur" + "Min rate" = "Minimale Rate" + "Month" = "Monat" + "Monthly Statistics and Plots" = "Monatsstatistiken und -diagramme" + "Monthly summary" = "Monatswerte" + "Monthly Weather Summary as of" = "Monatszusammenfassung vom" + "Monthly Weather Summary" = "Wetterübersicht für diesen Monat" + "Moon Phase" = "Mondphase" + "Moon" = "Mond" + "New moon" = "Neumond" + "Phase" = "Phase" + "Radar" = "Radar" + "Rain (daily total)" = "Regen (täglich gesamt)" + "Rain (hourly total)" = "Regen (stündlich gesamt)" + "Rain (weekly total)" = "Regen (wöchentlich gesamt)" + "Rain today" = "Regen heute" + "Rain total for month" = "Regen gesamt Monat" + "Rain total for year" = "Regen gesamt Jahr" + "Rain Total" = "Regen gesamt" + "Rain Year Total" = "Regen gesamt" + "Rain Year" = "Regenjahr" + "Right ascension" = "Rektaszension" + "Rise" = "Aufgang" + "RMS Wind" = "Wind Effektivwert" + "RSS feed" = "RSS-Feed" + "Select month" = "Monat wählen" + "Select year" = "Jahr wählen" + "Server uptime" = "Serverlaufzeit" + "Set" = "Untergang" + "Since Midnight" = "seit Mitternacht" + "Smartphone formatted" = "für Smartphone formatiert" + "Solstice" = "Sonnenwende" + "Sorry, no radar image" = "Kein Radarbild verfügbar" + "Start civil twilight" = "Beginn der bürglicherlichen Dämmerung" + "Sun" = "Sonne" + "Sunrise" = "Aufgang" + "Sunset" = "Untergang" + "This Month" = "Diesen Monat" + "This station uses a" = "Diese Station verwendet" + "This Week" = "Diese Woche" + "This week's max" = "Maximalwert dieser Woche" + "This week's min" = "Minimalwert dieser Woche" + "Time" = "Zeit" + "Today's Almanac" = "Sonne & Mond heute" + "Today's max" = "Maximalwert heute" + "Today's min" = "Minimalwert heute" + "Today's Rain" = "Regen heute" + "Transit" = "Transit" + "Vector Average Direction" = "Durchschn. Windrichtung" + "Vector Average Speed" = "Durchschn. Windstärke" + "Weather Conditions at" = "Wetter am" + "Weather Conditions" = "Wetter" + "Week" = "Woche" + "Weekly Statistics and Plots" = "Wochenstatistiken und -diagramme" + "Weekly Weather Summary" = "Wetterübersicht für diese Woche" + "WeeWX uptime" = "WeeWX-Laufzeit" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Grundsätze der Entwicklung von Weewx waren Einfachheit, Schnelligkeit und leichte Verständlichkeit durch Anwendung moderner Programmierkonzepte." + "Year" = "Jahr" + "Yearly Statistics and Plots" = "Jahresstatistiken und -diagramme" + "Yearly summary" = "Jahreswerte" + "Yearly Weather Summary as of" = "Jahreszusammenfassung vom" + "Yearly Weather Summary" = "Wetterübersicht für dieses Jahr" + + [[Geographical]] + "Altitude" = "Höhe" + + [[Astronomical]] + "Altitude" = "Höhe" + + [[Buttons]] + # Labels used in buttons + "Current" = " Jetzt " + "Month" = " Monat " + "Week" = " Woche " + "Year" = " Jahr " diff --git a/dist/weewx-4.10.1/skins/Standard/lang/en.conf b/dist/weewx-4.10.1/skins/Standard/lang/en.conf new file mode 100644 index 0000000..33fc367 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/lang/en.conf @@ -0,0 +1,222 @@ +############################################################################### +# Localization File --- Standard skin # +# English # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Units] + + [[Labels]] + + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Time + interval = Interval + altimeter = Altimeter # QNH + altimeterRate = Altimeter Trend + barometer = Barometer # QFF + barometerRate = Barometer Trend + pressure = Pressure # QFE + pressureRate = Pressure Trend + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + inDewpoint = Inside Dew Point + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + appTemp = Apparent Temperature + appTemp1 = Apparent Temperature + THSW = THSW Index + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + cloudbase = Cloud Base + + # used in Seasons skin, but not defined + feel = apparent temperature + + # Sensor status indicators + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +[Texts] + "7-day" = "7-day" + "24h" = "24h" + "About this weather station" = "About this weather station" + "Always down" = "Always down" + "Always up" = "Always up" + "an experimental weather software system written in Python." = "an experimental weather software system written in Python." + "at" = "at" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Average Wind" + "Azimuth" = "Azimuth" + "Big Page" = "Big Page" + "Calendar Year" = "Calendar Year" + "Click image for expanded radar loop" = "Click image for expanded radar loop" + "controlled by 'WeeWX'," = "controlled by 'WeeWX'," + "Current Conditions" = "Current Conditions" + "Current conditions, and daily, monthly, and yearly summaries" = "Current conditions, and daily, monthly, and yearly summaries" + "Current Weather Conditions" = "Current Weather Conditions" + "Daily Weather Summary as of" = "Daily Weather Summary as of" + "Day" = "Day" + "Declination" = "Declination" + "End civil twilight" = "End civil twilight" + "Equinox" = "Equinox" + "from" = "from" # Direction context. The wind is "from" the NE + "Full moon" = "Full moon" + "High Barometer" = "High Barometer" + "High Dewpoint" = "High Dewpoint" + "High ET" = "High ET" + "High Heat Index" = "High Heat Index" + "High Humidity" = "High Humidity" + "High Inside Temperature" = "High Inside Temperature" + "High Radiation" = "High Radiation" + "High Rain Rate" = "High Rain Rate" + "High Temperature" = "High Temperature" + "High UV" = "High UV" + "High Wind" = "High Wind" + "High" = "High" + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "last 30 days" + "Latitude" = "Latitude" + "Location" = "Location" + "Longitude" = "Longitude" + "Low Barometer" = "Low Barometer" + "Low Dewpoint" = "Low Dewpoint" + "Low ET" = "Low ET" + "Low Humidity" = "Low Humidity" + "Low Inside Temperature" = "Low Inside Temperature" + "Low Radiation" = "Low Radiation" + "Low Temperature" = "Low Temperature" + "Low UV" = "Low UV" + "Low Wind Chill" = "Low Wind Chill" + "Max barometer" = "Max barometer" + "Max inside temperature" = "Max inside temperature" + "Max outside temperature" = "Max outside temperature" + "Max rate" = "Max rate" + "Max wind" = "Max wind" + "max" = "max" + "Min barometer" = "Min barometer" + "Min inside temperature" = "Min inside temperature" + "Min outside temperature" = "Min outside temperature" + "Min rate" = "Min rate" + "Month" = "Month" + "Monthly Statistics and Plots" = "Monthly Statistics and Plots" + "Monthly summary" = "Monthly summary" + "Monthly Weather Summary as of" = "Monthly Weather Summary as of" + "Monthly Weather Summary" = "Monthly Weather Summary" + "Moon Phase" = "Moon Phase" + "Moon" = "Moon" + "New moon" = "New moon" + "Phase" = "Phase" + "Radar" = "Radar" + "Rain (daily total)" = "Rain (daily total)" + "Rain (hourly total)" = "Rain (hourly total)" + "Rain (weekly total)" = "Rain (weekly total)" + "Rain today" = "Rain today" + "Rain total for month" = "Rain total for month" + "Rain total for year" = "Rain total for year" + "Rain Total" = "Rain Total" + "Rain Year Total" = "Rain Year Total" + "Rain Year" = "Rain Year" + "Right ascension" = "Right ascension" + "Rise" = "Rise" + "RMS Wind" = "RMS Wind" + "RSS feed" = "RSS feed" + "Select month" = "Select month" + "Select year" = "Select year" + "Server uptime" = "Server uptime" + "Set" = "Set" + "Since Midnight" = "Since Midnight" + "Smartphone formatted" = "Smartphone formatted" + "Solstice" = "Solstice" + "Sorry, no radar image" = "Sorry, no radar image" + "Start civil twilight" = "Start civil twilight" + "Sun" = "Sun" + "Sunrise" = "Sunrise" + "Sunset" = "Sunset" + "This Month" = "This Month" + "This station uses a" = "This station uses a" + "This Week" = "This Week" + "This week's max" = "This week's max" + "This week's min" = "This week's min" + "Time" = "Time" + "Today's Almanac" = "Today's Almanac" + "Today's data" = "Today's data" + "Today's max" = "Today's max" + "Today's min" = "Today's min" + "Today's Rain" = "Today's Rain" + "Transit" = "Transit" + "Vector Average Direction" = "Vector Average Direction" + "Vector Average Speed" = "Vector Average Speed" + "Weather Conditions at" = "Weather Conditions at" + "Weather Conditions" = "Weather Conditions" + "Week" = "Week" + "Weekly Statistics and Plots" = "Weekly Statistics and Plots" + "Weekly Weather Summary" = "Weekly Weather Summary" + "WeeWX uptime" = "WeeWX uptime" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." + "Year" = "Year" + "Yearly Statistics and Plots" = "Yearly Statistics and Plots" + "Yearly summary" = "Yearly summary" + "Yearly Weather Summary as of" = "Yearly Weather Summary as of" + "Yearly Weather Summary" = "Yearly Weather Summary" + + [[Geographical]] + "Altitude" = "Altitude" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Altitude" # As in angle above the horizon + + [[Buttons]] + # Labels used in buttons + "Current" = " Current " + "Month" = " Month " + "Week" = " Week " + "Year" = " Year " diff --git a/dist/weewx-4.10.1/skins/Standard/lang/fr.conf b/dist/weewx-4.10.1/skins/Standard/lang/fr.conf new file mode 100644 index 0000000..a7fd0cf --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/lang/fr.conf @@ -0,0 +1,220 @@ +############################################################################### +# Localization File --- Standard skin # +# French # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Gérard" # +############################################################################### + +[Units] + + [[Labels]] + + day = " jour", " jours" + hour = " heure", " heures" + minute = " minute", " minutes" + second = " seconde", " secondes" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, O + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Datation + interval = Intervalle + altimeter = Altimètre # QNH + altimeterRate = Tendance Altimétrique + barometer = Baromètre # QFF + barometerRate = Tendance Barométrique + pressure = Pression # QFE + pressureRate = Tendance de la Pression + dewpoint = Point de Rosée + ET = Evapotranspiration + heatindex = Indice de Chaleur + inHumidity = Humidité Intérieure + inTemp = Température Intérieure + inDewpoint = Point de Rosée Intérieur + outHumidity = Humidité + outTemp = Température Extérieure + radiation = Rayonnement + rain = Pluie + rainRate = Taux de Pluie + UV = Indice UV + wind = Vent + windDir = Direction du Vent + windGust = Rafales + windGustDir = Direction des Rafales + windSpeed = Vitesse du Vent + windchill = Refroidissement Eolien + windgustvec = Vecteur Rafales + windvec = Vecteur Vent + windrun = Parcours du Vent + extraTemp1 = Température1 + extraTemp2 = Température2 + extraTemp3 = Température3 + appTemp = Température Apparente + appTemp1 = Température Apparente + THSW = Indice THSW + lightning_distance = Distance de la Foudre + lightning_strike_count = Impacts de Foudre + cloudbase = Base des Nuages + + # used in Seasons skin, but not defined + feel = Température Apparente + + # Sensor status indicators + rxCheckPercent = Qualité du Signal + txBatteryStatus = Batterie Emetteur + windBatteryStatus = Batterie Vent + rainBatteryStatus = Batterie Pluie + outTempBatteryStatus = Batterie Température Extérieure + inTempBatteryStatus = Batterie Température Intérieure + consBatteryVoltage = Tension Batterie Console + heatingVoltage = Tension Chauffage + supplyVoltage = Tension Alimentation + referenceVoltage = Tension Référence + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nouvelle, Premier Croissant, Premier Quartier, Gibbeuse Croissante, Pleine, Gibbeuse Décroissante, Dernier Quartier, Dernier Croissant + +[Texts] + "7-day" = "7-jours" + "24h" = "24h" + "About this weather station" = "A Propos de cette station" + "Always down" = "Toujours couché" + "Always up" = "Toujours levé" + "an experimental weather software system written in Python." = "un logiciel de système météorologique expérimental écrit en Python." + "at" = "à" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Vent Moyen" + "Azimuth" = "Azimut" + "Big Page" = "Grande Page" + "Calendar Year" = "Année Calendaire" + "controlled by 'WeeWX'," = "controllé par 'weewx'," + "Current Conditions" = "Conditions Actuelles" + "Current conditions, and daily, monthly, and yearly summaries" = "Conditions actuelles, et résumés quotidiens, mensuels et annuels" + "Current Weather Conditions" = "Conditions Météo Actuelles" + "Daily Weather Summary as of" = "Résumé Météo Quotidien au" + "Day" = "Jour" + "Declination" = "Déclinaison" + "End civil twilight" = "Crépuscule civil" + "Equinox" = "Equinoxe" + "from" = "du" # Direction context. The wind is "from" the NE + "Full moon" = "Pleine lune" + "High Barometer" = "Baromètre Haut" + "High Dewpoint" = "Point de Rosée Haut" + "High ET" = "ET Haute" + "High Heat Index" = "Indice de Chaleur Haut" + "High Humidity" = "Humidité Haute" + "High Inside Temperature" = "Température Intérieure Haute" + "High Radiation" = "Rayonnement Haut" + "High Rain Rate" = "Taux de Pluie Haut" + "High Temperature" = "Température Haute" + "High UV" = "UV Haut" + "High Wind" = "Vent Haut" + "in" = "à" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "30 derniers jours" + "Latitude" = "Latitude" + "Location" = "Lieu" + "Longitude" = "Longitude" + "Low Barometer" = "Baromètre Bas" + "Low Dewpoint" = "Point de Rosée Bas" + "Low ET" = "ET Basse" + "Low Humidity" = "Humidité Basse" + "Low Inside Temperature" = "Température Intérieure Basse" + "Low Radiation" = "Rayonnement Bas" + "Low Temperature" = "Température Basse" + "Low UV" = "UV Bas" + "Low Wind Chill" = "Refroidissement Eolien Bas" + "Max barometer" = "Baromètre max" + "Max inside temperature" = "Température intérieure max" + "Max outside temperature" = "Température extérieure max" + "Max rate" = "Taux max" + "Max wind" = "Vent max" + "Min barometer" = "Baromètre min" + "Min inside temperature" = "Température intérieure min" + "Min outside temperature" = "Température extérieure min" + "Min rate" = "Taux min" + "Month" = "Mois" + "Monthly Statistics and Plots" = "Graphiques et Statistiques Mensuels" + "Monthly summary" = "Résumé mensuel" + "Monthly Weather Summary as of" = "Résumé Météo Mensuel au" + "Monthly Weather Summary" = "Résumé Météo Mensuel" + "Moon Phase" = "Phase lunaire" + "Moon" = "Lune" + "New moon" = "Nouvelle lune" + "Phase" = "Phase" + "Radar" = "Radar" + "Rain (daily total)" = "Précipitations (total quotidien)" + "Rain (hourly total)" = "Précipitations (total horaire)" + "Rain (weekly total)" = "Précipitations (total hebdomadaire)" + "Rain today" = "Pluie du jour" + "Rain total for month" = "Pluie totale du mois" + "Rain total for year" = "Pluie totale de l'année" + "Rain Total" = "Pluie Totale" + "Rain Year Total" = "Pluie Annuelle Totale" + "Rain Year" = "Pluie Annuelle" + "Right ascension" = "Ascension droite" + "Rise" = "Lever" + "RMS Wind" = "Vent RMS" + "RSS feed" = "Flux RSS" + "Select month" = "Sélection mois" + "Select year" = "Sélection année" + "Server uptime" = "Disponibilité du serveur" + "Set" = "Coucher" + "Since Midnight" = "Depuis Minuit" + "Smartphone formatted" = "Format mobile multifonction" + "Solstice" = "Solstice" + "Sorry, no radar image" = "Désolé, pas d'image radar" + "Start civil twilight" = "Aube civile" + "Sun" = "Soleil" + "Sunrise" = "Lever" + "Sunset" = "Coucher" + "This Month" = "Ce Mois" + "This station uses a" = "Cette station utilise un" + "This Week" = "Cette Semaine" + "This week's max" = "Max cette semaine" + "This week's min" = "Min cette semaine" + "Time" = "Heure" + "Today's Almanac" = "Almanach du jour" + "Today's max" = "Max aujourd'hui" + "Today's min" = "Min aujourd'hui" + "Today's Rain" = "Pluie du Jour" + "Transit" = "Méridien" + "Vector Average Direction" = "Direction Moyenne du Vecteur" + "Vector Average Speed" = "Vitesse Moyenne du Vecteur" + "Weather Conditions at" = "Conditions météo à" + "Weather Conditions" = "Conditions Météo" + "Week" = "Semaine" + "Weekly Statistics and Plots" = "Graphiques et Statistiques Hebdomadaires" + "Weekly Weather Summary" = "Résumé Météo Hebdomadaire" + "WeeWX uptime" = "Disponibilité de WeeWX" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Weewx a été conçu pour être simple, rapide et facile à comprendre par l'exploitation de concepts logiciel modernes." + "Year" = "Année" + "Yearly Statistics and Plots" = "Graphiques et Statistiques Annuels" + "Yearly summary" = "Résumé annuel" + "Yearly Weather Summary as of" = "Résumé Météo Annuel au" + "Yearly Weather Summary" = "Résumé Météo Annuel" + + [[Geographical]] + "Altitude" = "Altitude" + + [[Astronomical]] + "Altitude" = "Site" + + [[Buttons]] + # Labels used in buttons + "Current" = " Jour " + "Month" = " Mois " + "Week" = " Semaine " + "Year" = " Année " diff --git a/dist/weewx-4.10.1/skins/Standard/lang/nl.conf b/dist/weewx-4.10.1/skins/Standard/lang/nl.conf new file mode 100644 index 0000000..0012094 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/lang/nl.conf @@ -0,0 +1,219 @@ +############################################################################### +# Localization File --- Standard skin # +# Dutch # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# Translation by Eelco # +############################################################################### + +[Units] + + [[Labels]] + + day = " dag", " dagen" + hour = " uur", " uren" + minute = " minuut", " minuten" + second = " seconde", " seconden" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNO, NO, ONO, O, OZO, ZO, ZZO, Z, ZZW, ZW, WZW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, Z, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Tiijd + interval = Interval + altimeter = Luchtdruk (QNH) # QNH + altimeterRate = Luchtdruk Trend + barometer = Barometer # QFF + barometerRate = Barometer Trend + pressure = Luchtdruk (QFE) # QFE + pressureRate = Luchtdruk Trend + dewpoint = Dauwpunt + ET = ET + heatindex = Hitte Index + inHumidity = Luchtvochtigheid Binnen + inTemp = Temperatuur Binnen + inDewpoint = Dauwpunt Binnen + outHumidity = Luchtvochtigheid Buiten + outTemp = Temperatuur Buiten + radiation = Zonnestraling + rain = Regen + rainRate = Regen Intensiteit + UV = UV Index + wind = Wind + windDir = Wind Richting + windGust = Windvlaag Snelheid + windGustDir = Windvlaag Richting + windSpeed = Wind Snelheid + windchill = Wind Chill + windgustvec = Windvlaag Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperatuur1 + extraTemp2 = Temperatuur2 + extraTemp3 = Temperatuur3 + appTemp = Gevoelstemperatuur + appTemp1 = Gevoelstemperatuur + THSW = THSW Index + lightning_distance = Bliksem Afstand + lightning_strike_count = Bliksem Ontladingen + cloudbase = Wolkenbasis + + # used in Seasons skin, but not defined + feel = gevoelstemperatuur + + # Sensor status indicators + rxCheckPercent = Signaalkwaliteit + txBatteryStatus = Zender Batterij + windBatteryStatus = Wind Batterij + rainBatteryStatus = Regen Batterij + outTempBatteryStatus = Buitentemperatuur Batterij + inTempBatteryStatus = Binnentemperatuur Batterij + consBatteryVoltage = Console Batterij + heatingVoltage = Verwarming Battery + supplyVoltage = Voeding Voltage + referenceVoltage = Referentie Voltage + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nieuw, Jonge Maansikkel, Eerste Kwartier, Wassende Maan, Vol, Afnemende Maan, Laatste Kwartier, Asgrauwe Maan + +[Texts] + "7-day" = "7-dagen" + "24h" = "24h" + "About this weather station" = "Over dit weerstation" + "Always down" = "Altijd onder" + "Always up" = "Altijd op" + "an experimental weather software system written in Python." = "een experimenteel weer software systeem geschreven in Python." + "at" = "om" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Gemiddelde Wind" + "Azimuth" = "Azimuth" + "Big Page" = "Grote Pagina" + "Calendar Year" = "Kalender Jaar" + "controlled by 'WeeWX'," = "onder 'weewx'," + "Current Conditions" = "Actuele Condities" + "Current conditions, and daily, monthly, and yearly summaries" = "Actuele conditions, and dagelijkse, maandelijkse en jaarlijkse samenvattingen" + "Current Weather Conditions" = "Actuele Weer Condities" + "Daily Weather Summary as of" = "Dagelijkse weersamenvatting van" + "Day" = "Dag" + "Declination" = "Declinatie" + "End civil twilight" = "Einde civiele schemering" + "Equinox" = "Equinox" + "from" = "uit" # Direction context. The wind is "from" the NE + "Full moon" = "Volle Maan" + "High Barometer" = "Max Barometer" + "High Dewpoint" = "Max Dauwpunt" + "High ET" = "Max ET" + "High Heat Index" = "Max Hitte Index" + "High Humidity" = "Max Luchtvochtigheid" + "High Inside Temperature" = "Max Temperatuur Binnen" + "High Radiation" = "Max Straling" + "High Rain Rate" = "Max Regen Intensiteit" + "High Temperature" = "Max Temperatuur" + "High UV" = "Max UV" + "High Wind" = "Max Wind" + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "laatste 30 dagen" + "Latitude" = "Breedtegraad" + "Location" = "Locatie" + "Longitude" = "Lengtegraad" + "Low Barometer" = "Min Barometer" + "Low Dewpoint" = "Min Dauwpunt" + "Low ET" = "Min ET" + "Low Humidity" = "Min Luchtvochtigheid" + "Low Inside Temperature" = "Min Binnen Temperatuur" + "Low Radiation" = "Min Straling" + "Low Temperature" = "Min Temperatuur" + "Low UV" = "Min UV" + "Low Wind Chill" = "Min Wind Chill" + "Max barometer" = "Max barometer" + "Max inside temperature" = "Max temperatuur binnen" + "Max outside temperature" = "Max temperatuur buiten" + "Max rate" = "Max rate" + "Max wind" = "Max wind" + "Min barometer" = "Min barometer" + "Min inside temperature" = "Min temperatuur binnen" + "Min outside temperature" = "Min temperatuur buiten" + "Min rate" = "Min rate" + "Month" = "Maand" + "Monthly Statistics and Plots" = "Maandelijkse Statistieken en Plots" + "Monthly summary" = "Maandelijkse Samenvatting" + "Monthly Weather Summary as of" = "Maandelijkse weersamenvatting van" + "Monthly Weather Summary" = "Maandelijkse Weersamenvatting" + "Moon Phase" = "Maan Fase" + "Moon" = "Maan" + "New moon" = "Nieuwe maan" + "Phase" = "Fase" + "Radar" = "Radar" + "Rain (daily total)" = "Regen (dag totaal)" + "Rain (hourly total)" = "Regen (uur totaal)" + "Rain (weekly total)" = "Regen (week totaal)" + "Rain today" = "Regen vandaag" + "Rain total for month" = "Regen totaal voor maand" + "Rain total for year" = "Regen totaal voor jaar" + "Rain Total" = "Regen Totaal" + "Rain Year Total" = "Regen Jaar Totaal" + "Rain Year" = "Regen Jaar" + "Right ascension" = "Rechte klimming" + "Rise" = "Opkomst" + "RMS Wind" = "RMS Wind" + "RSS feed" = "RSS feed" + "Select month" = "Selekteer Maand" + "Select year" = "Selekteer Jaar" + "Server uptime" = "Server uptime" + "Set" = "Ondergang" + "Since Midnight" = "Sinds Middernacht" + "Smartphone formatted" = "Smartphone formaat" + "Solstice" = "Zonnewende" + "Sorry, no radar image" = "Sorry, geen radarbeeld beschikbaar" + "Start civil twilight" = "Start civiele schemering" + "Sun" = "Zon" + "Sunrise" = "Zonsopkomst" + "Sunset" = "Zonsondergang" + "This Month" = "Deze Maand" + "This station uses a" = "Dit station gebruikt" + "This Week" = "Deze Week" + "This week's max" = "Max deze week" + "This week's min" = "Min deze week" + "Time" = "Tijd" + "Today's Almanac" = "Almanak Vandaag" + "Today's max" = "Max vandaag" + "Today's min" = "Min vandaag" + "Today's Rain" = "Regen Vandaag" + "Transit" = "Transit" + "Vector Average Direction" = "Vector Gemiddelde Richting" + "Vector Average Speed" = "Vector Gemiddelde Snelheid" + "Weather Conditions at" = "Weercondities om" + "Weather Conditions" = "Weer Condities" + "Week" = "Week" + "Weekly Statistics and Plots" = "Wekelijkse Statistieken en Plots" + "Weekly Weather Summary" = "Wekelijkse Weersamenvatting" + "WeeWX uptime" = "WeeWX uptime" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Doelstelling van WeeWx is een simpel, snel en makkelijk te begrijpen systeem te zijn door het gebruik van moderne software concepten." + "Year" = "Jaar" + "Yearly Statistics and Plots" = "Jaarlijkse Statistieken en Plots" + "Yearly summary" = "Jaaroverzicht" + "Yearly Weather Summary as of" = "Jaarlijkse Weersamenvatting van" + "Yearly Weather Summary" = "Jaarlijkse Weersamenvatting" + + [[Geographical]] + "Altitude" = "Hoogte" + + [[Astronomical]] + "Altitude" = "Hoogte" + + [[Buttons]] + # Labels used in buttons + "Current" = " Actueel " + "Month" = " Maand " + "Week" = " Week " + "Year" = " Jaar " diff --git a/dist/weewx-4.10.1/skins/Standard/lang/no.conf b/dist/weewx-4.10.1/skins/Standard/lang/no.conf new file mode 100644 index 0000000..85b31fc --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/lang/no.conf @@ -0,0 +1,249 @@ +############################################################################### +# Localization File --- Standard skin # +# Norwegian # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +# Generally want a metric system for the norwegian language: +unit_system = metricwx + +[Units] + + # [[Groups]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_degree_day = degree_C_day # Options are 'degree_F_day' or 'degree_C_day' + # group_distance = km # Options are 'mile' or 'km' + # group_pressure = mBar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_speed = meter_per_second # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + # group_speed2 = meter_per_second2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + # group_temperature = degree_C # Options are 'degree_F' or 'degree_C' + + [[Labels]] + + # These are singular, plural + meter = " meter", " meter" + day = " dag", " dager" + hour = " time", " timer" + minute = " minutt", " minutter" + second = " sekund", " sekunder" + + cm_per_hour = " cm/t" + hPa_per_hour = " hPa/t" + inch_per_hour = " in/t" + inHg_per_hour = " inHg/t" + km_per_hour = " km/t" + km_per_hour2 = " km/t" + kPa_per_hour = " kPa/t", + mbar_per_hour = " mbar/t" + mm_per_hour = " mm/t" + mmHg_per_hour = " mmHg/t" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NØ, ØNØ, Ø, ØSØ, SØ, SSØ, S, SSV, SV, VSV, V, VNV, NV, NNV, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, Ø, V + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Tid + interval = Intervall + altimeter = Altimeter # QNH + altimeterRate = Altimeter trend + barometer = Barometer # QFF + barometerRate = Barometer trend + pressure = Trykk # QFE + pressureRate = Trykk trend + dewpoint = Doggpunkt + ET = Fordampning + heatindex = Varmeindeks + inHumidity = Innendørs fuktighet + inTemp = Innendørs temperatur + inDewpoint = Innendørs doggpunkt + outHumidity = Luftfuktighet + outTemp = Utetemperatur + radiation = Stråling + rain = Regn + rainRate = Regnintensitet + UV = UV-indeks + wind = Vind + windDir = Vindretning + windGust = Vindkast + windGustDir = Vindkast retning + windSpeed = Vindhastighet + windchill = Føles som + windgustvec = Kastvektor + windvec = Vindvektor + windrun = Vind Run + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + appTemp = Føles som + appTemp1 = Føles som + THSW = THSW Indeks + lightning_distance = Lynavstand + lightning_strike_count = Lynnedslag + cloudbase = Skybase + + # used in Seasons skin, but not defined + feel = følt temperatur + + # Sensor status indicators + rxCheckPercent = Signalkvalitet + txBatteryStatus = Senderbatteri + windBatteryStatus = Vindbatteri + rainBatteryStatus = Regnbatteri + outTempBatteryStatus = Utetemperatur batteri + inTempBatteryStatus = Innetemperatur batteri + consBatteryVoltage = Konsollbatteri + heatingVoltage = Varmebatteri + supplyVoltage = Spenning strømforsyning + referenceVoltage = Referansespenning + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nymåne, Voksende månesigd, Halvmåne første kvarter, Voksende måne (ny), Fullmåne, Minkende måne (ne), Halvmåne siste kvarter, Minkende månesigd + +[Texts] + "7-day" = "7-dager" + "24h" = "24t" + "About this weather station" = "Om denne værstasjonen" + "Always down" = "Alltid nede" + "Always up" = "Alltid oppe" + "an experimental weather software system written in Python." = "en eksperimentell værprogramvare skrevet i Python." + "at" = "" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Gjennomsnittvind" + "Azimuth" = "Asimut" + "Big Page" = "Formattert for PC" + "Calendar Year" = "Kalenderår" + "Click image for expanded radar loop" = "Trykk på bildet for lokal værradar" + "controlled by 'WeeWX'," = "kontrollert av 'WeeWX'," + "Current Conditions" = "Været nå" + "Current conditions, and daily, monthly, and yearly summaries" = "Oppsummering været nå, daglig, månedlig og årlig" + "Current Weather Conditions" = "Været nå" + "Daily Weather Summary as of" = "Oppsummering daglig vær den" + "Day" = "Daglig" + "Declination" = "Deklinasjon" + "End civil twilight" = "Slutt skumring" + "Equinox" = "Jevndøgn" + "from" = "fra" # Direction context. The wind is "from" the NE + "Full moon" = "Fullmåne" + "High Barometer" = "Høyeste lufttrykk" + "High Dewpoint" = "Høyeste doggpunkt" + "High ET" = "Høyeste fordampning" + "High Heat Index" = "Høyeste varmeindeks" + "High Humidity" = "Høyeste luftfuktighet" + "High Inside Temperature" = "Høyeste innetemperatur" + "High Radiation" = "Høyeste stråling" + "High Rain Rate" = "Høyeste regnhastighet" + "High Temperature" = "Høyeste temperatur" + "High UV" = "Høyeste UV" + "High Wind" = "Høyeste vind" + "High" = "Høy" + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "siste 30 dager" + "Latitude" = "Bredde" + "Location" = "Plassering" + "Longitude" = "Lengde" + "Low Barometer" = "Laveste lufttrykk" + "Low Dewpoint" = "Laveste dokkpunkt" + "Low ET" = "Laveste fordampning" + "Low Humidity" = "Laveste fuktighet" + "Low Inside Temperature" = "Laveste innetemperatur" + "Low Radiation" = "Laveste stråling" + "Low Temperature" = "Laveste temperatur" + "Low UV" = "Laveste UV" + "Low Wind Chill" = "Laveste følte temperatur" + "Max barometer" = "Maks lufttrykk" + "Max inside temperature" = "Maks innetemperatur" + "Max outside temperature" = "Maks utetemperatur" + "Max rate" = "Maks regn" + "Max wind" = "Maks vind" + "max" = "maks" + "Min barometer" = "Min lufttrykk" + "Min inside temperature" = "Min innetemperatur" + "Min outside temperature" = "Min utetemperatur" + "Min rate" = "Min regn" + "Month" = "Måned" + "Monthly Statistics and Plots" = "Månedlig statistikk og grafikk" + "Monthly summary" = "Månedlig oppsummering" + "Monthly Weather Summary as of" = "Månedlig væroppsummering for" + "Monthly Weather Summary" = "Månedlig væroppsummering" + "Moon Phase" = "Månedfase" + "Moon" = "Måne" + "New moon" = "Nåmåne" + "Phase" = "Fase" + "Radar" = "Radar" + "Rain (daily total)" = "Regn (daglig total)" + "Rain (hourly total)" = "Regn (timestotal)" + "Rain (weekly total)" = "Regn (ukestotal)" + "Rain today" = "Regn i dag" + "Rain total for month" = "Regn totalt for måned" + "Rain total for year" = "Regn totalt for år" + "Rain Total" = "Regn totalt" + "Rain Year Total" = "Regn årstotal" + "Rain Year" = "Regnår" + "Right ascension" = "Rektascensjon" + "Rise" = "Opp" + "RMS Wind" = "RMS vind" + "RSS feed" = "RSS feed" + "Select month" = "Velg måned" + "Select year" = "Velg år" + "Server uptime" = "Oppetid server" + "Set" = "Ned" + "Since Midnight" = "Siden midnatt" + "Smartphone formatted" = "Formattert for smarttelefon" + "Solstice" = "Solverv" + "Sorry, no radar image" = "Beklager, mangler værradar" + "Start civil twilight" = "Start demring" + "Sun" = "Sol" + "Sunrise" = "Soloppgang" + "Sunset" = "Solnedgang" + "This Month" = "Denne måneden" + "This station uses a" = "Denne stasjonen bruker en" + "This Week" = "Denne uken" + "This week's max" = "Maks denne uken" + "This week's min" = "Min denne uken" + "Time" = "Tid" + "Today's Almanac" = "Sol og måne i dag" + "Today's data" = "Dagens vær" + "Today's max" = "Dagens maks" + "Today's min" = "Dagens min" + "Today's Rain" = "Dagens regn" + "Transit" = "Transit" + "Vector Average Direction" = "Gjennomsnittsvind retning" + "Vector Average Speed" = "Gjennomsnittsvind hastighet" + "Weather Conditions at" = "Værtilstand den" + "Weather Conditions" = "Værtilstand" + "Week" = "Uke" + "Weekly Statistics and Plots" = "Ukentlig statistikk og plott" + "Weekly Weather Summary" = "Ukentlig væroppsummering" + "WeeWX uptime" = "WeeWX oppetid" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "
Weewx ble utformet for å være enkel, hurtig og lett å bruke ved hjelp av moderne programmeringsmetoder." + "Year" = "År" + "Yearly Statistics and Plots" = "Årlig statistikk og plott" + "Yearly summary" = "Årlig oppsummering" + "Yearly Weather Summary as of" = "Årlig væroppsummering for" + "Yearly Weather Summary" = "Årlig væroppsummering" + + [[Geographical]] + "Altitude" = "Høyde" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Vinkelhøyde" # As in angle above the horizon + + [[Buttons]] + # Labels used in buttons + "Current" = " Nåværende " + "Month" = " Måned " + "Week" = " Uke " + "Year" = " År " diff --git a/dist/weewx-4.10.1/skins/Standard/month.html.tmpl b/dist/weewx-4.10.1/skins/Standard/month.html.tmpl new file mode 100644 index 0000000..8d88707 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/month.html.tmpl @@ -0,0 +1,419 @@ +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Monthly Weather Summary") + + + #if $station.station_url + + #end if + + + + +
+
+

$station.location

+

$gettext("Monthly Weather Summary")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("This Month") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $month.UV.has_data + + + + + #end if + #if $month.ET.has_data and $month.ET.sum.raw > 0.0 + + + + + #end if + #if $month.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $month.outTemp.max $gettext("at") $month.outTemp.maxtime
+ $month.outTemp.min $gettext("at") $month.outTemp.mintime +
+ $gettext("High Heat Index") + + $month.heatindex.max $gettext("at") $month.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $month.windchill.min $gettext("at") $month.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $month.outHumidity.max $gettext("at") $month.outHumidity.maxtime
+ $month.outHumidity.min $gettext("at") $month.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $month.dewpoint.max $gettext("at") $month.dewpoint.maxtime
+ $month.dewpoint.min $gettext("at") $month.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $month.barometer.max $gettext("at") $month.barometer.maxtime
+ $month.barometer.min $gettext("at") $month.barometer.mintime +
+ $gettext("Rain Total") + + $month.rain.sum +
+ $gettext("High Rain Rate") + + $month.rainRate.max $gettext("at") $month.rainRate.maxtime +
+ $gettext("High Wind") + + $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime +
+ $gettext("Average Wind") + + $month.wind.avg +
+ $gettext("RMS Wind") + + $month.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $month.wind.vecavg
+ $month.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $month.inTemp.max $gettext("at") $month.inTemp.maxtime
+ $month.inTemp.min $gettext("at") $month.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $month.UV.max $gettext("at") $month.UV.maxtime
+ $month.UV.min $gettext("at") $month.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $month.ET.max $gettext("at") $month.ET.maxtime
+ $month.ET.min $gettext("at") $month.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $month.radiation.max $gettext("at") $month.radiation.maxtime
+ $month.radiation.min $gettext("at") $month.radiation.mintime +
+
+ +

 

+ +
+
+ $gettext("Calendar Year") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $year.UV.has_data + + + + + #end if + #if $year.ET.has_data and $year.ET.sum.raw >0.0 + + + + + #end if + #if $year.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $year.outTemp.max $gettext("at") $year.outTemp.maxtime
+ $year.outTemp.min $gettext("at") $year.outTemp.mintime +
+ $gettext("High Heat Index") + + $year.heatindex.max $gettext("at") $year.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $year.windchill.min $gettext("at") $year.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $year.outHumidity.max $gettext("at") $year.outHumidity.maxtime
+ $year.outHumidity.min $gettext("at") $year.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $year.dewpoint.max $gettext("at") $year.dewpoint.maxtime
+ $year.dewpoint.min $gettext("at") $year.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $year.barometer.max $gettext("at") $year.barometer.maxtime
+ $year.barometer.min $gettext("at") $year.barometer.mintime +
+ $gettext("Rain Total") + + $year.rain.sum +
+ $gettext("High Rain Rate") + + $year.rainRate.max $gettext("at") $year.rainRate.maxtime +
+ $gettext("High Wind") + + $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime +
+ $gettext("Average Wind") + + $year.wind.avg +
+ $gettext("RMS Wind") + + $year.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $year.wind.vecavg
+ $year.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $year.inTemp.max $gettext("at") $year.inTemp.maxtime
+ $year.inTemp.min $gettext("at") $year.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $year.UV.max $gettext("at") $year.UV.maxtime
+ $year.UV.min $gettext("at") $year.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $year.ET.max $gettext("at") $year.ET.maxtime
+ $year.ET.min $gettext("at") $year.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $year.radiation.max $gettext("at") $year.radiation.maxtime
+ $year.radiation.min $gettext("at") $year.radiation.mintime +
+
+ +
+ +
+ +
+

$gettext("Monthly Statistics and Plots")

+
+
+ temperatures + heatchill + outside humidity + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $month.radiation.has_data + Radiation + #end if + #if $month.UV.has_data + UV Index + #end if + #if $month.rxCheckPercent.has_data + month rx percent + #end if +
+
+ + +
+ + ## Include the Google Analytics code if the user has supplied an ID: + #if 'googleAnalyticsId' in $Extras + + + #end if + + + diff --git a/dist/weewx-4.10.1/skins/Standard/skin.conf b/dist/weewx-4.10.1/skins/Standard/skin.conf new file mode 100644 index 0000000..e2319b9 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/skin.conf @@ -0,0 +1,505 @@ +############################################################################### +# STANDARD SKIN CONFIGURATION FILE # +# Copyright (c) 2010-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +SKIN_NAME = Standard +SKIN_VERSION = 4.10.1 + +############################################################################### + +# The following section is for any extra tags that you want to be available in the templates +[Extras] + + # This radar image would be available as $Extras.radar_img + # radar_img = https://radblast.wunderground.com/cgi-bin/radar/WUNIDS_map?station=RTX&brand=wui&num=18&delay=15&type=N0R&frame=0&scale=1.000&noclutter=1&showlabels=1&severe=1 + # This URL will be used as the image hyperlink: + # radar_url = https://radar.weather.gov/?settings=v1_eyJhZ2VuZGEiOnsiaWQiOm51bGwsImNlbnRlciI6Wy0xMjEuOTE3LDQ1LjY2XSwiem9vbSI6OH0sImJhc2UiOiJzdGFuZGFyZCIsImNvdW50eSI6ZmFsc2UsImN3YSI6ZmFsc2UsInN0YXRlIjpmYWxzZSwibWVudSI6dHJ1ZSwic2hvcnRGdXNlZE9ubHkiOmZhbHNlfQ%3D%3D#/ + + # If you have a Google Analytics ID, uncomment and edit the next line, and + # the analytics code will be included in your generated HTML files: + #googleAnalyticsId = UA-12345678-1 + +############################################################################### + +# The CheetahGenerator creates files from templates. This section +# specifies which files will be generated from which template. + +[CheetahGenerator] + + # Possible encodings include 'html_entities', 'utf8', 'strict_ascii', or 'normalized_ascii', + # as well as those listed in https://docs.python.org/3/library/codecs.html#standard-encodings + encoding = html_entities + + [[SummaryByMonth]] + # Reports that summarize "by month" + [[[NOAA_month]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m.txt.tmpl + + [[SummaryByYear]] + # Reports that summarize "by year" + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y.txt.tmpl + + [[ToDate]] + # Reports that show statistics "to date", such as day-to-date, + # week-to-date, month-to-date, etc. + [[[day]]] + template = index.html.tmpl + + [[[week]]] + template = week.html.tmpl + + [[[month]]] + template = month.html.tmpl + + [[[year]]] + template = year.html.tmpl + + [[[RSS]]] + template = RSS/weewx_rss.xml.tmpl + + [[[MobileSmartphone]]] + template = smartphone/index.html.tmpl + + [[[MobileTempOutside]]] + template = smartphone/temp_outside.html.tmpl + + [[[MobileRain]]] + template = smartphone/rain.html.tmpl + + [[[MobileBarometer]]] + template = smartphone/barometer.html.tmpl + + [[[MobileWind]]] + template = smartphone/wind.html.tmpl + + [[[MobileRadar]]] + template = smartphone/radar.html.tmpl + +############################################################################### + +[CopyGenerator] + + # This section is used by the generator CopyGenerator + + # List of files to be copied only the first time the generator runs + copy_once = backgrounds/*, weewx.css, mobile.css, favicon.ico, smartphone/icons/*, smartphone/custom.js + + # List of files to be copied each time the generator runs + # copy_always = + + +############################################################################### + +[ImageGenerator] + + # This section lists all the images to be generated, what SQL types are to be included in them, + # along with many plotting options. There is a default for almost everything. Nevertheless, + # values for most options are included to make it easy to see and understand the options. + # + # Fonts can be anything accepted by the Python Imaging Library (PIL), which includes truetype + # (.ttf), or PIL's own font format (.pil). Note that "font size" is only used with truetype + # (.ttf) fonts. For others, font size is determined by the bit-mapped size, usually encoded in + # the file name (e.g., courB010.pil). A relative path for a font is relative to the SKIN_ROOT. + # If a font cannot be found, then a default font will be used. + # + # Colors can be specified any of three ways: + # 1. Notation 0xBBGGRR; + # 2. Notation #RRGGBB; or + # 3. Using an English name, such as 'yellow', or 'blue'. + # So, 0xff0000, #0000ff, or 'blue' would all specify a pure blue color. + + image_width = 300 + image_height = 180 + image_background_color = "#f5f5f5" + + chart_background_color = "#d8d8d8" + chart_gridline_color = "#a0a0a0" + + # Setting to 2 or more might give a sharper image with fewer jagged edges. + anti_alias = 1 + + top_label_font_path = DejaVuSansMono-Bold.ttf + top_label_font_size = 10 + + unit_label_font_path = DejaVuSansMono-Bold.ttf + unit_label_font_size = 10 + unit_label_font_color = "#000000" + + bottom_label_font_path = DejaVuSansMono-Bold.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#000000" + bottom_label_offset = 3 + + axis_label_font_path = DejaVuSansMono-Bold.ttf + axis_label_font_size = 10 + axis_label_font_color = "#000000" + + # Options for the compass rose, used for progressive vector plots + rose_label = N + rose_label_font_path = DejaVuSansMono-Bold.ttf + rose_label_font_size = 10 + rose_label_font_color = "#000000" + + # Default colors for the plot lines. These can be overridden for + # individual lines using option 'color' + chart_line_colors = "#4282b4", "#b44242", "#42b442" + + # Type of line. Only 'solid' or 'none' is offered now + line_type = 'solid' + + # Size of marker in pixels + marker_size = 8 + # Type of marker. Pick one of 'cross', 'x', 'circle', 'box', or 'none' + marker_type ='none' + + # Default fill colors for bar charts. These can be overridden for + # individual bar plots using option 'fill_color' + chart_fill_colors = "#72b2c4", "#c47272", "#72c472" + + # The following option merits an explanation. The y-axis scale used for + # plotting can be controlled using option 'yscale'. It is a 3-way tuple, + # with values (ylow, yhigh, min_interval). If set to "None", a parameter is + # set automatically, otherwise the value is used. However, in the case of + # min_interval, what is set is the *minimum* y-axis tick interval. + yscale = None, None, None + + # For progressive vector plots, you can choose to rotate the vectors. + # Positive is clockwise. + # For my area, westerlies overwhelmingly predominate, so by rotating + # positive 90 degrees, the average vector will point straight up. + vector_rotate = 90 + + # This defines what fraction of the difference between maximum and minimum + # horizontal chart bounds is considered a gap in the samples and should not + # be plotted. + line_gap_fraction = 0.05 + + # This controls whether day/night bands will be shown. They only look good + # on the day and week plots. + show_daynight = true + # These control the appearance of the bands if they are shown. + # Here's a monochrome scheme: + daynight_day_color = "#dfdfdf" + daynight_night_color = "#bbbbbb" + daynight_edge_color = "#d0d0d0" + # Here's an alternative, using a blue/yellow tint: + #daynight_day_color = "#fffff8" + #daynight_night_color = "#f8f8ff" + #daynight_edge_color = "#fff8f8" + + # Default will be a line plot of width 1, without aggregation. + # Can get overridden at any level. + plot_type = line + width = 1 + aggregate_type = none + time_length = 86400 # == 24 hours + + # What follows is a list of subsections, each specifying a time span, such as a day, week, + # month, or year. There's nothing special about them or their names: it's just a convenient way + # to group plots with a time span in common. You could add a time span [[biweek_images]] and + # add the appropriate time length, aggregation strategy, etc., without changing any code. + # + # Within each time span, each sub-subsection is the name of a plot to be generated for that + # time span. The generated plot will be stored using that name, in whatever directory was + # specified by option 'HTML_ROOT' in weewx.conf. + # + # With one final nesting (four brackets!) is the sql type of each line to be included within + # that plot. + # + # Unless overridden, leaf nodes inherit options from their parent. + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %x %X + time_length = 97200 # == 27 hours + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[daytempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[dayhumidity]]] + [[[[outHumidity]]]] + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = hour + label = Rain (hourly total) + + [[[dayrx]]] + [[[[rxCheckPercent]]]] + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[dayinside]]] + [[[[inTemp]]]] + + [[[daywinddir]]] + # Hardwire in the y-axis scale for wind direction + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[[daywindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[dayradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + aggregate_interval = hour + label = max + + [[[dayuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + aggregate_interval = hour + label = max + + [[week_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 604800 # == 7 days + aggregate_type = avg + aggregate_interval = hour + + [[[weekbarometer]]] + [[[[barometer]]]] + + [[[weektempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[weektempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[weekhumidity]]] + [[[[outHumidity]]]] + + [[[weekrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = day + label = Rain (daily total) + + [[[weekrx]]] + [[[[rxCheckPercent]]]] + + [[[weekwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[weekinside]]] + [[[[inTemp]]]] + + [[[weekwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[wind]]]] + aggregate_type = vecdir + + [[[weekwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[weekradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + label = max + + [[[weekuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + label = max + + [[month_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 2592000 # == 30 days + aggregate_type = avg + aggregate_interval = 10800 # == 3 hours + show_daynight = false + + [[[monthbarometer]]] + [[[[barometer]]]] + + [[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[monthtempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[monthhumidity]]] + [[[[outHumidity]]]] + + [[[monthrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = day + label = Rain (daily total) + + [[[monthrx]]] + [[[[rxCheckPercent]]]] + + [[[monthwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[monthinside]]] + [[[[inTemp]]]] + + [[[monthwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[wind]]]] + aggregate_type = vecdir + + [[[monthwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[monthradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + label = max + + [[[monthuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + label = max + + [[year_images]] + x_label_format = %m/%d + bottom_label_format = %x %X + time_length = 31536000 # == 365 days + aggregate_type = avg + aggregate_interval = day + show_daynight = false + + [[[yearbarometer]]] + [[[[barometer]]]] + + + [[[yeartempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[yearhumidity]]] + [[[[outHumidity]]]] + + # Daily high/lows: + [[[yearhilow]]] + [[[[hi]]]] + data_type = outTemp + aggregate_type = max + label = High + [[[[low]]]] + data_type = outTemp + aggregate_type = min + label = Low Temperature + + [[[yearwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[yeartempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[yearrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = week + label = Rain (weekly total) + + [[[yearrx]]] + [[[[rxCheckPercent]]]] + + [[[yearinside]]] + [[[[inTemp]]]] + + [[[yearwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[wind]]]] + aggregate_type = vecdir + + [[[yearwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[yearradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + label = max + + [[[yearuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + label = max + + # A progressive vector plot of daily gust vectors overlayed + # with the daily wind average would look something like this: +# [[[yeargustvec]]] +# [[[[windvec]]]] +# plot_type = vector +# aggregate_type = avg +# [[[[windgustvec]]]] +# plot_type = vector +# aggregate_type = max + + +############################################################################### + +# +# The list of generators that are to be run: +# +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator + + diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/barometer.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/barometer.html.tmpl new file mode 100644 index 0000000..0c7f6e6 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/barometer.html.tmpl @@ -0,0 +1,35 @@ +#encoding UTF-8 + + + + $obs.label.barometer $gettext("in") $station.location + + + + + + +
+
+

$obs.label.barometer

+
+
+

$gettext("24h") $obs.label.barometer

+ +
    +
  • $gettext("Today's min"): $day.barometer.min $gettext("at") $day.barometer.mintime
  • +
  • $gettext("Today's max"): $day.barometer.max $gettext("at") $day.barometer.maxtime
  • +
+ +

$gettext("7-day") $obs.label.barometer

+ +
    +
  • $gettext("This week's min"): $week.barometer.min $gettext("at") $week.barometer.mintime
  • +
  • $gettext("This week's max"): $week.barometer.max $gettext("at") $week.barometer.maxtime
  • +
+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/custom.js b/dist/weewx-4.10.1/skins/Standard/smartphone/custom.js new file mode 100644 index 0000000..83de7c8 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/custom.js @@ -0,0 +1,4 @@ +$(document).bind("mobileinit", function(){ + $.mobile.defaultPageTransition = 'slide'; + $.mobile.page.prototype.options.addBackBtn = true; +}); \ No newline at end of file diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/humidity.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/humidity.html.tmpl new file mode 100644 index 0000000..70ea30d --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/humidity.html.tmpl @@ -0,0 +1,27 @@ +#encoding UTF-8 + + + + $obs.label.outHumidity $gettext("in") $station.location + + + + + + +
+
+

$obs.label.humidity

+
+
+

$gettext("24h") $obs.label.outHumidity

+ + +

$gettext("7-day") $obs.label.outHumidity

+ +
+
+

WeeWX vstation.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_ipad_x1.png b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_ipad_x1.png new file mode 100644 index 0000000..aec7f0d Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_ipad_x1.png differ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_ipad_x2.png b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_ipad_x2.png new file mode 100644 index 0000000..3dd7f78 Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_ipad_x2.png differ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_iphone_x1.png b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_iphone_x1.png new file mode 100644 index 0000000..985337f Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_iphone_x1.png differ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_iphone_x2.png b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_iphone_x2.png new file mode 100644 index 0000000..e2c54aa Binary files /dev/null and b/dist/weewx-4.10.1/skins/Standard/smartphone/icons/icon_iphone_x2.png differ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/index.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/index.html.tmpl new file mode 100644 index 0000000..07f1b08 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/index.html.tmpl @@ -0,0 +1,42 @@ +#encoding UTF-8 + + + + $station.location $gettext("Current Weather Conditions") + + + + + + + + + + + + + + diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/radar.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/radar.html.tmpl new file mode 100644 index 0000000..7ffd345 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/radar.html.tmpl @@ -0,0 +1,28 @@ +#encoding UTF-8 + + + + $station.location $gettext("Radar") + + + + + + +
+
+

$gettext("Radar")

+
+
+ #if 'radar_img' in $Extras + + Radar + #else + $gettext("Sorry, no radar image"). + #end if +
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/rain.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/rain.html.tmpl new file mode 100644 index 0000000..0a0c0de --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/rain.html.tmpl @@ -0,0 +1,34 @@ +#encoding UTF-8 + + + + + $obs.label.rain $gettext("in") $station.location + + + + + + +
+
+

$obs.label.rain

+
+
+

$gettext("24h") $obs.label.rain

+ +
    +
  • Today's data
  • +
  • $gettext("Today's Rain"): $day.rain.sum
  • +
  • $gettext("Min rate"): $day.rainRate.min $gettext("at") $day.rainRate.mintime
  • +
  • $gettext("Max rate"): $day.rainRate.max $gettext("at") $day.rainRate.maxtime
  • +
+ +

$obs.label.rain $gettext("last 30 days")

+ +
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/temp_outside.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/temp_outside.html.tmpl new file mode 100644 index 0000000..78b35dc --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/temp_outside.html.tmpl @@ -0,0 +1,35 @@ +#encoding UTF-8 + + + + $obs.label.outTemp $gettext("in") $station.location + + + + + + +
+
+

$obs.label.outTemp

+
+
+

$gettext("24h") $obs.label.outTemp

+ +
    +
  • $gettext("Today's min"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
  • +
  • $gettext("Today's max"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime
  • +
+

$gettext("7-day") $obs.label.outTemp

+ +
    +
  • $gettext("This week's min"): $week.outTemp.min $gettext("at") $week.outTemp.mintime
  • +
  • $gettext("This week's max"): $week.outTemp.max $gettext("at") $week.outTemp.maxtime
  • +
+
+
+

WeeWX v$station.version

+
+
+ + diff --git a/dist/weewx-4.10.1/skins/Standard/smartphone/wind.html.tmpl b/dist/weewx-4.10.1/skins/Standard/smartphone/wind.html.tmpl new file mode 100644 index 0000000..5e17762 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/smartphone/wind.html.tmpl @@ -0,0 +1,37 @@ +#encoding UTF-8 + + + + $obs.label.wind $gettext("in") $station.location + + + + + + +
+
+

$obs.label.wind

+
+
+

$gettext("24h") $obs.label.wind

+ + +
    +
  • $gettext("Today's min"): $day.windSpeed.min $gettext("at") $day.windSpeed.mintime
  • +
  • $gettext("Today's max"): $day.windSpeed.max $gettext("at") $day.windSpeed.maxtime
  • +
+ +

$gettext("7-day") $obs.label.wind

+ + +
    +
  • $gettext("This week's min"): $week.windSpeed.min $gettext("at") $week.windSpeed.mintime
  • +
  • $gettext("This week's max"): $week.windSpeed.max $gettext("at") $week.windSpeed.maxtime
  • +
+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-4.10.1/skins/Standard/week.html.tmpl b/dist/weewx-4.10.1/skins/Standard/week.html.tmpl new file mode 100644 index 0000000..afcebf9 --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/week.html.tmpl @@ -0,0 +1,419 @@ +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Weekly Weather Summary") + + + #if $station.station_url + + #end if + + + + +
+
+

$station.location

+

$gettext("Weekly Weather Summary")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("This Week") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $week.UV.has_data + + + + + #end if + #if $week.ET.has_data and $week.ET.sum.raw > 0.0 + + + + + #end if + #if $week.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $week.outTemp.max $gettext("at") $week.outTemp.maxtime
+ $week.outTemp.min $gettext("at") $week.outTemp.mintime +
+ $gettext("High Heat Index") + + $week.heatindex.max $gettext("at") $week.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $week.windchill.min $gettext("at") $week.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $week.outHumidity.max $week.outHumidity.maxtime
+ $week.outHumidity.min $week.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $week.dewpoint.max $week.dewpoint.maxtime
+ $week.dewpoint.min $week.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $week.barometer.max $gettext("at") $week.barometer.maxtime
+ $week.barometer.min $gettext("at") $week.barometer.mintime +
+ $gettext("Rain Total") + + $week.rain.sum +
+ $gettext("High Rain Rate") + + $week.rainRate.max $gettext("at") $week.rainRate.maxtime +
+ $gettext("High Wind") + + $week.wind.max $gettext("from") $week.wind.gustdir $gettext("at") $week.wind.maxtime +
+ $gettext("Average Wind") + + $week.wind.avg +
+ $gettext("RMS Wind") + + $week.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $week.wind.vecavg
+ $week.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $week.inTemp.max $gettext("at") $week.inTemp.maxtime
+ $week.inTemp.min $gettext("at") $week.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $week.UV.max $gettext("at") $week.UV.maxtime
+ $week.UV.min $gettext("at") $week.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $week.ET.max $gettext("at") $week.ET.maxtime
+ $week.ET.min $gettext("at") $week.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $week.radiation.max $gettext("at") $week.radiation.maxtime
+ $week.radiation.min $gettext("at") $week.radiation.mintime +
+
+ +

 

+ +
+
+ $gettext("This Month") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $month.UV.has_data + + + + + #end if + #if $month.ET.has_data and $month.ET.sum.raw >0.0 + + + + + #end if + #if $month.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $month.outTemp.max $gettext("at") $month.outTemp.maxtime
+ $month.outTemp.min $gettext("at") $month.outTemp.mintime +
+ $gettext("High Heat Index") + + $month.heatindex.max $gettext("at") $month.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $month.windchill.min $gettext("at") $month.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $month.outHumidity.max $gettext("at") $month.outHumidity.maxtime
+ $month.outHumidity.min $gettext("at") $month.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $month.dewpoint.max $gettext("at") $month.dewpoint.maxtime
+ $month.dewpoint.min $gettext("at") $month.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $month.barometer.max $gettext("at") $month.barometer.maxtime
+ $month.barometer.min $gettext("at") $month.barometer.mintime +
+ $gettext("Rain Total") + + $month.rain.sum +
+ $gettext("High Rain Rate") + + $month.rainRate.max $gettext("at") $month.rainRate.maxtime +
+ $gettext("High Wind") + + $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime +
+ $gettext("Average Wind") + + $month.wind.avg +
+ $gettext("RMS Wind") + + $month.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $month.wind.vecavg
+ $month.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $month.inTemp.max $gettext("at") $month.inTemp.maxtime
+ $month.inTemp.min $gettext("at") $month.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $month.UV.max $gettext("at") $month.UV.maxtime
+ $month.UV.min $gettext("at") $month.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $month.ET.max $gettext("at") $month.ET.maxtime
+ $month.ET.min $gettext("at") $month.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $month.radiation.max $gettext("at") $month.radiation.maxtime
+ $month.radiation.min $gettext("at") $month.radiation.mintime +
+
+ +
+ +
+ +
+

$gettext("Weekly Statistics and Plots")

+
+
+ temperatures + heatchill + outside humidity + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $week.radiation.has_data + Radiation + #end if + #if $week.UV.has_data + UV Index + #end if + #if $week.rxCheckPercent.has_data + week rx percent + #end if +
+
+ + +
+ + ## Include the Google Analytics code if the user has supplied an ID: + #if 'googleAnalyticsId' in $Extras + + + #end if + + + diff --git a/dist/weewx-4.10.1/skins/Standard/weewx.css b/dist/weewx-4.10.1/skins/Standard/weewx.css new file mode 100644 index 0000000..1908b8e --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/weewx.css @@ -0,0 +1,210 @@ +/* CSS for the weewx Standard skin + * + * Copyright (c) 2015 Tom Keffer + * + * See the file LICENSE.txt for your rights. + */ + +/* Global */ + +body { + margin: 0; + padding: 0; + border: 0; + font-family: Verdana, Arial, Helvetica, sans-serif; + font-size: 10pt; + background-color: #f2f2f7; + background-image: url('backgrounds/band.gif'); + background-repeat: repeat; + background-attachment: scroll; +} + +#container { + margin: 0; + padding: 0; + border: 0; +} + +/* + * This is the big header at the top of the page + */ +#masthead { + margin: 1% 1% 0 1%; + padding: 5px; + text-align: center; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; +} + +#masthead h1 { + color: #3d6c87; +} +#masthead h3 { + color: #5f8ea9; +} + +/* + * This holds the statistics (daily high/low, etc.) on the left: + */ +#stats_group { + width: 30%; + min-height: 500px; + margin: 1%; + padding: 5px; + float: left; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; +} + +.stats table { + border: thin solid #000000; + width: 100%; +} +.stats td { + border: thin solid #000000; + padding: 2px; +} + +.stats_header { + background-color: #000000; + color: #a8b8c8; + font-size: 14pt; + font-weight: bolder; +} + +.stats_label { + color: green; +} + +.stats_data { + color: red; +} + +/* + * This holds the "About", "Almanac", and plots on the right + */ +#content { + width: 62%; + min-height: 500px; + margin: 1%; + padding: 5px; + float: right; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; + text-align: center; +} + +#content .header { + font-size: 14pt; + font-weight: bolder; + color: #3d6c87; + margin-bottom: 10px; +} + + +#content .caption { + font-weight: bold; + color: #3d6c87; +} + +#content table { + text-align: center; + width: 100%; +} + +#content td { + width: 50%; +} + +#content .label { + text-align: right; + font-style: italic; +} + +#content .data { + text-align: left; +} + +#about, #almanac { + width: 90%; + margin-left: auto; + margin-right: auto; + margin-bottom: 30px; +} + +.celestial_group { +} + +.celestial_body { + width: 48%; + vertical-align: top; + display:inline-block; +} + +#plots { + width: 90%; + display: block; + margin-left: auto; + margin-right: auto; +} + +#plots img { + border: thin solid #3d6c87; + margin: 3px; + padding: 3px; +} + +#radar_img { + width: 100%; + display: block; + margin-left: auto; + margin-right: auto; + margin: 3px; + padding: 3px; +} + +#radar_img img { + margin-left: auto; + margin-right: auto; + width: 90%; + margin: 3px; + padding: 3px; +} + +#radar_img p { + width: 90%; + font-style: italic; + font-size: smaller; + text-align: center; + margin-top: 0; +} + +/* + * Navigation bar (week, month, etc.) at the bottom + */ +#navbar { + margin: 0 1% 1% 1%; + padding: 5px; + text-align: center; + clear: both; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; +} + +/*************** Global Styles ***************/ + +h2, h3, h4, h5, h6 { + color: #3d6c87; +} diff --git a/dist/weewx-4.10.1/skins/Standard/year.html.tmpl b/dist/weewx-4.10.1/skins/Standard/year.html.tmpl new file mode 100644 index 0000000..0a1456e --- /dev/null +++ b/dist/weewx-4.10.1/skins/Standard/year.html.tmpl @@ -0,0 +1,282 @@ +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Yearly Weather Summary") + + + #if $station.station_url + + #end if + + + + +
+
+

$station.location

+

$gettext("Yearly Weather Summary")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("Calendar Year") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $year.UV.has_data + + + + + #end if + #if $year.ET.has_data and $year.ET.sum.raw >0.0 + + + + + #end if + #if $year.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $year.outTemp.max $gettext("at") $year.outTemp.maxtime
+ $year.outTemp.min $gettext("at") $year.outTemp.mintime +
+ $gettext("High Heat Index") + + $year.heatindex.max $gettext("at") $year.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $year.windchill.min $gettext("at") $year.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $year.outHumidity.max $year.outHumidity.maxtime
+ $year.outHumidity.min $year.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $year.dewpoint.max $year.dewpoint.maxtime
+ $year.dewpoint.min $year.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $year.barometer.max $gettext("at") $year.barometer.maxtime
+ $year.barometer.min $gettext("at") $year.barometer.mintime +
+ $gettext("Rain Total") + + $year.rain.sum +
+ $gettext("High Rain Rate") + + $year.rainRate.max $gettext("at") $year.rainRate.maxtime +
+ $gettext("High Wind") + + $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime +
+ $gettext("Average Wind") + + $year.wind.avg +
+ $gettext("RMS Wind") + + $year.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $year.wind.vecavg
+ $year.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $year.inTemp.max $gettext("at") $year.inTemp.maxtime
+ $year.inTemp.min $gettext("at") $year.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $year.UV.max $gettext("at") $year.UV.maxtime
+ $year.UV.min $gettext("at") $year.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $year.ET.max $gettext("at") $year.ET.maxtime
+ $year.ET.min $gettext("at") $year.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $year.radiation.max $gettext("at") $year.radiation.maxtime
+ $year.radiation.min $gettext("at") $year.radiation.mintime +
+
+ +

 

+ +
+
+ $gettext("Rain Year") (1-$station.rain_year_str start) +
+ + + + + + + + + + + + +
+ $gettext("Rain Year Total") + + $rainyear.rain.sum +
+ $gettext("High Rain Rate") + + $rainyear.rainRate.max $gettext("at") $rainyear.rainRate.maxtime +
+
+ +
+ +
+ +
+

$gettext("Yearly Statistics and Plots")

+
+
+ temperatures + heatchill + outside humidity + Daily highs and lows for the year + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $year.radiation.has_data + Radiation + #end if + #if $year.UV.has_data + UV Index + #end if + #if $year.rxCheckPercent.has_data + year rx percent + #end if +
+
+ + +
+ + ## Include the Google Analytics code if the user has supplied an ID: + #if 'googleAnalyticsId' in $Extras + + + #end if + + + diff --git a/dist/weewx-4.10.1/util/apache/conf-available/weewx.conf b/dist/weewx-4.10.1/util/apache/conf-available/weewx.conf new file mode 100644 index 0000000..41c2e76 --- /dev/null +++ b/dist/weewx-4.10.1/util/apache/conf-available/weewx.conf @@ -0,0 +1,6 @@ +Alias /weewx /home/weewx/public_html + + Options FollowSymlinks + AllowOverride None + Require all granted + diff --git a/dist/weewx-4.10.1/util/apache/conf.d/weewx.conf b/dist/weewx-4.10.1/util/apache/conf.d/weewx.conf new file mode 100644 index 0000000..cec98b7 --- /dev/null +++ b/dist/weewx-4.10.1/util/apache/conf.d/weewx.conf @@ -0,0 +1,7 @@ +Alias /weewx /home/weewx/public_html + + Options FollowSymlinks + AllowOverride None + Order allow,deny + Allow from all + \ No newline at end of file diff --git a/dist/weewx-4.10.1/util/default/weewx b/dist/weewx-4.10.1/util/default/weewx new file mode 100644 index 0000000..1c43e91 --- /dev/null +++ b/dist/weewx-4.10.1/util/default/weewx @@ -0,0 +1,5 @@ +WEEWX_PYTHON=python3 +WEEWX_PYTHON_ARGS= +WEEWX_BINDIR=/home/weewx/bin +WEEWX_BIN=/home/weewx/bin/weewxd +WEEWX_CFG=/home/weewx/weewx.conf diff --git a/dist/weewx-4.10.1/util/i18n/i18n-report b/dist/weewx-4.10.1/util/i18n/i18n-report new file mode 100755 index 0000000..824dcc6 --- /dev/null +++ b/dist/weewx-4.10.1/util/i18n/i18n-report @@ -0,0 +1,239 @@ +#!/usr/bin/env python +# Copyright (c) 2022 Matthew Wall +"""Utility for managing translated strings in skins.""" + +# FIXME: improve the dotted notation to find pgettext instances + +import sys +import glob +import os +from optparse import OptionParser +import re +import configobj + +__version__ = '0.2' + +usagestr = """Usage: i18n-report --skin=PATH_TO_SKIN [--help] [--version] + +Examples: + i18n-report --skin=PATH_TO_SKIN + i18n-report --skin=PATH_TO_SKIN --action translations --lang=XX + i18n-report --skin=PATH_TO_SKIN --action strings + + Utility to manage translated strings in WeeWX skins.""" + +def main(): + parser = OptionParser(usage=usagestr) + parser.add_option("--version", action="store_true", dest="version", + help="Display version then exit") + parser.add_option("--skin", dest="skin_dir", type=str, metavar="SKIN", + help="Specify the path to the desired skin") + parser.add_option("--lang", type=str, metavar="LANGUAGE", + help="Specify two-letter language identifier") + parser.add_option("--action", type=str, metavar="ACTION", default='all', + help="Options include: all, languages, translations, strings") + + options, args = parser.parse_args() + + if options.version: + print(__version__) + sys.exit(0) + + # figure out which skin we should look at + skin_dir = options.skin_dir + if not skin_dir: + print("No skin specified") + sys.exit(1) + while skin_dir.endswith('/'): + skin_dir = skin_dir.rstrip('/') + if not os.path.isdir(skin_dir): + print("No skin found at %s" % skin_dir) + sys.exit(1) + print("i18n translation report for skin: %s" % skin_dir) + + # check for the skin config file + skin_conf = "%s/skin.conf" % skin_dir + if not os.path.isfile(skin_conf): + print("No skin configuration found at %s" % skin_conf) + sys.exit(0) + + # check for the lang files in the specified skin + lang_dir = "%s/lang" % skin_dir + if not os.path.isdir(lang_dir): + print("No language directory found at %s" % lang_dir) + sys.exit(0) + en_conf = "%s/en.conf" % lang_dir + if not os.path.isfile(en_conf): + print("No en.conf found at %s" % en_conf) + sys.exit(0) + + action = options.action + + # list all of the lang files that we find + if action == 'all' or action == 'languages': + confs = glob.glob("%s/*.conf" % lang_dir) + print("language files found:") + for f in confs: + print(" %s" % f) + + # report any untranslated strings. if a lang was specified, only report + # about that language. otherwise report on every language that is found. + if action == 'all' or action == 'translations': + lang = options.lang + confs = [] + if lang: + confs.append("%s/%s.conf" % (lang_dir, lang)) + else: + confs = glob.glob("%s/*.conf" % lang_dir) + results = dict() + for f in confs: + if f == en_conf: + continue + a, b = compare_files(en_conf, f) + if a or b: + results[f] = dict() + if a: + results[f]["found only in %s:" % en_conf] = a + if b: + results[f]["found only in %s:" % f] = b + prettyp(results) + + # report any mismatched strings, i.e., strings that are used in the skin + # but not enumerated in the base language file en.conf. the strings in + # the skin could come from gettext() invocations or plot labels. + # + # TODO: report *all* strings, not just those in gettext - this might not + # be feasible, since html/xml and other formats might use strings + # that should not be part of translation. of course if we find + # strings within xml delimiters we could ignore those... + if action == 'all' or action == 'strings': + known_strings = read_texts(en_conf) + ext_list = ['tmpl', 'inc'] + str_list = set() + for x in get_gettext_strings(skin_dir, ext_list): + str_list.add(x) + for x in read_image_labels(skin_conf): + str_list.add(x) + unused = set() # strings in en.conf that are not in any skin files + unlisted = set() # strings in skin files that are not in en.conf + for x in str_list: + if not x in known_strings: + unlisted.add(x) + for x in known_strings: + if not x in str_list: + unused.add(x) + if unused: + print("strings in en.conf but not found in the skin:") + for x in unused: + print(" %s" % x) + if unlisted: + print("strings in skin but not found in en.conf:") + for x in unlisted: + print(" %s" % x) + + sys.exit(0) + + +def compare_files(fn1, fn2): + """Print discrepancies between two files.""" + cfg_dict1 = configobj.ConfigObj(fn1, file_error=True, + encoding='utf-8', default_encoding='utf-8') + cfg_dict2 = configobj.ConfigObj(fn2, file_error=True, + encoding='utf-8', default_encoding='utf-8') + a_only = dict() # {label: a_val, ...} + b_only = dict() # {label: b_val, ...} + diffs = dict() # {label: (a_val, b_val), ...} + compare_dicts('', cfg_dict1, cfg_dict2, a_only, b_only, diffs) + return (a_only, b_only) + +def compare_dicts(section_name, a, b, a_only, b_only, diffs): + for x in a.sections: + label = "%s.%s" % (section_name, x) if section_name else x + compare_dicts(label, a[x], b.get(x), a_only, b_only, diffs) + + found = [] + for x in a.scalars: + label = "%s.%s" % (section_name, x) if section_name else x + if x in b: + found.append(x) + if a[x] != b[x]: + diffs[label] = (a[x], b[x]) + else: + a_only[label] = a[x] + + for x in b.scalars: + if x not in found: + label = "%s.%s" % (section_name, x) if section_name else x + b_only[label] = b[x] + +def prettyp(d, indent=0): + for key, value in d.items(): + if isinstance(value, dict): + print(' ' * indent + str(key)) + prettyp(value, indent+1) + else: + print(' ' * indent + "%s=%s" % (key, value)) + +def get_gettext_strings(dir_name, ext_list, string_list=set()): + """get all of the gettext strings from a skin""" + for f in os.listdir(dir_name): + fn = os.path.join(dir_name, f) + if os.path.isfile(fn): + found = False + for e in ext_list: + if f.endswith(".%s" % e): + found = True + if found: + with open(fn) as file: + for line in file: + for m in re.findall(r'\$pgettext\(\s*\"([^\"]*)\",\s*\"([^\"]*)\"\s*\)', line): + string_list.add(m[1]) + for m in re.findall(r'\$gettext\(\s*[\'\"]([^\)]*)[\'\"]\s*\)', line): + string_list.add(m) + elif os.path.isdir(fn): + get_gettext_strings(fn, ext_list, string_list) + + return string_list + +def read_texts(fn): + """ + return set of strings from Texts section. + + NOT IMPLEMENTED: + return set of strings from Texts section. format using dotted notation + e.g., Text.name = value, or Text.Specialization.name = value + """ + cfg_dict = configobj.ConfigObj(fn, file_error=True, + encoding='utf-8', default_encoding='utf-8') + texts = cfg_dict.get('Texts', {}) + str_list = texts.scalars + for x in texts.sections: + [str_list.append(s) for s in texts[x].scalars] + return str_list + +# texts = cfg_dict.get('Texts', {}) +# str_list = ['Texts.%s' % s for s in texts.scalars] +# for x in texts.sections: +# [str_list.append("Texts.%s.%s" % (x, s)) for s in texts[x].scalars] +# return str_list + +def read_image_labels(fn): + """return set of strings that are labels for plots in the imagegenerator""" + cfg_dict = configobj.ConfigObj(fn, file_error=True, + encoding='utf-8', default_encoding='utf-8') + imggen_dict = cfg_dict.get('ImageGenerator', {}) + # FIXME: this assumes that the images will be defined using the standard + # pattern for images. anything other than xxx_images will not be found. + str_list = [] + for period in ['day', 'week', 'month', 'year']: + plot_dicts = imggen_dict.get("%s_images" % period, {}) + if not plot_dicts: + continue + for plot_name in plot_dicts.sections: + for series in plot_dicts[plot_name].sections: + if 'label' in plot_dicts[plot_name][series].scalars: + label = plot_dicts[plot_name][series]['label'] + str_list.append(label) + return str_list + +main() diff --git a/dist/weewx-4.10.1/util/import/csv-example.conf b/dist/weewx-4.10.1/util/import/csv-example.conf new file mode 100644 index 0000000..88e5101 --- /dev/null +++ b/dist/weewx-4.10.1/util/import/csv-example.conf @@ -0,0 +1,205 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM CSV FILES +# +# Copyright (c) 2009-2022 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# Format is: +# source = (CSV | WU | Cumulus) +source = CSV + +############################################################################## + +[CSV] + # Parameters used when importing from a CSV file + + # Path and name of our CSV source file. Format is: + # file = full path and filename + file = /var/tmp/data.csv + + # The character used to separate fields. Format is: + # delimiter = + # Default is , (comma). + delimiter = , + + # Specify the character used as the decimal point. The character + # must be enclosed in quotes. + # Format is: + # decimal = '.' (dot) + # or + # decimal = ',' (comma) + decimal = '.' + + # If there is no mapped interval field how will the interval field be + # determined for the imported records. Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # + # Note: If there is a mapped interval field then this setting will be + # ignored. + # Format is: + # interval = (derive | conf | x) + interval = derive + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Specify whether a UV sensor was used to produce any UV observations. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV fields will be set to None/NULL. + # For a CSV import UV_sensor should be set to False if a UV sensor was + # NOT present when the import data was created. Otherwise it may be set to + # True or omitted. Format is: + # UV_sensor = (True | False) + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce any solar + # radiation observations. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation fields will be set to + # None/NULL. + # For a CSV import solar_sensor should be set to False if a solar radiation + # sensor was NOT present when the import data was created. Otherwise it may + # be set to True or omitted. Format is: + # solar_sensor = (True | False) + solar_sensor = True + + # Date-time format of CSV field from which the WeeWX archive record + # dateTime field is to be extracted. wee_import first attempts to interpret + # date/time info in this format, if this fails it then attempts to + # interpret it as a timestamp and if this fails it then raises an error. + # Uses Python strptime() format codes. + # raw_datetime_format = Python strptime() format string + raw_datetime_format = %Y-%m-%d %H:%M:%S + + # Does the imported rain field represent the total rainfall since the last + # record or a cumulative value. Available options are: + # discrete - rain field represents total rainfall since last record + # cumulative - rain field represents a cumulative rainfall reset at + # midnight + # rain = (discrete | cumulative) + rain = cumulative + + # Lower and upper bounds for imported wind direction. It is possible, + # particularly for a calculated direction, to have a value (eg -45) outside + # of the WeeWX limits (0 to 360 inclusive). Format is: + # + # wind_direction = lower,upper + # + # where : + # lower is the lower limit of acceptable wind direction in degrees + # (may be negative) + # upper is the upper limit of acceptable wind direction in degrees + # + # Imported values from lower to upper will be normalised to the range 0 to + # 360. Values outside of the parameter range will be stored as None. + # Default is -360,360. + wind_direction = -360,360 + + # Map CSV record fields to WeeWX archive fields. Format is: + # + # weewx_archive_field_name = csv_field_name, weewx_unit_name + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # csv_field_name - The name of a field from the CSV file. + # weewx_unit_name - The name of the units, as defined in WeeWX, + # used by csv_field_name. wee_import will do + # the necessary conversions to the unit system + # used by the WeeWX archive. + # For example, + # outTemp = Temp, degree_C + # would map the CSV field Temp, in degrees C, to the archive field outTemp. + # + # A mapping for WeeWX field dateTime is mandatory and the WeeWX unit name + # for the dateTime mapping must be unix_epoch. For example, + # dateTime = csv_date_and_time, unix_epoch + # would map the CSV field csv_date_and_time to the WeeWX dateTime field with + # the csv_date_and_time field being interpreted first using the format + # specified at the raw_datetime_format config option and if that fails as a + # unix epoch timestamp. + # + # Field mapping to the WeeWX usUnits archive field is currently not + # supported. If a usUnits field exists in the CSV data it should not be + # mapped, rather WeeWX unit names should included against each field to be + # imported in the field map. + # + # WeeWX archive fields that do not exist in the CSV data may be omitted. + # Any omitted fields that are derived (eg dewpoint) may be calculated + # during import using the equivalent of the WeeWX StdWXCalculate service + # through setting the calc-missing parameter above. + [[FieldMap]] + dateTime = timestamp, unix_epoch + interval = + barometer = barometer, inHg + pressure = + altimeter = + inTemp = + outTemp = Temp, degree_F + inHumidity = + outHumidity = humidity, percent + windSpeed = windspeed, mile_per_hour + windDir = wind, degree_compass + windGust = gust, mile_per_hour + windGustDir = gustDir, degree_compass + rainRate = rate, inch_per_hour + rain = dayrain, inch + dewpoint = + windchill = + heatindex = + ET = + radiation = + UV = diff --git a/dist/weewx-4.10.1/util/import/cumulus-example.conf b/dist/weewx-4.10.1/util/import/cumulus-example.conf new file mode 100644 index 0000000..a4b13b1 --- /dev/null +++ b/dist/weewx-4.10.1/util/import/cumulus-example.conf @@ -0,0 +1,184 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM CUMULUS +# +# Copyright (c) 2009-2019 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify our source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# Format is: +# source = (CSV | WU | Cumulus) +source = Cumulus + +############################################################################## + +[Cumulus] + # Parameters used when importing Cumulus monthly log files + # + # Directory containing Cumulus monthly log files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp/cumulus + + # When importing Cumulus monthly log file data the following WeeWX database + # fields will be populated directly by the imported data: + # barometer + # dateTime + # dewpoint + # heatindex + # inHumidity + # inTemp + # outHumidity + # outTemp + # radiation (if Cumulus data available) + # rain (requires Cumulus 1.9.4 or later) + # rainRate + # UV (if Cumulus data available) + # windDir + # windGust + # windSpeed + # windchill + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # pressure + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + # windrun + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # To import Cumulus records it is recommended that the interval setting + # be set to the value used in Cumulus as the 'data log interval' in minutes. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify the character used as the date separator as Cumulus monthly log + # files may not always use a solidus to separate date fields in the monthly + # log files. The character must be enclosed in quotes. Must not be the same + # as the delimiter setting below. Format is: + # separator = '/' + separator = '/' + + # Specify the character used as the field delimiter as Cumulus monthly log + # files may not always use a comma to delimit fields in the monthly log + # files. The character must be enclosed in quotes. Must not be the same + # as the decimal setting below. Format is: + # delimiter = ',' + delimiter = ',' + + # Specify the character used as the decimal point. Cumulus monthly log + # files may not always use a fullstop character as the decimal point. The + # character must be enclosed in quotes. Must not be the same as the + # delimiter setting. Format is: + # decimal = '.' + decimal = '.' + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Specify whether a UV sensor was used to produce any UV observations. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV fields will be set to None/NULL. + # For a Cumulus monthly log file import UV_sensor should be set to False if + # a UV sensor was NOT present when the import data was created. Otherwise + # it may be set to True or omitted. Format is: + # UV_sensor = (True | False) + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce any solar + # radiation observations. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation fields will be set to + # None/NULL. + # For a Cumulus monthly log file import solar_sensor should be set to False + # if a solar radiation sensor was NOT present when the import data was + # created. Otherwise it may be set to True or omitted. Format is: + # solar_sensor = (True | False) + solar_sensor = True + + # For correct import of the monthly logs wee_import needs to know what + # units are used in the imported data. The units used for temperature, + # pressure, rain and windspeed related observations in the Cumulus monthly + # logs are set at the Cumulus Station Configuration Screen. The + # [[Units]] settings below should be set to the WeeWX equivalent of the + # units of measure used by Cumulus (eg if Cumulus used 'C' for temperature, + # temperature should be set to 'degree_C'). Note that Cumulus does not + # support all units used by WeeWX (eg 'mmHg') so not all WeeWX unit are + # available options. + [[Units]] + temperature = degree_C # options are 'degree_F' or 'degree_C' + pressure = hPa # options are 'inHg', 'mbar' or 'hPa' + rain = mm # options are 'inch' or 'mm' + speed = km_per_hour # options are 'mile_per_hour', + # 'km_per_hour', 'knot' or + # 'meter_per_second' diff --git a/dist/weewx-4.10.1/util/import/wd-example.conf b/dist/weewx-4.10.1/util/import/wd-example.conf new file mode 100644 index 0000000..105345f --- /dev/null +++ b/dist/weewx-4.10.1/util/import/wd-example.conf @@ -0,0 +1,329 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM WEATHER DISPLAY (WD) +# +# Copyright (c) 2009-2019 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify our source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# Format is: +# source = (CSV | WU | Cumulus | WD) +source = WD + +############################################################################## + +[WD] + # Parameters used when importing WD monthly log files + # + # Directory containing WD monthly log files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp/WD + + # When importing WD monthly log file data the following WeeWX database + # fields will be populated directly by the imported data: + # barometer + # dateTime + # dewpoint + # extraHumid1 (if MMYYYvantageextrasensorslog.csv is available and field hum1 has data) + # extraHumid2 (if MMYYYvantageextrasensorslog.csv is available and field hum2 has data) + # extraTemp1 (if MMYYYvantageextrasensorslog.csv is available and field temp1 has data) + # extraTemp2 (if MMYYYvantageextrasensorslog.csv is available and field temp2 has data) + # extraTemp3 (if MMYYYvantageextrasensorslog.csv is available and field temp3 has data) + # heatindex + # outHumidity + # outTemp + # radiation (if MMYYYYvantagelog.txt or MMYYYYvantagelogcsv.csv is available) + # rain + # soilTemp1 (if MMYYYYvantagelog.txt or MMYYYYvantagelogcsv.csv is available) + # soilMoist1 (if MMYYYYvantagelog.txt or MMYYYYvantagelogcsv.csv is available) + # UV (if MMYYYYvantagelog.txt or MMYYYYvantagelogcsv.csv is available) + # windDir + # windGust + # windSpeed + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # pressure + # rainRate + # windchill + # + # The following WeeWX fields will be populated with values direct from the + # imported data. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. + # extraHumid3 (if MMYYYvantageextrasensorslog.csv is available and field hum3 has data) + # extraHumid4 (if MMYYYvantageextrasensorslog.csv is available and field hum4 has data) + # extraHumid5 (if MMYYYvantageextrasensorslog.csv is available and field hum5 has data) + # extraHumid6 (if MMYYYvantageextrasensorslog.csv is available and field hum6 has data) + # extraHumid7 (if MMYYYvantageextrasensorslog.csv is available and field hum7 has data) + # extraTemp4 (if MMYYYvantageextrasensorslog.csv is available and field temp4 has data) + # extraTemp5 (if MMYYYvantageextrasensorslog.csv is available and field temp5 has data) + # extraTemp6 (if MMYYYvantageextrasensorslog.csv is available and field temp6 has data) + # extraTemp7 (if MMYYYvantageextrasensorslog.csv is available and field temp7 has data) + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + # windrun + + # WD uses multiple log files some of which are in space delimited text + # format, some are in csv format and some in both. wee_import can process + # the following WD log files (actual log file names have 5 or 6 digits + # prepended representing a 1 or 2 digit month and a 4 digit year, these + # digits have been omitted for clarity): + # - lg.txt (same content as lgcsv.csv) + # - lgcsv.csv (same content as lg.txt) + # - vantageextrasensorslog.csv + # - vantagelog.txt (same content as vantagelogcsv.csv) + # - vantagelogcsv.csv (same content as vantagelog.txt) + # Specify log files to be imported. Format is a comma separated list + # including at least one of the supported log files. Do not include + # prepended month and year digits. Default is lg.txt, vantagelog.txt + # and vantageextrasensorslog.csv. + logs_to_process = lg.txt, vantagelog.txt, vantageextrasensorslog.csv + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # To import WD monthly log data it is recommended that the interval setting + # be set to 1. + # Format is: + # interval = (derive | conf | x) + interval = 1 + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify the character used as the field delimiter in .txt monthly log + # files. Normally set to the space character. The character must be + # enclosed in quotes. Must not be the sam# as the decimal setting below. + # Format is: + # txt_delimiter = ' ' + txt_delimiter = ' ' + + # Specify the character used as the field delimiter in .csv monthly log + # files. Normally set to a comma. The character must be enclosed in + # quotes. Must not be the same as the decimal setting below. Format is: + # csv_delimiter = ',' + csv_delimiter = ',' + + # Specify the character used as the decimal point. WD monthly log files + # normally use a full stop character as the decimal point. The character + # must be enclosed in quotes. Must not be the same as the xt_delimiter + # or csv_delimiter settings. Format is: + # decimal = '.' + decimal = '.' + + # Specify whether missing log files are to be ignored or abort the import. + # WD log files are complete in themselves and a missing log file will have + # no effect on any other records (eg rain as a delta). + # Format is: + # ignore_missing_log = (True | False) + # Default is True + ignore_missing_log = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Specify whether a UV sensor was used to produce any UV observations. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV fields will be set to None/NULL. + # For a WD monthly log file import UV_sensor should be set to False if a UV + # sensor was NOT present when the import data was created. Otherwise it may + # be set to True or omitted. Format is: + # UV_sensor = (True | False) + # The default is True. + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce any solar + # radiation observations. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation fields will be set to + # None/NULL. + # For a WD monthly log file import solar_sensor should be set to False if a + # solar radiation sensor was NOT present when the import data was created. + # Otherwise it may be set to True or omitted. Format is: + # solar_sensor = (True | False) + # The default is True. + solar_sensor = True + + # Specify whether to ignore temperature and humidity reading of 255.0. + # WD logs can include values of 255.0 or 255. These values are usually + # associated with an absent or disconnected senor. In WeeWX the lack of a + # sensor/sensor data results in the value None (or null in SQL) being + # recorded. If ignore_extreme_temp_hum is set to True temperature and + # humidity values of 255 are ignored. Format is: + # ignore_extreme_temp_hum = (True | False) + # The default is True + ignore_extreme_temp_hum = True + + # For correct import of the monthly logs wee_import needs to know what + # units are used in the imported data. The units used in the WD log files + # are set to either Metric or US in WD via the 'Log File' setting under + # 'Units' on the 'Units/Wind Chill' tab of the WD universal setup. If + # Metric or US units have been used in the log files then it is usually + # sufficient to set the units config option in the [[Units]] stanza to + # Metric or US. For example: + # [[Units]] + # units = metric + # However, in cases where Metric or US may not have been used (eg a custom + # log file) then the units config option should not be used but rather + # units should be specified for temperature, pressure, rainfall and speed + # groups using the temperature, pressure, rain and speed config options. + # In each case the config option should be set to a WeeWX unit option. For + # example: + # [[Units]] + # temperature = degree_C # options are 'degree_F' or 'degree_C' + # pressure = hPa # options are 'inHg', 'mbar' or 'hPa' + # rain = mm # options are 'inch' or 'mm' + # speed = km_per_hour # options are 'mile_per_hour', + # # 'km_per_hour', 'knot' or + # # 'meter_per_second' + # Note that either the units config option should be set or the individual + # unit group config options but not both. + [[Units]] + units = metric + + # Map WD log fields to WeeWX archive fields. Format is: + # + # weewx_archive_field_name = wd_field_name + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # wd_field_name - The name of a WD field from the CSV file. + # For example, + # outTemp = temperature + # would map the WD log field temperature to the archive field outTemp. + # + # Note that due to some issues with the field names used in some WD logs + # weeimport uses the following field names when reading WD log data: + # temperature + # humidity + # dewpoint + # barometer + # windspeed + # gustspeed + # direction + # rainlastmin + # heatindex + # radiation in lieu of 'Solar radation' and ''Solar ,radiat.' + # uv in lieu of 'UV' + # soilmoist in lieu of 'soil moist' + # soiltemp in lieu of 'soil temp' + # temp1 + # temp2 + # temp3 + # temp4 + # temp5 + # temp6 + # temp7 + # hum1 + # hum2 + # hum3 + # hum4 + # hum5 + # hum6 + # hum7 + # + # WeeWX archive fields that do not exist in the logs to be imported may be + # omitted (it is usually safest to omit fields for which you know there is + # no data to import as otherwise you may end up with a value of 0 (or + # something else) in the WeeWX archive when the appropriate value is + # None/null). Any omitted fields that are derived (eg dewpoint) may be + # calculated during import using the equivalent of the WeeWX StdWXCalculate + # service through setting the calc-missing config option above. Note also + # that whilst there may be a mapping for a field and the WD log may contain + # valid data the only fields that will be saved to the WeeWX archive are + # those fields in the in-use schema. Refer to the 'Archive types' appendix + # and the 'Customizing the database' section of the Customization Guide. + [[FieldMap]] + outTemp = temperature + outHumidity = humidity + dewpoint = dewpoint + barometer = barometer + windSpeed = windspeed + gustSpeed = gustspeed + windDir = direction + rain = rainlastmin + heatindex = heatindex + radiation = radiation + UV = uv + soilMoist1 = soilmoist + soilTemp1 = soiltemp + extraTemp1 = temp1 + extraTemp2 = temp2 + extraTemp3 = temp3 + extraTemp4 = temp4 + extraTemp5 = temp5 + extraTemp6 = temp6 + extraTemp7 = temp7 + extraHumid1 = hum1 + extraHumid2 = hum2 + extraHumid3 = hum3 + extraHumid4 = hum4 + extraHumid5 = hum5 + extraHumid6 = hum6 + extraHumid7 = hum7 diff --git a/dist/weewx-4.10.1/util/import/weathercat-example.conf b/dist/weewx-4.10.1/util/import/weathercat-example.conf new file mode 100644 index 0000000..dc65d32 --- /dev/null +++ b/dist/weewx-4.10.1/util/import/weathercat-example.conf @@ -0,0 +1,162 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM WEATHERCAT +# +# Copyright (c) 2009-2020 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify our source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WeatherCat - import obs from a one or more WeatherCat monthly .cat files +# Format is: +# source = (CSV | WU | Cumulus | WeatherCat) +source = WeatherCat + +############################################################################## + +[WeatherCat] + # Parameters used when importing WeatherCat monthly .cat files + # + # Directory containing WeatherCat year folders that contain the monthly + # .cat files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp + + # When importing WeatherCat monthly .cat file data the following WeeWX + # database fields will be populated directly by the imported data: + # barometer + # dateTime + # dewpoint + # inHumidity + # inTemp + # outHumidity + # outTemp + # radiation (if WeatherCat data available) + # rain + # rainRate + # UV (if WeatherCat data available) + # windDir + # windGust + # windSpeed + # windchill + # extraTemp1 (if WeatherCat data available) + # extraTemp2 (if WeatherCat data available) + # extraTemp3 (if WeatherCat data available) + # extraHumid1 (if WeatherCat data available) + # extraHumid2 (if WeatherCat data available) + # SoilTemp1 (if WeatherCat data available) + # SoilTemp2 (if WeatherCat data available) + # SoilTemp3 (if WeatherCat data available) + # SoilTemp4 (if WeatherCat data available) + # SoilMoist1 (if WeatherCat data available) + # SoilMoist2 (if WeatherCat data available) + # LeafTemp1 (if WeatherCat data available) + # LeafTemp1 (if WeatherCat data available) + # LeafWet1 (if WeatherCat data available) + # LeafWet2 (if WeatherCat data available) + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # heatindex + # pressure + # windrun + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # To import WeatherCat records it is recommended the interval setting be + # set to the Sampling Rate setting used by WeatherCat as set on the Misc2 + # tab under WeatherCat Preferences unless the Adaptive Sampling Rate was + # used in which case interval = derive should be used. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify the character used as the decimal point. WeatherCat monthly .cat + # files may not always use a fullstop character as the decimal point. The + # character must be enclosed in quotes. Format is: + # decimal = '.' + decimal = '.' + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # For correct import of the WeatherCat monthly .cat files wee_import needs + # to know what units are used in the imported data. The units used by + # WeatherCat are set via the 'Units/Misc1` tab under the WeatherCat + # 'Preferences' menu. The WeatherCat unit preferences should be mirrored + # below under the [[Units]] stanza and each assigned a WeeWX unit value. As + # WeatherCat allows dewpoint to have different units to other temperatures + # settings for both temperature and dewpoint should be specified; however, + # if only temperature is specified then the temperature units will be + # applied to all temperatures and dewpoint. + # + # An example [[Units]] stanza might be: + # [[Units]] + # temperature = degree_F # options are 'degree_F' or 'degree_C' + # dewpoint = degree_C # options are 'degree_F' or 'degree_C' + # pressure = hPa # options are 'inHg', 'mbar' or 'hPa' + # windspeed = km_per_hour # options are 'mile_per_hour', + # # 'km_per_hour', 'knot' or + # # 'meter_per_second' + # precipitation = mm # options are 'inch' or 'mm' + [[Units]] + temperature = degree_C + dewpoint = degree_C + pressure = hPa + windspeed = km_per_hour + precipitation = mm diff --git a/dist/weewx-4.10.1/util/import/wu-example.conf b/dist/weewx-4.10.1/util/import/wu-example.conf new file mode 100644 index 0000000..8a55e4f --- /dev/null +++ b/dist/weewx-4.10.1/util/import/wu-example.conf @@ -0,0 +1,149 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM THE WEATHER UNDERGROUND +# +# Copyright (c) 2009-2019 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify our source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# Format is: +# source = (CSV | WU | Cumulus) +source = WU + +############################################################################## + +[WU] + # Parameters used when importing from a WU PWS + + # WU PWS Station ID to be used for import. + station_id = XXXXXXXX123 + + # WU API key to be used for import. + api_key = XXXXXXXXXXXXXXXXXXXXXX1234567890 + + # + # When importing WU data the following WeeWX database fields will be + # populated directly by the imported data (provided the corresponding data + # exists on WU): + # barometer + # dateTime + # dewpoint + # heatindex + # outHumidity + # outTemp + # radiation + # rain + # rainRate + # windchill + # windDir + # windGust + # windSpeed + # UV + # + # The following WeeWX database fields will be populated from other + # settings/config files: + # interval + # usUnits + # + # The following WeeWX database fields will be populated with values derived + # from the imported data provided the --calc-missing command line option is + # used during import: + # altimeter + # ET + # pressure + # + # The following WeeWX fields will be populated with derived values from the + # imported data provided the --calc-missing command line option is used + # during import. These fields will only be saved to the WeeWX database if + # the WeeWX schema has been modified to accept them. Note that the pyephem + # module is required in order to calculate maxSolarRad - refer WeeWX Users + # Guide. + # appTemp + # cloudbase + # humidex + # maxSolarRad + # windrun + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # Due to WU frequently missing uploaded records, use of 'derive' may give + # incorrect or inconsistent interval values. Better results may be + # achieved by using the 'conf' setting (if WeeWX has been doing the WU + # uploading and the WeeWX archive_interval matches the WU observation + # spacing in time) or setting the interval to a fixed value (eg 5). The + # most appropriate setting will depend on the completeness and (time) + # accuracy of the WU data being imported. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. This is particulalrly useful + # for WU imports where WU often records clearly erroneous values against + # obs that are not reported. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + calc_missing = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + tranche = 250 + + # Lower and upper bounds for imported wind direction. It is possible, + # particularly for a calculated direction, to have a value (eg -45) outside + # of the WeeWX limits (0 to 360 inclusive). Format is: + # + # wind_direction = lower,upper + # + # where : + # lower is the lower limit of acceptable wind direction in degrees + # (may be negative) + # upper is the upper limit of acceptable wind direction in degrees + # + # WU has at times been known to store large values (eg -9999) for wind + # direction, often no wind direction was uploaded to WU. The wind_direction + # parameter sets a lower and upper bound for valid wind direction values. + # Values inside these bounds are normalised to the range 0 to 360. Values + # outside of the bounds will be stored as None. Default is 0,360 + wind_direction = 0,360 diff --git a/dist/weewx-4.10.1/util/init.d/weewx-multi b/dist/weewx-4.10.1/util/init.d/weewx-multi new file mode 100755 index 0000000..4842b95 --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx-multi @@ -0,0 +1,236 @@ +#! /bin/sh +# Copyright 2016-2022 Matthew Wall, all rights reserved +# init script to run multiple instances of weewx +# +# each weewx instance is identified by name. that name is used to identify the +# configuration and pid files. +# +# this init script expects the following configuration: +# /etc/weewx/a.conf +# /etc/weewx/b.conf +# /var/run/weewx-a.pid +# /var/run/weewx-b.pid +# +# with the appropriate rsyslog and logrotate configurations: +# /var/log/weewx/a.log +# /var/log/weewx/b.log +# +# to configure the script, override variables in /etc/default/weewx-multi +# for example: +# +# WEEWX_INSTANCES="vantage acurite" +# WEEWX_BINDIR=/opt/weewx/bin +# WEEWX_CFGDIR=/etc/weewx + +### BEGIN INIT INFO +# Provides: weewx-multi +# Required-Start: $local_fs $remote_fs $syslog $time +# Required-Stop: $local_fs $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: weewx-multi +# Description: Manages multiple instances of weewx +### END INIT INFO + +PATH=/sbin:/usr/sbin:/bin:/usr/bin + +WEEWX_INSTANCES="no_instances_specified" +WEEWX_USER=root +WEEWX_BINDIR=/home/weewx/bin +WEEWX_CFGDIR=/home/weewx +WEEWX_RUNDIR=/var/run + +# Try to keep systemd from screwing everything up +export SYSTEMCTL_SKIP_REDIRECT=1 + +# Read configuration variable file if it is present +[ -r /etc/default/weewx-multi ] && . /etc/default/weewx-multi + +DESC=weewx +DAEMON=$WEEWX_BINDIR/weewxd + +# Exit if the package is not installed +if [ ! -x "$DAEMON" ]; then + echo "The $DESC daemon is not installed at $DAEMON" + exit 0 +fi + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +# ensure that the rundir exists and is writable by the user running weewx +if [ ! -d $WEEWX_RUNDIR ]; then + mkdir -p $WEEWX_RUNDIR + chown $WEEWX_USER $WEEWX_RUNDIR +fi + +# start the daemon +# 0 if daemon has been started +# 1 if daemon was already running +# 2 if daemon could not be started +do_start() { + INSTANCE=$1 + NAME=weewx-$INSTANCE + PIDFILE=$WEEWX_RUNDIR/$NAME.pid + CFGFILE=$WEEWX_CFGDIR/$INSTANCE.conf + DAEMON_ARGS="--daemon --log-label $NAME --pidfile=$PIDFILE $CFGFILE" + + if [ ! -f "$CFGFILE" ]; then + echo "The instance $INSTANCE does not have a config at $CFGFILE" + return 2 + fi + + NPROC=$(count_procs $INSTANCE) + if [ $NPROC != 0 ]; then + return 1 + fi + start-stop-daemon --start --chuid $WEEWX_USER --pidfile $PIDFILE --exec $DAEMON -- $DAEMON_ARGS || return 2 + return 0 +} + +# stop the daemon +# 0 if daemon has been stopped +# 1 if daemon was already stopped +# 2 if daemon could not be stopped +# other if a failure occurred +do_stop() { + INSTANCE=$1 + NAME=weewx-$INSTANCE + PIDFILE=$WEEWX_RUNDIR/$NAME.pid + + # bail out if the app is not running + NPROC=$(count_procs $INSTANCE) + if [ $NPROC = 0 ]; then + return 1 + fi + # bail out if there is no pid file + if [ ! -f $PIDFILE ]; then + return 1 + fi + start-stop-daemon --stop --user $WEEWX_USER --pidfile $PIDFILE + # we cannot trust the return value from start-stop-daemon + RC=2 + c=0 + while [ $c -lt 24 -a "$RC" = "2" ]; do + c=`expr $c + 1` + # process may not really have completed, so check it + NPROC=$(count_procs $INSTANCE) + if [ $NPROC = 0 ]; then + RC=0 + else + echo -n "." + sleep 5 + fi + done + if [ "$RC" = "0" -o "$RC" = "1" ]; then + # delete the pid file just in case + rm -f $PIDFILE + fi + return "$RC" +} + +# send a SIGHUP to the daemon +do_reload() { + INSTANCE=$1 + NAME=weewx-$INSTANCE + PIDFILE=$WEEWX_RUNDIR/$NAME.pid + + start-stop-daemon --stop --signal 1 --quiet --user $WEEWX_USER --pidfile $PIDFILE + return 0 +} + +do_status() { + INSTANCE=$1 + NAME=weewx-$INSTANCE + NPROC=$(count_procs $INSTANCE) + echo -n "$INSTANCE is " + if [ $NPROC = 0 ]; then + echo -n "not " + fi + echo "running." +} + +count_procs() { + INSTANCE=$1 + NAME=weewx-$INSTANCE + NPROC=`ps ax | grep $DAEMON | grep $NAME.pid | wc -l` + echo $NPROC +} + +CMD=$1 +if [ "$1" != "" ]; then + shift +fi +INSTANCES="$@" +if [ "$INSTANCES" = "" ]; then + INSTANCES=$WEEWX_INSTANCES +fi + + +RETVAL=0 +case "$CMD" in + start) + for i in $INSTANCES; do + log_daemon_msg "Starting $DESC" "$i" + do_start $i + case "$?" in + 0) log_end_msg 0 ;; + 1) log_action_cont_msg " already running" && log_end_msg 0 ;; + 2) log_end_msg 1; RETVAL=1 ;; + esac + done + ;; + stop) + for i in $INSTANCES; do + log_daemon_msg "Stopping $DESC" "$i" + do_stop $i + case "$?" in + 0) log_end_msg 0 ;; + 1) log_action_cont_msg " not running" && log_end_msg 0 ;; + 2) log_end_msg 1; RETVAL=1 ;; + esac + done + ;; + status) + for i in $INSTANCES; do + do_status "$i" + done + ;; + reload|force-reload) + for i in $INSTANCES; do + log_daemon_msg "Reloading $DESC" "$i" + do_reload $i + log_end_msg $? + done + ;; + restart) + for i in $INSTANCES; do + log_daemon_msg "Restarting $DESC" "$i" + do_stop $i + case "$?" in + 0|1) + do_start $i + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1; RETVAL=1 ;; # Old process is still running + *) log_end_msg 1; RETVAL=1 ;; # Failed to start + esac + ;; + *) + log_end_msg 1 + RETVAL=1 + ;; + esac + done + ;; + *) + echo "Usage: $0 {start|stop|restart|reload} [instance]" >&2 + exit 3 + ;; +esac + +exit $RETVAL diff --git a/dist/weewx-4.10.1/util/init.d/weewx.bsd b/dist/weewx-4.10.1/util/init.d/weewx.bsd new file mode 100755 index 0000000..239123a --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx.bsd @@ -0,0 +1,43 @@ +#!/bin/sh +# Start script for FreeBSD, contributed by user Fabian Abplanalp +# Put this script in /usr/local/etc/rc.d then adjust WEEWX_BIN and +# WEEWX_CFG values in /etc/defaults/weewx + +WEEWX_BIN="/opt/weewx/bin/weewxd" +WEEWX_CFG="/opt/weewx/weewx.conf" +WEEWX_PID="/var/run/weewx.pid" + +# Read configuration variable file if it is present +[ -r /etc/defaults/weewx ] && . /etc/defaults/weewx + +case "$1" in + "start") + echo "Starting weewx..." + ${WEEWX_BIN} ${WEEWX_CFG} --daemon & + echo $! > ${WEEWX_PID} + echo "done" + ;; + + "stop") + echo "Stopping weewx..." + if [ -f ${WEEWX_PID} ] ; then + kill `cat ${WEEWX_PID}` + rm ${WEEWX_PID} + echo "done" + else + echo "not running?" + fi + ;; + + "restart") + echo "Restarting weewx..." + $0 stop + sleep 2 + $0 start + ;; + + *) + echo "$0 [start|stop|restart]" + ;; + +esac diff --git a/dist/weewx-4.10.1/util/init.d/weewx.debian b/dist/weewx-4.10.1/util/init.d/weewx.debian new file mode 100755 index 0000000..b0a5fc6 --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx.debian @@ -0,0 +1,181 @@ +#! /bin/sh +# Author: Tom Keffer +# Startup script for Debian derivatives +# +# the skeleton script in debian 6 does not work properly in package scripts. +# the return/exit codes cause {pre|post}{inst|rm} to fail regardless of the +# script completion status. this script exits explicitly. +# +# the skeleton script also does not work properly with python applications, +# as the lsb tools cannot distinguish between the python interpreter and +# the python code that was invoked. this script uses ps and grep to look +# for the application signature instead of using the lsb tools to determine +# whether the app is running. +# +### BEGIN INIT INFO +# Provides: weewx +# Required-Start: $local_fs $remote_fs $syslog $time +# Required-Stop: $local_fs $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: weewx weather system +# Description: Manages the weewx weather system +### END INIT INFO + +# Do NOT "set -e" + +# PATH should only include /usr/* if it runs after the mountnfs.sh script +PATH=/sbin:/usr/sbin:/bin:/usr/bin + +DESC="weewx weather system" +NAME=weewx + +# these can be overridden in the default file +WEEWX_BIN=/home/weewx/bin/weewxd +WEEWX_CFG=/home/weewx/weewx.conf +WEEWX_PID=/var/run/$NAME.pid +WEEWX_USER=root + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +# Exit if the package is not installed +[ -x "$WEEWX_BIN" ] || exit 0 + +DAEMON=$WEEWX_BIN +DAEMON_ARGS="--daemon --pidfile=$WEEWX_PID $WEEWX_CFG" + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +# start the daemon/service +# 0 if daemon has been started +# 1 if daemon was already running +# 2 if daemon could not be started +# check using ps not the pid file. pid file could be leftover. +do_start() { + NPROC=$(count_procs) + if [ $NPROC != 0 ]; then + return 1 + fi + start-stop-daemon --start --chuid $WEEWX_USER --pidfile $WEEWX_PID --exec $DAEMON -- $DAEMON_ARGS || return 2 + return 0 +} + +# stop the daemon/service +# 0 if daemon has been stopped +# 1 if daemon was already stopped +# 2 if daemon could not be stopped +# other if a failure occurred +do_stop() { + # bail out if the app is not running + NPROC=$(count_procs) + if [ $NPROC = 0 ]; then + return 1 + fi + # bail out if there is no pid file + if [ ! -f $WEEWX_PID ]; then + return 1 + fi + start-stop-daemon --stop --user $WEEWX_USER --pidfile $WEEWX_PID + # we cannot trust the return value from start-stop-daemon + RETVAL=2 + c=0 + while [ $c -lt 24 -a "$RETVAL" = "2" ]; do + c=`expr $c + 1` + # process may not really have completed, so check it + NPROC=$(count_procs) + if [ $NPROC = 0 ]; then + RETVAL=0 + else + echo -n "." + sleep 5 + fi + done + if [ "$RETVAL" = "0" -o "$RETVAL" = "1" ]; then + # delete the pid file just in case + rm -f $WEEWX_PID + fi + return "$RETVAL" +} + +# send a SIGHUP to the daemon/service +do_reload() { + start-stop-daemon --stop --signal 1 --quiet --user $WEEWX_USER --pidfile $WEEWX_PID + return 0 +} + +count_procs() { + NPROC=`ps ax | grep weewxd | grep $NAME.pid | wc -l` + echo $NPROC +} + +RETVAL=0 +case "$1" in + start) + log_daemon_msg "Starting $DESC" "$NAME" + do_start + case "$?" in + 0) log_end_msg 0; RETVAL=0 ;; + 1) log_action_cont_msg " already running" && log_end_msg 0; RETVAL=0 ;; + 2) log_end_msg 1; RETVAL=1 ;; + esac + ;; + stop) + log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0) log_end_msg 0; RETVAL=0 ;; + 1) log_action_cont_msg " not running" && log_end_msg 0; RETVAL=0 ;; + 2) log_end_msg 1; RETVAL=1 ;; + esac + ;; + status) + NPROC=$(count_procs) + if [ $NPROC -gt 1 ]; then + MSG="running multiple times" + elif [ $NPROC = 1 ]; then + MSG="running" + else + MSG="not running" + fi + log_daemon_msg "Status of $DESC" "$MSG" + log_end_msg 0 + RETVAL=0 + ;; + reload|force-reload) + log_daemon_msg "Reloading $DESC" "$NAME" + do_reload + RETVAL=$? + log_end_msg $RETVAL + ;; + restart) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0; RETVAL=0 ;; + 1) log_end_msg 1; RETVAL=1 ;; # Old process still running + *) log_end_msg 1; RETVAL=1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + RETVAL=1 + ;; + esac + ;; + *) + echo "Usage: $0 {start|stop|status|restart|reload}" + exit 3 + ;; +esac + +exit $RETVAL diff --git a/dist/weewx-4.10.1/util/init.d/weewx.freebsd b/dist/weewx-4.10.1/util/init.d/weewx.freebsd new file mode 100644 index 0000000..a6548a7 --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx.freebsd @@ -0,0 +1,31 @@ +# +# PROVIDE: weewx +# REQUIRE: DAEMON +# +# Add the following line to /etc/rc.conf.local or /etc/rc.conf +# to enable weewx: +# +# weewx_enable (bool): Set to NO by default +# Set it to YES to enable weewx +# + +. /etc/rc.subr + +name="weewx" +rcvar=weewx_enable + +load_rc_config $name + +start_cmd=weewx_start +weewx_daemon=/usr/local/etc/weewx/bin/weewxd +command=${weewx_daemon} +procname=${weewx_procname:-/usr/local/bin/python3} +weewx_pid=/var/run/weewx.pid +weewx_config=/usr/local/etc/weewx/weewx.conf + +weewx_start() { + echo "Starting ${name}." + ${weewx_daemon} --daemon --pidfile=${weewx_pid} ${weewx_config} & +} + +run_rc_command "$1" diff --git a/dist/weewx-4.10.1/util/init.d/weewx.lsb b/dist/weewx-4.10.1/util/init.d/weewx.lsb new file mode 100755 index 0000000..5b7ac29 --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx.lsb @@ -0,0 +1,275 @@ +#!/bin/bash +# Author: Tom Keffer +# LSB system startup script for weewx + +# derived from LSB template script by Kurt Garloff, SUSE / Novell +# +# see http://www.linuxbase.org/spec/ +# http://www.tldp.org/HOWTO/HighQuality-Apps-HOWTO/ +# +# Note: This script uses functions rc_XXX defined in /etc/rc.status on +# UnitedLinux/SUSE/Novell based Linux distributions. However, it will work +# on other distributions as well, by using the LSB (Linux Standard Base) +# or RH functions or by open coding the needed functions. + +# chkconfig: 345 99 00 +# description: weewx weather daemon + +### BEGIN INIT INFO +# Provides: weewx +# Required-Start: $local_fs $syslog $time +# Required-Stop: $local_fs $syslog $time +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: weewx weather system +# Description: Manages the weewx weather system +### END INIT INFO + +# Note on runlevels: +# 0 - halt/poweroff 6 - reboot +# 1 - single user 2 - multiuser without network exported +# 3 - multiuser w/ network (text mode) 5 - multiuser w/ network and X11 (xdm) + +# Check for missing binaries (stale symlinks should not happen) +# Note: Special treatment of stop for LSB conformance +WEEWX_BIN=/home/weewx/bin/weewxd +WEEWX_CFG=/home/weewx/weewx.conf +WEEWX_ARGS="--daemon $WEEWX_CFG" +test -x $WEEWX_BIN || { echo "$WEEWX_BIN not installed"; + if [ "$1" = "stop" ]; then exit 0; + else exit 5; fi; } + +# Source LSB init functions +# providing start_daemon, killproc, pidofproc, +# log_success_msg, log_failure_msg and log_warning_msg. +# This is currently not used by UnitedLinux based distributions and +# not needed for init scripts for UnitedLinux only. If it is used, +# the functions from rc.status should not be sourced or used. +#. /lib/lsb/init-functions + +# Shell functions sourced from /etc/rc.status: +# rc_check check and set local and overall rc status +# rc_status check and set local and overall rc status +# rc_status -v be verbose in local rc status and clear it afterwards +# rc_status -v -r ditto and clear both the local and overall rc status +# rc_status -s display "skipped" and exit with status 3 +# rc_status -u display "unused" and exit with status 3 +# rc_failed set local and overall rc status to failed +# rc_failed set local and overall rc status to +# rc_reset clear both the local and overall rc status +# rc_exit exit appropriate to overall rc status +# rc_active checks whether a service is activated by symlinks + +# Use the SUSE rc_ init script functions; +# emulate them on LSB, RH and other systems + +# Default: Assume sysvinit binaries exist +start_daemon() { /sbin/start_daemon ${1+"$@"}; } +killproc() { /sbin/killproc ${1+"$@"}; } +pidofproc() { /sbin/pidofproc ${1+"$@"}; } +checkproc() { /sbin/checkproc ${1+"$@"}; } +if test -e /etc/rc.status; then + # SUSE rc script library + . /etc/rc.status +else + export LC_ALL=POSIX + _cmd=$1 + declare -a _SMSG + if test "${_cmd}" = "status"; then + _SMSG=(running dead dead unused unknown reserved) + _RC_UNUSED=3 + else + _SMSG=(done failed failed missed failed skipped unused failed failed reserved) + _RC_UNUSED=6 + fi + if test -e /lib/lsb/init-functions; then + # LSB + . /lib/lsb/init-functions + echo_rc() + { + if test ${_RC_RV} = 0; then + log_success_msg " [${_SMSG[${_RC_RV}]}] " + else + log_failure_msg " [${_SMSG[${_RC_RV}]}] " + fi + } + # TODO: Add checking for lockfiles + checkproc() { return pidofproc ${1+"$@"} >/dev/null 2>&1; } + elif test -e /etc/init.d/functions; then + # RHAT + . /etc/init.d/functions + echo_rc() + { + #echo -n " [${_SMSG[${_RC_RV}]}] " + if test ${_RC_RV} = 0; then + success " [${_SMSG[${_RC_RV}]}] " + else + failure " [${_SMSG[${_RC_RV}]}] " + fi + } + checkproc() { return status ${1+"$@"}; } + start_daemon() { return daemon ${1+"$@"}; } + else + # emulate it + echo_rc() { echo " [${_SMSG[${_RC_RV}]}] "; } + fi + rc_reset() { _RC_RV=0; } + rc_failed() + { + if test -z "$1"; then + _RC_RV=1; + elif test "$1" != "0"; then + _RC_RV=$1; + fi + return ${_RC_RV} + } + rc_check() + { + return rc_failed $? + } + rc_status() + { + rc_failed $? + if test "$1" = "-r"; then _RC_RV=0; shift; fi + if test "$1" = "-s"; then rc_failed 5; echo_rc; rc_failed 3; shift; fi + if test "$1" = "-u"; then rc_failed ${_RC_UNUSED}; echo_rc; rc_failed 3; shift; fi + if test "$1" = "-v"; then echo_rc; shift; fi + if test "$1" = "-r"; then _RC_RV=0; shift; fi + return ${_RC_RV} + } + rc_exit() { exit ${_RC_RV}; } + rc_active() + { + if test -z "$RUNLEVEL"; then read RUNLEVEL REST < <(/sbin/runlevel); fi + if test -e /etc/init.d/S[0-9][0-9]${1}; then return 0; fi + return 1 + } +fi + +# Reset status of this service +rc_reset + +# Return values acc. to LSB for all commands but status: +# 0 - success +# 1 - generic or unspecified error +# 2 - invalid or excess argument(s) +# 3 - unimplemented feature (e.g. "reload") +# 4 - user had insufficient privileges +# 5 - program is not installed +# 6 - program is not configured +# 7 - program is not running +# 8--199 - reserved (8--99 LSB, 100--149 distrib, 150--199 appl) +# +# Note that starting an already running service, stopping +# or restarting a not-running service as well as the restart +# with force-reload (in case signaling is not supported) are +# considered a success. + +case "$1" in + start) + echo -n "Starting weewx " + ## Start daemon with startproc(8). If this fails + ## the return value is set appropriately by startproc. + start_daemon $WEEWX_BIN $WEEWX_ARGS + + # Remember status and be verbose + rc_status -v + ;; + stop) + echo -n "Shutting down weewx " + ## Stop daemon with killproc(8) and if this fails + ## killproc sets the return value according to LSB. + + killproc -TERM $WEEWX_BIN + + # Remember status and be verbose + rc_status -v + ;; + try-restart|condrestart) + ## Do a restart only if the service was active before. + ## Note: try-restart is now part of LSB (as of 1.9). + ## RH has a similar command named condrestart. + if test "$1" = "condrestart"; then + echo "${attn} Use try-restart ${done}(LSB)${attn} rather than condrestart ${warn}(RH)${norm}" + fi + $0 status + if test $? = 0; then + $0 restart + else + rc_reset # Not running is not a failure. + fi + # Remember status and be quiet + rc_status + ;; + restart) + ## Stop the service and regardless of whether it was + ## running or not, start it again. + $0 stop + $0 start + + # Remember status and be quiet + rc_status + ;; + force-reload) + ## Signal the daemon to reload its config. Most daemons + ## do this on signal 1 (SIGHUP). + ## If it does not support it, restart the service if it + ## is running. + + echo -n "Reload service weewx " + ## if it supports it: + killproc -HUP $WEEWX_BIN + touch /var/run/weewx.pid + rc_status -v + + ## Otherwise: + #$0 try-restart + #rc_status + ;; + reload) + ## Like force-reload, but if daemon does not support + ## signaling, do nothing (!) + + # If it supports signaling: + echo -n "Reload service weewx " + killproc -HUP $WEEWX_BIN + touch /var/run/weewx.pid + rc_status -v + + ## Otherwise if it does not support reload: + #rc_failed 3 + #rc_status -v + ;; + status) + echo -n "Checking for service weewx " + ## Check status with checkproc(8), if process is running + ## checkproc will return with exit status 0. + + # Return value is slightly different for the status command: + # 0 - service up and running + # 1 - service dead, but /var/run/ pid file exists + # 2 - service dead, but /var/lock/ lock file exists + # 3 - service not running (unused) + # 4 - service status unknown :-( + # 5--199 reserved (5--99 LSB, 100--149 distro, 150--199 appl.) + + # NOTE: checkproc returns LSB compliant status values. + checkproc $WEEWX_BIN + # NOTE: rc_status knows that we called this init script with + # "status" option and adapts its messages accordingly. + rc_status -v + ;; + probe) + ## Optional: Probe for the necessity of a reload, print out the + ## argument to this init script which is required for a reload. + ## Note: probe is not (yet) part of LSB (as of 1.9) + + #test /etc/FOO/FOO.conf -nt /var/run/FOO.pid && echo reload + echo "Probe not supported" + ;; + *) + echo "Usage: $0 {start|stop|status|try-restart|restart|force-reload|reload|probe}" + exit 1 + ;; +esac +rc_exit diff --git a/dist/weewx-4.10.1/util/init.d/weewx.redhat b/dist/weewx-4.10.1/util/init.d/weewx.redhat new file mode 100755 index 0000000..731eac4 --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx.redhat @@ -0,0 +1,74 @@ +#!/bin/sh +# Author: Mark Jenks +# Startup script for Redhat derivatives +# +# chkconfig: 2345 99 01 +# description: start and stop the weewx weather system +# +# Do NOT "set -e" + +# PATH should only include /usr/* if it runs after the mountnfs.sh script +PATH=/sbin:/usr/sbin:/bin:/usr/bin + +NAME=weewx + +WEEWX_BIN=/home/weewx/bin/weewxd +WEEWX_CFG=/home/weewx/weewx.conf +WEEWX_PID=/var/run/$NAME.pid +WEEWX_LOCK=/var/lock/subsys/$NAME + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +# Exit if the package is not installed +[ -x "$WEEWX_BIN" ] || exit 0 + +DAEMON_ARGS="--daemon --pidfile=$WEEWX_PID $WEEWX_CFG" + +# Source function library. +. /etc/init.d/functions + +# See how we were called. +case "$1" in + start) + # Start daemon. + echo -n $"Starting $NAME: " + daemon --pidfile $WEEWX_PID $WEEWX_BIN $DAEMON_ARGS + RETVAL=$? + echo + [ $RETVAL -eq 0 ] && touch $WEEWX_LOCK + ;; + stop) + # Stop daemon. + echo -n $"Shutting down $NAME: " + killproc $NAME + RETVAL=$? + echo + [ $RETVAL -eq 0 ] && rm -f $WEEWX_LOCK + ;; + status) + echo -n $"Checking for $NAME: " + status $NAME + RETVAL=$? + ;; + restart) + echo -n $"Restarting $NAME: " + $0 stop + $0 start + ;; + reload) + echo -n $"Reloading $NAME: " + killproc $NAME -HUP + RETVAL=$? + echo + ;; + condrestart) + [ -f $WEEWX_LOCK ] && restart || : + ;; + *) + echo $"Usage: $0 {start|stop|status|restart|reload}" + RETVAL=1 + ;; +esac + +exit $RETVAL diff --git a/dist/weewx-4.10.1/util/init.d/weewx.suse b/dist/weewx-4.10.1/util/init.d/weewx.suse new file mode 100755 index 0000000..cd3c778 --- /dev/null +++ b/dist/weewx-4.10.1/util/init.d/weewx.suse @@ -0,0 +1,151 @@ +#!/bin/bash +# Author: Tom Keffer +# Startup script for SuSE derivatives + +### BEGIN INIT INFO +# Provides: weewx +# Required-Start: $local_fs $syslog $time +# Required-Stop: $local_fs $syslog $time +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: weewx weather system +# Description: Manages the weewx weather system +### END INIT INFO +# chkconfig: 345 99 00 +# description: weewx weather daemon + +# runlevels: +# 0 - halt/poweroff 6 - reboot +# 1 - single user 2 - multiuser without network exported +# 3 - multiuser w/ network (text mode) 5 - multiuser w/ network and X11 (xdm) + +# LSB compatible service control script; see http://www.linuxbase.org/spec/ +# Please send feedback to http://www.suse.de/feedback/ +# See also http://www.tldp.org/HOWTO/HighQuality-Apps-HOWTO/ +# +# This scriopt uses functions rc_XXX defined in /etc/rc.status on +# UnitedLinux/SUSE/Novell based Linux distributions. + +WEEWX_BIN=/home/weewx/bin/weewxd +WEEWX_CFG=/home/weewx/weewx.conf +WEEWX_ARGS="--daemon $WEEWX_CFG" +WEEWX_PID_FILE=/var/run/weewx.pid + +# Read configuration variable file if it is present +[ -r /etc/default/weewx ] && . /etc/default/weewx + +test -x $WEEWX_BIN || { echo "$WEEWX_BIN not installed"; + if [ "$1" = "stop" ]; then exit 0; + else exit 5; fi; } + +# Shell functions sourced from /etc/rc.status: +# rc_check check and set local and overall rc status +# rc_status check and set local and overall rc status +# rc_status -v be verbose in local rc status and clear it afterwards +# rc_status -v -r ditto and clear both the local and overall rc status +# rc_status -s display "skipped" and exit with status 3 +# rc_status -u display "unused" and exit with status 3 +# rc_failed set local and overall rc status to failed +# rc_failed set local and overall rc status to +# rc_reset clear both the local and overall rc status +# rc_exit exit appropriate to overall rc status +# rc_active checks whether a service is activated by symlinks + +# Assume sysvinit binaries exist +start_daemon() { /sbin/start_daemon ${1+"$@"}; } +killproc() { /sbin/killproc ${1+"$@"}; } +pidofproc() { /sbin/pidofproc ${1+"$@"}; } +checkproc() { /sbin/checkproc ${1+"$@"}; } +# SUSE rc script library +. /etc/rc.status + +# Reset status of this service +rc_reset + +# Return values according to LSB for all commands but status: +# 0 - success +# 1 - generic or unspecified error +# 2 - invalid or excess argument(s) +# 3 - unimplemented feature (e.g. "reload") +# 4 - user had insufficient privileges +# 5 - program is not installed +# 6 - program is not configured +# 7 - program is not running +# 8--199 - reserved (8--99 LSB, 100--149 distrib, 150--199 appl) +# +# Note that starting an already running service, stopping +# or restarting a not-running service as well as the restart +# with force-reload (in case signaling is not supported) are +# considered a success. + +case "$1" in + start) + echo -n "Starting weewx " + ## Start daemon with startproc(8). If this fails + ## the return value is set appropriately by startproc. + start_daemon $WEEWX_BIN $WEEWX_ARGS + rc_status -v + ;; + stop) + echo -n "Shutting down weewx " + ## Stop daemon with killproc(8) and if this fails + ## killproc sets the return value according to LSB. + killproc -TERM -p $WEEWX_PID_FILE python + rc_status -v + ;; + try-restart|condrestart) + ## Do a restart only if the service was active before. + ## RH has a similar command named condrestart. + if test "$1" = "condrestart"; then + echo "Use try-restart rather than condrestart" + fi + $0 status + if test $? = 0; then + $0 restart + else + rc_reset # Not running is not a failure. + fi + rc_status + ;; + restart) + ## Stop the service and regardless of whether it was + ## running or not, start it again. + $0 stop + $0 start + rc_status + ;; + force-reload) + echo -n "Reload service weewx " + killproc -HUP -p $WEEWX_PID_FILE python + rc_status -v + ;; + reload) + echo -n "Reload service weewx " + killproc -HUP -p $WEEWX_PID_FILE python + rc_status -v + ;; + status) + echo -n "Checking for service weewx " + ## Check status with checkproc(8), if process is running + ## checkproc will return with exit status 0. + + # Return value is slightly different for the status command: + # 0 - service up and running + # 1 - service dead, but /var/run/ pid file exists + # 2 - service dead, but /var/lock/ lock file exists + # 3 - service not running (unused) + # 4 - service status unknown :-( + # 5--199 reserved (5--99 LSB, 100--149 distro, 150--199 appl.) + + checkproc $WEEWX_BIN + rc_status -v + ;; + probe) + echo "Probe not supported" + ;; + *) + echo "Usage: $0 {start|stop|status|try-restart|restart|force-reload|reload|probe}" + exit 1 + ;; +esac +rc_exit diff --git a/dist/weewx-4.10.1/util/launchd/com.weewx.weewxd.plist b/dist/weewx-4.10.1/util/launchd/com.weewx.weewxd.plist new file mode 100644 index 0000000..27d360d --- /dev/null +++ b/dist/weewx-4.10.1/util/launchd/com.weewx.weewxd.plist @@ -0,0 +1,24 @@ + + + + + + + + + + Label + com.weewx.weewxd + Disabled + + RunAtLoad + + ProgramArguments + + /Users/Shared/weewx/bin/weewxd + /Users/Shared/weewx/weewx.conf + + StandardErrorPath + /Users/Shared/weewx/weewx.stderr + + diff --git a/dist/weewx-4.10.1/util/logrotate.d/weewx b/dist/weewx-4.10.1/util/logrotate.d/weewx new file mode 100644 index 0000000..8309480 --- /dev/null +++ b/dist/weewx-4.10.1/util/logrotate.d/weewx @@ -0,0 +1,45 @@ +/var/log/weewxd.log /var/log/weewx.log { + weekly + missingok + rotate 52 + compress +# delaycompress # do not compress the most recently rotated file + copytruncate # copy the file, then truncate + notifempty + +# on some systems the permissions do not propagate, so force them +# create 644 root adm # default user/group on Debian +# create 644 syslog adm # default user/group on Ubuntu +# create 644 root root # default user/group on Redhat + +## The default logrotate behavior is to use "create" instead of "copytruncate". +## However, if you do not use "copytruncate", then on some systems rsyslog +## must be reloaded, or weewx must be restarted, otherwise the weewx log +## messages will go to the previous log file, not the newly rotated one. +## +## The following example shows how to use the 'sharedscripts' and 'postrotate' +## options to restart rsyslog. +## +## The command to restart rsyslog depends on the operating system - some +## examples are given below. In the postrotate section, uncomment only one +## of the commands. If you choose this approach, be sure to test! +## +# sharedscripts # Run the command only _once_, not pr file. +# postrotate # Run the script after rotation is done. +### On systems with SystemD +# systemctl kill -s HUP rsyslog.service +### On systems that have "pgrep / pkill" +# pkill -HUP rsyslog +### If SysV init scripts are available +# /etc/init.d/rsyslog stop +# /etc/init.d/rsyslog start +### SysV where reload is available +# /etc/init.d/rsyslog reload > /dev/null +### Ubuntu systems with upstart +# service rsyslog restart > /dev/null +### Some (older?) RedHat/Fedora systems have the "reload" command +# reload rsyslog > /dev/null 2>&1 +### Debian systems might have this command available +# invoke-rc.d rsyslog reload > /dev/null +# endscript +} diff --git a/dist/weewx-4.10.1/util/logwatch/conf/logfiles/weewx.conf b/dist/weewx-4.10.1/util/logwatch/conf/logfiles/weewx.conf new file mode 100644 index 0000000..32c1468 --- /dev/null +++ b/dist/weewx-4.10.1/util/logwatch/conf/logfiles/weewx.conf @@ -0,0 +1,4 @@ +LogFile = /var/log/syslog +LogFile = syslog.? +Archive = syslog.?.gz +*ApplyStdDate = diff --git a/dist/weewx-4.10.1/util/logwatch/conf/services/weewx.conf b/dist/weewx-4.10.1/util/logwatch/conf/services/weewx.conf new file mode 100644 index 0000000..b2d5c0e --- /dev/null +++ b/dist/weewx-4.10.1/util/logwatch/conf/services/weewx.conf @@ -0,0 +1,2 @@ +Title = "weewx" +LogFile = weewx diff --git a/dist/weewx-4.10.1/util/logwatch/scripts/services/weewx b/dist/weewx-4.10.1/util/logwatch/scripts/services/weewx new file mode 100755 index 0000000..87e333a --- /dev/null +++ b/dist/weewx-4.10.1/util/logwatch/scripts/services/weewx @@ -0,0 +1,1210 @@ +#!/usr/bin/perl +# logwatch script to process weewx log files +# Copyright 2013 Matthew Wall + +# FIXME: break this into modules instead of a single, monolithic blob + +use strict; + +my %counts; +my %errors; + +# keys for individual counts +my $STARTUPS = 'engine: startups'; +my $HUP_RESTARTS = 'engine: restart from HUP'; +my $KBD_INTERRUPTS = 'engine: keyboard interrupts'; +my $RESTARTS = 'engine: restarts'; +my $GARBAGE = 'engine: Garbage collected'; +my $ARCHIVE_RECORDS_ADDED = 'archive: records added'; +my $IMAGES_GENERATED = 'imagegenerator: images generated'; +my $FILES_GENERATED = 'filegenerator: files generated'; +my $FILES_COPIED = 'copygenerator: files copied'; +my $RECORDS_PUBLISHED = 'restful: records published'; +my $RECORDS_SKIPPED = 'restful: records skipped'; +my $RECORDS_FAILED = 'restful: publish failed'; +my $FORECAST_RECORDS = 'forecast: records generated'; +my $FORECAST_PRUNINGS = 'forecast: prunings'; +my $FORECAST_DOWNLOADS = 'forecast: downloads'; +my $FORECAST_SAVED = 'forecast: records saved'; +my $FTP_UPLOADS = 'ftp: files uploaded'; +my $FTP_FAILS = 'ftp: failures'; +my $RSYNC_UPLOADS = 'rsync: files uploaded'; +my $RSYNC_FAILS = 'rsync: failures'; +my $FOUSB_UNSTABLE_READS = 'fousb: unstable reads'; +my $FOUSB_MAGIC_NUMBERS = 'fousb: unrecognised magic number'; +my $FOUSB_RAIN_COUNTER = 'fousb: rain counter decrement'; +my $FOUSB_SUSPECTED_BOGUS = 'fousb: suspected bogus data'; +my $FOUSB_LOST_LOG_SYNC = 'fousb: lost log sync'; +my $FOUSB_LOST_SYNC = 'fousb: lost sync'; +my $FOUSB_MISSED_DATA = 'fousb: missed data'; +my $FOUSB_STATION_SYNC = 'fousb: station sync'; +my $WS23XX_CONNECTION_CHANGE = 'ws23xx: connection change'; +my $WS23XX_INVALID_WIND = 'ws23xx: invalid wind reading'; +my $ACURITE_DODGEY_DATA = 'acurite: R1: ignoring dodgey data'; +my $ACURITE_BAD_R1_LENGTH = 'acurite: R1: bad length'; +my $ACURITE_BAD_R2_LENGTH = 'acurite: R2: bad length'; +my $ACURITE_FAILED_USB_CONNECT = 'acurite: Failed attempt'; +my $ACURITE_STALE_DATA = 'acurite: R1: ignoring stale data'; +my $ACURITE_NO_SENSORS = 'acurite: R1: no sensors found'; +my $ACURITE_BOGUS_STRENGTH = 'acurite: R1: bogus signal strength'; + +# BARO CHARGER DOWNLOAD DST HEADER LOGINT MAX MEM MIN NOW STATION TIME UNITS RAIN VERSION + +my $CC3000_VALUES_HEADER_MISMATCHES = 'cc3000: Values/Header mismatch'; + +# BARO=XX +my $CC3000_BARO_SET_CMD_ECHO_TIMED_OUT = 'cc3000: BARO=XX cmd echo timed out'; +my $CC3000_BARO_SET_MISSING_COMMAND_ECHO = 'cc3000: BARO=XX echoed as empty string'; +my $CC3000_BARO_SET_SUCCESSFUL_RETRIES = 'cc3000: BARO=XX successful retries'; +my $CC3000_BARO_SET_FAILED_RETRIES = 'cc3000: BARO=XX failed retries'; + +# BARO +my $CC3000_BARO_CMD_ECHO_TIMED_OUT = 'cc3000: BARO cmd echo timed out'; +my $CC3000_BARO_MISSING_COMMAND_ECHO = 'cc3000: BARO echoed as empty string'; +my $CC3000_BARO_SUCCESSFUL_RETRIES = 'cc3000: BARO successful retries'; +my $CC3000_BARO_FAILED_RETRIES = 'cc3000: BARO failed retries'; + +# CHARGER +my $CC3000_CHARGER_CMD_ECHO_TIMED_OUT = 'cc3000: CHARGER cmd echo timed out'; +my $CC3000_CHARGER_MISSING_COMMAND_ECHO = 'cc3000: CHARGER echoed as empty string'; +my $CC3000_CHARGER_SUCCESSFUL_RETRIES = 'cc3000: CHARGER successful retries'; +my $CC3000_CHARGER_FAILED_RETRIES = 'cc3000: CHARGER failed retries'; + +# DOWNLOAD=XX +my $CC3000_DOWNLOAD_XX_CMD_ECHO_TIMED_OUT = 'cc3000: DOWNLOAD=XX cmd echo timed out'; +my $CC3000_DOWNLOAD_XX_MISSING_COMMAND_ECHO = 'cc3000: DOWNLOAD=XX echoed as empty string'; +my $CC3000_DOWNLOAD_XX_SUCCESSFUL_RETRIES = 'cc3000: DOWNLOAD=XX successful retries'; +my $CC3000_DOWNLOAD_XX_FAILED_RETRIES = 'cc3000: DOWNLOAD=XX failed retries'; + +# DOWNLOAD +my $CC3000_DOWNLOAD_CMD_ECHO_TIMED_OUT = 'cc3000: DOWNLOAD cmd echo timed out'; +my $CC3000_DOWNLOAD_MISSING_COMMAND_ECHO = 'cc3000: DOWNLOAD echoed as empty string'; +my $CC3000_DOWNLOAD_SUCCESSFUL_RETRIES = 'cc3000: DOWNLOAD successful retries'; +my $CC3000_DOWNLOAD_FAILED_RETRIES = 'cc3000: DOWNLOAD failed retries'; + +# DST=? +my $CC3000_DST_CMD_ECHO_TIMED_OUT = 'cc3000: DST=? cmd echo timed out'; +my $CC3000_DST_MISSING_COMMAND_ECHO = 'cc3000: DST=? echoed as empty string'; +my $CC3000_DST_SUCCESSFUL_RETRIES = 'cc3000: DST=? successful retries'; +my $CC3000_DST_FAILED_RETRIES = 'cc3000: DST=? failed retries'; + +# DST=XX +my $CC3000_DST_SET_CMD_ECHO_TIMED_OUT = 'cc3000: DST=XX cmd echo timed out'; +my $CC3000_DST_SET_MISSING_COMMAND_ECHO = 'cc3000: DST=XX echoed as empty string'; +my $CC3000_DST_SET_SUCCESSFUL_RETRIES = 'cc3000: DST=XX successful retries'; +my $CC3000_DST_SET_FAILED_RETRIES = 'cc3000: DST=XX failed retries'; + +# ECHO=? +my $CC3000_ECHO_QUERY_CMD_ECHO_TIMED_OUT = 'cc3000: ECHO=? cmd echo timed out'; +my $CC3000_ECHO_QUERY_MISSING_COMMAND_ECHO = 'cc3000: ECHO=? echoed as empty string'; +my $CC3000_ECHO_QUERY_SUCCESSFUL_RETRIES = 'cc3000: ECHO=? successful retries'; +my $CC3000_ECHO_QUERY_FAILED_RETRIES = 'cc3000: ECHO=? failed retries'; + +# ECHO=XX +my $CC3000_ECHO_XX_CMD_ECHO_TIMED_OUT = 'cc3000: ECHO=XX cmd echo timed out'; +my $CC3000_ECHO_XX_MISSING_COMMAND_ECHO = 'cc3000: ECHO=XX echoed as empty string'; +my $CC3000_ECHO_XX_SUCCESSFUL_RETRIES = 'cc3000: ECHO=XX successful retries'; +my $CC3000_ECHO_XX_FAILED_RETRIES = 'cc3000: ECHO=XX failed retries'; + +# HEADER +my $CC3000_HEADER_CMD_ECHO_TIMED_OUT = 'cc3000: HEADER cmd echo timed out'; +my $CC3000_HEADER_MISSING_COMMAND_ECHO = 'cc3000: HEADER echoed as empty string'; +my $CC3000_HEADER_SUCCESSFUL_RETRIES = 'cc3000: HEADER successful retries'; +my $CC3000_HEADER_FAILED_RETRIES = 'cc3000: HEADER failed retries'; + +# LOGINT=? +my $CC3000_LOGINT_CMD_ECHO_TIMED_OUT = 'cc3000: LOGINT=? cmd echo timed out'; +my $CC3000_LOGINT_MISSING_COMMAND_ECHO = 'cc3000: LOGINT=? echoed as empty string'; +my $CC3000_LOGINT_SUCCESSFUL_RETRIES = 'cc3000: LOGINT=? successful retries'; +my $CC3000_LOGINT_FAILED_RETRIES = 'cc3000: LOGINT=? failed retries'; + +# LOGINT=XX +my $CC3000_LOGINT_SET_CMD_ECHO_TIMED_OUT = 'cc3000: LOGINT=XX cmd echo timed out'; +my $CC3000_LOGINT_SET_MISSING_COMMAND_ECHO = 'cc3000: LOGINT=XX echoed as empty string'; +my $CC3000_LOGINT_SET_SUCCESSFUL_RETRIES = 'cc3000: LOGINT=XX successful retries'; +my $CC3000_LOGINT_SET_FAILED_RETRIES = 'cc3000: LOGINT=XX failed retries'; + +# MAX=? +my $CC3000_MAX_CMD_ECHO_TIMED_OUT = 'cc3000: MAX=? cmd echo timed out'; +my $CC3000_MAX_MISSING_COMMAND_ECHO = 'cc3000: MAX=? echoed as empty string'; +my $CC3000_MAX_SUCCESSFUL_RETRIES = 'cc3000: MAX=? successful retries'; +my $CC3000_MAX_FAILED_RETRIES = 'cc3000: MAX=? failed retries'; + +# MAX=RESET +my $CC3000_MAX_RESET_CMD_ECHO_TIMED_OUT = 'cc3000: MAX=RESET cmd echo timed out'; +my $CC3000_MAX_RESET_MISSING_COMMAND_ECHO = 'cc3000: MAX=RESET echoed as empty string'; +my $CC3000_MAX_RESET_SUCCESSFUL_RETRIES = 'cc3000: MAX=RESET successful retries'; +my $CC3000_MAX_RESET_FAILED_RETRIES = 'cc3000: MAX=RESET failed retries'; + +# MEM=? +my $CC3000_MEM_CMD_ECHO_TIMED_OUT = 'cc3000: MEM=? cmd echo timed out'; +my $CC3000_MEM_MISSING_COMMAND_ECHO = 'cc3000: MEM=? echoed as empty string'; +my $CC3000_MEM_SUCCESSFUL_RETRIES = 'cc3000: MEM=? successful retries'; +my $CC3000_MEM_FAILED_RETRIES = 'cc3000: MEM=? failed retries'; + +# MEM=CLEAR +my $CC3000_MEM_CLEAR_CMD_ECHO_TIMED_OUT = 'cc3000: MEM=CLEAR cmd echo timed out'; +my $CC3000_MEM_CLEAR_MISSING_COMMAND_ECHO = 'cc3000: MEM=CLEAR echoed as empty string'; +my $CC3000_MEM_CLEAR_SUCCESSFUL_RETRIES = 'cc3000: MEM=CLEAR successful retries'; +my $CC3000_MEM_CLEAR_FAILED_RETRIES = 'cc3000: MEM=CLEAR failed retries'; + +# MIN=? +my $CC3000_MIN_CMD_ECHO_TIMED_OUT = 'cc3000: MIN=? cmd echo timed out'; +my $CC3000_MIN_MISSING_COMMAND_ECHO = 'cc3000: MIN=? echoed as empty string'; +my $CC3000_MIN_SUCCESSFUL_RETRIES = 'cc3000: MIN=? successful retries'; +my $CC3000_MIN_FAILED_RETRIES = 'cc3000: MIN=? failed retries'; + +# MIN=RESET +my $CC3000_MIN_RESET_CMD_ECHO_TIMED_OUT = 'cc3000: MIN=RESET cmd echo timed out'; +my $CC3000_MIN_RESET_MISSING_COMMAND_ECHO = 'cc3000: MIN=RESET echoed as empty string'; +my $CC3000_MIN_RESET_SUCCESSFUL_RETRIES = 'cc3000: MIN=RESET successful retries'; +my $CC3000_MIN_RESET_FAILED_RETRIES = 'cc3000: MIN=RESET failed retries'; + +# NOW +my $CC3000_NOW_CMD_ECHO_TIMED_OUT = 'cc3000: NOW cmd echo timed out'; +my $CC3000_NOW_MISSING_COMMAND_ECHO = 'cc3000: NOW echoed as empty string'; +my $CC3000_NOW_SUCCESSFUL_RETRIES = 'cc3000: NOW successful retries'; +my $CC3000_NOW_FAILED_RETRIES = 'cc3000: NOW failed retries'; + +# RAIN=RESET +my $CC3000_RAIN_RESET_CMD_ECHO_TIMED_OUT = 'cc3000: RAIN=RESET cmd echo timed out'; +my $CC3000_RAIN_RESET_MISSING_COMMAND_ECHO = 'cc3000: RAIN=RESET echoed as empty string'; +my $CC3000_RAIN_RESET_SUCCESSFUL_RETRIES = 'cc3000: RAIN=RESET successful retries'; +my $CC3000_RAIN_RESET_FAILED_RETRIES = 'cc3000: RAIN=RESET failed retries'; + +# RAIN +my $CC3000_RAIN_CMD_ECHO_TIMED_OUT = 'cc3000: RAIN cmd echo timed out'; +my $CC3000_RAIN_MISSING_COMMAND_ECHO = 'cc3000: RAIN echoed as empty string'; +my $CC3000_RAIN_SUCCESSFUL_RETRIES = 'cc3000: RAIN successful retries'; +my $CC3000_RAIN_FAILED_RETRIES = 'cc3000: RAIN failed retries'; + +# STATION=X +my $CC3000_STATION_SET_CMD_ECHO_TIMED_OUT = 'cc3000: STATION=X cmd echo timed out'; +my $CC3000_STATION_SET_MISSING_COMMAND_ECHO = 'cc3000: STATION=X echoed as empty string'; +my $CC3000_STATION_SET_SUCCESSFUL_RETRIES = 'cc3000: STATION=X successful retries'; +my $CC3000_STATION_SET_FAILED_RETRIES = 'cc3000: STATION=X failed retries'; + +# STATION +my $CC3000_STATION_CMD_ECHO_TIMED_OUT = 'cc3000: STATION cmd echo timed out'; +my $CC3000_STATION_MISSING_COMMAND_ECHO = 'cc3000: STATION echoed as empty string'; +my $CC3000_STATION_SUCCESSFUL_RETRIES = 'cc3000: STATION successful retries'; +my $CC3000_STATION_FAILED_RETRIES = 'cc3000: STATION failed retries'; + +# TIME=? +my $CC3000_TIME_CMD_ECHO_TIMED_OUT = 'cc3000: TIME=? cmd echo timed out'; +my $CC3000_TIME_MISSING_COMMAND_ECHO = 'cc3000: TIME=? echoed as empty string'; +my $CC3000_TIME_SUCCESSFUL_RETRIES = 'cc3000: TIME=? successful retries'; +my $CC3000_TIME_FAILED_RETRIES = 'cc3000: TIME=? failed retries'; + +# TIME=XX +my $CC3000_TIME_SET_CMD_ECHO_TIMED_OUT = 'cc3000: TIME=XX cmd echo timed out'; +my $CC3000_TIME_SET_MISSING_COMMAND_ECHO = 'cc3000: TIME=XX echoed as empty string'; +my $CC3000_TIME_SET_SUCCESSFUL_RETRIES = 'cc3000: TIME=XX successful retries'; +my $CC3000_TIME_SET_FAILED_RETRIES = 'cc3000: TIME=XX failed retries'; + +# UNITS=? +my $CC3000_UNITS_CMD_ECHO_TIMED_OUT = 'cc3000: UNITS=? cmd echo timed out'; +my $CC3000_UNITS_MISSING_COMMAND_ECHO = 'cc3000: UNITS=? echoed as empty string'; +my $CC3000_UNITS_SUCCESSFUL_RETRIES = 'cc3000: UNITS=? successful retries'; +my $CC3000_UNITS_FAILED_RETRIES = 'cc3000: UNITS=? failed retries'; + +# UNITS=XX +my $CC3000_UNITS_SET_CMD_ECHO_TIMED_OUT = 'cc3000: UNITS=XX cmd echo timed out'; +my $CC3000_UNITS_SET_MISSING_COMMAND_ECHO = 'cc3000: UNITS=XX echoed as empty string'; +my $CC3000_UNITS_SET_SUCCESSFUL_RETRIES = 'cc3000: UNITS=XX successful retries'; +my $CC3000_UNITS_SET_FAILED_RETRIES = 'cc3000: UNITS=XX failed retries'; + +# VERSION +my $CC3000_VERSION_CMD_ECHO_TIMED_OUT = 'cc3000: VERSION cmd echo timed out'; +my $CC3000_VERSION_MISSING_COMMAND_ECHO = 'cc3000: VERSION echoed as empty string'; +my $CC3000_VERSION_SUCCESSFUL_RETRIES = 'cc3000: VERSION successful retries'; +my $CC3000_VERSION_FAILED_RETRIES = 'cc3000: VERSION failed retries'; + +my $CC3000_GET_TIME_FAILED = 'cc3000: TIME get failed'; + +my $CC3000_SET_TIME_SUCCEEDED = 'cc3000: TIME set succeeded'; + +my $CC3000_MEM_CLEAR_SUCCEEDED = 'cc3000: MEM clear succeeded'; + +my $CC3000_FAILED_CMD = 'cc3000: FAILED to Get Data'; + +my $RSYNC_REPORT_CONN_TIMEOUT = 'rsync: report: connection timeout'; +my $RSYNC_REPORT_NO_ROUTE_TO_HOST = 'rsync: report: no route to host'; + +my $RSYNC_GAUGE_DATA_NO_ROUTE_TO_HOST = 'rsync: gauge-data: no route to host'; +my $RSYNC_GAUGE_DATA_CANT_RESOLVE_HOST = 'rsync: gauge-data: cannot resolve host'; +my $RSYNC_GAUGE_DATA_CONN_REFUSED = 'rsync: gauge-data: connection_refused'; +my $RSYNC_GAUGE_DATA_CONN_TIMEOUT = 'rsync: gauge-data: connection timeouts'; +my $RSYNC_GAUGE_DATA_IO_TIMEOUT = 'rsync: gauge-data: IO timeout-data'; +my $RSYNC_GAUGE_DATA_SKIP_PACKET = 'rsync: gauge-data: skipped packets'; +my $RSYNC_GAUGE_DATA_WRITE_ERRORS = 'rsync: gauge-data: write errors'; +my $RSYNC_GAUGE_DATA_REMOTE_CLOSED = 'rsync: gauge-data: closed by remote host'; + +# any lines that do not match the patterns we define +my @unmatched = (); + +# track individual publishing counts +my %publish_counts = (); +my %publish_fails = (); + +# track individual forecast stats +my %forecast_records = (); +my %forecast_prunings = (); +my %forecast_downloads = (); +my %forecast_saved = (); + +my %summaries = ( + 'counts', \%counts, + 'errors', \%errors, + 'uploads', \%publish_counts, + 'upload failures', \%publish_fails, + 'forecast records generated', \%forecast_records, + 'forecast prunings', \%forecast_prunings, + 'forecast downloads', \%forecast_downloads, + 'forecast records saved', \%forecast_saved + ); + +# track upload errors to help diagnose network/server issues +my @upload_errors = (); + +# keep details of ws23xx behavior +my @ws23xx_conn_change = (); +my @ws23xx_invalid_wind = (); + +# keep details of fine offset behavior +my @fousb_station_status = (); +my @fousb_unstable_reads = (); +my @fousb_magic_numbers = (); +my @fousb_rain_counter = (); +my @fousb_suspected_bogus = (); + +# keep details of acurite behavior +my @acurite_dodgey_data = (); +my @acurite_r1_length = (); +my @acurite_r2_length = (); +my @acurite_failed_usb = (); +my @acurite_stale_data = (); +my @acurite_no_sensors = (); +my @acurite_bogus_strength = (); + +# keep details of PurpleAir messages +my @purpleair = (); + +# keep details of rain counter resets +my @rain_counter_reset = (); + +# keep details of cc3000 behavior +my @cc3000_timings = (); +my @cc3000_mem_clear_info = (); +my @cc3000_retry_info = (); + +my %itemized = ( + 'upload errors', \@upload_errors, + 'fousb station status', \@fousb_station_status, + 'fousb unstable reads', \@fousb_unstable_reads, + 'fousb magic numbers', \@fousb_magic_numbers, + 'fousb rain counter', \@fousb_rain_counter, + 'fousb suspected bogus data', \@fousb_suspected_bogus, + 'ws23xx connection changes', \@ws23xx_conn_change, + 'ws23xx invalid wind', \@ws23xx_invalid_wind, + 'acurite dodgey data', \@acurite_dodgey_data, + 'acurite bad R1 length', \@acurite_r1_length, + 'acurite bad R2 length', \@acurite_r2_length, + 'acurite failed usb connect', \@acurite_failed_usb, + 'acurite stale_data', \@acurite_stale_data, + 'acurite no sensors', \@acurite_no_sensors, + 'acurite bogus signal strength', \@acurite_bogus_strength, + 'rain counter reset', \@rain_counter_reset, + 'PurpleAir messsages', \@purpleair, + 'cc3000 Retry Info', \@cc3000_retry_info, + 'cc3000 Command Timings: flush/cmd/echo/values', \@cc3000_timings, + 'cc3000 Mem Clear Info', \@cc3000_mem_clear_info, + ); + +my $clocksum = 0; +my $clockmin = 0; +my $clockmax = 0; +my $clockcount = 0; + +while(defined($_ = )) { + chomp; + if (/engine: Starting up weewx version/) { + $counts{$STARTUPS} += 1; + } elsif (/engine: Received signal HUP/) { + $counts{$HUP_RESTARTS} += 1; + } elsif (/engine: Keyboard interrupt/) { + $counts{$KBD_INTERRUPTS} += 1; + } elsif (/engine: retrying/) { + $counts{$RESTARTS} += 1; + } elsif (/engine: Garbage collected (\d+) objects/) { + $counts{$GARBAGE} += $1; + } elsif (/engine: Clock error is ([0-9,.-]+)/) { + $clocksum += $1; + $clockmin = $1 if $1 < $clockmin; + $clockmax = $1 if $1 > $clockmax; + $clockcount += 1; + } elsif (/manager: Added record/ || /archive: added record/) { + $counts{$ARCHIVE_RECORDS_ADDED} += 1; + } elsif (/imagegenerator: Generated (\d+) images/) { + $counts{$IMAGES_GENERATED} += $1; + } elsif (/imagegenerator: aggregate interval required for aggregate type/ || + /imagegenerator: line type \S+ skipped/) { + $errors{$_} = $errors{$_} ? $errors{$_} + 1 : 1; + } elsif (/cheetahgenerator: Generated (\d+)/ || + /cheetahgenerator: generated (\d+)/ || + /filegenerator: generated (\d+)/) { + $counts{$FILES_GENERATED} += $1; + } elsif (/reportengine: Copied (\d+) files/) { + $counts{$FILES_COPIED} += $1; + } elsif (/restful: Skipped record/) { + $counts{$RECORDS_SKIPPED} += 1; + } elsif (/restful: Published record/) { + $counts{$RECORDS_PUBLISHED} += 1; + } elsif (/ftpupload: Uploaded file/) { + $counts{$FTP_UPLOADS} += 1; + } elsif (/ftpupload: Failed to upload file/) { + $counts{$FTP_FAILS} += 1; + } elsif (/rsync\'d (\d+) files/) { + $counts{$RSYNC_UPLOADS} += $1; + } elsif (/rsyncupload: rsync reported errors/) { + $counts{$RSYNC_FAILS} += 1; + } elsif (/restful: Unable to publish record/) { + if (/restful: Unable to publish record \d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \S\S\S \(\d+\) to (\S+)/) { + $publish_fails{$1} += 1; + } + $counts{$RECORDS_FAILED} += 1; + push @upload_errors, $_; + } elsif (/restx: ([^:]*): Published record/) { + $publish_counts{$1} += 1; + $counts{$RECORDS_PUBLISHED} += 1; + } elsif (/restx: ([^:]*): Failed to publish/) { + $publish_fails{$1} += 1; + $counts{$RECORDS_FAILED} += 1; + push @upload_errors, $_; + } elsif (/fousb: station status/) { + push @fousb_station_status, $_; + } elsif (/fousb: unstable read: blocks differ/) { + push @fousb_unstable_reads, $_; + $counts{$FOUSB_UNSTABLE_READS} += 1; + } elsif (/fousb: unrecognised magic number/) { + push @fousb_magic_numbers, $_; + $counts{$FOUSB_MAGIC_NUMBERS} += 1; + } elsif (/fousb: rain counter decrement/ || + /fousb: ignoring spurious rain counter decrement/) { + push @fousb_rain_counter, $_; + $counts{$FOUSB_RAIN_COUNTER} += 1; + } elsif (/fousb:.*ignoring suspected bogus data/) { + push @fousb_suspected_bogus, $_; + $counts{$FOUSB_SUSPECTED_BOGUS} += 1; + } elsif (/fousb: lost log sync/) { + $counts{$FOUSB_LOST_LOG_SYNC} += 1; + } elsif (/fousb: lost sync/) { + $counts{$FOUSB_LOST_SYNC} += 1; + } elsif (/fousb: missed data/) { + $counts{$FOUSB_MISSED_DATA} += 1; + } elsif (/fousb: synchronising to the weather station/) { + $counts{$FOUSB_STATION_SYNC} += 1; + } elsif (/ws23xx: connection changed from/) { + push @ws23xx_conn_change, $_; + $counts{$WS23XX_CONNECTION_CHANGE} += 1; + } elsif (/ws23xx: invalid wind reading/) { + push @ws23xx_invalid_wind, $_; + $counts{$WS23XX_INVALID_WIND} += 1; + } elsif (/acurite: R1: ignoring dodgey data/) { + push @acurite_dodgey_data, $_; + $counts{$ACURITE_DODGEY_DATA} += 1; + } elsif (/acurite: R1: bad length/) { + push @acurite_r1_length, $_; + $counts{$ACURITE_BAD_R1_LENGTH} += 1; + } elsif (/acurite: R2: bad length/) { + push @acurite_r2_length, $_; + $counts{$ACURITE_BAD_R2_LENGTH} += 1; + } elsif (/acurite: Failed attempt/) { + push @acurite_failed_usb, $_; + $counts{$ACURITE_FAILED_USB_CONNECT} += 1; + } elsif (/acurite: R1: ignoring stale data/) { + push @acurite_stale_data, $_; + $counts{$ACURITE_STALE_DATA} += 1; + } elsif (/acurite: R1: no sensors found/) { + push @acurite_no_sensors, $_; + $counts{$ACURITE_NO_SENSORS} += 1; + } elsif (/acurite: R1: bogus signal strength/) { + push @acurite_bogus_strength, $_; + $counts{$ACURITE_BOGUS_STRENGTH} += 1; + } elsif (/purpleair: /) { + push @purpleair, $_; + } elsif (/cc3000: Values\/header mismatch/) { + $counts{$CC3000_VALUES_HEADER_MISMATCHES} += 1; + + # BARO CHARGER DOWNLOAD DST HEADER LOGINT MAX MEM MIN NOW STATION TIME UNITS RAIN VERSION + # Example + # cc3000: NOW: times: 0.000050 0.000091 1.027548 -retrying- + # cc3000: NOW: Reading cmd echo timed out (1.027548 seconds), retrying. + # cc3000: NOW: Accepting empty string as cmd echo. + # cc3000: NOW: Retry worked. Total tries: 2 + + # BARO=XX + } elsif (/cc3000: BARO=.*: Reading cmd echo timed out/) { + $counts{$CC3000_BARO_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: BARO=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_BARO_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: BARO=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_BARO_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: BARO=.*: Retry failed./) { + $counts{$CC3000_BARO_SET_FAILED_RETRIES} += 1; + + # BARO + } elsif (/cc3000: BARO: Reading cmd echo timed out/) { + $counts{$CC3000_BARO_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: BARO: Accepting empty string as cmd echo./) { + $counts{$CC3000_BARO_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: BARO: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_BARO_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: BARO: Retry failed./) { + $counts{$CC3000_BARO_FAILED_RETRIES} += 1; + + # CHARGER + } elsif (/cc3000: CHARGER: Reading cmd echo timed out/) { + $counts{$CC3000_CHARGER_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: CHARGER: Accepting empty string as cmd echo./) { + $counts{$CC3000_CHARGER_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: CHARGER: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_CHARGER_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: CHARGER: Retry failed./) { + $counts{$CC3000_CHARGER_FAILED_RETRIES} += 1; + + # DOWNLOAD=XX + } elsif (/cc3000: DOWNLOAD=.*: Reading cmd echo timed out/) { + $counts{$CC3000_DOWNLOAD_XX_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DOWNLOAD=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_DOWNLOAD_XX_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DOWNLOAD=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DOWNLOAD_XX_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DOWNLOAD=.*: Retry failed./) { + $counts{$CC3000_DOWNLOAD_XX_FAILED_RETRIES} += 1; + + # DOWNLOAD + } elsif (/cc3000: DOWNLOAD: Reading cmd echo timed out/) { + $counts{$CC3000_DOWNLOAD_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DOWNLOAD: Accepting empty string as cmd echo./) { + $counts{$CC3000_DOWNLOAD_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DOWNLOAD: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DOWNLOAD_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DOWNLOAD: Retry failed./) { + $counts{$CC3000_DOWNLOAD_FAILED_RETRIES} += 1; + + # DST=? + } elsif (/cc3000: DST=\?: Reading cmd echo timed out/) { + $counts{$CC3000_DST_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DST=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_DST_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DST=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DST_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DST=\?: Retry failed./) { + $counts{$CC3000_DST_FAILED_RETRIES} += 1; + + # DST=XX + } elsif (/cc3000: DST=.*: Reading cmd echo timed out/) { + $counts{$CC3000_DST_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DST=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_DST_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DST=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DST_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DST=.*: Retry failed./) { + $counts{$CC3000_DST_FAILED_RETRIES} += 1; + + # ECHO=? + } elsif (/cc3000: ECHO=\?: Reading cmd echo timed out/) { + $counts{$CC3000_ECHO_QUERY_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: ECHO=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_ECHO_QUERY_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: ECHO=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_ECHO_QUERY_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: ECHO=\?: Retry failed./) { + $counts{$CC3000_ECHO_QUERY_FAILED_RETRIES} += 1; + + # ECHO=XX + } elsif (/cc3000: ECHO=.*: Reading cmd echo timed out/) { + $counts{$CC3000_ECHO_XX_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: ECHO=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_ECHO_XX_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: ECHO=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_ECHO_XX_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: ECHO=.*: Retry failed./) { + $counts{$CC3000_ECHO_XX_FAILED_RETRIES} += 1; + + # HEADER + } elsif (/cc3000: HEADER: Reading cmd echo timed out/) { + $counts{$CC3000_HEADER_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: HEADER: Accepting empty string as cmd echo./) { + $counts{$CC3000_HEADER_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: HEADER: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_HEADER_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: HEADER: Retry failed./) { + $counts{$CC3000_HEADER_FAILED_RETRIES} += 1; + + # LOGINT=? + } elsif (/cc3000: LOGINT=\?: Reading cmd echo timed out/) { + $counts{$CC3000_LOGINT_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: LOGINT=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_LOGINT_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: LOGINT=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_LOGINT_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: LOGINT=\?: Retry failed./) { + $counts{$CC3000_LOGINT_FAILED_RETRIES} += 1; + + # LOGINT=XX + } elsif (/cc3000: LOGINT=.*: Reading cmd echo timed out/) { + $counts{$CC3000_LOGINT_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: LOGINT=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_LOGINT_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: LOGINT=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_LOGINT_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: LOGINT=.*: Retry failed./) { + $counts{$CC3000_LOGINT_SET_FAILED_RETRIES} += 1; + + # MAX=? + } elsif (/cc3000: MAX=\?: Reading cmd echo timed out/) { + $counts{$CC3000_MAX_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MAX=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_MAX_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MAX=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MAX_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MAX=\?: Retry failed./) { + $counts{$CC3000_MAX_FAILED_RETRIES} += 1; + + # MAX=RESET + } elsif (/cc3000: MAX=RESET: Reading cmd echo timed out/) { + $counts{$CC3000_MAX_RESET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MAX=RESET: Accepting empty string as cmd echo./) { + $counts{$CC3000_MAX_RESET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MAX=RESET: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MAX_RESET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MAX=RESET: Retry failed./) { + $counts{$CC3000_MAX_RESET_FAILED_RETRIES} += 1; + + # MEM=? + } elsif (/cc3000: MEM=\?: Reading cmd echo timed out/) { + $counts{$CC3000_MEM_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MEM=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_MEM_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MEM=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MEM_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MEM=\?: Retry failed./) { + $counts{$CC3000_MEM_FAILED_RETRIES} += 1; + + # MEM=CLEAR + } elsif (/cc3000: MEM=CLEAR: Reading cmd echo timed out/) { + $counts{$CC3000_MEM_CLEAR_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MEM=CLEAR: Accepting empty string as cmd echo./) { + $counts{$CC3000_MEM_CLEAR_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MEM=CLEAR: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MEM_CLEAR_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MEM=CLEAR: Retry failed./) { + $counts{$CC3000_MEM_CLEAR_FAILED_RETRIES} += 1; + + # MIN=? + } elsif (/cc3000: MIN=\?: Reading cmd echo timed out/) { + $counts{$CC3000_MIN_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MIN=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_MIN_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MIN=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MIN_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MIN=\?: Retry failed./) { + $counts{$CC3000_MIN_FAILED_RETRIES} += 1; + + # MIN=RESET + } elsif (/cc3000: MIN=RESET: Reading cmd echo timed out/) { + $counts{$CC3000_MIN_RESET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MIN=RESET: Accepting empty string as cmd echo./) { + $counts{$CC3000_MIN_RESET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MIN=RESET: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MIN_RESET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MIN=RESET: Retry failed./) { + $counts{$CC3000_MIN_RESET_FAILED_RETRIES} += 1; + + # NOW + } elsif (/cc3000: NOW: Reading cmd echo timed out/) { + $counts{$CC3000_NOW_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: NOW: Accepting empty string as cmd echo./) { + $counts{$CC3000_NOW_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: NOW: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_NOW_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: NOW: Retry failed./) { + $counts{$CC3000_NOW_FAILED_RETRIES} += 1; + + # RAIN=RESET + } elsif (/cc3000: RAIN=RESET: Reading cmd echo timed out/) { + $counts{$CC3000_RAIN_RESET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: RAIN=RESET: Accepting empty string as cmd echo./) { + $counts{$CC3000_RAIN_RESET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: RAIN=RESET: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_RAIN_RESET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: RAIN=RESET: Retry failed./) { + $counts{$CC3000_RAIN_RESET_FAILED_RETRIES} += 1; + + # RAIN + } elsif (/cc3000: RAIN: Reading cmd echo timed out/) { + $counts{$CC3000_RAIN_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: RAIN: Accepting empty string as cmd echo./) { + $counts{$CC3000_RAIN_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: RAIN: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_RAIN_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: RAIN: Retry failed./) { + $counts{$CC3000_RAIN_FAILED_RETRIES} += 1; + + # STATION=X + } elsif (/cc3000: STATION=.*: Reading cmd echo timed out/) { + $counts{$CC3000_STATION_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: STATION=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_STATION_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: STATION=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_STATION_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: STATION=.*: Retry failed./) { + $counts{$CC3000_STATION_SET_FAILED_RETRIES} += 1; + + # STATION + } elsif (/cc3000: STATION: Reading cmd echo timed out/) { + $counts{$CC3000_STATION_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: STATION: Accepting empty string as cmd echo./) { + $counts{$CC3000_STATION_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: STATION: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_STATION_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: STATION: Retry failed./) { + $counts{$CC3000_STATION_FAILED_RETRIES} += 1; + + # TIME=? + } elsif (/cc3000: TIME=\?: Reading cmd echo timed out/) { + $counts{$CC3000_TIME_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: TIME=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_TIME_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: TIME=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_TIME_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: TIME=\?: Retry failed./) { + $counts{$CC3000_TIME_FAILED_RETRIES} += 1; + + # TIME=XX + } elsif (/cc3000: TIME=.*: Reading cmd echo timed out/) { + $counts{$CC3000_TIME_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: TIME=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_TIME_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: TIME=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_TIME_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: TIME=.*: Retry failed./) { + $counts{$CC3000_TIME_SET_FAILED_RETRIES} += 1; + + # UNITS=? + } elsif (/cc3000: UNITS=\?: Reading cmd echo timed out/) { + $counts{$CC3000_UNITS_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: UNITS=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_UNITS_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: UNITS=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_UNITS_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: UNITS=\?: Retry failed./) { + $counts{$CC3000_UNITS_FAILED_RETRIES} += 1; + + # UNITS=XX + } elsif (/cc3000: UNITS=.*: Reading cmd echo timed out/) { + $counts{$CC3000_UNITS_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: UNITS=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_UNITS_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: UNITS=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_UNITS_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: UNITS=.*: Retry failed./) { + $counts{$CC3000_UNITS_SET_FAILED_RETRIES} += 1; + + # VERSION + } elsif (/cc3000: VERSION: Reading cmd echo timed out/) { + $counts{$CC3000_VERSION_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: VERSION: Accepting empty string as cmd echo./) { + $counts{$CC3000_VERSION_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: VERSION: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_VERSION_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: VERSION: Retry failed./) { + $counts{$CC3000_VERSION_FAILED_RETRIES} += 1; + + } elsif (/engine: Error reading time: Failed to get time/) { + $counts{$CC3000_GET_TIME_FAILED} += 1; + + } elsif (/cc3000: Set time to /) { + $counts{$CC3000_SET_TIME_SUCCEEDED} += 1; + + } elsif (/cc3000: MEM=CLEAR succeeded./) { + $counts{$CC3000_MEM_CLEAR_SUCCEEDED} += 1; + + } elsif (/cc3000: Failed attempt .* of .* to get data: command: Command failed/) { + $counts{$CC3000_FAILED_CMD} += 1; + + # cc3000: NOW: times: 0.000036 0.000085 0.022008 0.027476 + } elsif (/cc3000: .*: times: .*/) { + push @cc3000_timings, $_; + + # cc3000: Logger is at 11475 records, logger clearing threshold is 10000 + } elsif (/cc3000: Logger is at.*/) { + push @cc3000_mem_clear_info, $_; + # cc3000: Clearing all records from logger + } elsif (/cc3000: Clearing all records from logger/) { + push @cc3000_mem_clear_info, $_; + # cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.001586 seconds. + # cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001755 seconds. + } elsif (/cc3000: MEM=CLEAR: The resetting of timeout to .*/) { + push @cc3000_mem_clear_info, $_; + + } elsif (/Rain counter reset detected:/) { + push @rain_counter_reset, $_; + + } elsif (/html.*Connection timed out\. rsync: connection unexpectedly closed/) { + $counts{$RSYNC_REPORT_CONN_TIMEOUT} += 1; + } elsif (/public_html.*No route to host. rsync: connection unexpectedly closed/) { + $counts{$RSYNC_REPORT_NO_ROUTE_TO_HOST} += 1; + + } elsif (/gauge-data\.txt.*No route to host.*rsync: connection unexpectedly closed/) { + $counts{$RSYNC_GAUGE_DATA_NO_ROUTE_TO_HOST} += 1; + } elsif (/gauge-data.txt.*Could not resolve hostname/) { + $counts{$RSYNC_GAUGE_DATA_CANT_RESOLVE_HOST} += 1; + } elsif (/gauge-data\.txt.*Connection refused.*rsync: connection unexpectedly closed/) { + $counts{$RSYNC_GAUGE_DATA_CONN_REFUSED} += 1; + } elsif (/gauge-data\.txt.*Connection timed out.*rsync: connection unexpectedly closed/) { + $counts{$RSYNC_GAUGE_DATA_CONN_TIMEOUT} += 1; + } elsif (/gauge-data\.txt.*rsync error: timeout in data send\/receive/) { + $counts{$RSYNC_GAUGE_DATA_IO_TIMEOUT} += 1; + } elsif (/rsync_data: skipping packet .* with age:/) { + $counts{$RSYNC_GAUGE_DATA_SKIP_PACKET} += 1; + } elsif (/rsyncupload:.*gauge-data\.txt'.*reported errors: rsync:.*write error:/) { + $counts{$RSYNC_GAUGE_DATA_WRITE_ERRORS} += 1; + } elsif (/rsyncupload:.*gauge-data.*closed by remote host/) { + $counts{$RSYNC_GAUGE_DATA_REMOTE_CLOSED} += 1; + + } elsif (/forecast: .*Thread: ([^:]+): generated 1 forecast record/) { + $forecast_records{$1} += 1; + $counts{$FORECAST_RECORDS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): got (\d+) forecast records/) { + $forecast_records{$1} += $2; + $counts{$FORECAST_RECORDS} += $2; + } elsif (/forecast: .*Thread: ([^:]+): deleted forecasts/) { + $forecast_prunings{$1} += 1; + $counts{$FORECAST_PRUNINGS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): downloading forecast/) { + $forecast_downloads{$1} += 1; + $counts{$FORECAST_DOWNLOADS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): download forecast/) { + $forecast_downloads{$1} += 1; + $counts{$FORECAST_DOWNLOADS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): saving (\d+) forecast records/) { + $forecast_saved{$1} += $2; + $counts{$FORECAST_SAVED} += $2; + } elsif (/awekas: Failed upload to (AWEKAS)/ || + /cosm: Failed upload to (COSM)/ || + /emoncms: Failed upload to (EmonCMS)/ || + /owm: Failed upload to (OpenWeatherMap)/ || + /seg: Failed upload to (SmartEnergyGroups)/ || + /wbug: Failed upload to (WeatherBug)/) { + $publish_fails{$1} += 1; + push @upload_errors, $_; + } elsif (/last message repeated/ || + /archive: Created and initialized/ || + /reportengine: Running reports for latest time/ || + /reportengine: Found configuration file/ || + /ftpgenerator: FTP upload not requested/ || + /reportengine: Running report / || # only when debug=1 + /rsyncgenerator: rsync upload not requested/ || + /restful: station will register with/ || + /restful: Registration interval/ || + /\*\*\*\* Registration interval/ || + /restful: Registration successful/ || + /restful: Attempting to register/ || + /stats: Back calculated schema/ || + /stats: Backfilling stats database/ || + /stats: backfilled \d+ days of statistics/ || + /stats: stats database up to date/ || + /stats: Created schema for statistical database/ || + /stats: Schema exists with/ || + /\*\*\*\* \'station\'/ || + /\*\*\*\* required parameter \'\'station\'\'/ || + /\*\*\*\* Waiting 60 seconds then retrying/ || + /engine: The archive interval in the configuration file/ || + /engine: Station does not support reading the time/ || + /engine: Starting main packet loop/ || + /engine: Shut down StdReport thread/ || + /engine: Shut down StdRESTful thread/ || + /engine: Shutting down StdReport thread/ || + /engine: Loading service/ || + /engine: Finished loading service/ || + /engine: Using archive interval of/ || + /engine: Using archive database/ || + /engine: Using configuration file/ || + /engine: Using stats database/ || + /engine: Using station hardware archive interval/ || + /engine: Using config file archive interval of/ || + /engine: Record generation will be attempted in/ || + /engine: StdConvert target unit is/ || + /engine: Data will not be posted to/ || + /engine: Data will be posted to / || + /engine: Started thread for RESTful upload sites./ || + /engine: No RESTful upload sites/ || + /engine: Loading station type/ || + /engine: Initializing weewx version/ || + /engine: Initializing engine/ || + /engine: Using Python/ || + /engine: Terminating weewx version/ || + /engine: PID file is / || + /engine: Use LOOP data in/ || + /engine: Received signal/ || + /engine: Daily summaries up to date/ || + /engine: Using binding/ || + /engine: Archive will use/ || + /engine: Platform/ || + /engine: Locale is/ || + /wxservices: The following values will be calculated:/ || + /wxservices: The following algorithms will be used for calculations:/ || + /manager: Starting backfill of daily summaries/ || + /manager: Created daily summary tables/ || + /cheetahgenerator: Running / || + /cheetahgenerator: skip/ || + /VantagePro: Catch up complete/ || + /VantagePro: successfully woke up console/ || + /VantagePro: Getting archive packets since/ || + /VantagePro: Retrieving/ || + /VantagePro: DMPAFT complete/ || + /VantagePro: Requesting \d+ LOOP packets/ || + /VantagePro: Clock set to/ || + /VantagePro: Opened up serial port/ || + /owfss: interface is/ || + /owfss: sensor map is/ || + /owfss: sensor type map is/ || + /acurite: driver version is/ || + /fousb: driver version is/ || + /fousb: found station on USB/ || + /fousb: altitude is/ || + /fousb: archive interval is/ || + /fousb: pressure offset is/ || + /fousb: polling mode is/ || + /fousb: polling interval is/ || + /fousb: using \S+ polling mode/ || + /fousb: ptr changed/ || + /fousb: new ptr/ || + /fousb: new data/ || + /fousb: live synchronised/ || + /fousb: log synchronised/ || + /fousb: log extended/ || + /fousb: delay/ || + /fousb: avoid/ || + /fousb: setting sensor clock/ || + /fousb: setting station clock/ || + /fousb: estimated log time/ || + /fousb: returning archive record/ || + /fousb: packet timestamp/ || + /fousb: log timestamp/ || + /fousb: found \d+ archive records/ || + /fousb: get \d+ records since/ || + /fousb: synchronised to/ || + /fousb: pressures:/ || + /fousb: status / || + /ws28xx: MainThread: driver version is/ || + /ws28xx: MainThread: frequency is/ || + /ws28xx: MainThread: altitude is/ || + /ws28xx: MainThread: pressure offset is/ || + /ws28xx: MainThread: found transceiver/ || + /ws28xx: MainThread: manufacturer: LA CROSSE TECHNOLOGY/ || + /ws28xx: MainThread: product: Weather Direct Light Wireless/ || + /ws28xx: MainThread: interface/ || + /ws28xx: MainThread: base frequency/ || + /ws28xx: MainThread: frequency correction/ || + /ws28xx: MainThread: adjusted frequency/ || + /ws28xx: MainThread: transceiver identifier/ || + /ws28xx: MainThread: transceiver serial/ || + /ws28xx: MainThread: execute/ || + /ws28xx: MainThread: setState/ || + /ws28xx: MainThread: setPreamPattern/ || + /ws28xx: MainThread: setRX/ || + /ws28xx: MainThread: readCfgFlash/ || + /ws28xx: MainThread: setFrequency/ || + /ws28xx: MainThread: setDeviceID/ || + /ws28xx: MainThread: setTransceiverSerialNumber/ || + /ws28xx: MainThread: setCommModeInterval/ || + /ws28xx: MainThread: frequency registers/ || + /ws28xx: MainThread: initTransceiver/ || + /ws28xx: MainThread: startRFThread/ || + /ws28xx: MainThread: stopRFThread/ || + /ws28xx: MainThread: detach kernel driver/ || + /ws28xx: MainThread: release USB interface/ || + /ws28xx: MainThread: claiming USB interface/ || + /ws28xx: MainThread: CCommunicationService.init/ || + /ws28xx: MainThread: Scanning historical records/ || + /ws28xx: MainThread: Scanned/ || + /ws28xx: MainThread: Found/ || + /ws28xx: RFComm: console is paired to device/ || + /ws28xx: RFComm: starting rf communication/ || + /ws28xx: RFComm: stopping rf communication/ || + /ws28xx: RFComm: setTX/ || + /ws28xx: RFComm: setRX/ || + /ws28xx: RFComm: setState/ || + /ws28xx: RFComm: getState/ || + /ws28xx: RFComm: setFrame/ || + /ws28xx: RFComm: getFrame/ || + /ws28xx: RFComm: InBuf/ || + /ws28xx: RFComm: OutBuf/ || + /ws28xx: RFComm: generateResponse: sleep/ || + /ws28xx: RFComm: generateResponse: id/ || + /ws28xx: RFComm: handleCurrentData/ || + /ws28xx: RFComm: handleHistoryData/ || + /ws28xx: RFComm: handleNextAction/ || + /ws28xx: RFComm: handleConfig/ || + /ws28xx: RFComm: buildACKFrame/ || + /ws28xx: RFComm: buildTimeFrame/ || + /ws28xx: RFComm: buildConfigFrame/ || + /ws28xx: RFComm: setCurrentWeather/ || + /ws28xx: RFComm: setHistoryData/ || + /ws28xx: RFComm: setDeviceCS/ || + /ws28xx: RFComm: setRequestType/ || + /ws28xx: RFComm: setResetMinMaxFlags/ || + /ws28xx: RFComm: setLastStatCache/ || + /ws28xx: RFComm: setLastConfigTime/ || + /ws28xx: RFComm: setLastHistoryIndex/ || + /ws28xx: RFComm: setLastHistoryDataTime/ || + /ws28xx: RFComm: CCurrentWeatherData.read/ || + /ws28xx: RFComm: CWeatherStationConfig.read/ || + /ws28xx: RFComm: CHistoryDataSet.read/ || + /ws28xx: RFComm: testConfigChanged/ || + /ws28xx: RFComm: SetTime/ || + /ws23xx: driver version is / || + /ws23xx: polling interval is / || + /ws23xx: station archive interval is / || + /ws23xx: using computer clock with / || + /ws23xx: using \d+ sec\S* polling interval/ || + /ws23xx: windchill will be / || + /ws23xx: dewpoint will be / || + /ws23xx: pressure offset is / || + /ws23xx: serial port is / || + /ws23xx: downloading \d+ records from station/ || + /ws23xx: count is \d+ to satisfy timestamp/ || + /ws23xx: windchill: / || + /ws23xx: dewpoint: / || + /ws23xx: station clock is / || + /te923: driver version is / || + /te923: polling interval is / || + /te923: windchill will be / || + /te923: sensor map is / || + /te923: battery map is / || + /te923: Found device on USB/ || + /te923: TMP\d / || + /te923: UVX / || + /te923: PRS / || + /te923: WGS / || + /te923: WSP / || + /te923: WDR / || + /te923: RAIN / || + /te923: WCL / || + /te923: STT / || + /te923: Bad read \(attempt \d of/ || # normal when debugging + /te923: usb error.* No data available/ || # normal when debug=1 + /cc3000: Archive interval:/ || + /cc3000: Calculated checksum/ || + /cc3000: Channel:/ || + /cc3000: Charger status:/ || + /cc3000: Clear logger at/ || + /cc3000: Clear memory/ || + /cc3000: Close serial port/ || + /cc3000: Downloaded \d+ new records/ || + /cc3000: Downloaded \d+ records, yielded \d+/ || + /cc3000: Downloading new records/ || + /cc3000: Driver version is/ || + /cc3000: Firmware:/ || + /cc3000: Found checksum at/ || + /cc3000: Flush input bugger/ || + /cc3000: Flush output bugger/ || + /cc3000: gen_records: Requested \d+ latest of \d+ records/ || + /cc3000: gen_records_since_ts: Asking for \d+ records/ || + /cc3000: GenStartupRecords: since_ts/ || + /cc3000: Get baro/ || + /cc3000: Get channel/ || + /cc3000: Get charger/ || + /cc3000: Get daylight saving/ || + /cc3000: Get firmware version/ || + /cc3000: Get header/ || + /cc3000: Get logging interval/ || + /cc3000: Get memory status/ || + /cc3000: Get rain total/ || + /cc3000: Get time/ || + /cc3000: Get units/ || + /cc3000: Header:/ || + /cc3000: Memory:/ || + /cc3000: No rain in packet:/ || + /cc3000: Open serial port/ || + /cc3000: Packet:/ || + /cc3000: Parsed:/ || + /cc3000: Polling interval is/ || + /cc3000: Read:/ || + /cc3000: Reset rain counter/ || + /cc3000: Sensor map is/ || + /cc3000: Set barometer offset to/ || + /cc3000: Set channel to/ || + /cc3000: Set DST to/ || + /cc3000: Set echo to/ || + /cc3000: Set logging interval to/ || + /cc3000: Set units to/ || + /cc3000: Units:/ || + /cc3000: Using computer time/ || + /cc3000: Using serial port/ || + /cc3000: Using station time/ || + /cc3000: Values:/ || + /cc3000: Write:/ || + /owfs: driver version is / || + /owfs: interface is / || + /owfs: polling interval is / || + /owfs: sensor map is / || + /cmon: service version is/ || + /cmon: cpuinfo: / || + /cmon: sysinfo: / || + /cmon: Skipping record/ || + /forecast: .* starting thread/ || + /forecast: .* terminating thread/ || + /forecast: .* not yet time to do the forecast/ || + /forecast: .* last forecast issued/ || + /forecast: .* using table/ || + /forecast: .* tstr=/ || + /forecast: .* interval=\d+ max_age=/ || + /forecast: .* deleted forecasts/ || + /forecast: .* saved \d+ forecast records/ || + /forecast: ZambrettiThread: Zambretti: generating/ || + /forecast: ZambrettiThread: Zambretti: pressure/ || + /forecast: ZambrettiThread: Zambretti: code is/ || + /forecast: NWSThread: NWS: forecast matrix/ || + /forecast: XTideThread: XTide: tide matrix/ || + /forecast: XTideThread: XTide: generating tides/ || + /forecast: XTideThread: XTide: got no tidal events/ || + /forecast: .*: forecast version/ || + /restx: StationRegistry: Station will/ || + /restx: StationRegistry: Registration not requested/ || + /restx: .*Data will be uploaded/ || + /restx: .*wait interval/ || + /restx: Shut down/ || + /restx: \S+: Data will not be posted/ || + /restx: \S+: service version is/ || + /restx: \S+: skipping upload/ || + /restx: \S+: desired unit system is/ || + /restx: AWEKAS: Posting not enabled/ || + /restx: Wunderground: Posting not enabled/ || + /restx: PWSweather: Posting not enabled/ || + /restx: CWOP: Posting not enabled/ || + /restx: WOW: Posting not enabled/ || + /restx: AWEKAS: Data for station/ || + /restx: Wunderground: Data for station/ || + /restx: PWSweather: Posting not enabled/ || + /restx: CWOP: Posting not enabled/ || + /restx: WOW: Data for station/ || + /restx: AWEKAS: url/ || + /restx: EmonCMS: url/ || + /restx: EmonCMS: prefix is/ || + /restx: EmonCMS: desired unit system is/ || + /restx: EmonCMS: using specified input map/ || + /restx: MQTT: Topic is/ || + /restx: OWM: data/ || + /restx: SEG: data/ || + /restx: Twitter: binding is/ || + /restx: Twitter: Data will be tweeted/ || + /restx: WeatherBug: url/ || + /restx: Xively: data/ || + /restx: Xively: url/ || + /ftpupload: attempt/ || + /ftpupload: Made directory/ || + /rsyncupload: rsync executed in/ || + /awekas: code/ || + /awekas: read/ || + /awekas: url/ || + /awekas: data/ || + /cosm: code/ || + /cosm: read/ || + /cosm: url/ || + /cosm: data/ || + /emoncms: code/ || + /emoncms: read/ || + /emoncms: data/ || + /emoncms: url/ || + /owm: code/ || + /owm: read/ || + /owm: url/ || + /owm: data/ || + /seg: code/ || + /seg: read/ || + /seg: url/ || + /seg: data/ || + /wbug: code/ || + /wbug: read/ || + /wbug: url/ || + /wbug: data/ || + /GaugeGenerator:/) { + # ignore + } elsif (! /weewx/) { + # ignore + } else { + push @unmatched, $_; + } +} + +if($clockcount > 0) { + my $clockskew = $clocksum / $clockcount; + print "average station clock skew: $clockskew\n"; + print " min: $clockmin max: $clockmax samples: $clockcount\n"; + print "\n"; +} + +foreach my $slabel (sort keys %summaries) { + my $s = $summaries{$slabel}; + if(scalar(keys %$s)) { + print "$slabel:\n"; + foreach my $k (sort keys %$s) { + next if $s->{$k} == 0; + printf(" %-45s %6d\n", $k, $s->{$k}); + } + print "\n"; + } +} + +foreach my $k (sort keys %itemized) { + report($k, $itemized{$k}) if scalar @{$itemized{$k}} > 0; +} + +report("Unmatched Lines", \@unmatched) if $#unmatched >= 0; + +exit 0; + +sub report { + my($label, $aref, $href) = @_; + print "\n$label:\n"; + foreach my $x (@$aref) { + my $str = $x; + if ($href && $href->{$x} > 1) { + $str .= " ($href->{$x} times)"; + } + print " $str\n"; + } +} diff --git a/dist/weewx-4.10.1/util/newsyslog.d/weewx.conf b/dist/weewx-4.10.1/util/newsyslog.d/weewx.conf new file mode 100644 index 0000000..3687705 --- /dev/null +++ b/dist/weewx-4.10.1/util/newsyslog.d/weewx.conf @@ -0,0 +1,2 @@ +/var/log/weewx.log 644 5 * $D0 +/var/log/weewx_error.log 644 5 * $W0 diff --git a/dist/weewx-4.10.1/util/rsyslog.d/weewx.conf b/dist/weewx-4.10.1/util/rsyslog.d/weewx.conf new file mode 100644 index 0000000..f80d12d --- /dev/null +++ b/dist/weewx-4.10.1/util/rsyslog.d/weewx.conf @@ -0,0 +1,59 @@ +# +# Configuration options for rsyslog / log handling +# +# weewxd.log - messages from the weewx daemon +# weewx.log - messages from the weewx utilities +# +# This configuration file defaults to a traditional rsyslog configuration. +# However, it also includes examples for various other types of rsyslog +# configurations that might be better suited to specific rsyslog versions +# and/or operating system specifics. + +# Default to old-style Property-Based Filters syntax for rsyslog +:programname,isequal,"weewx" /var/log/weewxd.log +:programname,isequal,"weewx" stop +:programname,startswith,"wee_" /var/log/weewx.log +:programname,startswith,"wee_" stop + + +## EXAMPLE 1: +## Rsyslog 'RainerScript' full syntax with multiple conditions +## 'RainerScript' has replaced the BSD syntax, but both are supported. +## Basic if...else support since v5, fully supported since v6 +## +## The conditionals for 'journal' handle the case where the systemd journal +## is messing around with logging. In that case, the programname is journal, +## and the log message will start with the actual programname. +## +#if $programname == "weewx" or ($programname == "journal" and $msg startswith "weewx") then { +# /var/log/weewxd.log +# stop +#} +#if $programname startswith "wee_" or ($programname == "journal" and $msg startswith "wee_") then { +# /var/log/weewx.log +# stop +#} + + +## EXAMPLE 2: +## A more basic 'RainerScript' syntax +## +#if $programname == 'weewx' then /var/log/weewxd.log +#if $programname == 'weewx' then stop +#if $programname startswith 'wee_' then /var/log/weewx.log +#if $programname startswith 'wee_' then stop + +## Same example, but deal with systemd/journald +#if $programname == 'journal' and $msg startswith 'weewx' then /var/log/weewxd.log +#if $programname == 'journal' and $msg startswith 'weewx' then stop +#if $programname == 'journal' and $msg startswith 'wee_' then /var/log/weewx.log +#if $programname == 'journal' and $msg startswith 'wee_' then stop + + +## EXAMPLE 3 - deprecated: +## The tilde "~" discard action is deprecated, but supported, since v6 +## +#:programname,isequal,"weewx" /var/log/weewxd.log +#:programname,isequal,"weewx" ~ +#:programname,startswith,"wee_" /var/log/weewx.log +#:programname,startswith,"wee_" ~ diff --git a/dist/weewx-4.10.1/util/scripts/wee_config b/dist/weewx-4.10.1/util/scripts/wee_config new file mode 100755 index 0000000..3006667 --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_config @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_config + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wee_database b/dist/weewx-4.10.1/util/scripts/wee_database new file mode 100755 index 0000000..c9f1f49 --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_database @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_database + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wee_debug b/dist/weewx-4.10.1/util/scripts/wee_debug new file mode 100755 index 0000000..b769430 --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_debug @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_debug + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wee_device b/dist/weewx-4.10.1/util/scripts/wee_device new file mode 100755 index 0000000..df8854f --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_device @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_device + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wee_extension b/dist/weewx-4.10.1/util/scripts/wee_extension new file mode 100755 index 0000000..9ffdb4f --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_extension @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_extension + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wee_import b/dist/weewx-4.10.1/util/scripts/wee_import new file mode 100755 index 0000000..58bf04a --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_import @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_import + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wee_reports b/dist/weewx-4.10.1/util/scripts/wee_reports new file mode 100755 index 0000000..b219e07 --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wee_reports @@ -0,0 +1,9 @@ +#!/bin/sh +app=wee_reports + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/weewxd b/dist/weewx-4.10.1/util/scripts/weewxd new file mode 100755 index 0000000..09fcfa4 --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/weewxd @@ -0,0 +1,9 @@ +#!/bin/sh +app=weewxd + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/scripts/wunderfixer b/dist/weewx-4.10.1/util/scripts/wunderfixer new file mode 100755 index 0000000..e3ebf05 --- /dev/null +++ b/dist/weewx-4.10.1/util/scripts/wunderfixer @@ -0,0 +1,9 @@ +#!/bin/sh +app=wunderfixer + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=/home/weewx/bin +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-4.10.1/util/solaris/weewx-smf.xml b/dist/weewx-4.10.1/util/solaris/weewx-smf.xml new file mode 100644 index 0000000..2fccc20 --- /dev/null +++ b/dist/weewx-4.10.1/util/solaris/weewx-smf.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dist/weewx-4.10.1/util/systemd/weewx.service b/dist/weewx-4.10.1/util/systemd/weewx.service new file mode 100644 index 0000000..0d96013 --- /dev/null +++ b/dist/weewx-4.10.1/util/systemd/weewx.service @@ -0,0 +1,22 @@ +# systemd unit configuration file for WeeWX +# +# For information about running WeeWX under systemd, +# be sure to read https://github.com/weewx/weewx/wiki/systemd +# +[Unit] +Description=WeeWX weather system +Documentation=https://weewx.com/docs + +Requires=time-sync.target +After=time-sync.target +RequiresMountsFor=/home + +[Service] +ExecStart=/home/weewx/bin/weewxd /home/weewx/weewx.conf +StandardOutput=null +# To run as a non-root user, uncomment and set username and group here: +#User=weewx +#Group=weewx + +[Install] +WantedBy=multi-user.target diff --git a/dist/weewx-4.10.1/util/tmpfiles.d/weewx.conf b/dist/weewx-4.10.1/util/tmpfiles.d/weewx.conf new file mode 100644 index 0000000..36e7ec8 --- /dev/null +++ b/dist/weewx-4.10.1/util/tmpfiles.d/weewx.conf @@ -0,0 +1 @@ +d /run/weewx 0755 weewx weewx - - diff --git a/dist/weewx-4.10.1/util/udev/rules.d/acurite.rules b/dist/weewx-4.10.1/util/udev/rules.d/acurite.rules new file mode 100644 index 0000000..009f439 --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/acurite.rules @@ -0,0 +1,2 @@ +# make any acurite station connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="24C0", ATTRS{idProduct}=="0003", MODE="0666" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/cc3000.rules b/dist/weewx-4.10.1/util/udev/rules.d/cc3000.rules new file mode 100644 index 0000000..a7c0a17 --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/cc3000.rules @@ -0,0 +1,2 @@ +# udev rules for rainwise cc3000 +ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="cc3000" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/fousb.rules b/dist/weewx-4.10.1/util/udev/rules.d/fousb.rules new file mode 100644 index 0000000..2507a4a --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/fousb.rules @@ -0,0 +1,2 @@ +# make any fine offset station connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="1941", ATTRS{idProduct}=="8021", MODE="0666" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/te923.rules b/dist/weewx-4.10.1/util/udev/rules.d/te923.rules new file mode 100644 index 0000000..b61c8ba --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/te923.rules @@ -0,0 +1,2 @@ +# make any te923 station connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="1130", ATTRS{idProduct}=="6801", MODE="0666" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/vantage.rules b/dist/weewx-4.10.1/util/udev/rules.d/vantage.rules new file mode 100644 index 0000000..da91513 --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/vantage.rules @@ -0,0 +1,7 @@ +# udev rules for davis vantage connected via usb (ea60 or ea61) +ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="vantage" +ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea61", SYMLINK+="vantage" + +# use this rule if you are using systemd +#ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="vantage", TAG+="systemd", ENV{SYSTEMD_WANTS}="weewx.service" +#ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea61", SYMLINK+="vantage", TAG+="systemd", ENV{SYSTEMD_WANTS}="weewx.service" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/weewx.rules b/dist/weewx-4.10.1/util/udev/rules.d/weewx.rules new file mode 100644 index 0000000..97f88a5 --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/weewx.rules @@ -0,0 +1,25 @@ +# udev rules for hardware recognized by weewx +# +# copy this file to /etc/udev/rules.d +# sudo udevadm control --reload-rules +# unplug then replug the USB device + +# make any acurite station connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="24C0", ATTRS{idProduct}=="0003", MODE="0666" +# make any fine offset station connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="1941", ATTRS{idProduct}=="8021", MODE="0666" +# make any te923 station connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="1130", ATTRS{idProduct}=="6801", MODE="0666" +# make any oregon scientific wmr100 connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="0FDE", ATTRS{idProduct}=="CA01", MODE="0666" +# make any oregon scientific wmr200 connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="0FDE", ATTRS{idProduct}=="CA01", MODE="0666" +# make any oregon scientific wmr300 connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="0FDE", ATTRS{idProduct}=="CA08", MODE="0666" +# make any ws28xx transceiver connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="6666", ATTRS{idProduct}=="5555", MODE="0666" + +# make symlink for rainwise cc3000 connected via usb-serial +ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="cc3000" +# make symlink for davis vantage connected via usb-serial +ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="vantage" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/wmr100.rules b/dist/weewx-4.10.1/util/udev/rules.d/wmr100.rules new file mode 100644 index 0000000..dc86eff --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/wmr100.rules @@ -0,0 +1,2 @@ +# make the oregon scientific wmr100 connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="0FDE", ATTRS{idProduct}=="CA01", MODE="0666" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/wmr300.rules b/dist/weewx-4.10.1/util/udev/rules.d/wmr300.rules new file mode 100644 index 0000000..f6ebdd9 --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/wmr300.rules @@ -0,0 +1,2 @@ +# make the oregon scientific wmr300 connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="0FDE", ATTRS{idProduct}=="CA08", MODE="0666" diff --git a/dist/weewx-4.10.1/util/udev/rules.d/ws28xx.rules b/dist/weewx-4.10.1/util/udev/rules.d/ws28xx.rules new file mode 100644 index 0000000..08ed3b1 --- /dev/null +++ b/dist/weewx-4.10.1/util/udev/rules.d/ws28xx.rules @@ -0,0 +1,2 @@ +# make any ws28xx transceiver connected via usb accessible to non-root +SUBSYSTEM=="usb", ATTRS{idVendor}=="6666", ATTRS{idProduct}=="5555", MODE="0666" diff --git a/dist/weewx-4.10.1/weewx.conf b/dist/weewx-4.10.1/weewx.conf new file mode 100644 index 0000000..517af0b --- /dev/null +++ b/dist/weewx-4.10.1/weewx.conf @@ -0,0 +1,487 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2022 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations. May get overridden below. +log_success = True + +# Whether to log unsuccessful operations. May get overridden below. +log_failure = True + +# Do not modify this. It is used when installing and updating weewx. +version = 4.10.1 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude in decimal degrees. Negative for southern hemisphere + latitude = 0.00 + # Longitude in decimal degrees. Negative for western hemisphere. + longitude = 0.00 + + # Altitude of the station, with the unit it is in. This is used only + # if the hardware cannot supply a value. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file, which includes a value for the 'driver' option. + station_type = unspecified + + # If you have a website, you may specify an URL. This is required if you + # intend to register your station. + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + # Uncomment and change to override logging for uploading services. + # log_success = True + # log_failure = True + + [[StationRegistry]] + # To register this weather station with weewx, set this to true, + # then fill out option 'station_url', located in the [Station] section above. + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to post to AWEKAS, set the option 'enable' to true, then specify a username + # and password. To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to post to CWOP, set the option 'enable' to true, + # then specify the station ID (e.g., CW1234). + enable = false + station = replace_me + # If this is an APRS (radio amateur) station, specify the + # passcode (e.g., 12345). Otherwise, ignore. + passcode = replace_me + + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to post to PWSweather.com, set the option 'enable' to true, then specify a + # station and password. To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to post to WOW, set the option 'enable' to true, then specify a station and + # password. To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to post to the Weather Underground, set the option 'enable' to true, then + # specify a station (e.g., 'KORHOODR3') and password. To guard against parsing errors, put + # the password in quotes. + enable = false + station = replace_me + password = replace_me + + # If you plan on using wunderfixer, set the following + # to your API key: + api_key = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # Uncomment and change to override logging for reports + # log_success = True + # log_failure = True + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = replace_me + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + # Options in the [[Defaults]] section below will apply to all reports. + # What follows are a few of the more popular options you may want to + # uncomment, then change. + [[Defaults]] + + # Which language to use for all reports. Not all skins support all languages. + # You can override this for individual reports. + lang = en + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us + + [[[Units]]] + + # Option "unit_system" above sets the general unit system, but overriding specific unit + # groups is possible. These are popular choices. Uncomment and set as appropriate. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_pressure = mbar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_temperature = degree_C # Options are 'degree_C', 'degree_F', or 'degree_K' + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + # Uncommenting the following section frequently results in more + # attractive formatting of times and dates, but may not work in + # your locale. + [[[[TimeFormats]]]] + # day = %H:%M + # week = %H:%M on %A + # month = %d-%b-%Y %H:%M + # year = %d-%b-%Y %H:%M + # rainyear = %d-%b-%Y %H:%M + # current = %d-%b-%Y %H:%M + # ephem_day = %H:%M + # ephem_year = %d-%b-%Y %H:%M + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + [[[Labels]]] + # Users frequently change the labels for these observation types + [[[[Generic]]]] + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # extraTemp1 = Temperature1 + # extraTemp2 = Temperature2 + # extraTemp3 = Temperature3 + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # Uncomment and change to override logging for archive operations + # log_success = True + # log_failure = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password (use quotes to guard against parsing errors) + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + # The following section specifies which services should be run and in what order. + [[Services]] + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/CODE_SUMMARY.txt b/dist/weewx-5.0.2/CODE_SUMMARY.txt new file mode 100644 index 0000000..7ee7577 --- /dev/null +++ b/dist/weewx-5.0.2/CODE_SUMMARY.txt @@ -0,0 +1,184 @@ +the makefile target 'code-summary' will display a summary of the codebase. it +uses the tool cloc (github.com/AlDanial/cloc). + +As of 10jan2024: + make code-summary +cloc --force-lang="HTML",tmpl --force-lang="INI",conf --force-lang="INI",inc src docs_src + 437 text files. + 419 unique files. + 189 files ignored. + +github.com/AlDanial/cloc v 1.96 T=0.79 s (532.7 files/s, 157059.0 lines/s) +------------------------------------------------------------------------------- +Language files blank comment code +------------------------------------------------------------------------------- +Python 139 9309 17588 34993 +Markdown 102 6034 0 23538 +INI 89 3583 0 16311 +HTML 44 384 0 7043 +Text 29 202 0 1439 +Perl 1 99 84 1027 +CSS 5 144 76 774 +Bourne Shell 5 76 91 440 +JavaScript 2 17 12 172 +XML 2 10 8 81 +SVG 1 0 0 1 +------------------------------------------------------------------------------- +SUM: 419 19858 17859 85819 +------------------------------------------------------------------------------- + + +As of 23jan2023: + +tkeffer@gray-owl-air git % cloc.pl git/weewx + 466 text files. + 312 unique files. + 556 files ignored. + +github.com/AlDanial/cloc v 1.96 T=0.42 s (747.0 files/s, 252228.4 lines/s) +-------------------------------------------------------------------------------- +Language files blank comment code +-------------------------------------------------------------------------------- +Python 133 9552 18004 34844 +HTML 11 1649 30 13211 +Markdown 61 4119 0 11447 +Text 32 418 0 2379 +XML 18 31 14 1406 +JavaScript 8 308 190 1319 +Perl 2 123 156 1239 +CSS 6 211 83 1092 +Pascal 11 59 11 765 +Bourne Shell 19 133 229 759 +make 2 82 71 423 +Lisp 1 0 0 227 +Bourne Again Shell 2 36 186 204 +YAML 1 27 17 135 +TOML 1 6 4 76 +diff 3 7 31 37 +SVG 1 0 0 1 +-------------------------------------------------------------------------------- +SUM: 312 16761 19026 69564 +-------------------------------------------------------------------------------- + + +================================================================================ + +here is the summary as of 14jan2019: + + 308 text files. + 294 unique files. + 142 files ignored. + +github.com/AlDanial/cloc v 1.81 T=2.12 s (78.6 files/s, 39192.0 lines/s) +------------------------------------------------------------------------------- +Language files blank comment code +------------------------------------------------------------------------------- +Python 101 7816 15299 29005 +HTML 17 3044 43 20501 +CSS 9 171 146 1824 +Pascal 11 59 11 1091 +Perl 2 45 83 755 +Bourne Shell 9 115 188 664 +JavaScript 8 382 277 611 +make 2 54 47 310 +Bourne Again Shell 2 34 185 203 +Markdown 3 34 0 149 +XML 3 8 7 84 +------------------------------------------------------------------------------- +SUM: 167 11762 16286 55197 +------------------------------------------------------------------------------- + +the bin directory: +------------------------------------------------------------------------------- +Language files blank comment code +------------------------------------------------------------------------------- +Python 88 7513 14822 27888 +HTML 2 56 0 1316 +Markdown 1 9 0 61 +------------------------------------------------------------------------------- +SUM: 91 7578 14822 29265 +------------------------------------------------------------------------------- + +the test directories: +------------------------------------------------------------------------------- +weecfg/test +Python 1 117 176 370 +weecfg/test +Python 4 107 62 464 +weeutil/test +Python 2 146 88 606 +weewx/test +Python 9 311 237 1048 +------------------------------------------------------------------------------- +SUM: 16 681 563 2488 +------------------------------------------------------------------------------- + +the driver directory: +------------------------------------------------------------------------------- +Python 15 2808 5530 13796 +------------------------------------------------------------------------------- + +bin less drivers and tests directories: +------------------------------------------------------------------------------- +Python 57 4024 8729 11604 +------------------------------------------------------------------------------- + +================================================================================ + + +--------------------------------------------------------------------------------------------------- +As of 12-Dec-2020 +--------------------------------------------------------------------------------------------------- +cloc --force-lang="HTML",tmpl --force-lang="INI",conf --force-lang="INI",inc bin docs examples skins util + 321 text files. + 317 unique files. + 54 files ignored. + +github.com/AlDanial/cloc v 1.84 T=0.73 s (367.6 files/s, 152852.8 lines/s) +-------------------------------------------------------------------------------- +Language files blank comment code +-------------------------------------------------------------------------------- +Python 119 9103 17309 33363 +HTML 54 3686 59 26686 +INI 55 2874 0 11229 +JavaScript 8 308 190 1317 +CSS 7 214 86 1100 +Perl 1 99 84 1027 +Markdown 5 261 0 900 +Bourne Shell 13 61 136 406 +Bourne Again Shell 2 36 186 204 +XML 2 8 12 83 +SVG 1 0 0 1 +-------------------------------------------------------------------------------- +SUM: 267 16650 18062 76316 +-------------------------------------------------------------------------------- + +================================================================================ + + +--------------------------------------------------------------------------------------------------- +As of 10-Oct-2021 +--------------------------------------------------------------------------------------------------- + cloc --force-lang="HTML",tmpl --force-lang="INI",conf --force-lang="INI",inc bin docs examples skins util + 350 text files. + 344 unique files. + 56 files ignored. + +github.com/AlDanial/cloc v 1.84 T=0.77 s (383.1 files/s, 152427.0 lines/s) +-------------------------------------------------------------------------------- +Language files blank comment code +-------------------------------------------------------------------------------- +Python 120 9168 17378 33511 +HTML 54 3796 66 27517 +INI 81 3437 0 14962 +JavaScript 8 308 190 1319 +Markdown 6 331 0 1298 +CSS 6 207 82 1075 +Perl 1 99 84 1027 +Bourne Shell 13 61 136 406 +Bourne Again Shell 2 36 186 204 +XML 2 8 12 83 +SVG 1 0 0 1 +-------------------------------------------------------------------------------- +SUM: 294 17451 18134 81403 +-------------------------------------------------------------------------------- diff --git a/dist/weewx-5.0.2/DEV_NOTES.txt b/dist/weewx-5.0.2/DEV_NOTES.txt new file mode 100644 index 0000000..32436f3 --- /dev/null +++ b/dist/weewx-5.0.2/DEV_NOTES.txt @@ -0,0 +1,500 @@ +checklist for doing a release: + +1. Check weewx.conf for local changes. In particular, check to make sure: + 1. debug=0 in weewx.conf +2. Make sure the version is correct + 1. modify pyproject.toml + 2. make version +3. Make sure all changes have been logged + 1. doc_src/changes.md + 2. doc_src/upgrade.md + 3. make deb-changelog + 4. make redhat-changelog + 5. make suse-changelog +4. Build the documentation + 1. make build-docs +5. Create the packages + 1. make pypi-package + 2. make debian-package + 3. make redhat-package + 4. make suse-package +6. Check of each installation method + 1. do a git install + upgrade + 2. do a pip install + upgrade + 3. do a debian install + upgrade + 4. do a redhat install + upgrade +7. Commit and tag + 1. git commit -m "Version X.Y.Z" + 2. git push + 3. git tag vX.Y.Z + 4. git push --tags +8. Upload wheel to pypi.org + 1. make upload-pypi +9. Upload to weewx.com + 1. make upload-docs + 2. make upload-src + 3. make upload-debian + 4. make upload-redhat + 5. make upload-suse +10. Update the deb repository + 1. make pull-apt-repo + 2. make update-apt-repo + 3. make push-apt-repo +11. Update the redhat repository + 1. make pull-yum-repo + 2. make update-yum-repo + 3. make push-yum-repo +12. Update the suse repository + 1. make pull-suse-repo + 2. make update-suse-repo + 3. make push-suse-repo +13. Reshuffle files on the server + 1. make release + 2. make release-apt-repo + 3. make release-yum-repo + 4. make release-suse-repo +14. Announce the release to the weewx user's group. + + +pre-requisites ---------------------------------------------------------------- + +Building the documentation requires mkdocs, which is installed using pip. +Signing packages requires gpg and local copy of authorized private+public keys. +Verifying the packages requires rpmlint/lintian. +The debian repo management requires aptly. +The redhat repo management requires createrepo. +The suse repo management requires createrepo. + +To build the docs: + pip3 install --user mkdocs + pip3 install --user mkdocs-material + +To build pypi package install the following (see the pypi section): + pip3 install --user poetry + +To build debian package install the following (use 'apt-get install'): + git + rsync + gpg + debhelper + lintian + +To build redhat package install the following (use 'yum install'): + git + rsync + gnupg + rpm-build + rpm-sign + rpmlint + +To build suse package install the following (use 'zypper install'): + git + rsync + rpm-build + rpmlint + createrepo_c + + +howto ------------------------------------------------------------------------- + +how to update the version number: + Change the version number in pyproject.toml + make version # this propagates the version number everywhere + git commit -a -m "bump to version x.y.z" + +how to build wheel and source tarball: + make pypi-package + +how to build debian package: + make deb-changelog + emacs pkg/debian/changelog # add package-specific changes, if any + git commit -m "update deb changelog" pkg/debian/changelog + make debian-package + +how to build redhat package: + make redhat-changelog + emacs pkg/changelog.el # add package-specific changes, if any + git commit -m "update redhat changelog" pkg/changelog.el + make redhat-package + +how to build redhat package: + make suse-changelog + emacs pkg/changelog.suse # add any package-specific changes, if any + git commit -m "update suse changelog" pkg/changelog.suse + make suse-package + +to build redhat packages with custom rpm revision: + make redhat-changelog RPMREVISION=2 + make redhat-package RPMREVISION=2 + make pull-yum-repo + make update-yum-repo RPMREVISION=2 + make push-yum-repo + ssh weewx.com rsync -arv /var/www/html/yum-test/ /var/www/html/yum +suse also uses RPMREVISION, but debian uses DEBREVISION. this is useful when +there is a change to asset(s) in the packaging, but not part of weewx itself. + +to display debconf variables: + sudo debconf-show weewx + +to manually purge debconf variables: + echo PURGE | sudo debconf-communicate weewx + +to sign rpm packages you need .rpmmacros in your home directory: +~/.rpmmacros + %_gpg_name YOUR_NAME_HERE + +to sign the apt Release using key 'XXX': + gpg -abs -u XXX -o Release.gpg Release + +to sign the RPM repository metadata using key 'XXX': + gpg -abs -u XXX -o repomd.xml.asc repomd.xml + +to generate gpg key used for signing packages: + gpg --gen-key + gpg --list-keys + gpg --list-secret-keys + +to export the text version of a public key: + gpg --export --armor > username.gpg.key + +list keys known by rpm: + rpm -q --gpg-pubkey + rpm -q --gpg-pubkey --qf '%{NAME}-%{VERSION}-%{RELEASE}\t%{SUMMARY}\n' + +delete keys known by rpm: + rpm -e gpg-pubkey-XXXXX + +Install using pip + make pypi-package + pip install dist/weewx-x.y.z-py3-none-any.whl + weectl station create + +debian install/remove: + apt-get install weewx # install with apt + apt-get remove weewx # remove with apt + apt-get purge weewx # purge removes /etc/weewx and debconf settings + dpkg -i weewx_x.y.z-r.deb # install + (apt-get install weewx) # finish install if dependencies failed + dpkg -r weewx # remove + dpkg -P weewx # purge + +redhat install/remove: + yum install weewx-x.y.z-r.rpm [--nogpgcheck] # install with yum + yum remove weewx # remove with yum + rpm -i weewx-x.y.z-r.rpm # install with rpm directly + rpm -e weewx # remove with rpm + +suse install/remove: + zypper install weewx-x.y.z-r.rpm [--nogpgcheck] # install with zypper + zypper remove weewx # remove with zypper + rpm -i weewx-x.y.z-r.rpm # install with rpm directly + rpm -e weewx # remove with rpm + + +howto: build the documents -------------------------------------------------- + +Prerequisites: + - Python 3.7 or greater, with pip installed + - Install mkdocs: + python3 -m pip install mkdocs + - Install the theme "material" for mkdocs: + python3 -m pip install mkdocs-material + +Steps: + - make build-docs + + +howto: build and publish wheels to pypi.org ----------------------------------- + +Prerequisites: + - Python 3.7 or greater, with pip installed + - Install poetry: + curl -sSL https://install.python-poetry.org | python3 - + - Get an API token from pypi.org + See https://pypi.org/manage/account/token/ + - Tell poetry to use it: + poetry config pypi-token.pypi pypi-substitute-your-pypi-key + +Steps: + - Build the wheel + make pypi-package + - Publish to pypi.org + make upload-pypi + + +howto: deb repository --------------------------------------------------------- + +aptly has two different mechanisms for doing a 'publish': switch or update. +we use snapshots, and publish using 'publish switch', rather than publishing +using a simple 'publish update'. + +There are two apt repositories: python2 and python3 + +to do apt repo updates you must first install aptly: + https://www.aptly.info/download/ +for example, on debian: + echo "deb http://repo.aptly.info/ squeeze main" | sudo tee /etc/apt/sources.list.d/aptly.list + wget -qO - https://www.aptly.info/pubkey.txt | sudo apt-key add - + sudo apt-get update + sudo apt-get install aptly + +create local debian repo using aptly: + aptly repo create -distribution=squeeze -component=main -architectures=all python2-weewx + aptly repo create -distribution=buster -component=main -architectures=all python3-weewx + +put a bunch of deb files into an empty apt repo: + for f in `ls distdir`; do aptly repo add python2-weewx distdir/$f; done + +create a snapshot: + aptly snapshot create python-weewx-x.y.z-n from repo python2-weewx + aptly snapshot create python3-weewx-x.y.z-n from repo python3-weewx + +publish using snapshot: + aptly publish -architectures=all snapshot python-weewx-x.y.z-n python2 + aptly publish -architectures=all snapshot python3-weewx-x.y.z-n python3 + +update using 'publish switch': + aptly repo add python2-weewx dist/python-weewx_x.y.z-n_all.deb + aptly snapshot create python-weewx-x.y.z-n from repo python2-weewx + aptly publish switch squeeze python2 python-weewx-x.y.z-n + + aptly repo add python3-weewx dist/python3-weewx_x.y.z-n_all.deb + aptly snapshot create python3-weewx-x.y.z-n from repo python3-weewx + aptly publish switch buster python3 python3-weewx-x.y.z-n + +update using 'publish update': + aptly publish repo -architectures=all python2-weewx squeeze + aptly repo add python2-weewx dist/squeeze/python-weewx_x.y.z-n_all.deb + aptly publish update squeeze python2 + + aptly publish repo -architectures=all python3-weewx buster + aptly repo add python3-weewx dist/buster/python3-weewx_x.y.z-n_all.deb + aptly publish update buster python3 + +clone the published apt repo to local space: + mkdir -p ~/.aptly + rsync -arv USER@weewx.com:/var/www/html/aptly-test/ ~/.aptly + +synchronize local aptly changes with the published apt repo: + rsync -arv ~/.aptly/ USER@weewx.com:/var/www/html/aptly-test + +switch from testing to production (this is done at weewx.com): + rsync -arv /var/www/html/aptly-test/ /var/www/html/aptly + +for clients to use an apt repo at weewx.com: + curl -s http://weewx.com/keys.html | sudo apt-key add - + echo "deb [arch=all] http://weewx.com/apt/ squeeze main" | sudo tee /etc/apt/sources.list.d/weewx.list + echo "deb [arch=all] http://weewx.com/apt/ buster main" | sudo tee /etc/apt/sources.list.d/python3-weewx.list + + +howto: yum repository --------------------------------------------------------- + +create yum repo: + mkdir -p ~/.yum/weewx/{el7,el8,el9}/RPMS + +update local yum repo with latest rpm: + cp *.el7.rpm ~/.yum/weewx/el7/RPMS + createrepo -o ~/.yum/weewx/el7 ~/.yum/weewx/el7 + cp *.el8.rpm ~/yum/weewx/el8/RPMS + createrepo -o ~/.yum/weewx/el8 ~/.yum/weewx/el8 + cp *.el9.rpm ~/yum/weewx/el9/RPMS + createrepo -o ~/.yum/weewx/el9 ~/.yum/weewx/el9 + +clone the published yum repo to local space: + mkdir -p ~/.yum + rsync -arv USER@weewx.com:/var/www/html/yum-test/ ~/.yum + +synchronize local yum changes with published yum repo: + rsync -arv ~/.yum/ USER@weewx.com:/var/www/html/yum-test + +switch from testing to production (this is done at weewx.com): + rsync -arv /var/www/html/yum-test/ /var/www/html/yum + + +notes ------------------------------------------------------------------------- + +there are multiple changelogs: + docs/changes.md - definitive changelog for the application + pkg/debian/changelog - changes to the debian packaging + pkg/changelog.el - changes to the redhat packaging + pkg/changelog.suse - changes to the suse packaging + +when signing RPMs, gpg info must match the name and email in the latest package +changelog entry. + +when signing apt Release using aptly, beware that aptly uses the first gpg key +that it finds. that might not be what you want. + +the debian changelog *must* have a version number that matches the app version. +the redhat package will build if the version numbers do not match. use the +rpm-changelog and deb-changelog targets to ensure that changelog versions match +the application version for a release. + +there are many ways to build a debian package. first tried dpkg (uses DEBIAN +dir and is fairly low-level) but that does not create changes and source diffs. +then dried dpkg-buildpackage (uses debian dir and is higher level) but misses +the config and templates. ended up using dpkg-buildpackage with some manual +(scripted) file manipulation. + +both debian and redhat deprecate the use of '/usr/bin/env python' as the +shebang - they want a very tight binding to the operating system python as the +shebang. in fact, since late 2019 redhat build tools see any shebang other +than a tight coupling to the operating system's python as an error and refuse +to accept it. however, the bsd platforms prefer this as the shebang, and the +other approach fails on bsd. macos uses something else. + +since weewx5 supports any python 3.6+, older weewx supports python 2.7 and any +python 3.5+, weewx does not have to be tightly coupled to any specific python +installation on the system. + +so the source code should use the '/usr/bin/env python' shebang. on platforms +that refuse to accept this, the package builder will replace this with +whatever that platform will accept. for pip installs, pip does the shebang +mangling and sets entry points that are appropriate for its configuration. +for everything else, using the env in shebang and making the entry points +executable enables either 'python foo.py' or 'foo.py' invocation. + +the /etc/default/weewx plus shell stubs in /usr/bin/wee* is used in deb/rpm +and non-linux installations to provide python flexibility, so that users can +use a single weewx installation to experiment with different python versions. +this is particularly helpful when running weewx directly from source in a git +clone, it also works in the deb/rpm installs where the python is managed +separately from the system's python, as well as the non-linux, non-pip +installations. + +unfortunately, this can cause problems during an installation. if the pre/post +install scripts invoke /usr/bin/weectl instead of the python code directly, +they can end up getting python2 or a python3 that does not have the right +modules installed. so maintainer scripts and scriptlets must ensure that they +use a known-working python. + + +signing packages -------------------------------------------------------------- + +gpg is used to sign the deb repository and rpm packages. SHA1 is no longer +acceptable for signing, so be sure that your gpg keys and the signing command +use SHA256 instead. this should be the default when building on redhat9 and +later. if it is not the default, then it can be forced with a change to the +signing macro in .rpmmacros - add the line --digest-algo sha256 + +%__gpg_sign_cmd %{__gpg} \ + gpg --no-verbose --no-armor \ + %{?_gpg_digest_algo:--digest-algo %{_gpg_digest_algo}} \ + --no-secmem-warning \ + --digest-algo sha256 \ + %{?_gpg_sign_cmd_extra_args:%{_gpg_sign_cmd_extra_args}} \ + -u "%{_gpg_name}" -sbo %{__signature_filename} %{__plaintext_filename} + +In the debian world, you sign the repository (specifically the file 'Release'), +not the individual .deb files. So if you need to re-sign, you re-build and +re-sign the repository; there is no need touch the individual .deb files. +Signing is controlled by the -us and -uc options to dpkg-build. If you do not +specify those options, then dpkg-build will try to sign the .dsc, .buildinfo, +and .changes files. The .deb itself is not signed. + +SUSE wants you to sign the RPMs as well as the repository metadata. The meta +data are in repomd.xml, and a fully signed repository must include the files +repomd.xml.asc and repomd.xml.key. So although it is possible to use one key +for the meta data and different keys for the RPMs, it is probably best to sign +with a single, shared key. + +On SUSE, zypper keeps the repo information in a local cache /var/cache/zypp/raw + + +unit tests -------------------------------------------------------------------- + +prerequisites: + +python 3.7 +python-usb +pyephem + +to set up mysql server with user and permissions for testing: + +make test-setup + +to run all unit tests: + +make test +(note: do not run this as root) + +to clean up after running tests: + +make test-clean + +guidelines: + +unit tests should put transient files in /var/tmp/weewx_test + + +testing ----------------------------------------------------------------------- + +what to test when creating debian and redhat packages: + install, upgrade, remove, purge + install, modify files, remove + install previous release, modify files, upgrade, remove + +Using pip: + +- new install to user space + make pypi-package + pip install dist/weewx-x.y.z-py3-none-any.whl --user + weectl station create + +- upgrade user data + modify ~/weewx-data/weewx.conf + weectl station upgrade + +- new install using pip to /opt/weewx + make pypi-package + sudo pip install dist/weewx-x.y.z-py3-none-any.whl + sudo weectl station create /opt/weewx/weewx.conf + +- upgrade using setup.py to /opt/weewx + setup.py install home=/opt/weewx + modify /opt/weewx/weewx.conf + setup.py install home=/opt/weewx + +on centos and suse: + +- new install using rpm + rpm -i weewx_x.y.z.rpm + +- upgrade using rpm + rpm -i weewx_r.s.t.rpm + rpm -U weewx_x.y.z.rpm + +- upgrade using rpm with extensions installed + rpm -i weewx_r.s.t.rpm + wee_extension --install cmon + rpm -U weewx_x.y.z.rpm + +debian: + +- new install usinb dpkg + dpkg -i weewx_x.y.z.deb + +- upgrade using dpkg take maintainer's version of weewx.conf + dpkg -i weewx_r.s.t.deb + modify /etc/weewx/weewx.conf + dpkg -i weewx_x.y.z.deb + +- upgrade using dpkg use old version of weewx.conf + dpkg -i weewx_r.s.t.deb + modify /etc/weewx/weewx.conf + dpkg -i weewx_x.y.z.deb + +- reconfigure using dpkg + dpkg-reconfigure weewx + +all platforms: + +- installation and removal of extensions + weectl extension install https://github.com/matthewwall/weewx-cmon/archive/master.zip + weectl extension install ~/weewx-data/examples/pmon + weectl extension uninstall cmon + weectl extension uninstall pmon + +- reconfigure + weectl station reconfigure + weectl station reconfigure --driver=weewx.drivers.vantage --no-prompt diff --git a/dist/weewx-5.0.2/LICENSE.txt b/dist/weewx-5.0.2/LICENSE.txt new file mode 100644 index 0000000..94a0453 --- /dev/null +++ b/dist/weewx-5.0.2/LICENSE.txt @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/dist/weewx-5.0.2/README.md b/dist/weewx-5.0.2/README.md new file mode 100644 index 0000000..36a64e6 --- /dev/null +++ b/dist/weewx-5.0.2/README.md @@ -0,0 +1,121 @@ +![CI workflow](https://github.com/weewx/weewx/actions/workflows/ci.yaml/badge.svg) +[![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/) + +# [WeeWX](https://www.weewx.com) +*Open source software for your weather station* + +## Description + +The WeeWX weather system is written in Python and runs on Linux, MacOSX, +Solaris, and *BSD. It runs exceptionally well on a Raspberry Pi. It collects +data from many different types of weather stations and sensors, then generates +plots, HTML pages, and monthly and yearly summary reports. It can push plots, +pages, and reports to a web server, as well as upload weather-related data to +many online weather services. Thousands of users worldwide! + +See the WeeWX website for [examples](https://weewx.com/showcase.html) of +websites generated by WeeWX, and a [map](https://weewx.com/stations.html) of +stations using WeeWX. + +* Robust and hard-to-crash +* Designed with the enthusiast in mind +* Simple internal design that is easily extended (Python skills recommended) +* Large ecosystem of 3rd party extensions +* Internationalized language support +* Localized date/time support +* Support for US and metric units +* Support for multiple skins +* Support for sqlite and MySQL +* Extensive almanac information +* Uploads to your website via FTP, FTPS, or rsync +* Uploads to online weather services +* Requires Python 3.6 or greater + +Support for many online weather services, including: + +* The Weather Underground +* CWOP +* PWSweather +* WOW +* AWEKAS +* Windy +* Open Weathermap +* WeatherBug +* Weather Cloud +* Wetter +* Windfinder + +Support for many data publishing and aggregation services, including: + +* MQTT +* InfluxDB + +Support for over 70 types of hardware including, but not limited to: + +* Davis Vantage Pro, Pro2, Vue, Envoy; +* Oregon Scientific WMR100, WMR300, WMR9x8, and other variants; +* Oregon Scientific LW300/LW301/LW302; +* Fine Offset WH10xx, WH20xx, and WH30xx series (including Ambient, Elecsa, Maplin, Tycon, Watson, and others); +* Fine Offset WH23xx, WH4000 (including Tycon TP2700, MiSol WH2310); +* Fine Offset WH2600, HP1000 (including Ambient Observer, Aercus WeatherSleuth, XC0422); +* Fine Offset GW1000, GW1100, GW2000 (including Ecowitt) +* LaCrosse WS-23XX and WS-28XX (including TFA); +* LaCrosse GW1000U bridge; +* Hideki TE923, TE831, TE838, DV928 (including TFA, Cresta, Honeywell, and others); +* PeetBros Ultimeter; +* RainWise CC3000 and MKIII; +* AcuRite 5-in-1 via USB console or bridge; +* AcuRite Atlas; +* Argent Data Systems WS1; +* KlimaLogg Pro; +* New Mountain; +* AirMar 150WX; +* Texas Weather Instruments; +* Dyacon; +* Meteostick; +* Ventus W820; +* Si1000 radio receiver; +* Software Defined Radio (SDR); +* One-wire (including Inspeed, ADS, AAG, Hobby-Boards). + +See the [hardware list](https://www.weewx.com/hardware.html) for a complete +list of supported stations, and for pictures to help identify your hardware! +The [hardware comparison](https://www.weewx.com/hwcmp.html) has specifications +for many different types of hardware, including some not yet supported by +WeeWX. + +## Install + +[https://weewx.com/docs/quickstarts.html](https://weewx.com/docs/quickstarts.html) + +## Download + +For current and previous releases: + +[https://weewx.com/downloads](https://weewx.com/downloads) + +For the latest source code: + +[https://github.com/weewx/weewx](https://github.com/weewx/weewx) + +## Documentation and Support + +Guides for installation, upgrading, and customization: + +[https://weewx.com/docs/](https://weewx.com/docs/) + +The wiki includes user-contributed extensions and suggestions: + +[https://github.com/weewx/weewx/wiki](https://github.com/weewx/weewx/wiki) + +Community support can be found at: + +[https://groups.google.com/group/weewx-user](https://groups.google.com/group/weewx-user) + +## Licensing + +WeeWX is licensed under the GNU Public License v3. + +## Copyright + +© 2009-2024 Thomas Keffer, Matthew Wall, and Gary Roderick diff --git a/dist/weewx-5.0.2/TODO.md b/dist/weewx-5.0.2/TODO.md new file mode 100644 index 0000000..9e43cdd --- /dev/null +++ b/dist/weewx-5.0.2/TODO.md @@ -0,0 +1,31 @@ +## Future + +- mw how to provide output from redhat sriptlets without adding noise to yum + or dnf output? +- mw or tk: Look into unifying the two versions of the systemd weewx service + files. +- mw ensure that maintainer scripts use a known working python, not just what + might be defined in /usr/bin/weectl + +## Testing + +- mw convert to pytest +- mw Automate the testing of install/upgrade/uninstall for each installation + method using vagrant + + +## Drivers + +- mw The `fousb` driver needs to be ported to Python 12. post weewx 5.0 release + + +## Wiki + +Update the wiki entries for going from MySQL to SQLite and for SQLite to MySQL, +this time by using `weectl database transfer`. + + +# Future + +- mw implement weewx-multi that works on any SysV init (no lsb dependencies) + diff --git a/dist/weewx-5.0.2/bin/weectl b/dist/weewx-5.0.2/bin/weectl new file mode 100755 index 0000000..0aea7ed --- /dev/null +++ b/dist/weewx-5.0.2/bin/weectl @@ -0,0 +1,9 @@ +#!/bin/sh +app=weectl.py + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=$(dirname "$0")/../src +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-5.0.2/bin/weewxd b/dist/weewx-5.0.2/bin/weewxd new file mode 100755 index 0000000..67658b2 --- /dev/null +++ b/dist/weewx-5.0.2/bin/weewxd @@ -0,0 +1,9 @@ +#!/bin/sh +app=weewxd.py + +# Get the weewx location and interpreter. Default to something sane, but +# look for overrides from the system defaults. +WEEWX_BINDIR=$(dirname "$0")/../src +WEEWX_PYTHON=python3 +[ -r /etc/default/weewx ] && . /etc/default/weewx +exec "$WEEWX_PYTHON" $WEEWX_PYTHON_ARGS "$WEEWX_BINDIR/$app" "$@" diff --git a/dist/weewx-5.0.2/docs_src/changes.md b/dist/weewx-5.0.2/docs_src/changes.md new file mode 100644 index 0000000..d62b236 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/changes.md @@ -0,0 +1,3280 @@ +WeeWX change history +-------------------- + +### 5.0.2 02/10/2024 + +Add target `network-online.target` to the weewx systemd unit file. This prevents +`weewxd` from starting until the network is ready. + + +### 5.0.1 02/04/2024 + +Include backwards compatible reference to `weewx.UnknownType`. + +Fix problem with installing extensions into installations that used V4 config +files that were installed by a package installer. + +Fix problem with `weectl device` when using drivers that were installed +using the extension installer. Fixes issue #918. + +Fix problem that prevented daily summaries from being rebuilt if they had been +modified by using `weectl database drop-columns`. + +Allow the use of the tilde (`~`) prefix with `--config` options. + +Fix problem that prevented debug statements from being logged. + +Minor corrections to the Norwegian translations. Thanks to user Aslak! +PR #919. + +Change Chinese language code to `zh`. Fixes issue #912. + +Fix bug in redhat/suse scriptlet that incorrectly substituted `{weewx}` +instead of `weewx` in the udev rules file. + +In the redhat/suse installers, use `/var/lib/weewx` as `HOME` for user `weewx`. + + +### 5.0.0 01/14/2024 + +Python 2.7 is no longer supported. You must have Python 3.6 (introduced +December 2016) or greater. WeeWX 5 uses the module `importlib.resources`, +which was introduced in Python 3.7. So those using Python 3.6 must install +the backport, either using the system's package manager, or pip. + +WeeWX can now be installed using [pip](https://pip.pypa.io). + +With pip installs, station data is stored in `~/weewx-data` by default, +instead of `/home/weewx`. This allows pip installs to be done without +root privileges. However, `/home/weewx` can still be used. + +The new utility [`weectl`](utilities/weectl-about.md) replaces `wee_database`, +`wee_debug`, `wee_device`, `wee_extension`, `wee_import`, `wee_reports`, +and `wee_config`. Try `weectl --help` to see how to use it. + +Individual reports can now be run using `weectl report run`. For example, +`weectl report run MobileReport`. + +The extension installer can now install from an `http` address, not just a +file or directory. + +When using `weectl database` with action `calc-missing`, the tranche size can +now be set. + +Documentation now uses [MkDocs](https://www.mkdocs.org/). It is no longer included in the +distribution, but can always be accessed online at https://weewx.com/docs. + +Package installs now use `systemd` instead of the old System V `/etc/init.d`. + +Allow `StdCalibrate` to operate only on LOOP packets, or only on archive +records. Addresses [issue #895](https://github.com/weewx/weewx/issues/895). + +Removed all references to the deprecated package `distutils`, which is due to +be removed in Python v3.12. + +Removed the utility `wunderfixer`. The Weather Underground no longer +allows posting past-dated records. + +Method `ImageDraw.textsize()` and constants `ImageFont.LAYOUT_BASIC`, and +`Image.ANTIALIAS` were deprecated in Pillow 9.2 (1-Jul-2022), then removed in +Pillow 10.0 (1-Jul-2023). V5.0 replaces them with alternatives. Fixes +issue [#884](https://github.com/weewx/weewx/issues/884). + +Fix bug when using Pillow v9.5.0. Fixes issue +[#862](https://github.com/weewx/weewx/issues/862). + +The *Standard* skin now uses the font `DejaVuSansMono-Bold` and includes a +copy. Before, it had to rely on hardwired font paths, which were less reliable. + +If the uploaders get a response code of 429 ("TOO MANY REQUESTS"), they no +longer bother trying again. + +Limit station registration to once a day, max. + +Station registration now uses HTTP POST, instead of HTTP GET. + +Station registration is delayed by a random length of time to avoid everyone +hitting the server at the same time. + +Fix problem where aggregation of null wind directions returns 90° instead of +null. Fixes issue [#849](https://github.com/weewx/weewx/issues/849). + +Fix wrong station type for Vantage `weectl device --info` query. + +Add retransmit information for Vantage `weectl device --info` query. + +Fix problem when setting Vantage repeater. Fixes issue +[#863](https://github.com/weewx/weewx/issues/863). + +Detect "dash" values for rain-related measurements on Vantage stations. + +Change aggregations `minsumtime` and `maxsumtime` to return start-of-day, +rather than the time of max rainfall during the day. + +Relax requirement that column `dateTime` be the first column in the database. +Fixes issue [#855](https://github.com/weewx/weewx/issues/855). + +Allow aggregation of xtypes that are not in the database schema. +Fixes issue [#864](https://github.com/weewx/weewx/issues/864). + +Tag suffix `has_data()` now works for xtypes. Fixes issue +[#877](https://github.com/weewx/weewx/issues/877). + +Additional shorthand notations for aggregation and trend intervals. For +example, `3h` for three hours. + +Accumulator `firstlast` no longer coerces values to a string. Thanks to user +"Karen" for spotting this! + +Fix problem that caused crashes with `firstlast` accumulator type. +Fixes issue [#876](https://github.com/weewx/weewx/issues/876). + +Fixed problem that prevented the astrometric heliocentric longitude of a body +from being calculated properly. + +Default format for azimuth properties (such as wind direction) is now zero +padded 3 digits. E.g., `005°` instead of `5°`. + +Most almanac properties are now returned as `ValueHelpers`, so they will +obey local formatting conventions (in particular, decimal separators). To +avoid breaking old skins, these properties now have new names. For example, +use `$almanac.venus.altitude` instead of `$almanac.venus.alt`. + +Fix problem that prevented database from getting hit when calculating +`pressure`. Fixes issue [#875](https://github.com/weewx/weewx/issues/875). + +Fix problem that prevented option +[`stale_age`](reference/skin-options/imagegenerator.md#stale_age) from being +honored in image generation. Thanks to user Ian for +[PR #879](https://github.com/weewx/weewx/pull/879)! + +Fix problem that prevented complex aggregates such as `max_ge` from being used +in plots. Fixes issue [#881](https://github.com/weewx/weewx/issues/881). + +Updated humidex formula and reference. Fixes issue +[#883](https://github.com/weewx/weewx/issues/883). + +Fix bugs in the "basic" skin example. + +Fix bug that prevented calculating `$trend` when one of the two records is +missing. + +Fix bug that caused the extension installer to crash if one of the service +groups was missing in the configuration file. Fixes issue +[#886](https://github.com/weewx/weewx/issues/886). + +New option [`retry_wait`](reference/weewx-options/general.md#retry_wait). If +`weewxd` encounters a critical error, it will sleep this long before doing a +restart. + +Change from old Google Analytics UA code to the GA4 tag system in the Standard +and Seasons skins. Fixes issue [#892](https://github.com/weewx/weewx/issues/892). + +All `weectl import` sources now include support for a field map meaning any +source field can be imported to any WeeWX archive field. + +Units for `weectl import` sources that require user specified source data +units are now specified in the `[[FieldMap]]` stanza. + +Fixed problem when plotting wind vectors from a database that does not include +daily summaries. + +Fixed a long-standing bug in the log message format that made 'python' or +'journal' appear as the process name instead of 'weewx'. + +The process name for weewxd is now 'weewxd'. In V4 it was 'weewx'. + +The rc script and configuration for FreeBSD/OpenBSD has been updated and now +uses standard BSD conventions. + +The DEB/RPM packaging now detect whether systemd is running, so on systems that +use SysV, the rc scripts will be installed, and on systems such as docker that +do not use systemd, no systemd dependencies will be introduced. + + +### 4.10.2 02/22/2023 + +Removed errant "f-string" in `imagegenerator.py`. + +Added missing `.long_form` to `celestial.inc` that would cause total daylight +to be given in seconds, instead of long form. + +Fix problem that a `None` value in `long_form()` would raise an exception. +PR #843. Thanks to user Karen! + +The module `user.extensions` is now imported into `wee_reports`. Thanks to +user jocelynj! PR #842. + +Fix problem that prevented `wee_device --set-retransmit` from working on +Vantage stations. + +Using a bad data binding with an aggregation tag no longer results in an +exception. Instead, it shows the tag in the results. Related to PR #817. + + +### 4.10.1 01/30/2023 + +Logging handler `rotate` has been removed. Its need to access privileged +location `/var/log/weewx.log` on start up would cause crashes, even if it was +never used. + + +### 4.10.0 01/29/2023 + +Don't inject `txBatteryStatus` and `consBatteryVoltage` into records in the +Vantage driver. Let the accumulators do it. Fixes issue #802. + +Different wake-up strategy for the Vantage console. + +Do not write `config_path` and `entry_path` to updated configuration dictionary. +Fixes issue #806. + +Allow more flexible formatting for delta times. This can break old skins. +See Upgrade Guide. PR #807. + +Fix bug that prevents `group_deltatime` from being used by timespans. Users +who used custom formatting for delta times will be affected. See the Upgrade +Guide. Fixes issue #808. + +Add suffix `.length` to class TimespanBinder. This allows expressions such as +$month.length. PR #809. Thanks to user Karen! + +Added new unit `hertz`. PR #812. Again, thanks to user Karen! + +Calculate `*.wind.maxtime` out of `windGust` like `*.wind.max` +Fixes issue #833 + +Fix bug that prevents `group_deltatime` from being used by timespans. Users +Add suffix `.length` to class TimespanBinder. This allows expressions such as + +Option `line_gap_fraction` can now be used with bar plots. Fixes issue #818. + + +### 4.9.1 10/25/2022 + +Fix problem with `wind` for older versions of sqlite. + + +### 4.9.0 10/24/2022 + +Fix problem that create 'ghost' values for VantageVue stations. +Fix problem that causes `leafWet3` and `leafWet4` to be emitted in VP2 +stations that do not have the necessary sensors. +Fixes issue #771. + +Try waking the Vantage console before giving up on LOOP errors. +Better Vantage diagnostics. +Fixes issue #772. + +Add missing 30-day barometer graph to Smartphone skin. +Fixes issue #774. + +Fix check for `reuse_ssl` for Python versions greater than 3.10. +Fixes issue #775. + +The utility `wee_reports` can now be invoked by specifying a `--date` +and `--time`. +Fixes issue #776. + +Allow timestamps that are not integers. +Fixes issue #779. + +Add localization file for Traditional Chinese. Thanks to user lyuxingliu! +PR #777. + +Don't swallow syntax errors when `wee_config` is looking for drivers. + +Include `wind` in daily summary if `windSpeed` is present. + +Refine translations for French skin. Thanks to user Pascal! + +Allow a custom cipher to be specified for FTP uploads. See option `cipher` +under `[[FTP]]`. + +Ensure that `rundir` exists and has correct permissions in weewx-multi + +Allow auto-provisioning feature of Seasons to work when using a SQL expression +for option `data_type` in the ImageGenerator. Fixes issue #782. + +Allow constants `albedo`, `cn`, and `cd` to be specified when calculating ET. +See the User's Guide. Resolves issue #730. + +Fix problem that prevented `wee_reports` from using a default location +for `weewx.conf`. + +Post location of the configuration file and the top-level module to the station +registry. Thanks to Vince! PR #705. + +Fix minor install warning under Python 3.10. Fixes issue #799. + +Fix problem where `xtypes.ArchiveTable.get_series()` does not pass `option_dict` +on to `get_aggregate()`. Fixes issue #797 + +Added `copytruncate` option to default log rotation configuration. Thanks to +user sastorsl. Addresses PR #791. + +Update the default and example rules in rsyslog configuration. The output +from the weewx daemon goes to `weewxd.log` whereas the output from wee_xxx +utilities goes to `weewx.log`. Also added examples of how to deal with +systemd/journald messing with log output. Addresses PR #788 and PR #790. +Thanks to user sastorsl. + +Allow additional aggregation intervals for observation type `$wind`. In +particular, `vecdir` and `vecavg` can be done for aggregation intervals other +than multiples of a day. +Fixes issue #800. + + +### 4.8.0 04/21/2022 + +Allow unit to be overridden for a specific plot by using new option `unit`. +Fixes issue #729. + +Fix problem that prevented wind from appearing in NOAA yearly summaries. + +Fix honoring global values for `log_success` and `log_failure`. Fix issue #757. + +wee_import CSV imports now allow import of text fields. Addresses issue #732. + +Explain in the Customization Guide how to include arbitrary SQL expressions +in a plot. + +Reorder font download order, for slightly faster downloads. +Fix issue #760. + +Add observation types `highOutTemp` and `lowOutTemp` to group_temperature. + +Add unit groups for sunshine and rain duration, cloudcover, and pop. +PR #765 + +Do not allow HUP signals (reload is not allowed as of V4.6). +PR #766. + +Do not fork if using systemd. +PR #767. + +Fix problem that prevented `wee_config --reconfigure` from working when +using Python 2.7, if the configuration file contained UTF-8 characters. + + +### 4.7.0 03/01/2022 + +Introduced new option `generate_once`. If `True`, templates will be generated +only on the first run of the reporting engine. Thanks to user Rich! PR #748. + +Added option `wee_device --current` for Vantage. + +Fixed two typos in the Standard skin. + +Fixed spelling mistakes in the Norwegian translations. Thanks to Aslak! PR#746 + +Supply a sensible default context for `group_deltatime` when no context has been +specified. + +If `windGustDir` is missing, extract a value from the accumulators. + +Fixed typo that shows itself if no `[Labels]/[[Generic]]` section is supplied. +Fixes issue #752. + +Fixed calculation of field `bar_reduction` for Vantage type 2 LOOP packets. + +Fix problem that prevents `windSpeed` and `windDir` from being displayed in +the RSS feed. Fixes issue #755. + + +### 4.6.2 02/10/2022 + +Removed diagnostic code that was inadverently left in the `titlebar.inc` file +in Seasons skin. + + +### 4.6.1 02/10/2022 + +Make the `show_rss` and `show_reports` flags work properly. Fixes issue #739. + +Added `$to_list()` utility for use in Cheetah templates. + +Fixed a few more untranslated fields in Seasons skin. + +Observation types that use the `sum` extractor are set to `None` if no LOOP +packets contributed to the accumulator. Fixes issue #737. + +Added `ppm` as default `group_fraction`. Added default label string for `ppm`. + +Added Norwegian translations. Thanks to user Aslak! PR #742. + +Fixed problem that caused `wee_database --check-strings` / `--fix-strings` +to fail on TEXT fields. Fixes issue #738. + + +### 4.6.0 02/04/2022 + +Easy localization of all skins that come with WeeWX. Big thanks to user Karen, +who drove the effort! PR #665. + +Allow options `--date`, `--from`, and `--to` to be used with +`wee_database --reweight`. PR #659. Thanks to user edi-x! + +Added Cheetah helper functions `$jsonize()`, `$rnd()`, and `$to_int()`. + +The tag `$alltime`, formerly available as an example, is now a part of WeeWX +core. + +New SLE example `$colorize()`. New document on how to write SLEs. + +Added conversions for `unix_epoch_ms` and `unix_epoch_ns`. Calculations in +`celestial.inc` now explicitly use `unix_epoch`. + +Added almanac attribute `visible` and `visible_change`. For example, +`$almanac.sun.visible` returns the amount of daylight, +`$almanac.sun.visible_change` the difference since yesterday. + +Fixed problem that could cause weather xtypes services not to shut down +properly. PR #672. Thanks again to user edi-x! + +Added Cheetah tag `$filename`, the relative path of the generated file. Useful +for setting canonical URLs. PR #671. Thanks again to user Karen! + +XType `get_scalar()` and `get_series()` calls can now take extra keyword +arguments. PR #673. + +Fixed problem where a bad clock packet could crash the WMR100 driver. + +Davis documentation for LOOP2 10-minute wind gusts is wrong. The Vantage +actually emits mph, not tenths of mph. Changed driver so it now decodes the +field correctly. Fixes issue #686. + +Sending a HUP signal to `weewxd` no longer causes the configuration file to be +reread. + +Logging is not done until after the configuration file has been read. This +allows customized logging to start from the very beginning. Fixes issue #699. + +Simplified the logging of Cheetah exceptions to show only what's relevant. +Fixes issue #700. + +Include a `requirements.txt` file, for installing using pip. Thanks to user +Clément! PR #691. + +Fixed problem where `ConfigObj` interpolation would interfere with setting +logging formats. + +Added option `--batch-size` to the Vantage version of `wee_device`. See PR #693. + +Slightly faster evaluation of the tag suffix `has_data`. +New aggregation type `not_null`. + +A string in the database no longer raises an error. Fixes issue #695. + +Added plot option `skip_if_empty`. If set to `True`, and there is no non-null +data in the plot, then the plot will not be generated at all. If set to +a time domain (such as `year`), then it will do the check over that domain. +See PR #702. + +Parameterized the Seasons skin, making it considerably smaller, while requiring +less configuration. It now includes all types found in the wview-extended +schema. See PR #702. + +New FTP option `ftp_encoding` for oddball FTP servers that send their responses +back in something other than UTF-8. + +Availability of the pyephem module and extended almanac data is now logged +during startup. + +Added column for `last contact` in the sensor status table in the Season skin +to help diagnose missing/flaky sensors. + +Fix the weewx.debian and weewx-multi init scripts to work with non-root user. + +Added sample tmpfiles configuration to ensure run directory on modern systems +when running weewx as non-root user. + +Fixed bug that prevented the ssh port from being specified when using rsync. +Fixes issue #725. + +Improved alphanumeric sorting of loop packet/archive record fields displayed +when WeeWX is run directly. + +Added sample weewxd init file for 'service' based init on freebsd. Thanks to +user ryan. + +Added i18n-report utility to help check skins for translated strings. + + +### 4.5.1 04/02/2021 + +Reverted the wview schema back to the V3 style. + +Fixed problem where setup.py would fail if the station description used UTF-8 +characters. + +Fixed problem where unit labels would not render correctly under Python 2.7 if +they were set by a 3rd party extension. Fixes issue #662. + +Added TCP support to the WS1 driver. Thanks to user Mike Juniper! +Fixes issue #664. + + +### 4.5.0 04/02/2021 + +The utility `wee_database` has new options `--add-column`, `--rename-column`, +and `--drop-columns` for adding, renaming, and deleting columns in the database. + +New optional tag `.series()`, for creating and formatting series in templates. +See the document [Tabs for +series](https://github.com/weewx/weewx/wiki/Tags-for-series) in the wiki. This +is still experimental and subject to change! Addresses issue #341. + +New optional tag `.json()` for formatting results as JSON. + +New optional tag `.round()`. Useful for rounding results of `.raw` and `.json` +options. + +Improved performance when calculating series using aggregation periods that are +multiples of a day. + +Changed NOAA reports to use the `normalized_ascii` encoding instead of `utf8` +(which did not display correctly for most browsers). Fixes issue #646. + +Plots longer than 2 years use a 6 month time increment. + +Uploads to PWSWeather and WOW now use HTTPS. Fixes issue #650. + +Fixed bug that prevented the Vantage driver from waiting before a wakeup retry. +Thanks to user Les Niles! + +Changed the way of expressing the old "wview" schema to the new V4 way. +Hopefully, this will lead to fewer support issues. Fixes issue #651. + +Fixed problem where iterating over a time period without an aggregation would +wrongly include the record on the left. + +Fixed bug that caused the incorrect label to be applied to plots where the +aggregation type changes the unit. Fixes issue #654. + +Plots now locate the x-coordinate in the middle of the aggregation interval for +all aggregation types (not just min, max, avg). Revisits PR #232. + +Added new time units `unix_epoch_ms` and `unix_epoch_ns`, which are unix epoch +time in milliseconds and nanoseconds, respectively. + +The FTP uploader now calculates and saves a hash value for each uploaded file. +If it does not change, the file is not uploaded, resulting in significant +time savings. PR #655. Thanks to user Karen! + +Updated the version of `six.py` included with WeeWX to 1.15.0. Fixes issue #657. + +Option aggregate_interval can now be specified by using one of the "shortcuts", +that is, `hour`, `day`, `week`, `month`, or `year`. + +Options `log_success` and `log_failure` are now honored by the `StdArchive` and +`StdQC` services. Fixes issue #727. + + +### 4.4.0 01/30/2021 + +`StdWXCalculate` can now do calculations for only LOOP packets, only archive +records, or both. PR #630. Thanks to user g-eddy! + +Introduced aggregate types `avg_ge` and `avg_le`. PR #631. Thanks to user +edi-x! + +NOAA reports now use a `utf8` encoding instead of `strict_ascii`. This will +only affect new installations. Fixes issue #644. + +Introduced new encoding type `normalized_ascii`, which replaces characters that +have accented marks with analogous ascii characters. For example, ö gets +replaced with o. + +Patching process is more forgiving about records with interval less than or +equal to zero. + +Fixed problem where invalid `mintime` or `maxtime` was returned for days with no +data. Fixes issue #635. + +Syntax errors in `weewx.conf` are now logged. PR #637. Thanks to user Rich Bell! + +Fixed problem where plots could fail if the data range was outside of a +specified axes range. Fixes issue #638. + +Fixed problem that could cause reporting to fail under Python2.7 if the +configuration dictionary contained a comment with a UTF-8 character. Fixes +issue #639. + +Fixed problem that could cause program to crash if asking for deltas of a non- +existent key. + +The version 4.3.0 patch to fix the incorrect calculation of sums in the daily +summary tables itself contained a bug. This version includes a patch to fix the +problem. It runs once at startup. Fixes issue #642. + + +### 4.3.0 01/04/2020 + +Version 4.2.0 had a bug, which caused the sums in the daily summary to be +incorrectly calculated. This version includes a patch to fix the problem. It +runs once at startup. Fixes issue #623. + +The WMR200 driver is no longer supported. An unsupported version can be found +at https://github.com/weewx/weewx-wmr200. Support for LaCrosse WS23xx and +Oregon WMR300 will continue. + +Service `weewx.wxxtypes.StdDelta` was inadvertently left out of the list of +services to be run. Fortunately, it is not used. Yet. Added it back in. + +Added the "old" NWS algorithm as an option for calculating heat index. + +Changed how various undocumented parameters in `[StdWXCalculate]` are specified. +The only one people are likely to have used is `ignore_zero_wind`. Its name has +changed to `force_null`, and it has been moved. See the *Upgrading Guide*. + +Documented the various `[StdWXCalculate]` options. + +Fixed corner case for `windDir` when using software record generation, +`ignore_zero_wind=True`, and `windSpeed=0` for entire record interval. Now emits +last `windDir` value. + +Fixed problem when looking up stars with more than one word in their name. +Fixes issue #620. + +Fixed problem where wind gust direction is not available when using software +record generation. + +Added `--no-prompt` action to `wee_import`, allowing wee_import to be run +unattended. + +Fixed problem that prevented option `observations` from being used in the +simulator. Thanks to user Graham! + +Fixed problem where wind chill was calculated incorrectly for `METRICWX` +databases. PR #627. Thanks to user edi-x! + +Allow wind vectors to be converted to unit of beaufort. Fixes issue #629. + +Option `log_failure` under `[StdReport]` is set to `True` by the upgrade +process. See the *Upgrading Guide*. + + +### 4.2.0 10/26/2020 + +CHANGES COMING! This is the last release that will support the LaCrosse WS23xx, +Oregon WMR200 and WMR300 stations. In the future, they will be published as +unsupported extensions. + +Made it easier to add new, derived types via `StdWXCalculate`. Fixes issue #491. + +Changed the tag system slightly in order to make it possible for the XTypes +system to add new aggregations that take an argument. + +Added the new data types in the `extended_wview` schema to the WeeWX types +system. Fixes issue #613. + +Added ability to label left, right or both y-axes of graphs. PR #610. +Fixes issue #609. Thanks to user Brent Fraser! + +Added units and labels for the lightning data types. + +Fixed problem where threads attempt to access non-existent database. Fixes +issue #579. + +Fixed problem that caused reporting units to revert to `US` if they were in a +mixed unit system. Fixes issue #576. + +Fixed problem that could cause the station registry to fail if given a location +with a non-ASCII location name. + +Changed TE923 bucket size from 0.02589 inches to 1/36 of an inch +(0.02777778 in). PR #575. Fixes issue #574. Thanks to user Timothy! + +Undocumented option `retry_certificate` has been renamed to `retry_ssl`, and now +covers all SSL errors (not just certificate errors). Fixes issue #569. Thanks +to user Eric! + +Fixed problem caused by specifying a `[Logging]/[[formatters]]` section in +`weewx.conf` that uses interpolated variables. + +Fixed problem in the Vantage driver that resulted in incorrect `sunrise` +/ `sunset` being included in loop packets when run under Python 3. Thanks to +users Constantine and Jacques! + +Improved auto-scaling of plot axes. + +Fixed problem where aggregates of `windvec` and `windgustvec` returned the +aggregate since start of day, not the start of the aggregation period. +Fixes issue #590. + +New unit `beaufort`, included in `group_speed`. Treating beaufort as a separate +type has been deprecated. Fixes issue #591. + +New unit `kPa`, included in `group_pressure`. Fixes issue #596. + +Fixed bug in the simulator. Made it easier to subclass class `Simulator`. + +Expressions in `StdCalibration` are now ordered. Later corrections can depend on +earlier corrections. + +Fixed problem under Python 2, where option `none` could cause exception. +PR #597. Thanks to user Clément! + +Fixed problem with ws23xx driver under Python 3 that caused it to crash. + +Use a more modern formula for heat index. Fixes issue #601. Thanks to +user Peter Q! + +Allow overriding the data binding when using iterators. Fixes issue #580. + +Fixed problem where old daily summaries may not have a version number. + +Fixed problem in WMR200 driver where missing UV reports as index 255. + +Added option `force_direction` for working around a WU bug. Fixes issue #614. + +Fixed problem where null bytes in an import data file would cause `wee_import` +to fail. + + +### 4.1.1 06/01/2020 + +Fixed problem that caused wind speed to be reported to AWEKAS in m/s instead +of km/h. + +Fixed problem that caused FTP attempts not to be retried. + +Fixed problem that caused monthly and yearly summaries to appear only +sporadically. + +Fixed problem when using the ultimeter driver under Python 2. + +Fixed problem when using the ws1 driver under Python 2. + +Fixed problem that prevented remote directories from being created by FTP. + +New strategy for calculating system uptime under Python 3. Revisits +issue #428. Alternative to PR #561. + + +### 4.1.0 05/25/2020 + +Archive records emitted by the Vantage driver now include the number of wind +samples per archive interval in field wind_samples. + +wee_import can now import WeatherCat monthly .cat files. + +Changed the logging configuration dictionary to match the Python documents. +Thanks to user Graham for figuring this out! + +Fixed problem that prevented ws1 driver from working under Python 3. PR #556. + +Eliminate use of logging in wee_config, allowing it to be used for installs +without syslog. + +Allow expressions to be used as a datatype when plotting. + +Added option 'reuse_ssl' to FTP. This activates a workaround for a bug in the +Python ftp library that causes long-lived connections to get closed +prematurely. Works only with Python 3.6 and greater. + +The cc3000 driver will automatically reboot the hardware if it stops sending +observations. PR #549. + +Install using setup.py forgot to set WEEWX_ROOT when installing in non-standard +places. Fixes issue #546. + +Fixed bug in ws28xx driver that prevented it from running under Python 3. +Fixes issue #543. + +Changed query strategy for calculating min and max wind vectors, which +should result in much faster plot generation. + +Fixed bug in wmr9x8 driver that prevented it from running under Python 3. + +Fixed several bugs in the te923 driver that prevented it from running under +Python 3. + +Added a logging handler for rotating files. See https://bit.ly/2StYSHb for how +to use it. It is the default for macOS. + +More information if an exception is raised while querying for vantage hardware +type. + +wunderfixer: fixed problem under Python 3 where response was not converted to +str before attempting to parse the JSON. Option --simulate now requires api_key +and password, so it can hit the WU. + +Fixed problem in te923 driver under Python 3 that caused it to crash. + + +### 4.0.0 04/30/2020 + +Ported to Python 3. WeeWX should now run under Python 3.5 and greater, as well +as Python 2.7. Support for Python 2.5 and 2.6 has been dropped. + +New facility for creating new user-defined derived types. See the Wiki article +https://github.com/weewx/weewx/wiki/WeeWX-V4-user-defined-types + +WeeWX now uses the Python 'logging' facility. This means log, formats, and +other things can now be customized. Fixes issue #353. + +Strings appearing in the data stream no longer cause a TypeError if they can be +converted to a number. + +Strings can now be accumulated and extracted in the accumulators, making it +possible to include them in the database schemas. + +The utility wee_reports now loads services, allowing it to use user-supplied +extensions. Fixes issue #95. + +New default schema ("wview_extended") that offers many new types. The old +schema is still supported. Fixes issue #115. + +Optional, more flexible, way of specifying schemas for the daily summaries. The +old way is still supported. + +The install process now offers to register a user's station with weewx.com. + +The package MySQL-python, which we used previously, is not always available on +Python 3. Ported the MySQL code to use the package mysqlclient as an +alternative. + +The default for WOW no longer throttles posting frequency (the default used to +be no more than once every 15 minutes). + +Added new aggregate types minsum, minsumtime, sum_le. PR #382. + +Unit group group_distance is now a first-class group. + +Added new tag $python_version. + +Ported to Python2-PyMySQL package on OpenSUSE. + +Added new aggregation types 'first' (similar to 'last'), 'diff' (the difference +between last and first value in the aggregation interval), and 'tderiv' (the +difference divided by the time difference). + +Created new unit group 'group_energy2', defined as watt-seconds. Useful for +high resolution energy monitors. + +An observation type known to the system, but not in a record, will now return a +proper ValueTuple, rather than UnknownType. + +Type "stormStart" was added to the unit system. Fixes issue #380. + +Added new aggregation type 'growdeg'. Similar to 'heatdeg', or 'cooldeg', it +measures growing degree-days. Basically, cooldeg, but with a different base. +Fixes issue #367. Thanks to user Clay Jackson for guidance! + +Ported OS uptime to OpenBSD. Fixes issue #428. Thanks to user Jeff Ross! + +Catch SSL certificate errors in uploaders. Retry after an hour. Fixes issue #413. + +Wunderfixer has been ported to the new WU API. This API requires an API key, +which you can get from WU. Put it in weewx.conf. Added option --upload-only. +Thanks to user Leon Shaner! Fixes issues #414 and #445. + +Wee_import can now import Weather Display monthly log files. + +Fixed problem where sub-sections DegreeDays and Trend were located under the +wrong weewx.conf section. Fixes issue #432. Thanks to user mph for spotting +this! + +Added new parameters to the Weather Underground uploader. Fixes issue #435. + +Added new air quality types pm1_0, pm2_5, and pm10_0 to the unit system. Added +new unit microgram_per_meter_cubed. Added new unit group, group_concentration. + +Plist for the Mac launcher now includes a log file for stderr. + +Night-day transition in plots now uses shortest travel distance around color +wheel to minimize extra colors. Fixes issue #457. Thanks to user Alex Edwards! + +Fixed bug that causes plots to fail when both min and max are zero. Fixes issue #463. + +Fixed problem with sqlite driver that can lead to memory growth. See PR #467. +Thanks to user Rich Bell! + +Fixed bug that caused windrun to be calculated wrongly under METRICWX unit +system. Fixes issue #452. + +If a bad value of 'interval' is encountered in a record, the program will +simply ignore it, rather than stopping. Address issue #375. + +Change in how the archive timespan is calculated in the engine. This allows +oddball archive intervals. Fixes issue #469. + +NOAA reports are now more tolerant of missing data. Fixes issue #300. + +Use of strftime() date and time format codes in template file names is now +supported as an alternative to the legacy 'YYYY', 'MM' and 'DD'. The legacy +codes continue to be supported for backwards compatibility. Fixes issue #415. + +New --calc-missing action added to wee_database to calculate and store derived +observations. + +wee_import now calculates missing derived observations once all imported data +has been saved to archive. Fixes issue #443. + +wee_import now tolerates periods that contain no source data. Fixes issue #499. + +wee_import now accepts strings representing cardinal, intercardinal and +secondary intercardinal directions in CSV imports. Partially fixes issue #238. + +The field delimiter character may now be defined for wee_import CSV imports. + +Ignore historical records if the timestamp is in the future. + +Can now recover from MariaDB-specific database connection error 1927. + +Changed the name of the unit "litre" to "liter", making its spelling more +consistent with "meter". The spelling "litre" is still accepted. + +Systemd type changed from "simple" to "forking". Thanks to user Jaap de Munck +for figuring this one out! + +The configuration file is now an optional argument. This means most users will +be able to use the simple command line 'sudo weewxd'. + +Use correct log path for netbsd and openbsd in logger setup. + +StdWXCalculate no longer calculates anything by default. Instead, types to be +calculated must be listed in weewx.conf. See the Upgrade Guide. + +setup.py install no longer saves the old 'bin' subdirectory. Instead, it simply +overwrites it. + +Support for the vantage LOOP2 packet format. Fixes issue #374. + +The vantage driver now allows 3 retries per read, rather than per +archive interval. + + +### 3.9.2 07/14/2019 + +StdPrint now explicitly converts loop and archive fields to UTF-8 before +printing. This means unicode strings can now be included in loop and archive +fields. + +Fix WMR300 driver so that it will reject (corrupt) logger records if they +have negative interval (similar to issue #375). + +Added 'rain_warning' option in WMR300 driver to remind station owners to reset +the rain counter when the rain counter exceeds a threshold (default is 90%). +Thanks to weewx user Leon! + +Added several other debug tools to the WMR300 driver. PR #402. + +Wunderfixer now keeps going if a post does not satisfy [[Essentials]]. +Fixes issue #329 (again). + +Fixed problem that prevented the wee_device --history option from +working with the CC3000 driver. + +Fix incorrect `log_success` operation in ftp, rsync, copy +and image generators. PR #373. Partial fix of issue #370. + +Fixed problem that could cause the WMR200 to crash WeeWX if the +record interval is zero. Fixes issue #375. + +Posts to the Weather Underground now use https, instead of http. +Thanks to user mljenkins! PR #378. + +Fixed problem with handling CWOP connection errors. Commit 0a21a72 + +Fixed problem that prevented to CopyGenerator from handling nested +directories. Fixes issue #379. + +Fixed problem that prevented humidity calibration from being set +on Vantage stations. + +Improved accuracy of the calculation of Moon phases. +Revisits issue #342. + +When the AWEKAS code augments the record for rainRate, it now +checks for a bad timestamp. + +The ts1 driver now returns 'barometer' instead of 'pressure'. +Thanks to user 'windcrusader'! Fixes issue #393. + +Fixed problem when calculating vector averages. Thanks to user +timtsm! PR #396. + +windrun is now calculated on a per-archive period basis, instead +of for the whole day. Thanks to user 'windcrusader'! +PR #399. Fixes issue #250. + +Wunderfixer has new option to set socket timeout. This is to compensate +for WU "capacity issues". Thanks to user Leon! See PR #403. + +Fixed bug in WS1 driver that caused crash if rain_total was None. + +If a file user/schemas.py still exists (a relic from V2.7 and earlier), it +is renamed to user/schemas.py.old. Fixes issue #54. + +V3.x test suites now use same data set as V4.x, minimizing the chance of +a false negative when switching versions. + +Fixed fine offset driver to warn about (and skip) historical data that have +zero for interval. + +Correct rounding problems for rain and other types when posting to CWOP. +Fixes issue #431. Thanks to user ls4096! + +Fixed problem that can cause an exception with restx services that do not use +the database manager. See commit 459ccb1. + +Sending a SIGTERM signal to weewxd now causes it to exit with status +128 + signal#. PR #442. Thanks to user sshambar! + +Fixed bug that could cause WMR200 packets with a timestamp between startup +and the minimum interval to have an interval length of zero. Fixes +issue #375 (again!). + + +### 3.9.1 02/06/2019 + +In genplot, do not attempt to normalize unspecified paths. + +Introduced option no_catchup. If set to true, a catchup will not be +attempted. Fixes issue #368. + + +### 3.9.0 02/05/2019 + +New skin called Seasons. For new users, it will be installed and enabled. +For old users, it will be installed, but not enabled. Fixes issue #75. + +There are also two new skins for mobile phones: Mobile and Smartphone. +These are installed, but not enabled, for all users. + +Reworked how options get applied for reports. Backstop default values +are supplied by a new file weewx/defaults.py. These can get overridden +by skin.conf, or in a new section [StdReports] / [[Defaults]] in weewx.conf. +See the Customization Guide, section "How options work", for details. +Fixes issue #248. + +The skin.conf is optional. It is possible to specify the entire skin +configuration in the configuration file weewx.conf. + +Dropped support of Python 2.5. You must now use either Python 2.6 or 2.7. This +is in anticipation of porting to Python 3. + +The image generator now supports the use of a 'stale_age' option. Thanks to +user John Smith. Fixes issue #290. + +Rose line width can now be specified with option rose_line_width. + +The Felsius unit of temperature (see https://xkcd.com/1923/) is now supported. + +New tag $almanac.sidereal_time for calculating current sidereal time + +New tag $almanac.separation() for calculating planet separations. + +Can now use ephem.readdb() to load arbitrary bodies into the almanac. + +StdQC now includes an entry for rain again (inexplicably removed in v3.1.0). + +If software record generation is used, the archive interval is now what is +specified in weewx.conf, even if the station supports hardware generation. + +Fixed problem where records were downloaded on startup, even if software +record generation was specified. + +The tag formatting taxonomy has been simplified. Just use suffix ".format()" +now. Documentation updated to reflect changes. Backwards compatibility with old +suffixes is supported. Fixes issue #328. + +Can now set Rapidfire parameter rtfreq. Thanks to user 'whorfin'. PR #304. + +Template names can now include the week number. Thanks to user 'stimpy23'. +PR #319. + +New aggregation type for plots: 'cumulative'. Thanks to user 'henrikost'. +PR #302. + +Fixed problem where MySQL error 2013 could crash WeeWX. Fixes issue #327. + +Posts to the Weather Underground will be skipped if an observation type that +is listed in the [[[Essentials]]] subsection is missing. Fixes issue #329. + +Upgrade process now explicity upgrades version-to-version, instead of +doing a merge to the new config file. Fixes issue #217. + +Example xstats now includes tags for last_year and last_year_todate. +Thanks to user evilbunny2008. PR #325. + +Guard against a negative value for 'interval' in WMR200 stations. + +Check for missing or negative values for the record field 'interval'. + +Changed the formula used to calculate percentage illumination of the moon to +something more accurate around 2018. This formula is only used if pyephem is +not installed. Fixes issue #342. + +Fixed bug that caused older, "type A" Vantage Pro2, to crash. Fixes issue #343. + +Fixed a bug that caused a divide-by-zero error if a plot range was the same as +a specified min or max scale. Fixes issue #344. + +Fixed bug that prevented an optional data_binding from being used in tags +when iterating over records. Fixes issue #345. + +Examples lowBattery and alarm now try SMTP_SSL connections, then degrade if +that's not available. Fixes issue #351. + +Fixed problem with Brazilian locations at the start of DST. It manifested +itself with the error "NoColumnError: no such column: wind". Fixes issue #356. + +Fixed problem that caused sunrise/sunset to be calculated improperly in Sun.py. + +Improved coverage of test suites. Fixes issue #337. + +wee_device, when used with the Vantage series, now honors the "no prompt" (-y) +option. Fixes issue #361. + +Log watch now correctly logs garbage collection events. Thanks to user +buster-one. PR #340. + + +### 3.8.2 08/15/2018 + +Added flag to weewx-multi init script to prevent systemd from breaking it. +Thanks to users Timo, Glenn McKechnie, and Paul Oversmith. + +Fixed problem that caused wind direction in archive records to always be +calculated in software, even with stations that provide it in hardware. +Fixes issue #336. + +### 3.8.1 06/27/2018 + +Map cc3000 backup battery to consBatteryVoltage and station battery to +supplyVoltage to more accurately reflect the battery functions. + +Update the syntax in the rsyslog configuration sample + +Significant overhaul to the WMR300 driver. The driver should now work reliably +on any version of pyusb and libusb. The driver will now delete history records +from the logger before the logger fills up (the WMR300 logger is not a circular +buffer). Thanks to users Markus Biewer and Cameron. Fixes issue #288. + +Added automatic clearing of logger for CC3000 driver to prevent logger +overflow (the CC3000 logger is not a circular buffer). The default is to +not clear the history, but it is highly recommended that you add a logging +threshold once you are confident that all logger data have been captured to +the weewx database. + +Improved the robustness of reading from the CC3000 logger. + +Better CRC error message in Vantage driver. + +Parameterize the configuration directory in weewx-multi init script. + +In StdWXCalculate, use None for an observation only if the variables on which +the derived depends are available and None. Fixes issue #291. + +Fixed bug that prevented specifying an explicit alamanac time from working. + +Fixed bug that prevented historical records from being downloaded from ws23xx +stations. Thanks to user Matt Brown! Fixes issue #295 + +Fixed bug that crashed program if a sqlite permission error occurred. + +If wind speed is zero, accumulators now return last known wind direction +(instead of None). Thanks to user DigitalDan05. PR #303. + +Windrun calculations now include the "current" record. Fixes issue #294. + +Fixed bug involving stations that report windGust, but not windGustDir, in +their LOOP data (e.g., Fine Offset), which prevented the direction of max +wind from appearing in statistics. Fixes issue #320. + +The engine now waits until the system time is greater than the creation time +of the weewx.conf file before starting up. Fixes issue #330. + + +### 3.8.0 11/22/2017 + +The `stats.py` example now works with heating and cooling degree days. +Fixes issue #224. + +The ordinal increment between x- and y-axis labels can now be chosen. +The increment between x-axis tick marks can now be chosen. Thanks +to user paolobenve! PR #226. + +Bar chart fill colors can now be specified for individual observation +types. Fixes issue #227. + +For aggregation types of `avg`, `min` and `max`, plots now locate the x- +coordinate in the middle of the aggregation interval (instead of the end). +Thanks again to user paolobenve! PR #232. + +The nominal number of ticks on the y-axis can now be specified using +option `y_nticks`. + +Fixed bug that could cause tick crowding when hardwiring y-axis min and +max values. + +The uploader framework in restx.py now allows POSTS with a JSON payload, +and allows additional headers to be added to the HTTP request object. + +MySQL error 2006 ("MySQL server has gone away") now gets mapped to +`weedb.CannotConnectError`. PR #246 + +Whether to use an FTP secure data connection is now set separately +from whether to authenticate using TLS. Fixes issue #284. + +Corrected formatting used to report indoor temp and humidity to the +Weather Underground. + +Added inDewpoint to the observation group dictionary. + +Added missing aggregation type 'min_ge'. Thanks to user Christopher McAvaney! + +Plots can now use an `aggregate_type` of `last`. Fixes issue #261. + +When extracting observation type stormRain (Davis Vantage only), the +accumulators now extract the last (instead of average) value. + +Added additional accumulator extractors. + +Allow reports to be run against a binding other than `wx_binding`. + +Do chdir at start of ImageGenerator so that skin.conf paths are treated the +same as those of other generators. + +Changed default value of `stale` for CWOP from 60 to 600 seconds. PR #277. + +Vantage driver: +Allow user to specify the Vantage Pro model type in weewx.conf. +Repeater support added to '-—set-transmitter-type' command. +New commands: '—-set-retransmit', + '--set-latitude', '--set-longitude' + '--set-wind-cup', + '--set-temperature-logging' +Details explained in hardware.htm. Thanks to user dl1rf! PR #270, #272. + +Using the `set-altitude` command in `wee_device` no longer changes the +barometer calibration constant in Vantage devices. See PR #263. +Thanks to user dl1rf! + +Fixed bug in wmr200 driver that resulted in archive records with no +interval field and 'NOT NULL constraint failed: archive.interval' errors. + +Fixed bug in wmr200 driver that caused `windDir` to always be `None` when +`windSpeed` is zero. + +Include rain count in cc3000 status. + +In the restx posting, catch all types of httplib.HTTPException, not just +BadStatusLine and IncompleteRead. + + +### 3.7.1 03/22/2017 + +Fixed log syntax in wmr100 and wmr9x8 drivers. + +Emit Rapidfire cache info only when debug is level 3 or higher. Thanks to +user Darryn Capes-Davis. + +Fixed problem that prevented Rapidfire from being used with databases in +metric units. Fixes issue #230. + +Set WOW `post_interval` in config dict instead of thread arguments so that +overrides are possible. Thanks to user Kenneth Baker. + +Distribute example code and example extensions in a single examples directory. +Ensure that the examples directory is included in the rpm and deb packages. + +Fixed issue that prevented a port from being specified for MySQL installations. + +MySQL error 2003 ("Can't connect to MySQL server...") now gets mapped to +`weedb.CannotConnectError`. PR #234. + +By default, autocommit is now enabled for the MySQL driver. Fixes issue #237. + +Highs and lows from LOOP packets were not being used in preference to archive +records in daily summaries. Fixed issue #239. + + +### 3.7.0 03/11/2017 + +The tag `$current` now uses the record included in the event `NEW_ARCHIVE_RECORD`, +rather than retrieve the last record from the database. This means you can +use the tag `$current` for observation types that are in the record, but not +necessarily in the database. Fixes issue #13. + +Most aggregation periods now allow you to go farther in the past. For +example, the tag `$week($weeks_ago=1)` would give you last week. You +can also now specify the start and end of an aggregation period, such +as `$week.start` and `$week.end`. + +Can now do `SummaryByDay` (as well as `SummaryByMonth` and `SummaryByYear`). +NB: This can generate *lots* of files --- one for every day in your database! +Leaving this undocumented for now. Fixes issue #185. + +When doing hardware record generation, the engine now augments the record with +any additional observation types it can extract out of the accumulators. +Fixes issue #15. + +It's now possible to iterate over every record within a timespan. +Fixes issue #182. + +Use schema_name = hardware_name pattern in sensor map for drivers that support +extensible sensor suites, including the drivers for cc3000, te923, wmr300, +wmr100, wmr200, wmr9x8 + +Simplified sensor mapping implementation for wmr100 and wmr200 drivers. For +recent weewx releases, these are the default mappings for wmr200: + + - 3.6.0: in:0, out:1, e2:2, e3:3, ..., e8:8 hard-coded + - 3.6.1: in:0, out:1, e1:2, e2:3, ..., e7:8 hard-coded + - 3.7.0: in:0, out:1, e1:2, e2:3, ..., e7:8 sensor_map + +and these are default mappings for wmr100: + + - 3.6.2: in:0, out:1, e1:2, e2:3, ..., e7:8 hard-coded + - 3.7.0: in:0, out:1, e1:2, e2:3, ..., e7:8 sensor_map + +Enabled battery status for every remote T/H and T sensor in wmr100 driver. + +Enabled heatindex for each remote T/H sensor in wmr200 driver. + +Fixed inverted battery status indicator in wmr200 driver. + +Fixed 'Calculatios' typo in wmr100, wmr200, wmr9x8, and wmr300 drivers. + +Fixed usb initialization issues in the wmr300 driver. + +Added warning in wmr300 driver when rain counter reaches maximum value. + +Decode `heatindex` and `windchill` from wmr300 sensor outputs. + +Report the firmware version when initializing the cc3000 driver. + +Fixed bug in vantage driver that would prevent console wake up during +retries when fetching EEPROM vales. Thanks to user Dan Begallie! + +The vantage driver no longer emits values for non-existent sensors. +As a result, LOOP and archive packets are now much smaller. If this works +out, other drivers will follow suit. Partial fix of issue #175. + +The vantage driver now emits the barometer trend in LOOP packets as +field `trendIcon`. + +The engine now logs locale. Additional information if a TERM signal is +received. + +Removed the site-specific "Pond" extensions from the Standard skin. + +The Standard skin now includes plots of outside humidity. Fixes +issue #181. + +Fixed reference to `index.html.tmpl` in the xstats example. + +Changed algorithm for calculating ET to something more appropriate for +hourly values (former algorithm assumed daily values). Fixes issue #160. + +Fixed bug in Celsius to Fahrenheit conversion that affected pressure +conversions in `uwxutils.py`, none of which were actually used. + +Fixed bug that was introduced in v3.6.0, which prevented `wee_reports` from +working for anything other than the current time. + +Documented the experimental anti-alias feature, which has been in weewx +since v3.1.0. Fixes issue #6. + +Fixed problem where multiple subsections under `[SummaryBy...]` stanzas could +cause multiple copies of their target date to be included in the Cheetah +variable `$SummaryByYear` and `$SummaryByMonth`. Fixes issue #187. + +Moved examples out of `bin` directory. Eliminated experimental directory. +Reinforce the use of `user` directory, eliminate use of `examples` directory. +Renamed `xsearch.py` to `stats.py`. + +OS uptime now works for freeBSD. Thanks to user Bill Richter! +PR #188. + +Broke out developer's notes into a separate document. + +Added `@media` CSS for docs to improve printed/PDF formatting. Thanks to user +Tiouck! + +Added a 0.01 second delay after each `read_byte` in ws23xx driver to reduce +chance of data spikes caused by RS232 line contention. Thanks lionel.sylvie! + +The observation `windGustDir` has been removed from wmr100, wmr200, te923, and +fousb drivers. These drivers were simply assigning `windGustDir` to `windDir`, +since none of the hardware reports an actual wind gust. + +Calculation of aggregates over a period of one day or longer can now respect any +change in archive interval. To take advantage of this feature, you will have to +apply an update to your daily summaries. This can be done using the tool +`wee_database`, option `--update`. Refer to the _Changes to daily summaries_ +section in the Upgrade Guide to determine whether you should update or not. +Fixes issue #61. + +Max value of `windSpeed` for the day is now the max archive value of +`windSpeed`. Formerly, it was the max LOOP value. If you wish to patch your +older daily summaries to interpret max windSpeed this way, use the tool +`wee_database` with option `--update`. Fixes issue #195. + +The types of accumulators, and the strategies to put and extract records +out of them, can now be specified by config stanzas. This will be of +interest to extension writers. See issue #115. + +Fixed battery status label in acurite driver: changed from `txTempBatteryStatus` +to `outTempBatteryStatus`. Thanks to user manos! + +Made the lowBattery example more robust - it now checks for any known low +battery status, not just `txBatteryStatus`. Thanks to user manos! + +Added info-level log message to `calculate_rain` so that any rain counter reset +will be logged. + +Added better logging for cc3000 when the cc3000 loses contact with sensors +for extended periods of time. + +How long to wait before retrying after a bad uploader login is now settable +with option `retry_login`. Fixes issue #212. + +The test suites now use dedicated users `weewx1` and `weewx2`. A shell script +has been included to setup these users. + +A more formal exception hierarchy has been adopted for the internal +database library `weedb`. See `weedb/NOTES.md`. + +The weedb Connection and Cursor objects can now be used in a "with" clause. + +Slightly more robust mechanism for decoding last time a file was FTP'd. + + +### 3.6.2 11/08/2016 + +Fixed incorrect WU daily rain field name + +Fixed bug that crashed Cheetah if the `weewx.conf` configuration file included +a BOM. Fixes issue #172. + + +### 3.6.1 10/13/2016 + +Fixed bug in wunderfixer. + +Fixed handling of `StdWXCalculate.Calculations` in `modify_config` in the +wmr100, wmr200, wmr300, and wmr9x8 drivers. + +Eliminate the apache2, ftp, and rsync suggested dependencies from the deb +package. This keeps the weewx dependencies to a bare minimum. + +Added retries to usb read in wmr300 driver. + +Remapped sensor identifiers in wmr200 driver so that `extraTemp1` and +`extraHumid1` are usable. + +Standardized format to be used for times to `YYYY-mm-ddTHH:MM`. + + +### 3.6.0 10/07/2016 + +Added the ability to run reports using a cron-like notation, instead of with +every report cycle. See User's Guide for details. Thanks to user Gary Roderick. +PR #122. Fixes issue #17. + +Added the ability to easily import CSV, Weather Underground, and Cumulus +data using a new utility, wee_import. Thanks again to über-user Gary Roderick. +PR #148. Fixes issue #97. + +Refactored documentation so that executable utilities are now in their own +document, utilities.htm. + +Fixed rpm package so that it will retain any changes to the user directory. +Thanks to user Pat OBrien. + +No ET when beyond the reach of the sun. + +Software calculated ET now returns the amount of evapotranspiration that +occurred during the archive interval. Fixes issue #160 + +Fixed wee_config to handle config files that have no FTP or RSYNC. + +Fixed bug in StdWXCalculate that ignored setting of 'None' (#110). + +Which derived variables are to be calculated are now in a separate +subsection of [StdWXCalculate] called [[Calculations]]. +Upgrade process takes care of upgrading your config file. + +Reset weewx launchtime when waiting for sane system clock (thanks to user +James Taylor). + +Fixed anti-alias bug in genplot. Issue #111. + +Corrected the conversion factor between inHg and mbar. Thanks to user Olivier. + +Consolidated unit conversions into module weewx.units. + +Plots longer than two years now use an x-axis increment of one year. Thanks to +user Olivier! + +The WS1 driver now retries connection if it fails. Thanks to user +Kevin Caccamo! PR #112. + +Major update to the CC3000 driver: + - reading historical records is more robust + - added better error handling and reporting + - fixed to handle random NULL characters in historical records + - fixed rain units + - added ability to get data from logger as fast as it will send it + - added support for additional temperature sensors T1 and T2 + - added transmitter channel in station diagnostics + - added option to set/get channel, baro offset + - added option to reset rain counter + +Fixed brittle reference to USBError.args[0] in wmr200, wmr300, and te923 +drivers. + +Fixed typo in default te923 sensor mapping for h_3. Thanks to user ngulden. + +Added flag for reports so that reports can be disabled by setting enable=False +instead of deleting or commenting entire report sections in weewx.conf. + +The vantage and ws23xx drivers now include the fix for the policy of +"wind direction is undefined when no wind speed". This was applied to other +drivers in weewx 3.3.0. + +Fixed te923 driver behavior when reading from logger, especially on stations +with large memory configuration. Thanks to users Joep and Nico. + +Fixed rain counter problems in wmr300 driver. The driver no longer attempts +to cache partial packets. Do no process packets with non-loop data when +reading loop data. Thanks to user EricG. + +Made wmr300 driver more robust against empty usb data buffers. + +Fixed pressure/barometer in wmr300 driver when reading historical records. + +Fixed problem with the Vantage driver where program could crash if a +serial I/O error happens during write. Fixes issue #134. + +Changed name of command to clear the Vantage memory from --clear to +--clear-memory to make it more consistent with other devices. + +Fixed problem that prevented channel 8 from being set by the Vantage driver. + +Added solaris .smf configuration. Thanks to user whorfin. + +Added option post_indoor_observations for weather underground. + +Added maximum value to radiation and UV plots. + +In the .deb package, put weewx reports in /var/www/html/weewx instead of +/var/www/weewx to match the change of DocumentRoot in debian 8 and later. + + +### 3.5.0 03/13/2016 + +Fixed bug that prevented rsync uploader from working. + +Fixed bug in wmr300 driver when receiving empty buffers from the station. + +The type of MySQL database engine can now be specified. Default is 'INNODB'. + +Updated userguide with capabilities of the TE923 driver added in 3.4.0. + +Added aggregation type min_ge(val). + +Provide better feedback when a driver does not implement a configurator. + +Added humidex and appTemp to group_temperature. Fixed issue #96. + +Backfill of the daily summary is now done in "tranches," reducing the memory +requirements of MySQL. Thanks to über-user Gary Roderick! Fixes issue #83. + +Made some changes in the Vantage driver to improve performance, particularly +with the IP version of the logger. Thanks to user Luc Heijst for nagging +me that the driver could be improved, and for figuring out how. + +Plotting routines now use Unicode internally, but convert to UTF-8 if a font +does not support it. Fixes issue #101. + +Improved readability of documents on mobile devices. Thank you Chris +Davies-Barnard! + +The loop_on_init option can now be specified in weewx.conf + +When uploading data to CWOP, skip records older than 60 seconds. Fixes +issue #106. + +Added modify_config method to the driver's configuration editor so that drivers +can modify the configuration during installation, if necessary. + +The fousb and ws23xx drivers use modify_config to set record_generation to +software. This addresses issue #84. + +The wmr100, wmr200, wmr9x8, and wmr300 drivers use modify_config to set +rainRate, heatindex, windchill, and dewpoint calculations to hardware instead +of prefer_hardware since each of these stations has partial packets. This +addresses issue #7 (SF #46). + + +### 3.4.0 01/16/2016 + +The tag $hour has now been added. It's now possible to iterate over hours. +Thanks to user Julen! + +Complete overhaul of the te923 driver. Thanks to user Andrew Miles. The +driver now supports the data logger and automatically detects small or large +memory models. Added ability to set/get the altitude, lat/lon, and other +station parameters. Significant speedup to reading station memory, from 531s +to 91s, which is much closer to the 53s for the te923tool written in C (all for +a station with the small memory model). + +The wee_debug utility is now properly installed, not just distributed. + +Fixed bug in almanac code that caused an incorrect sunrise or sunset to be +calculated if it followed a calculation with an explicit horizon value. + +Localization of tags is now optional. Use function toString() with +argument localize set to False. Example: +$current.outTemp.toString($localize=False) +Fixes issue #88. + +In the acurite driver, default to use_constants=True. + +Fixed bug in the rhel and suse rpm packaging that resulted in a configuration +file with html, database, and web pages in the setup.py locations instead of +the rpm locations. + +The extension utility wee_extension now recognizes zip archives as well as +tar and compressed tar archives. + +Check for a sane system time when starting up. If time is not reasonable, +wait for it. Log the time status while waiting. + +Added log_success option to cheetah, copy, image, rsync, and ftp generators. + +Older versions of MySQL (v5.0 and later) are now supported. + + +### 3.3.1 12/06/2015 + +Fixed bug when posting to WOW. + +Fixed bug where the subsection for a custom report gets moved to the very +end of the [StdReport] section of a configuration file on upgrade. +Fixes issue #81. + + +### 3.3.0 12/05/2015 + +Now really includes wunderfixer. It was inadvertently left out of the install +script. + +Rewrote the almanac so it now supports star ephemeris. For example, +$almanac.rigel.next_rising. Fixes issue #79. + +Uninstalling an extension with a skin now deletes all empty directories. This +fixes issue #43. + +Fixed bug in WMR200 driver that caused it to emit dayRain, when what it was +really emitting was the "rain in the last 24h, excluding current hour." +Fixes issue #62. + +Fixed bug in WMR200 driver that caused it to emit gauge pressure for altimeter +pressure. Thanks to user Mark Jenks for nagging me that something was wrong. + +Fixed bug that caused wind direction to be calculated incorrectly, depending +on the ordering of a dictionary. Thanks to user Chris Matteri for not only +spotting this subtle bug, but offering a solution. + +StdPrint now prints packets and records in (case-insensitive) alphabetical +order. + +Fixed wind speed decoding in the acurite driver. Thanks to aweatherguy. + +The StdRESTful service now supports POSTs, as well as GETs. + +The FTP utility now catches PickleError exceptions, then does a retry. + +Added unit 'minute' to the conversion dictionary. + +The vertical position of the bottom label in the plots can now be +set in skin.conf with option bottom_label_offset. + +An optional port number can now be specified with the MySQL database. + +Added option use_constants in the Acurite driver. Default is false; the +calibration constants are ignored, and a linear approximation is used for +all types of consoles. Specify use_constants for 01035/01036 consoles to +calculate using the constants. The 02032/02064 consoles always use the +linear approximation. + +Fixed test for basic sensor connectivity in the Acurite driver. + +The policy of "wind direction is undefined when no wind speed" is enforced +by the StdWXCalculate service. There were a few drivers that were still +applying the policy: acurite, cc3000, fousb, ws1, wmr100, wmr200, ultimeter. +These have been fixed. + +Changed logic that decides whether the configuration file includes a custom +schema, or the name of an existing schema. + +Added new command-line utility wee_debug, for generating a troubleshooting +information report. + +Added option --log-label to specify the label that appears in syslog entries. +This makes it possible to organize output from multiple weewx instances +running on a single system. + +Fixed problem with the Vantage driver that caused it to decode the console +display units incorrectly. Thanks to Luc Heijst! + +The WMR300 driver is now part of the weewx distribution. + + +### 3.2.1 07/18/15 + +Fixed problem when using setup.py to install into a non-standard location. +Weewx would start a new database in the "standard" location, ignoring the +old one in the non-standard location. + + +### 3.2.0 07/15/15 + +There are now five command-line utilities, some new, some old + + - `wee_config`: (New) For configuring weewx.conf, in particular, + selecting a new device driver. + - `wee_extension`: (New) For adding and removing extensions. + - `wee_database`: (Formerly called wee_config_database) + - `wee_device`: (Formerly called wee_config_device) + - `wee_reports`: No changes. + +The script setup.py is no longer used to install or uninstall extensions. +Instead, use the new utility wee_extension. + +Wunderfixer is now included with weewx --- no need to download it separately. +It now works with MySQL, as well as sqlite, databases. It also supports +metric databases. Thanks to user Gary Roderick! + +Fixed bug in 12-hour temperature lookup for calculating station pressure from +sea level pressure when database units are other than US unit system. + +Added guards for bogus values in various wxformula functions. + +Added windrun, evapotranspiration, humidex, apparent temperature, maximum +theoretical solar radiation, beaufort, and cloudbase to StdWXCalculate. + +If StdWXCalculate cannot calculate a derived variable when asked to, it now +sets the value to null. Fixes issue #10. + +Added option to specify algorithm in StdWXCalculate. So far this applies +only to the altimeter calculation. + +Added option max_delta_12h in StdWXCalculate, a window in which a record will +be accepted as being "12 hours ago." Default is 1800 seconds. + +Fixed bug in debian install script - 'Acurite' was not in the list of stations. + +$almanac.sunrise and $almanac.sunset now return ValueHelpers. Fixes issue #26. + +Added group_distance with units mile and km. + +Added group_length with units inch and cm. + +Failure to launch a report thread no longer crashes program. + +The WU uploader now publishes soil temperature and moisture, as well as +leaf wetness. + +Increased precision of wind and wind gust posts to WU from 0 to 1 +decimal point. + +Increased precision of barometer posts to WOW from 1 to 3 decimal points. + +A bad CWOP server address no longer crashes the CWOP thread. + +The "alarm" example now includes a try block to catch a NameError exception +should the alarm expression include a variable not in the archive record. + +Fixed bug that shows itself if marker_size is not specified in skin.conf + +Show URLs in the log for restful uploaders when debug=2 or greater. + +Fixed problem that could cause an exception in the WMR200 driver when +formatting an error string. + +Added better recovery from USB failures in the ws28xx driver. + +Added data_format option to FineOffset driver. Thanks to Darryl Dixon. + +Decoding of data is now more robust in the WS1 driver. Get data from the +station as fast as the station can spit it out. Thanks to Michael Walker. + +Changes to the WS23xx driver include: + - Fixed wind speed values when reading from logger. Values were too + high by a factor of 10. + - wrapped non-fatal errors in WeeWXIO exceptions to improve error + handling and failure recovery + +Changes to the AcuRite driver include: + - The AcuRite driver now reports partial packets as soon as it gets them + instead of retaining data until it can report a complete packet + - Improved timing algorithm for AcuRite data. Thanks to Brett Warden. + - Added acurite log entries to logwatch script. Thanks to Andy. + - Prevent negative rainfall amounts in acurite driver by detecting + counter wraparound + - Use 13 bits for rain counter instead of 12 bits + - Use only 12 bits for inside temperature in acurite driver when decoding + for 02032 stations + +Changes to the TE923 driver include: + - consolidated retries + - improved error handling and reporting + +Changes to the WMR9x8 driver include: + - Correct bug that caused yesterday's rain to be decoded as dayRain + - LOOP packet type 'battery' is now an int, instead of a bool + - The driver can now be run standalone for debugging purposes. + +The Vantage driver now catches undocumented termios exceptions and converts +them to weewx exceptions. This allows retries if flushing input or output +buffers fail. Fixes issue #34. + +Default values for databases are now defined in a new section [DatabaseTypes]. +New option "database_type" links databases to database type. Installer will +automatically update old weewx.conf files. + +The RESTful services that come with weewx are now turned on and off by +option "enable". Installer will automatically update old weewx.conf files. +Other RESTful services that don't use this method will continue to work. + +Option bar_gap_fraction is now ignored. Bar plot widths are rendered explicitly +since V3.0, making the option unnecessary. Fixes issue #25. + +Added additional debug logging to main engine loop. + +FTP uploader now retries several times to connect to a server, instead of +giving up after one try. Thanks to user Craig Hunter! + + +### 3.1.0 02/05/15 + +Fixed setup.py bug that caused list-drivers to fail on deb and rpm installs. + +Added a wait-and-check to the stop option in the weewx.debian rc script. + +Fixed bug in the Vantage driver that causes below sea-level altitudes +to be read as a large positive number. Also, fixed problem with altitude +units when doing --info (ticket #42). + +Fixed bug in wmr100 driver that causes gust wind direction to be null. + +Fixed bug in wmr200 driver that causes gust wind direction to be null. + +Fixed ultimeter driver to ensure wind direction is None when no wind speed +Thanks to user Steve Sykes. + +Fixed bug in calculation of inDewpoint. Thanks to user Howard Walter. + +Assign default units for extraHumid3,4,5,6,7, extraTemp4,5,6,7, leafTemp3,4, +and leafWet1,2. + +Use StdWXCalculate to ensure that wind direction is None if no wind speed. + +Fixed sign bug in ultimeter driver. Thanks to user Garrett Power. + +Use a sliding window with default period of 15 minutes to calculate the +rainRate for stations that do not provide it. + +Added support for AcuRite weather stations. Thanks to users Rich of Modern +Toil, George Nincehelser, Brett Warden, Preston Moulton, and Andy. + +The ultimeter driver now reads data as fast as the station can produce it. +Typically this results in LOOP data 2 or 3 times per second, instead of +once per second. Thanks to user Chris Thompstone. + +The daily summary for wind now uses type INTEGER for column sumtime, +like the other observation types. + +Utility wee_reports no longer chokes if the optionally-specified timestamp +is not in the database. Can also use "nearest time" if option 'max_delta' +is specified in [CheetahGenerator]. + +Utility wee_config_device can now dump Vantage loggers to metric databases. +Fixes ticket #40. + +Fixed problem where dumping to database could cause stats to get added to +the daily summaries twice. + +FTP over TLS (FTPS) sessions are now possible, but don't work very well with +Microsoft FTP servers. Requires Python v2.7. Will not work with older +versions of Python. Fixes ticket #37. + +WeatherUnderground passwords are now quoted, allowing special characters +to be used. Fixes ticket #35. + +New tag $obs, allowing access to the contents of the skin configuration +section [Labels][[Generic]]. Fixes ticket #33. + +Better error message if there's a parse error in the configuration file. + +Added wxformulas for evapotranspiration, humidex, apparent temperature, and +other calculations. + +Added --loop-on-init option for weewxd. If set, the engine will keep retrying +if the device cannot be loaded. Otherwise, it will exit. + +Changed the weedb exception model to bring it closer to the MySQL exception +model. This will only affect those programming directly to the weedb API. + + +### 3.0.1 12/07/14 + +Fixed bug in setup.py that would forget to insert device-specific options +in weewx.conf during new installations. + + +### 3.0.0 12/04/14 + +Big update with lots of changes and lots of new features. The overall +goal was to make it easier to write and install extensions. Adding +custom databases, units, accumulators and many other little things +have been made easier. + +Skins and skin.conf continue to be 100% backwards compatible (since +V1.5!). However, search-list extensions will have to be rewritten. +Details in the Upgrading Guide. + +Previously, configuration options for all possible devices were +included in the configuration file, weewx.conf. Now, for new installs, +it has been slimmed down to the minimum and, instead, configuration +options are added on an "as needed" basis, using a new setup.py option +"configure". + +Your configuration file, weewx.conf should be automatically updated to +V3 by the upgrade process, using your previously chosen hardware. But, +check it over. Not sure we got everything correct. See the Upgrading +Guide. + +Specific changes follow. + +There is no longer a separate "stats" database. Instead, statistics +are included in the regular database (e.g., 'weewx.sdb') as separate +tables, one for each observation type. + +Total rewrite of how data gets bound to a database. You now specify a +"data binding" to indicate where data should be going, and where it is +coming from. The default binding is "wx_binding," the weather binding, +so most users will not have to change a thing. + +Other database bindings can be used in tags. Example: + $current($data_binding=$alt_binding).outTemp +Alternate times can also be specified: + $current($timestamp=$othertime).outTemp + +Explicit time differences for trends can now be specified: + $trend($time_delta=3600).barometer + +Introduced a new tag $latest, which uses the last available timestamp +in a data binding (which may or may not be the same as the "current" +timestamp). + +Introduced a new tag $hours_ago, which returns the stats for X hours +ago. So, the max temperature last hour would be +$hours_ago($hours_ago=1).outTemp.max. + +Introduced a shortcut $hour, which returns the stats for this hour. +So, the high temperature for this hour would be $hour.outTemp.max + +Introduced a new tag $days_ago, which returns the stats for X days +ago. So, the max temperature the day before yesterday would be +$days_ago($days_ago=2).outTemp.max. + +Included a shortcut $yesterday: The tag $yesterday.outTemp.max would +be yesterday's max temperature. + +Introduced a new aggregation type ".last", which returns the last +value in an aggregation interval. E.g., $week.outTemp.last would +return the last temperature seen in the week. + +Introduced a new aggregation type ".lasttime" which returns the time +of the above. + +Can now differentiate between the max speed seen in the archive +records (e.g., $day.windSpeed.max) and the max gust seen +($day.wind.max or $day.windGust.max). + +Allow other data bindings to be used in images. + +Made it easier to add new unit types and unit groups. + +The archive interval can now change within a database without +consequences. + +Total rewrite of how devices are configured. A single utility +wee_config_device replaces each of the device-specific configuration +utilities. + +The Customization Guide was extended with additional examples and option +documentation. + +Removed the no longer needed serviced StdRESTful, obsolete since V2.6 + +Fixed bug in querying for schemas that prevented older versions of +MySQL (V4.X) from working. + +Improved error handling and retries for ws1, ultimeter, and cc3000 +drivers. + +Fixed missing dew initializations in wmr9x8 driver. Fixed +model/_model initialization in wmr9x8 driver. + +Fixed uninitialized usb interface in wmr200 driver. + +Fixed wakup/wakeup typo in _getEEPROM_value in vantage driver. + +Made the ftpupload algorithm a little more robust to corruption of the +file that records the last upload time. + +Added observation type 'snow'. It generally follows the semantics of +'rain'. + +Fixed possible fatal exception in WS23xx driver. Fixed use of str as +variable name in WS23xx driver. + +Now catches database exceptions raised during commits, converting them +to weedb exceptions. Weewx catches these, allowing the program to keep +going, even in the face of most database errors. + +For the fine offset stations, record connection status as rxCheckPercent +(either 0 or 100%) and sensor battery status as outTempBatteryStatus (0 +indicates ok, 1 indicates low battery). + +For WS23xx stations, hardware record generation is now enabled and is the +default (previously, software record generation was the default). + +Fixed bug in WS28xx driver the prevented reading of historical records +when starting with an empty database. + +The database schemas are now their own package. The schema that was in +user/schemas.py can now be found in weewx/schemas/wview.py. + + +### 2.7.0 10/11/14 + +Added the ability to configure new Vantage sensor types without using +the console. This will be useful to Envoy users. Thanks to user Deborah +Pickett for this contribution! + +Allow calibration constants to be set in the Vantage EEPROM. This will +particularly be useful to southern hemisphere users who may want to +align their ISS to true north (instead of south), then apply a 180 +correction. Thanks again to Deborah Pickett! + +Enabled multiple rsync instances for a single weewx instance. + +More extensive debug information for rscync users. + +Added the ability to localize the weewx and server uptime. See the +Customization Guide for details. This will also cause a minor backwards +incompatibility. See the Upgrading Guide for details. + +Added catchup to the WS28xx driver, but still no hardware record generation. + +Changed lux-to-W/m^2 conversion factor in the fine offset driver. + +Added rain rate calculation to Ultimeter driver. + +Changed setTime to retrieve system time directly rather than using a value +passed by the engine. This greatly improves the accuracy of StdTimeSync, +particularly in network based implementations. Thanks to user Denny Page! + +Moved clock synchronization options clock_check and max_drift back to +section [StdTimeSynch]. + +Fixed ENDPOINT_IN in the te923 driver. This should provide better +compatibility with a wider range of pyusb versions. + +Now catches MySQL exceptions during commits and rollbacks (as well +as queries) and converts them to weedb exceptions. + +Catch and report configobj errors when reading skin.conf during the +generation of reports. + +Ensure correct location, lat, lon, and altitude modifications in the +debian postinst installer script. + +In the debian installer, default to ttyUSB0 instead of ttyS0 for stations +with a serial interface. + +Added CC3000 to debian installer scripts. + +Fixed bug that can affect hardware that emits floating point timestamps, +where the timestamp is within 1 second of the end of an archive interval. + +Changed UVBatteryStatus to uvBatteryStatus in the WMR100 driver in order +to match the convention used by other drivers. + +Fixed the shebang for te923, ws23xx, ultimeter, ws1, and cc3000 drivers. + + +### 2.6.4 06/16/14 + +The WMR100 driver now calculates SLP in software. This fixes a problem +with the WMRS200 station, which does not allow the user to set altitude. + +The WMR100 driver was incorrectly tagging rain over the last 24 hours as +rain since midnight. This caused a problem with WU and CWOP posts. + +Fix cosmetic problem in wee_config_fousb pressure calibration. + +Detect both NP (not present) and OFL (outside factory limits) on ws28xx. + +Added driver for PeetBros Ultimeter stations. + +Added driver for ADS WS1 stations. + +Added driver for RainWise Mark III stations and CC3000 data logger. + +Added automatic power cycling to the FineOffsetUSB driver. Power cycle the +station when a USB lockup is detected. Only works with USB hubs that provide +per-port power switching. + +Fix imagegenerator aggregation to permit data tables with no 'interval' column. + +Prompt for metric/US units for debian installations. + +For WS28xx stations, return 0 for battery ok and 1 for battery failure. + +If a connection to the console has been successfully opened, but then on +subsequent connection attempts suffers an I/O error, weewx will now attempt +a retry (before it would just exit). + + +### 2.6.3 04/10/14 + +Hardened the WMR100 driver against malformed packets. + +The plot images can now use UTF-8 characters. + +Fixed a problem where the Ambient threads could crash if there were +no rain database entries. + +Battery status values txBatteryStatus and consBatteryVoltage now appear in +the archive record. The last LOOP value seen is used. + +CWOP posts are now slightly more robust. + +Fixed pressure calculation in wee_config_fousb. + +Prevent failures in imagegenerator when no unicode-capable fonts are installed. + +Provide explicit pairing feedback using wee_config_ws28xx + +Count wxengine restarts in logwatch. + +Cleaned up USB initialization for fousb, ws28xx, and te923 drivers. + + +### 2.6.2 02/16/14 + +Fixed bug that crashes WMR200 driver if outTemp is missing. + +Fixed bug that can crash RESTful threads if there is missing rain data. + +Sqlite connections can now explicitly specify a timeout and +isolation_level. + +Server uptime now reported for MacOS + +Fixed bug that prevented Rapidfire posts from being identified as such. + + +### 2.6.1 02/08/14 + +Fixed bug that crashed main thread if a StdQC value fell out of range. + + +### 2.6.0 02/08/14 + +Changed the RESTful architecture so RESTful services are now first-class +weewx services. This should simplify the installation of 3rd party +extensions that use these services. + +Broke up service_list, the very long list of services to be run, into +five separate lists. This will allow services to be grouped together, +according to when they should be run. + +Defined a structure for packaging customizations into extensions, and added +an installer for those extensions to setup.py. + +Changed the default time and date labels to use locale dependent formatting. +The defaults should now work in most locales, provided you set the +environment variable LANG before running weewx. + +Changed default QC barometric low from 28 to 26. Added inTemp, +inHumidity, and rain. + +Ranges in MinMax QC can now include units. + +When QC rejects values it now logs the rejection. + +Introduced a new unit system, METRICWX. Similar to METRIC, it uses +mm for rain, mm/hr for rain rate, and m/s for speed. + +Added an option --string-check and --fix to the utility wee_config_database +to fix embedded strings found in the sqlite archive database. + +Font handles are now cached in order to work around a memory leak in PIL. + +Now does garbage collection every 3 hours through the main loop. + +Image margins now scale with image and font sizes. + +Now works with the pure Python version of Cheetah's NameMapper, albeit very +slowly. + +Fixed bug that prevented weewx from working with Python v2.5. + +Fixed bug in SummaryByMonth and SummaryByYear that would result in duplicate +month/year entries when generating from multiple ByMonth or ByYear templates. + +Consolidated pressure calculation code in ws23xx, ws28xx, te923, and fousb. + +Catch USB failures when reading Fine Offset archive interval. + +Added Vantage types stormRain and windSpeed10 to the list of observation +types. + +Simulator now generates types dewpoint, pressure, radiation, and UV. + +The forecast extension is once again distributed separately from weewx. + +Minor cleanup to Standard skin for better out-of-the-box behavior: + - default to no radar image instead of pointing every station to Oregon + - not every WMR100 is a WMR100N + +Failure to set an archive interval when using bar plots no longer results +in an exception. + +Change to skin directory before invoking Cheetah on any templates. + + +### 2.5.1 12/30/13 + +Added UV plots to the templates. They will be shown automatically if you +have any UV data. + +Fixed bug when reading cooling_base option. + +Default to sane behavior if skin does not define Labels. + +Fixed bug in setting of CheetahGenerator options. + +Fixed qsf and qpf summary values in forecast module. + +Fixed handling of empty sky cover fields in WU forecasts. + +Forecast module now considers the fctcode, condition, and wx fields for +precipitation and obstructions to visibility. + +Added options to forecast module to help diagnose parsing failures and new +forecast formats. + +Added retries when saving forecast to database and when reading from database. + +Fixes to the Fine Offset driver to eliminate spikes caused by reading from +memory before the pointer had been updated (not the same thing as an unstable +read). + +Added driver for LaCrosse 2300 series of weather stations. + +Added driver for Hideki TE923 series of weather stations. + + +### 2.5.0 10/29/13 + +Introduced a new architecture that makes it easier to define search +list extensions. The old architecture should be 100% backwards compatible. + +Added station registry service. This allows weewx to optionally +"phone home" and put your station location on a map. + +Added a forecast service and reporting options. The forecast service +can generate Zambretti weather or XTide tide forecasts, or it can download +Weather Underground or US National Weather Service weather forecasts. These +data can then be displayed in reports using the Cheetah template engine. The +forecast service is disabled by default. + +Weewx now allows easier localization to non-English speaking locales. +In particular, set the environment variable LANG to your locale, and +then weewx date and number formatting will follow local conventions. +There are also more labeling options in skin.conf. Details in a new section +in the Customization Guide. + +Added aggregate type "minmax" and "maxmin". Thank you user Gary Roderick! + +New option in [StdArchive] called "loop_hilo". Setting to True will +cause both LOOP and archive data to be used for high/low statistics. +This is the default. Setting to False causes only archive data to be used. + +When a template fails, skip only that template, not everything that the +generator is processing. + +Trend calculations no longer need a record at precisely (for example) +3 hours in the past. It can be within a "grace" period. + +FineOffset driver now uses the 'delay' field instead of the fixed_block +'read_period' for the archive record interval when reading records from +console memory. + +FineOffset driver now support for multiple stations on the same USB. + +FineOffset driver now reduces logging verbosity when bad magic numbers +appear. Log only when the numbers are unrecognized or change. +The purpose of the magic numbers is still unknown. + +WMR100, Vantage, FineOffset, and WS28xx drivers now emit a null wind +direction when the wind speed is zero. Same for wind gust. + +For WMR9x8 stations, wind chill is now retrieved from the console +rather than calculated in software. Thank you user Peter Ferencz! + +For WMR9x8 stations, the first extra temperature sensor (packet code 4) +now shows up as extraTemp1 (instead of outTemp). Thanks again to +Peter Ferencz. + +For WMR9x8 stations, packet types 2 and 3 have been separated. Only the +latter is used for outside temperature, humidity, dewpoint. The former +is used for "extra" sensors. Corrected the calculation for channel +numbers >=3. Also, extended the number of battery codes. Thanks to Per +Edström for his patience in figuring this out! + +For WMR200 stations, altitude-corrected pressure is now emitted correctly. + +ws28xx driver improvements, including: better thread control; better logging +for debugging/diagnostics; better timing to reduce dropouts; eliminate writes +to disk to reduce wear when used on flash devices. Plus, support for +multiple stations on the same USB. + +Fixed rain units in ws28xx driver. + +The LOOP value for daily ET on Vantages was too high by a factor of 10. +This has been corrected. + +Fixed a bug that caused values of ET to be miscalculated when using +software record generation. + +Ported to Korora 19 (Fedora 19). Thanks to user zmodemguru! + +Plots under 16 hours in length, now use 1 hour increments (instead of +3 hours). + +No longer emits "deprecation" warning when working with some versions +of the MySQLdb python driver. + +Added ability to build platform-specific RPMs, e.g., one for RedHat-based +distributions and one for SuSE-based distributions. + +Fixed the 'stop' and 'restart' options in the SuSE rc script. + +The weewx logwatch script now recognizes more log entries and errors. + + +### 2.4.0 08/03/13 + +The configuration utility wee_config_vantage now allows you to set +DST to 'auto', 'off', or 'on'. It also lets you set either a time +zone code, or a time zone offset. + +The service StdTimeSync now catches startup events and syncs the clock +on them. It has now been moved to the beginning of the list +"service_list" in weewx.conf. Users may want to do the same with their +old configuration file. + +A new event, END_ARCHIVE_PERIOD has been added, signaling the end of +the archive period. + +The LOOP packets emitted by the driver for the Davis Vantage series +now includes the max wind gust and direction seen since the beginning +of the current archive period. + +Changed the null value from zero (which the Davis documentation specifies) +to 0x7fff for the VP2 type 'highRadiation'. + +Archive record packets with date and time equal to zero or 0xff now +terminate dumps. + +The code that picks a filename for "summary by" reports has now been +factored out into a separate function (getSummaryByFileName). This +allows the logic to be changed by subclassing. + +Fixed a bug that did not allow plots with aggregations less than 60 minutes +across a DST boundary. + +Fixed bug in the WMR100 driver that prevented UV indexes from being +reported. + +The driver for the LaCrosse WS-28XX weather series continues to evolve and +mature. However, you should still consider it experimental. + + +### 2.3.3 06/21/13 + +The option week_start now works. + +Updated WMR200 driver from Chris Manton. + +Fixed bug that prevented queries from being run against a MySQL database. + + +### 2.3.2 06/16/13 + +Added support for the temperature-only sensor THWR800. Thanks to +user fstuyk! + +Fixed bug that prevented overriding the FTP directory in section +[[FTP]] of the configuration file. + +Day plots now show 24 hours instead of 27. If you want the old +behavior, then change option "time_length" to 97200. + +Plots shorter than 24 hours are now possible. Thanks to user Andrew Tridgell. + +If one of the sections SummaryByMonth, SummaryByYear, or ToDate is missing, +the report engine no longer crashes. + +If you live at a high latitude and the sun never sets, the Almanac now +does the right thing. + +Fixed bug that caused the first day in the stats database to be left out +of calculations of all-time stats. + + +### 2.3.1 04/15/13 + +Fixed bug that prevented Fine Offset stations from downloading archive +records if the archive database had no records in it. + +rsync should now work with Python 2.5 and 2.6 (not just 2.7) + + +### 2.3.0 04/10/13 + +Davis Vantage stations can now produce station pressures (aka, "absolute +pressure"), altimeter pressures, as well as sea-level pressure. These will +be put in the archive database. + +Along the same line, 'altimeter' pressure is now reported to CWOP, rather +than the 'barometer' pressure. If altimeter pressure is not available, +no pressure is reported. + +Fixed bug in CWOP upload that put spaces in the upload string if the pressure +was under 1000 millibars. + +A bad record archive type now causes a catch up to be abandoned, rather +than program termination. + +Fixed bug in trends, when showing changes in temperature. NB: this fix will +not work with explicit unit conversion. I.e., $trend.outTemp.degree_C will +not work. + +Modified wee_config_vantage and wee_config_fousb so that the configuration +file will be guessed if none is specified. + +Fixed wxformulas.heatindexC to handle arguments of None type. + +Fixed bug that causes Corrections to be applied twice to archive records if +software record generation is used. + +rsync now allows a port to be specified. + +Fixed day/night transition bug. + +Added gradients to the day/night transitions. + +Numerous fixes to the WMR200 driver. Now has a "watchdog" thread. + +All of the device drivers have now been put in their own package +'weewx.drivers' to keep them together. Many have also had name changes +to make them more consistent: + OLD NEW + VantagePro.py (Vantage) vantage.py (Vantage) + WMR918.py (WMR-918) wmr9x8.py (WMR9x8) + wmrx.py (WMR-USB) wmr100.py (WMR100) + + new (experimental) drivers: + wmr200.py (WMR200) + ws28xx.py (WS28xx) + +The interface to the device driver "loader" function has changed slightly. It +now takes a second parameter, "engine". Details are in the Upgrading doc. + +The FineOffsetUSB driver now supports hardware archive record generation. + +When starting weewx, the FineOffsetUSB driver will now try to 'catch up' - it +will read the console memory for any records that are not yet in the database. + +Added illuminance-to-radiation conversion in FineOffsetUSB driver. + +Added pressure calibration option to wee_config_fousb and explicit support for +pressure calibration in FineOffsetUSB driver. + +Fixed windchill calculation in FineOffsetUSB driver. + +Fixed FineOffsetUSB driver to handle cases where the 'delay' is undefined, +resulting in a TypeError that caused weewx to stop. + +The FineOffsetUSB driver now uses 'max_rain_rate' (measured in cm/hr) instead +of 'max_sane_rain' (measured in mm) to filter spurious rain sensor readings. +This is done in the driver instead of StdQC so that a single parameter can +apply to both LOOP and ARCHIVE records. + +### 2.2.1 02/15/13 + +Added a function call to the Vantage driver that allows the lamp to be +turned on and off. Added a corresponding option to wee_config_vantage. + +Fixed bug where an undefined wind direction caused an exception when using +ordinal wind directions. + +### 2.2.0 02/14/13 + +Weewx can now be installed using Debian (DEB) or Redhat (RPM) packages, as well +as with the old 'setup.py' method. Because they install things in different +places, you should stick with one method or another. Don't mix and match. +Thanks to Matthew Wall for putting this together! + +Added plot options line_gap_fraction and bar_gap_fraction, which control how +gaps in the data are handled by the plots. Also, added more flexible control of +plot colors, using a notation such as 0xBBGGRR, #RRGGBB, or the English name, +such as 'yellow'. Finally, added day/night bands to the plots. All contributed +by Matthew Wall. Thanks again, Matthew! + +Ordinal wind directions can now be shown, just by adding the tag suffix +".ordinal_compass". For example, $current.windDir.ordinal_compass might show +'SSE' The abbreviations are set in the skin configuration file. + +Fixed bug that caused rain totals to be misreported to Weather Underground when +using a metric database. + +Generalized the weewx machinery so it can be used for applications other than +weather applications. + +Got rid of option stats_types in weewx.conf and put it in +bin/user/schemas.py. See upgrading.html if you have a specialized stats +database. + +The stats database now includes an internal table of participating observation +types. This allows it to be easily combined with the archive database, should +you choose to do so. The table is automatically created for older stats +databases. + +Added rain rate calculation to FineOffsetUSB driver. Added adaptive polling +option to FineOffsetUSB driver. Fixed barometric pressure calculation for +FineOffsetUSB driver. + +Changed the name of the utilities, so they will be easier to find in /usr/bin: + weewxd.py -> weewxd + runreports.py -> wee_reports + config_database.py -> wee_config_database + config_vp.py -> wee_config_vantage + config_fousb.py -> wee_config_fousb + +### 2.1.1 01/02/13 + +Fixed bug that shows itself when one of the variables is 'None' when +calculating a trend. + +### 2.1.0 01/02/13 + +Now supports the Oregon Scientific WMR918/968 series, courtesy of user +William Page. Thanks, William!! + +Now supports the Fine Offset series of weather stations, thanks to user +Matthew Wall. Thanks, Matthew!! + +Now includes a Redhat init.d script, contributed by Mark Jenks. Thanks, +Mark!! + +Added rsync report type as an alternative to the existing FTP report. +Another thanks to William Page! + +Fill color for bar charts can now be specified separately from the outline +color, resulting in much more attractive charts. Another thanks to Matthew +Wall!! + +Added a tag for trends. The barometer trend can now be returned as +$trend.barometer. Similar syntax for other observation types. + +config_vp.py now returns the console version number if available (older +consoles do not offer this). + +Hardware dewpoint calculations with the WMR100 seem to be unreliable below +about 20F, so these are now done in software. Thanks to user Mark Jenks for +sleuthing this. + +### 2.0.2 11/23/12 + +Now allows both the archive and stats data to be held in the same database. + +Improved chances of weewx.Archive being reused by allowing optional table +name to be specified. + +### 2.0.1 11/05/12 + +Fixed problem with reconfiguring databases to a new unit system. + +### 2.0.0 11/04/12 + +A big release with lots of changes. The two most important are the support +of additional weather hardware, and the support of the MySQL database. + +All skin configurations are backwardly compatible, but the configuration +file, weewx.conf, is not. The install utility setup.py will install a fresh +version, which you will then have to edit by hand. + +If you have written a custom service, see the upgrade guide on how to port +your service to the new architecture. + +Added the ability to generate archive records in software, thus opening the +door for supporting weather stations that do not have a logger. + +Support for the Oregon Scientific WMR100, the cheapest weather station I +could find, in order to demonstrate the above! + +Added a software weather station simulator. + +Introduced weedb, a database-independent Python wrapper around sqlite3 and +MySQLdb, which fixes some of their flaws. + +Ported everything to use weedb, and thus MySQL (as well as sqlite) + +Internally, the databases can now use either Metric units, or US Customary. +NB: you cannot switch systems in the middle of a database. You have to +stick to one or other other. However, the utility config_database.py does +have a reconfigure option that allows copying the data to a new database, +performing the conversion along the way. See the Customization Guide. + +You can now use "mmHg" as a unit of pressure. + +Added new almanac information, such as first and last quarter moons, and +civil twilight. + +Changed the engine architecture so it is more event driven. It now uses +callbacks, making it easier to add new event types. + +Added utility config_vp.py, for configuring the VantagePro hardware. + +Added utility config_database.py, for configuring the databases. + +Made it easier to write custom RESTful protocols. Thanks to user Brad, for +the idea and the use case! + +The stats type 'squarecount' now contains the number of valid wind +directions that went into calculating 'xsum' and 'ysum'. It used to be the +number of valid wind speeds. Wind direction is now calculated using +'squarecount' (instead of 'count'). + +Simplified and reduced the memory requirements of the CRC16 calculations. + +Improved test suites. + +Lots of little nips and tucks here and there, mostly to reduce the coupling +between different modules. In particular, now a service generally gets +configured only using its section of weewx.conf. + +I also worked hard at making sure that cursors, connections, files, and +lots of other bits and pieces get properly closed instead of relying on +garbage collection. Hopefully, this will reduce the long-term growth of +memory usage. + +### 1.14.1 07/06/12 + +Hardened retry strategy for the WeatherLink IP. If the port fails to open +at all, or a socket error occurs, it will thrown an exception (resulting in +a retry in 60 seconds). If a socket returns an incomplete result, it will +continue to retry until everything has been read. + +Fixed minor bug that causes the reporting thread to prematurely terminate +if an exception is thrown while doing an FTP. + +### 1.14.0 06/18/12 + +Added smartphone formatted mobile webpage, contributed by user Torbjörn +Einarsson. If you are doing a fresh install, then these pages will be +generated automatically. If you are doing an upgrade, then see the upgrade +guide on how to have these webpages generated. Thanks, Tobbe! + +Three changes suggested by user Charlie Spirakis: o Changed umask in +daemon.py to 0022; o Allow location of process ID file to be specified on +the command line of weewx; o Start script allows daemon to be run as a +specific user. Thanks, Charlie! + +Corrected bug in humidity reports to CWOP that shows itself when the +humidity is in the single digits. + +Now includes software in CWOP APRS equipment field. + +### 1.13.2 05/02/12 + +Now allows CWOP stations with prefix 'EW'. + +Fixed bug that showed itself in the line color with plots with 3 or more +lines. + +Changed debug message when reaching the end of memory in the VP2 to +something slightly less alarming. + +### 1.13.1 03/25/12 + +Added finer control over the line plots. Can now add optional markers. The +marker_type can be 'none' (the default), 'cross', 'box', 'circle', or 'x'. +Also, line_type can now either be 'solid' (the default) or 'none' (for +scatter plots). Same day I'll add 'dashed', but not now. :-) + +Conditionally imports sqlite3. If it does not support the "with" statement, +then imports pysqlite2 as sqlite3. + +### 1.13.0 03/13/12 + +The binding to the SQL database to be used now happens much later when +running reports. This allows more than one database to be used when running +a report. Extra databases can be specified in the option list for a report. +I use this to display broadband bandwidth information, which was collected +by a separate program. Email me for details on how to do this. Introducing +this feature changed the signature of a few functions. See the upgrade +guide for details. + +### 1.12.4 02/13/12 + +User Alf Høgemark found an error in the encoding of solar data for CWOP +and sent me a fix. Thanks, Alf! + +Now always uses "import sqlite3", resulting in using the version of +pysqlite that comes with Python. This means the install instructions have +been simplified. + +Now doesn't choke when using the (rare) Python version of NameMapper used +by Cheetah. + +### 1.12.3 02/09/12 + +Added start script for FreeBSD, courtesy of user Fabian Abplanalp. Thanks, +Fabian! + +Added the ability to respond to a "status" query to the Debian startup +script. + +RESTful posts can now recover from more HTTP errors. + +Station serial port can now recover from a SerialException error (usually +caused when there is a process competing for the serial port). + +Continue to fiddle with the retry logic when reading LOOP data. + +### 1.12.2 01/18/12 + +Added check for FTP error code '521' to the list of possibilities if a +directory already exists. Thanks to user Clyde! + +More complete information when unable to load a module file. Thanks, Jason! + +Added a few new unit types to the list of possible target units when using +explicit conversion. Thanks, Antonio! + +Discovered and fixed problem caused by the Davis docs giving the wrong +"resend" code (should be decimal 21, not hex 21). + +Improved robustness of VantagePro configuration utility. + +Fixed problem where an exception gets thrown when changing VP archive +interval. + +Simplified some of the logic in the VP2 driver. + +### 1.12.1 11/03/11 + +Now corrects for rain bucket size if it is something other than the +standard 0.01 inch bucket. + +### 1.12.0 10/29/11 + +Added the ability to change bucket type, rain year start, and barometer +calibration data in the console using the utility configure.py. Added +option "--info", which queries the console and returns information about +EEPROM settings. Changed configure.py so it can do hardware-specific +configurations, in anticipation of supporting hardware besides the Davis +series. + +Reorganized the documentation. + +### 1.11.0 10/06/11 + +Added support for the Davis WeatherLinkIP. Thanks, Peter Nock and Travis +Pickle! + +Added support for older Rev A type archive records. + +Added patch from user Dan Haller that sends UV and radiation data to the +WeatherUnderground if available. Thanks, Dan! + +Added patch from user Marijn Vriens that allows fallback to the version of +pysqlite that comes with many versions of Python. Thanks, Marijn! + +Now does garbage collection after an archive record is obtained and before +the main loop is restarted. + +### 1.10.2 04/14/11 + +Added RA and declination for the Sun and Moon to the Daily Almanac. Equinox +and solstice are now displayed in chronological order. Same with new and +full moons. + +Examples alarm.py and lowBattery.py now include more error checks, allow an +optional 'subject' line to the sent email, and allow a comma separated list +of recipients. + +### 1.10.1 03/30/11 + +Substitutes US Units if a user does not specify anything (instead of +exception KeyError). + +Almanac uses default temperature and pressure if they are 'None'. + +Prettied up web page almanac data in the case where pyephem has not been +installed. + +Fixed up malformed CSS script weewx.css. + +### 1.10.0 03/29/11 + +Added extensive almanac information if the optional package 'pyephem' has +been installed + +Added a weewx "favorite icon" favicon.ico that displays in your browser +toolbar. + +Added a mobile formatted HTML page, courtesy of user Vince Skahan (thanks, +Vince!!). + +Tags can now be ended with a unit type to convert to a new unit. For +example, say your pressure group ("group_pressure") has been set to show +inHg. The normal tag notation of "$day.barometer.avg" will show something +like "30.05 inHg". However, the tag "$day.barometer.avg.mbar" will show +"1017.5 mbar". + +Added special tag "exists" to test whether an observation type exists. +Example "$year.foo.exists" will return False if there is no type "foo" in +the statistical database. + +Added special tag "has_data" to test whether an observation type exists and +has a non-zero number of data points over the aggregation period. For +example, "$year.soilMoist1.has_data" will return "True" if soilMoist1 both +exists in the stats database and contains some data (meaning, you have the +hardware). + +Y-axis plot labels (such as "°F") can now be overridden in the plot +configuration section of skin.conf by using option "y_label". + +Added executable module "runreports.py" for running report generation only. + +Added package "user", which can contain any user extensions. This package +will not get overridden in the upgrade process. + +Added the ability to reconfigure the main database, i.e., add or drop data +types. Along the same line, statistical types can also be added or dropped. +Email me for details on how to do this. + +Now makes all of the LOOP and archive data available to services. This +includes new keys: + + LOOP data: 'extraAlarm1' 'extraAlarm2' 'extraAlarm3' 'extraAlarm4' +'extraAlarm5' 'extraAlarm6' 'extraAlarm7' 'extraAlarm8' 'forecastIcon' +'forecastRule' 'insideAlarm' 'outsideAlarm1' 'outsideAlarm2' 'rainAlarm' +'soilLeafAlarm1' 'soilLeafAlarm2' 'soilLeafAlarm3' 'soilLeafAlarm4' +'sunrise' 'sunset' + + Archive data: 'forecastRule' 'highOutTemp' 'highRadiation' 'highUV' +'lowOutTemp' + +Started a more formal test suite. There are now tests for the report +generators. These are not included in the normal distribution, but can be +retrieved from SourceForge via svn. + +### 1.9.3 02/04/11 + +Now correctly decodes temperatures from LOOP packets as signed shorts +(rather than unsigned). + +Now does a CRC check on LOOP data. + +Changed VantagePro.accumulateLoop to make it slightly more robust. + +### 1.9.2 11/20/10 + +Now catches exception of type OverflowError when calculating celsius +dewpoint. (Despite the documentation indicating otherwise, math.log() can +still throw an OverflowError) + +Fixed bug that causes crash in VantagePro.accumulateLoop() during fall DST +transition in certain situations. + +VP2 does not store records during the one hour fall DST transition. +Improved logic in dealing with this. + +Changed install so that it backs up the ./bin subdirectory, then overwrites +the old one. Also, does not install the ./skins subdirectory at all if one +already exists (thus preserving any user customization). + +### 1.9.1 09/09/10 + +Now catches exceptions of type httplib.BadStatusLine when doing RESTful +posts. + +Added an extra decimal point of precision to dew point reports to the +Weather Underground and PWS. + +### 1.9.0 07/04/10 + +Added a new service, StdQC, that offers a rudimentary data check. + +Corrected error in rain year total if rain year does not start in January. + +Moved option max_drift (the max amount of clock drift to tolerate) to +section [Station]. + +Added check for a bad storm start time. + +Added checks for bad dateTime. + +Simplified VantagePro module. + +### 1.8.4 06/06/10 + +Fixed problem that shows itself if weewx starts up at precisely the +beginning of an archive interval. Symptom is max recursion depth exceeded. + +Units for UV in LOOP records corrected. Also, introduced new group for UV, +group_uv_index. Thanks to user A. Burriel for this fix! + +### 1.8.3 05/20/10 + +Problem with configuring archive interval found and fixed by user A. +Burriel (thanks, Antonio!) + +### 1.8.2 05/09/10 + +Added check to skip calibration for a type that doesn't exist in LOOP or +archive records. This allows windSpeed and windGust to be calibrated +separately. + +### 1.8.1 05/01/10 + +Ported to Cheetah V2.4.X + +### 1.8.0 04/28/10 + +Added CWOP support. + +Storage of LOOP and archive data into the SQL databases is now just another +service, StdArchive. + +Added a calibration service, StdCalibrate, that can correct LOOP and +archive data. + +Average console battery voltage is now calculated from LOOP data, and saved +to the archive as 'consBatteryVoltage'. + +Transmitter battery status is now ORd together from LOOP data, and saved to +the archive as 'txBatteryStatus'. + +Added stack tracebacks for unrecoverable exceptions. + +Added a wrapper to the serial port in the VantagePro code. When used in a +Python "with" statement, it automatically releases the serial port if an +exception happens, allowing a more orderly shutdown. + +Offered some hints in the documentation on how to automount your VP2 when +using a USB connection. + +Corrected error in units. getTargetType() that showed itself with when the +console memory was freshly cleared, then tried to graph something +immediately. + +### 1.7.0 04/15/10 + +Big update. + +Reports now use skins for their "look or feel." Options specific to the +presentation layer have been moved out of the weewx configuration file +'weewx.conf' to a skin configuration file, 'skin.conf'. Other options have +remained behind. + +Because the configuration file weewx.conf was split, the installation +script setup.py will NOT merge your old configuration file into the new +one. You will have to reedit weewx.conf to put in your customizations. + +FTP is treated as just another report, albeit with an unusual generator. +You can have multiple FTP sessions, each to a different server, or +uploading to or from a different area. + +Rewrote the FTP upload package so that it allows more than one FTP session +to be active in the same local directory. This version also does fewer hits +on the server, so it is significantly faster. + +The configuration files weewx.conf and skin.conf now expect UTF-8 +characters throughout. + +The encoding for reports generated from templates can be chosen. By +default, the day, week, month, and year HTML files are encoded using HTML +entities; the NOAA reports encoded using 'strict ascii.' Optionally, +reports can be encoded using UTF-8. + +Revamped the template formatting. No longer use class ModelView. Went to a +simpler system built around classes ValueHelper and UnitInfo. + +Optional formatting was added to all tags in the templates. There are now +optional endings: 'string': Use specified string for None value. +'formatted': No label. 'format': Format using specified string format. +'nolabel': Format using specified string format; no label. 'raw': return +the underlying data with no string formatting or label. + +For the index, week, month, and year template files, added conditional to +not include ISS extended types (UV, radiation, ET) unless they exist. + +Added an RSS feed. + +Added support for PWSweather.com + +Both WeatherUnderground and PWSweather posts are now retried up to 3 times +before giving up. + +Now offer a section 'Extras' in the skin configuration file for including +tags added by the user. As an example, the tag radar_url has been moved +into here. + +Data files used in reports (such as weewx.css) are copied over to the HTML +directory on program startup. + +Included an example of a low-battery alarm. + +Rearranged distribution directory structure so that it matches the install +directory structure. + +Moved base temperature for heating and cooling degree days into skin.conf. +They now also require a unit. + +Now require unit to be specified for 'altitude'. + +### 1.5.0 03/07/10 + +Added support for other units besides the U.S. Customary. Plots and HTML +reports can be prepared using any arbitrary combination of units. For +example, pressure could be in millibars, while everything else is in U.S. +Customary. + +Because the configuration file weewx.conf changed significantly, the +installation script setup.py will NOT merge your old configuration file +into the new one. You will have to reedit weewx.conf to put in your +customizations. + +Added an exception handler for exception OSError, which is typically thrown +when another piece of software attempts to access the same device port. +Weewx catches the exception, waits 10 seconds, then starts again from the +top. + +### 1.4.0 02/22/10 + +Changed the architecture of stats.py to one that uses very late binding. +The SQL statements are not run until template evaluation. This reduces the +amount of memory required (by about 1/2), reduces memory fragmentation, as +well as greatly simplifying the code (file stats.py shed over 150 lines of +non-test code). Execution time is slightly slower for NOAA file generation, +slightly faster for HTML file generation, the same for image generation, +although your actual results will depend on your disk speed. + +Now possible to tell weewx to reread the configuration file without +stopping it. Send signal HUP to the process. + +Added option week_start, for specifying which day a calendar week starts +on. Default is 6 (Sunday). + +Fixed reporting bug when the reporting time falls on a calendar month or +year boundary. + +### 1.3.4 02/08/10 + +Fixed problem when plotting data where all data points are bad (None). + +### 1.3.3 01/10/10 + +Fixed reporting bug that shows itself if rain year does not start in +January. + +### 1.3.2 12/26/09 + +LOOP data added to stats database. + +### 1.3.1 12/22/09 + +Added a call to syslog.openlog() that inadvertently got left out when +switching to the engine driven architecture. + +### 1.3.0 12/21/09 + +Moved to a very different architecture to drive weewx. Consists of an +engine, that manages a list of 'services.' At key events, each service is +given a chance to participate. Services are easy to add, to allow easy +customization. An example is offered of an 'alarm' service. + +Checking the clock of the weather station for drift is now a service, so +the option clock_check was moved from the station specific [VantagePro] +section to the more general [Station] section. + +Added an example service 'MyAlarm', which sends out an email should the +outside temperature drop below 40 degrees. + +In a similar manner, all generated files, images, and reports are the +product of a report engine, which can run any number of reports. New +reports are easily added. + +Moved the compass rose used in progressive vector plots into the interior +of the plot. + +Install now deletes public_html/#upstream.last, thus forcing all files to +be uploaded to the web server at the next opportunity. + +### 1.2.0 11/22/09 + +Added progressive vector plots for wind data. + +Improved axis scaling. The automatic axis scaling routine now does a better +job for ranges less than 1.0. The user can also hardwire in min and max +values, as well as specify a minimum increment, through parameter 'yscale' +in section [Images] in the configuration file. + +Now allows the same SQL type to be used more than once in a plot. This +allows, say, instantaneous and average wind speed to be shown in the same +plot. + +Rain year is now parameterized in file templates/year.tmpl (instead of +being hardwired in). + +Now does LOOP caching by default. + +When doing backfilling to the stats database, configure now creates the +stats database if it doesn't already exist. + +setup.py now more robust to upgrading the FTP and Wunderground sections + +### 1.1.0 11/14/09 + +Added the ability to cache LOOP data. This can dramatically reduce the +number of writes to the stats database, reducing wear on solid-state disk +stores. + +Introduced module weewx.mainloop. Introduced class weewx.mainloop.MainLoop +This class offers many opportunities to customize weewx through +subclassing, then overriding an appropriate member function. + +Refactored module weewx.wunderground so it more closely resembles the +(better) logic in wunderfixer. + +setup.py no longer installs a daemon startup script to /etc/init.d. It must +now be done by hand. + +setup.py now uses the 'home' value in setup.cfg to set WEEWX_ROOT in +weewx.conf and in the daemon start up scripts + +Now uses FTP passive mode by default. + +### 1.0.1 11/09/09 + +Fixed bug that prevented backfilling the stats database after modifying the +main archive. + +### 1.0.0 10/26/09 + +Took the module weewx.factory back out, as it was too complicated and hard +to understand. + +Added support for generating NOAA monthly and yearly reports. Completely +rewrote the filegenerator.py module, to allow easy subclassing and +specialization. + +Completely rewrote the stats.py module. All aggregate quantities are now +calculated dynamically. + +Labels for HTML generation are now held separately from labels used for +image generation. This allows entities such as '°' to be used for the +former. + +LOOP mode now requests only 200 LOOP records (instead of the old 2000). It +then renews the request should it run out. This was to get around an +(undocumented) limitation in the VP2 that limits the number of LOOP records +that can be requested to something like 220. This was a problem when +supporting VP2s that use long archive intervals. + +Cut down the amount of computing that went on before the processing thread +was spawned, thus allowing the main thread to get back into LOOP mode more +quickly. + +Added type 'rainRate' to the types decoded from a Davis archive record. For +some reason it was left out. + +Added retries when doing FTP uploads. It will now attempt the upload +several times before giving up. + +Much more extensive DEBUG analysis. + +Nipped and tucked here and there, trying to simplify. + +### 0.6.5 10/11/09 + +Ported to Cheetah V2.2.X. Mostly, this is making sure that all strings that +cannot be converted with the 'ascii' codec are converted to Unicode first +before feeding to Cheetah. + +### 0.6.4 09/22/09 + +Fixed an error in the calculation of heat index. + +### 0.6.3 08/25/09 + +FTP transfers now default to ACTIVE mode, but a configuration file option +allows PASSIVE mode. This was necessary to support Microsoft FTP servers. + +### 0.6.2 08/01/09 + +Exception handling in weewx/ftpdata.py used socket.error but failed to +declare it. Added 'import socket' to fix. + +Added more complete check for unused pages in weewx/VantagePro.py. Now the +entire record must be filled with 0xff, not just the time field. This fixes +a bug where certain time stamps could look like unused records. + +### 0.6.1 06/22/09 + +Fixed minor ftp bug. + +### 0.6.0 05/20/09 + +Changed the file, imaging, ftping functions into objects, so they can be +more easily specialized by the user. + +Introduced a StationData object. + +Introduced module weewx.factory that produces these things, so the user has +a place to inject his/her new types. + +### 0.5.1 05/13/09 + +1. Weather Underground thread now run as daemon thread, allowing the +program to exit even if it is running. + +2. WU queue now hold an instance of archive and the time to be published, +rather than a record. This allows dailyrain to be published as well. + +3. WU date is now given in the format "2009-05-13+12%3A35%3A00" rather than +"2009-05-13 12:35:00". Seems to be more reliable. But, maybe I'm imagining +things... + diff --git a/dist/weewx-5.0.2/docs_src/copyright.md b/dist/weewx-5.0.2/docs_src/copyright.md new file mode 100644 index 0000000..ee53138 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/copyright.md @@ -0,0 +1,50 @@ +# WeeWX Copyright {#weewx-copyright} + +Copyright 2009-2024 by Thomas Keffer (tkeffer@gmail.com), Matthew Wall, +and Gary Roderick. + +This program is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +Public License for more details. + +[http://www.gnu.org/licenses](http://www.gnu.org/licenses/) + +The WMR9x8 driver is Copyright Will Page. + +The FineOffsetUSB driver is Copyright Matthew Wall, based on the open +source pywws by Jim Easterbrook. + +The WS23xx driver is Copyright Matthew Wall, including significant +portions from ws2300 by Russel Stuart. + +The WS28xx driver is Copyright Matthew Wall, based on the original +Python implementation by Eddi de Pieri and with significant +contributions by LJM Heijst. + +The TE923 driver is Copyright Matthew Wall with significant +contributions from Andrew Miles. + +The CC3000 driver is Copyright Matthew Wall, thanks to hardware +contributed by Annie Brox. + +The WS1 driver is Copyright Matthew Wall. + +The Ultimeter driver is Copyright Matthew Wall and Nate Bargmann. + +The Acurite driver is Copyright Matthew Wall. + +The WMR300 driver is Copyright Matthew Wall, thanks to hardware +contributed by EricG. + +Some icons are Copyright Tatice (http://tatice.deviantart.com), licensed +under the terms of Creative Commons Attribution-NonCommercial-NoDerivs +3.0. + +The debian, redhat, and suse packaging configurations are Copyright +Matthew Wall, licensed under the terms of GNU Public License 3. diff --git a/dist/weewx-5.0.2/docs_src/css/weewx_docs.css b/dist/weewx-5.0.2/docs_src/css/weewx_docs.css new file mode 100644 index 0000000..b3507d6 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/css/weewx_docs.css @@ -0,0 +1,306 @@ +/* Styles for the weewx documentation + * + * Copyright (c) 2009-2019 Tom Keffer + * + * See the file LICENSE.txt for your rights. + * + */ +/*noinspection CssUnknownTarget,CssUnknownTarget*/ +@import url('https://fonts.googleapis.com/css?family=Roboto:700|Noto+Sans|Inconsolata:400,700|Droid+Serif'); + +:root { + --md-accent-fg-color: teal; +} + +h1 { + background-color: #aacccc; + border-radius: 3px; + border: 1px solid #999999; + clear:both; + color:teal; + font-family: 'Roboto', sans-serif; + font-size: 160%; + font-weight: bold; + margin-bottom: 0; + margin-top: 2em; + padding-left: .5em; + padding-right: .5em; +} + +h2 { + border-bottom: 1px solid #999999; + clear:both; + color: teal; + font-family: 'Roboto', sans-serif; + font-size: 140%; + font-weight: bold; + margin-bottom: 0; + margin-top: 2em; +} + +h3 { + clear:left; + color: teal; + font-family: 'Roboto', sans-serif; + font-size: 120%; + font-weight: bold; + margin-bottom: 0; + margin-top: 1.5em; +} + +h4 { + clear:left; + color: teal; + font-family: 'Roboto', sans-serif; + font-size: 100%; + font-weight: bold; +} + +table { + border-collapse: collapse; +} + +table .tty { + margin: 0; + border: none; +} + +tr { + vertical-align: top; +} + +th { + background-color: #ddefef; + border: .05rem solid #cccccc; + padding: 4px 20px 4px 10px; +} + +td { + border: .05rem solid #cccccc; + padding: 4px 20px 4px 10px; +} + +table .first_row { + font-weight: bold; + background-color: #ddefef; + padding-left: 10px; + padding-right: 10px; +} + +table.fixed_width td { + width: 10%; +} + +caption { + background-color: #aacccc; + margin: 0 0 8px; + border: 1px solid #888888; + padding: 6px 16px; + font-weight: bold; +} + +.code { + font-family: 'Inconsolata', monospace; +} + +p .code, td .code, li .code, dd .code { + background-color: #ecf3f3; + padding-left: 3px; + padding-right: 3px; +} + +.symcode { + font-family: 'Inconsolata', monospace; + font-style: italic; +} + +p .symcode { + background-color: #ecf3f3; + padding-left: 1px; + padding-right: 1px; +} + +.station_data { + margin-left: 40px; + margin-right: 80px; + width: 500px; +} + +.station_data_key { + font-size: 80%; + font-style: italic; + margin-left: 40px; + margin-right: 80px; + width: 500px; +} + +.cmd { +/* font-weight: bold; */ +} + +.tty { + font-family: 'Inconsolata', monospace; + background-color: var(--md-code-bg-color), #f5f5f5; + border: 1px solid #ccd3d3; + padding: 3px 8px 3px 8px; + margin: 5px 15px 5px 15px; + white-space: pre; + line-height: normal; +/* font-weight: bold; */ + color: var(--md-code-fg-color), #36464e; + overflow: auto; +} + +.highlight { + background-color: #ffff99; +} + +.text_highlight, .first_col { + font-weight: bold; + background-color: #eef0f0; + padding-left: 10px; + padding-right: 10px; +} + +.center { + text-align: center; +} + +.example_output { + font-family: 'Droid Serif', serif; + padding: 15px 20px 15px 20px; + margin: 5px 15px 5px 15px; + border: 1px solid #cccccc; + box-shadow: 2px 2px 2px #dddddd; + display: inline-block; +} + +.example_text { + font-family: 'Noto Sans', sans-serif; + font-weight: bold; + background-color: #ecf3f3; + padding: 0px 4px 0px 4px; +} + +.image { + padding: 5px; +} + +.note { + background-color: #ddf0e0; + border: 1px solid #bbd0c0; + margin: 10px 30px 10px 30px; + padding: 10px; + border-radius: 6px; +} + +.warning { + background-color: #ffeeee; + border: 1px solid #ffdddd; + margin: 10px 30px 10px 30px; + padding: 10px; + border-radius: 6px; +} + +/* + * The stats styles mimic the styles used in the default standard template + * output so that examples in the docs match those of the standard template. + */ +.stats { + font-family: 'Noto Sans', sans-serif; + padding: 13px 58px 13px 58px; +} + +.stats table { + border: thin solid #000000; + width: 100%; +} + +.stats td { + border: thin solid #000000; + padding: 2px; +} + +.stats_label { + color: green; +} + +.stats_data { + color: red; +} + +/* change indicators in the upgrade guide */ +.removed { background: #ffdddd; } +.changed { background: #ffe0b0; } +.added { background: #ccffcc; } + +/* override settings in material */ + +.md-typeset table:not([class]) { + border: none; /* no border on table, since we do it on cells */ +} + +.md-typeset table:not([class]) tbody tr:hover { + background-color: inherit; /* do not change backgrounds on mouseovers */ +} + +.md-typeset table:not([class]) th { + padding: 0.2em 1em; +} + +.md-typeset table:not([class]) td { + padding: 0.2em 1em; +} + +.md-typeset table:not([class]) { + font-size: inherit; /* match the body; the default of 0.64 is too small */ +} + +.md-typeset .tabbed-labels > label { + font-size: inherit; /* match the body; the default of 0.64 is too small */ +} + +/* make the admonitions (note/warning) less dominant */ +.md-typeset .admonition-title, md-typeset summary { + padding-bottom: 0; + padding-top: 0; +} + +.md-typeset .admonition-title::before, md-typeset summary:before { + top: 0.2em; +} + +.md-typeset .admonition, .md-typeset details { + font-size: 0.7rem; /* match the body; the default of 0.64 is too small */ + margin: 0.4em .8em; /* make the box a bit smaller than the doc width */ +} + +.md-typeset code, .md-typeset .linenos { + font-weight: bold; + font-size: .9em; +} + +.md-source__facts li { + margin-right: 7%; /* over 7% breaks the github banner */ +} + +.md-header__button.md-logo { + background-color: white; /* make logo fully visible */ +} + +.md-typeset h1 { + color: teal; /* ensure that h1 looks ok in dark mode */ +} + +.md-typeset pre > code { + scrollbar-width: auto; /* make scrollbars wide enough to use */ +} + +.md-typeset pre > code:hover { + scrollbar-color: teal #0000; /* replace the default blue color */ + scrollbar-width: auto; /* make scrollbars wide enough to use */ +} + +.md-typeset__table { + display: inherit; /* eliminate extra padding */ +} diff --git a/dist/weewx-5.0.2/docs_src/custom/cheetah-generator.md b/dist/weewx-5.0.2/docs_src/custom/cheetah-generator.md new file mode 100644 index 0000000..907f462 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/cheetah-generator.md @@ -0,0 +1,2164 @@ +# The Cheetah generator + +File generation is done using the [Cheetah](https://cheetahtemplate.org/) +templating engine, which processes a _template_, replacing any symbolic _tags_, +then produces an output file. Typically, it runs after each new archive record +(usually about every five minutes), but it can also run on demand using the +utility +[`weectl report run`](../utilities/weectl-report.md#run-reports-on-demand). + +The Cheetah engine is very powerful, essentially letting you have the full +semantics of Python available in your templates. As this would make the +templates incomprehensible to anyone but a Python programmer, WeeWX adopts +a very small subset of its power. + +The Cheetah generator is controlled by the configuration options in the +section [`[CheetahGenerator]`](../reference/skin-options/cheetahgenerator.md) +of the skin configuration file. + +Let's take a look at how this works. + +## Which files get processed? + +Each template file is named something like `D/F.E.tmpl`, where `D` is the +(optional) directory the template sits in and will also be the directory the +results will be put in, and `F.E` is the generated file name. So, given a +template file with name `Acme/index.html.tmpl`, the results will be put in +`HTML_ROOT/Acme/index.html`. + +The configuration for a group of templates will look something like this: + +```init +[CheetahGenerator] + [[index]] + template = index.html.tmpl + [[textfile]] + template = filename.txt.tmpl + [[xmlfile]] + template = filename.xml.tmpl +``` + +There can be only one template in each block. In most cases, the block name +does not matter — it is used only to isolate each template. However, there +are four block names that have special meaning: `SummaryByDay`, +`SummaryByMonth`, `SummaryByYear`, and `ToDate`. + +### Specifying template files + +By way of example, here is the `[CheetahGenerator]` section from the +`skin.conf` for the skin _`Seasons`_. + +```{ini linenums=1} +[CheetahGenerator] + # The CheetahGenerator creates files from templates. This section + # specifies which files will be generated from which template. + + # Possible encodings include 'html_entities', 'strict_ascii', 'normalized_ascii', + # as well as those listed in https://docs.python.org/3/library/codecs.html#standard-encodings + encoding = html_entities + + [[SummaryByMonth]] + # Reports that summarize "by month" + [[[NOAA_month]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m.txt.tmpl + + [[SummaryByYear]] + # Reports that summarize "by year" + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y.txt.tmpl + + [[ToDate]] + # Reports that show statistics "to date", such as day-to-date, + # week-to-date, month-to-date, etc. + [[[index]]] + template = index.html.tmpl + [[[statistics]]] + template = statistics.html.tmpl + [[[telemetry]]] + template = telemetry.html.tmpl + [[[tabular]]] + template = tabular.html.tmpl + [[[celestial]]] + template = celestial.html.tmpl + # Uncomment the following to have WeeWX generate a celestial page only once an hour: + # stale_age = 3600 + [[[RSS]]] + template = rss.xml.tmpl +``` + +The skin contains three different kinds of generated output: + +1. Summary by Month (line 9). The skin uses `SummaryByMonth` to produce NOAA + summaries, one for each month, as a simple text file. + +2. Summary by Year (line 15). The skin uses `SummaryByYear` to produce NOAA + summaries, one for each year, as a simple text file. + +3. Section "To Date" (line 21). The skin produces an HTML `index.html` page, + as well as HTML files for detailed statistics, telemetry, and celestial + information. It also includes a master page (`tabular.html`) in which NOAA + information is displayed. All these files are HTML. + +Because the option + + encoding = html_entities + +appears directly under `[CheetahGenerator]`, this will be the default encoding +of the generated files unless explicitly overridden. We see an example of this +under `[SummaryByMonth]` and `[SummaryByYear]`, which override the default by +specifying option `normalized_ascii` (which replaces accented characters with a +non-accented analog). + +Other than `SummaryByMonth` and `SummaryByYear`, the section names are +arbitrary. The section `[[ToDate]]` could just as well have been called +`[[files_to_date]]`, and the sections `[[[index]]]`, `[[[statistics]]]`, and +`[[[telemetry]]]` could just as well have been called `[[[tom]]]`, +`[[[dick]]]`, and `[[[harry]]]`. + +### [[SummaryByYear]] + +Use `SummaryByYear` to generate a set of files, one file per year. The name of +the template file should contain a [strftime()](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) +code for the year; this will be replaced with the year of the data in the file. + +```ini +[CheetahGenerator] + [[SummaryByYear]] + # Reports that summarize "by year" + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y.txt.tmpl +``` + +The template `NOAA/NOAA-%Y.txt.tmpl` might look something like this: + +``` + SUMMARY FOR YEAR $year.dateTime + +MONTHLY TEMPERATURES AND HUMIDITIES: +#for $record in $year.records +$record.dateTime $record.outTemp $record.outHumidity +#end for +``` + +### [[SummaryByMonth]] + +Use `SummaryByMonth` to generate a set of files, one file per month. The name +of the template file should contain a [strftime()](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) +code for year and month; these will be replaced with the year and month of +the data in the file. + +```ini +[CheetahGenerator] + [[SummaryByMonth]] + # Reports that summarize "by month" + [[[NOAA_month]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m.txt.tmpl +``` + +The template `NOAA/NOAA-%Y-%m.txt.tmpl` might look something like this: + +``` + SUMMARY FOR MONTH $month.dateTime + +DAILY TEMPERATURES AND HUMIDITIES: +#for $record in $month.records +$record.dateTime $record.outTemp $record.outHumidity +#end for +``` + +### [[SummaryByDay]] + +While the _Seasons_ skin does not make use of it, there is also a +`SummaryByDay` capability. As the name suggests, this results in one file per +day. The name of the template file should contain a +[strftime()](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) +code for the year, month and day; these will be replaced with the year, month, +and day of the data in the file. + +```ini +[CheetahGenerator] + [[SummaryByDay]] + # Reports that summarize "by day" + [[[NOAA_day]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m-%d.txt.tmpl +``` + +The template `NOAA/NOAA-%Y-%m-%d.txt.tmpl` might look something like this: + +``` + SUMMARY FOR DAY $day.dateTime + +HOURLY TEMPERATURES AND HUMIDITIES: +#for $record in $day.records +$record.dateTime $record.outTemp $record.outHumidity +#end for +``` + +!!! Note + This can create a _lot_ of files — one per day. If you have 3 years + of records, this would be more than 1,000 files! + + +## Tags + +If you look inside a template, you will see it makes heavy use of _tags_. As +the Cheetah generator processes the template, it replaces each tag with an +appropriate value and, sometimes, a label. This section discusses the details +of how that happens. + +If there is a tag error during template generation, the error will show up in +the log file. Many errors are obvious — Cheetah will display a line number and +list the template file in which the error occurred. Unfortunately, in other +cases, the error message can be very cryptic and not very useful. So make small +changes and test often. Use the utility [weectl report +run](../utilities/weectl-report.md#run-reports-on-demand) to speed up the +process. + +Here are some examples of tags: + +``` +$current.outTemp +$month.outTemp.max +$month.outTemp.maxtime +``` + +These code the current outside temperature, the maximum outside temperature +for the month, and the time that maximum occurred, respectively. So a template +file that contains: + +```html + + + Current conditions + + +

Current temperature = $current.outTemp

+

Max for the month is $month.outTemp.max, which occurred at $month.outTemp.maxtime

+ + +``` + +would be all you need for a very simple HTML page that would display the text +(assuming that the unit group for temperature is `degree_F`): + + +Current temperature = 51.0°F +Max for the month is 68.8°F, which occurred at 07-Oct-2009 15:15 + + +The format that was used to format the temperature (`51.0`) is specified in +section [`[Units][[StringFormat]]`](../reference/skin-options/units.md#stringformats). +The unit label `°F` is from section +[`[Units][[Labels]]`](../reference/skin-options/units.md#labels), while the +time format is from +[`[Units][[TimeFormats]]`](../reference/skin-options/units.md#timeformats). + +As we saw above, the tags can be very simple: + +``` +# Output max outside temperature using an appropriate format and label: +$month.outTemp.max +``` + +Most of the time, tags will "do the right thing" and are all you will need. +However, WeeWX offers extensive customization of the tags for specialized +applications such as XML RSS feeds, or rigidly formatted reports (such as +the NOAA reports). This section specifies the various tag options available. + +There are two different versions of the tags, depending on whether the data +is "current", or an aggregation over time. However, both versions are similar. + +### Time period `$current` + +Time period `$current` represents a _current observation_. An example would be +the current barometric pressure: + + $current.barometer + +Formally, for current observations, WeeWX first looks for the observation type +in the record emitted by the `NEW_ARCHIVE_RECORD` event. This is generally the +data emitted by the station console, augmented by any derived variables +(_e.g._, wind chill) that you might have specified. If the observation type +cannot be found there, the most recent record in the database will be searched. +If it still cannot be found, WeeWX will attempt to calculate it using the +[xtypes system](derived.md). + +The most general tag for a "current" observation looks like: + +``` +$current(timestamp=some_time, max_delta=delta_t,data_binding=binding_name) + .obstype + [.unit_conversion] + [.rounding] + [.formatting] +``` + +Where: + +_`timestamp`_ is a timestamp that you want to display in unix epoch time. It +is optional, The default is to display the value for the current time. + +_`max_delta`_ is the largest acceptable time difference (in seconds) between +the time specified by `timestamp` and a record's timestamp in the database. By +default, it is zero, which means there must be an exact match with a specified +time for a record to be retrieved. If it were `30`, then a record up to 30 +seconds away would be acceptable. + +_`data_binding`_ is a _binding name_ to a database. An example would be +`wx_binding`. See the section +_[Binding names](../reference/weewx-options/data-bindings.md)_ +for more details. + +_`obstype`_ is an _observation type_, such as `barometer`. This type must appear +either in the current record, as a field in the database, +or can be derived from some combination of the two as an +[XType](https://github.com/weewx/weewx/wiki/xtypes). + +_`unit_conversion`_ is an optional unit conversion tag. If provided, +the results will be converted into the specified units, otherwise the default +units specified in the skin configuration file (in section `[Units][[Groups]]`) +will be used. See the section +_[Unit conversion options](#unit-conversion-options)_. + +_`rounding`_ is an optional rounding tag. If provided, it rounds the result +to a fixed number of decimal digits. See the section +_[Rounding options](#rounding-options)_. + +_`formatting`_ is an optional formatting tag. If provided, it controls how the +value will appear. See the section _[Formatting options](#formatting-options)_. + +### Time period $latest + +Time period `$latest` is very similar to `$current`, except that it uses the +last available timestamp in a database. Usually, `$current` and `$latest` are +the same, but if a data binding points to a remote database, they may not be. +See the section _[Using multiple bindings](multiple-bindings.md)_ +for an example where this happened. + +### Aggregation periods + +Aggregation periods is the other kind of tag. For example, + + $week.rain.sum + +represents an _aggregation over time_, using a certain _aggregation type_. In +this example, the aggregation time is a week, and the aggregation type is +summation. So, this tag represents the total rainfall over a week. + +The most general tag for an aggregation over time looks like: + +``` +$period(data_binding=binding_name[, ago=delta]) + .obstype + .aggregation + [.unit_conversion] + [.rounding] + [.formatting] +``` + +Where: + +_`period`_ is the _aggregation period_ over which the aggregation is to be +done. Possible choices are listed in the +[aggregation periods table](#aggregation-periods-table). + +_`data_binding`_ is a _binding name_ to a database. An example would be +`wx_binding`. See the section +_[Binding names](../reference/weewx-options/data-bindings.md)_ +for more details. + +_`ago`_ is a keyword that depends on the aggregation period. For example, for +week, it would be `weeks_ago`, for day, it would be `days_ago`, _etc._ + +_`delta`_ is an integer indicating which aggregation period is desired. For +example `$week(weeks_ago=1)` indicates last week, `$day(days_ago=2)` would be +the day-before-yesterday, _etc_. The default is zero: that is, this +aggregation period. + +_`obstype`_ is an _observation type_. This is generally any observation type that +appears in the database (such as `outTemp` or `windSpeed`), as well a most +[XTypes](https://github.com/weewx/weewx/wiki/xtypes). However, not all +aggregations are supported for all types. + +_`aggregation`_ is an _aggregation type_. If you ask for `$month.outTemp.avg` +you are asking for the _average_ outside temperature for the month. Possible +aggregation types are given in the reference [_Aggregation +types_](../reference/aggtypes.md). + +_`unit_conversion`_ is an optional unit conversion tag. If provided, the results +will be converted into the specified units, otherwise the default units +specified in the skin configuration file (in section `[Units][[Groups]]`) will +be used. See the section _[Unit conversion options](#unit-conversion-options)_. + +_`rounding`_ is an optional rounding tag. If provided, it rounds the result to +a fixed number of decimal digits. See the section _[Rounding +options](#rounding-options)_. + +_`formatting`_ is an optional formatting tag. If provided, it controls how the +value will appear. See the section _[Formatting options](#formatting-options)_. + +There are several _aggregation periods_ that can be used: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aggregation periods
Aggregation periodMeaningExampleMeaning of example
$hourThis hour.$hour.outTemp.maxtimeThe time of the max temperature this hour.
$dayToday (since midnight).$day.outTemp.maxThe max temperature since midnight
$yesterdayYesterday. Synonym for $day($days_ago=1). + $yesterday.outTemp.maxtimeThe time of the max temperature yesterday.
$weekThis week. The start of the week is set by option week_start. + $week.outTemp.maxThe max temperature this week.
$monthThis month (since the first of the month).$month.outTemp.minThe minimum temperature this month.
$yearThis year (since 1-Jan).$year.outTemp.maxThe max temperature since the start of the year.
$rainyearThis rain year. The start of the rain year is set by option rain_year_start. + $rainyear.rain.sumThe total rainfall for this rain year.
$alltime + All records in the database given by binding_name. + $alltime.outTemp.max + The maximum outside temperature in the default database. +
+ +The "_`ago`_" parameters can be useful for statistics farther in the past. +Here are some examples: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aggregation periodExampleMeaning
$hour(hours_ago=h) + $hour(hours_ago=1).outTemp.avgThe average temperature last hour (1 hour ago).
$day(days_ago=d) + $day(days_ago=2).outTemp.avgThe average temperature day before yesterday (2 days ago). +
$week(weeks_ago=w) + $week(weeks_ago=1).outTemp.maxThe maximum temperature last week.
$month(months_ago=m) + $month(months_ago=1).outTemp.maxThe maximum temperature last month.
$year(years_ago=y) + $year(years_ago=1).outTemp.maxThe maximum temperature last year.
+ +### Unit conversion options + +The option _`unit_conversion`_ can be used with either current observations +or with aggregations. If supplied, the results will be converted to the +specified units. For example, if you have set `group_pressure` to inches +of mercury (`inHg`), then the tag + + Today's average pressure=$day.barometer.avg + +would normally give a result such as + +
+Today's average pressure=30.05 inHg +
+ +However, if you add `mbar` to the end of the tag, + + Today's average pressure=$day.barometer.avg.mbar + +then the results will be in millibars: + +
+Today's average pressure=1017.5 mbar +
+ +If an inappropriate or nonsense conversion is asked for, _e.g._, + +``` +Today's minimum pressure in mbars: $day.barometer.min.mbar +or in degrees C: $day.barometer.min.degree_C +or in foobar units: $day.barometer.min.foobar +``` + +then the offending tag(s) will be put in the output: + +
+Today's minimum pressure in mbars: 1015.3 +or in degrees C: $day.barometer.min.degree_C +or in foobar units: $day.barometer.min.foobar +
+ +### Rounding options + +The data in the resultant tag can be optionally rounded to a fixed number of +decimal digits. This is useful when emitting raw data or JSON strings. It +should _not_ be used with formatted data. In that case, using a `format string` would +be a better choice. + +The structure of the option is + + .round(ndigits=None) + +where `ndigits` is the number of decimal digits to retain. If `None` (the +default), then all digits will be retained. + +### Formatting options + +A variety of options are available to you to customize the formatting of the +final observation value. They can be used whenever a tag results in a +[`ValueHelper`](../reference/valuehelper.md), which is almost all the time. +This table summarizes the options: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Formatting options
Formatting optionComment
.format(args) + Format the value as a string, according to a set of optional + args. +
.long_form(args) + Format delta times in the "long form", according to a + set of optional args. +
.ordinal_compassFormat the value as a compass ordinals (e.g., "SW"), useful + for wind directions. The ordinal abbreviations are set by option + directions + in the skin configuration file. +
.json + Format the value as a + JSON string. +
.raw + Return the value "as is", without being converted to a string and + without any formatting applied. This can be useful for doing + arithmetic directly within the templates. You must be prepared + to deal with a potential value of None. +
+ + +#### format() + +The results of a tag can be optionally formatted using option `format()`. +It has the formal structure: + + format(format_string=None, None_string=None, add_label=True, localize=True) + +Here is the meaning of each of the optional arguments: + + + + + + + + + + + + + + + + + + + + + + + + + +
Optional arguments for format()
Optional argumentComment
format_string +If set, use the supplied string to format the value. Otherwise, if set to +None, then an appropriate value from +[Units][[StringFormats]] +will be used. +
None_string +Should the observation value be NONE, then use the +supplied string (typically, something like "N/A"). If +None_string is set to None, +then the value for NONE in +[Units][[StringFormats]] +will be used. +
add_label +If set to True (the default), then a unit label +(e.g., °F) from skin.conf will be +attached to the end. Otherwise, it will be left out. +
localize +If set to True (the default), then localize the +results. Otherwise, do not. +
+ +If you're willing to honor the ordering of the arguments, the argument name +can be omitted. + +#### long_form() + +The option `long_form()`, can be used to format _delta times_. A _delta time_ +is the difference between two times, for example, the amount of uptime (the +difference between start up and the current time). By default, this will be +formatted as the number of elapsed seconds. For example, a template with the +following + +

WeeWX has been up $station.uptime

+ +will result in + +
+WeeWX has been up 101100 seconds +
+ +The "long form" breaks the time down into constituent time elements. For +example, + +

WeeWX has been up $station.uptime.long_form

+ +results in + +
+WeeWX has been up 1 day, 4 hours, 5 minutes +
+ +The option `long_form()` has the formal structure + + long_form(format_string=None, None_string=None) + +Here is the meaning of each of the optional arguments: + + + + + + + + + + + + + + + + + +
Optional arguments for long_form()
Optional argumentComment
format_string + Use the supplied string to format the value. +
None_string + Should the observation value be NONE, + then use the supplied string to format the value (typically, + something like "N/A"). +
+ +The argument `format_string` uses special symbols to represent its constitutent +components. Here's what they mean: + +| Symbol | Meaning | +|----------------|-----------------------------| +| `day` | The number of days | +| `hour` | The number of hours | +| `minute` | The number of minutes | +| `second` | The number of seconds | +| `day_label` | The label used for days | +| `hour_label` | The label used for hours | +| `minute_label` | The label used for minutes | +| `second_label` | The label used for seconds | + +Putting this together, the example above could be written + +``` +

WeeWX has been up $station.uptime.long_form(format_string="%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s")

+``` + +### Formatting examples + +This section gives a number of example tags, and their expected output. The +following values are assumed: + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Values used in the formatting examples
ObservationValue
+ outTemp + 45.2°F
+ UV + + None +
+ windDir + 138°
+ dateTime + 1270250700
+ uptime + 101100 seconds
+ +Here are the examples: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Formatting options with expected results
TagResultResult
type
Comment
$current.outTemp45.2°Fstr +String formatting from [Units][[StringFormats]]. Label from [Units][[Labels]]. +
$current.outTemp.format45.2°Fstr + Same as the $current.outTemp. +
$current.outTemp.format()45.2°Fstr + Same as the $current.outTemp. +
$current.outTemp.format(format_string="%.3f")45.200°Fstr + Specified string format used; label from [Units][[Labels]]. +
$current.outTemp.format("%.3f")45.200°Fstr + As above, except a positional argument, instead of the named argument, is being used. +
$current.outTemp.format(add_label=False)45.2str + No label. The string formatting is from [Units][[StringFormats]]. +
$current.UVN/Astr + The string specified by option NONE in [Units][[StringFormats]]. +
$current.UV.format(None_string="No UV")No UVstr + Specified None_string is used. +
$current.windDir138°str + Formatting is from option degree_compass in [Units][[StringFormats]]. +
$current.windDir.ordinal_compassSWstr + Ordinal direction from section [Units][[Ordinates]] is being substituted. +
$current.dateTime02-Apr-2010 16:25str + Time formatting from [Units][[TimeFormats]] is being used. +
$current.dateTime.format(format_string="%H:%M")16:25str + Specified time format used. +
$current.dateTime.format("%H:%M")16:25str + As above, except a positional argument, instead of the named + argument, is being used. +
$current.dateTime.raw1270250700int + Raw Unix epoch time. The result is an integer. +
$current.outTemp.raw45.2float + Raw float value. The result is a float. +
$current.outTemp.degree_C.raw7.33333333float + Raw float value in degrees Celsius. The result is a float. +
$current.outTemp.degree_C.json7.33333333str + Value in degrees Celsius, converted to a JSON string. +
$current.outTemp.degree_C.round(2).json7.33str + Value in degrees Celsius, rounded to two decimal digits, then + converted to a JSON string. +
$station.uptime101100 secondsstr + WeeWX uptime. +
$station.uptime.hour28.1 hoursstr + WeeWX uptime, with unit conversion to hours. +
$station.uptime.long_form1 day, 4 hours, 5 minutesstr + WeeWX uptime with "long form" formatting. +
+ + +### start, end, and dateTime + +While not an observation type, in many ways the time of an observation, +`dateTime`, can be treated as one. A tag such as + + $current.dateTime + +represents the _current time_ (more properly, the time as of the end of the +last archive interval) and would produce something like + +
+01/09/2010 12:30:00 +
+ +Like true observation types, explicit formats can be specified, except that +they require a [strftime() _time format_](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) , +rather than a _string format_. + +For example, adding a format descriptor like this: + + $current.dateTime.format("%d-%b-%Y %H:%M") + +produces + +
+09-Jan-2010 12:30 +
+ +For _aggregation periods_, such as `$month`, you can request the _start_, +_end_, or _length_ of the period, by using suffixes `.start`, `.end`, or +`.length`, respectively. For example, + + The current month runs from $month.start to $month.end and has $month.length.format("%(day)d %(day_label)s"). + +results in + +
+The current month runs from 01/01/2010 12:00:00 AM to 02/01/2010 12:00:00 AM and has 31 days. +
+ +The returned string values will always be in _local time_. However, if you ask +for the raw value + + $current.dateTime.raw + +the returned value will be in Unix Epoch Time (number of seconds since 00:00:00 +UTC 1 Jan 1970, _i.e._, a large number), which you must convert yourself. It +is guaranteed to never be `None`, so you don't worry have to worry about +handling a `None` value. + +### Tag $trend + +The tag `$trend` is available for time trends, such as changes in barometric +pressure. Here are some examples: + +| Tag | Results | +|--------------------------------------|------------| +| `$trend.barometer` | -.05 inHg | +| `$trend(time_delta=3600).barometer` | -.02 inHg | +| `$trend.outTemp` | 1.1 °C | +| `$trend.time_delta` | 10800 secs | +| `$trend.time_delta.hour` | 3 hrs | + +Note how you can explicitly specify a time interval in the tag itself (2nd row +in the table above). If you do not specify a value, then a default time +interval, set by option +[`time_delta`](../reference/skin-options/units.md#time_delta) in the skin +configuration file, will be used. This value can be retrieved by using the +syntax `$trend.time_delta` (4th row in the table). + +For example, the template expression + + The barometer trend over $trend.time_delta.hour is $trend.barometer.format("%+.2f") + +would result in + +
+The barometer trend over 3 hrs is +.03 inHg. +
+ +### Tag $span + +The tag `$span` allows aggregation over a user defined period up to and +including the current time. Its most general form looks like: + +``` +$span([data_binding=binding_name][,delta=delta][,boundary=(None|'midnight')]) + .obstype + .aggregation + [.unit_conversion] + [.formatting] +``` + +Where: + +_`data_binding`_ is a _binding name_ to a database. An example would be +`wx_binding`. See the section +_[Binding names](../reference/weewx-options/data-bindings.md)_ +for more details. + +_`delta`_ is one or more comma separated delta settings from the table below. +If more than one delta setting is included then the period used for the +aggregate is the sum of the individual delta settings. If no delta setting +is included, or all included delta settings are zero, the returned aggregate +is based on the current obstype only. + +_`boundary`_ is an optional specifier that can force the starting time to a +time boundary. If set to 'midnight', then the starting time will be at the +previous midnight. If left out, then the start time will be the sum of the +optional deltas. + +_`obstype`_ is an _observation type_, such as `outTemp`. + +_`aggregation`_ is an _aggregation type_. If you ask for `$month.outTemp.avg` +you are asking for the _average_ outside temperature for the month. Possible +aggregation types are given in the reference _[Aggregation types](../reference/aggtypes.md)_. + +_`unit_conversion`_ is an optional unit conversion tag. If provided, +the results will be converted into the specified units, otherwise the default +units specified in the skin configuration file (in section `[Units][[Groups]]`) +will be used. See the section +_[Unit conversion options](#unit-conversion-options)_. + +_`formatting`_ is an optional formatting tag. If provided, it controls how the +value will appear. See the section _[Formatting options](#formatting-options)_. + +There are several delta settings that can be used: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Delta SettingExampleMeaning
time_delta=seconds$span(time_delta=1800).outTemp.avgThe average temperature over the last immediate 30 minutes (1800 seconds). +
hour_delta=hours$span(hour_delta=6).outTemp.avgThe average temperature over the last immediate 6 hours. +
day_delta=days$span(day_delta=1).rain.sumThe total rainfall over the last immediate 24 hours. +
week_delta=weeks$span(week_delta=2).barometer.maxThe maximum barometric pressure over the last immediate 2 weeks. +
+ +For example, the template expressions + + The total rainfall over the last 30 hours is $span($hour_delta=30).rain.sum + +and + + The total rainfall over the last 30 hours is $span($hour_delta=6, $day_delta=1).rain.sum + +would both result in + +
+The total rainfall over the last 30 hours is 1.24 in +
+ + + +### Tag $unit + +The type, label, and string formats for all units are also available, allowing +you to do highly customized labels: + +| Tag | Results | +|---------------------------|------------| +| `$unit.unit_type.outTemp` | `degree_C` | +| `$unit.label.outTemp` | °C | +| `$unit.format.outTemp` | `%.1f` | + +For example, the tag + + $day.outTemp.max.format(add_label=False)$unit.label.outTemp + +would result in + +
+21.2°C +
+ +(assuming metric values have been specified for `group_temperature`), +essentially reproducing the results of the simpler tag `$day.outTemp.max`. + +### Tag $obs + +The labels used for the various observation types are available using tag +`$obs`. These are basically the values given in the skin dictionary, section +[`[Labels][[Generic]]`](../reference/skin-options/labels.md#generic). + +| Tag | Results | +|----------------------|---------------------| +| `$obs.label.outTemp` | Outside Temperature | +| `$obs.label.UV` | UV Index | + +### Iteration + +It is possible to iterate over the following: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tag suffixResults
.recordsIterate over every record
.hoursIterate by hours
.daysIterate by days
.monthsIterate by months
.yearsIterate by years
.spans(interval=seconds) + + Iterate by custom length spans. The default interval is 10800 + seconds (3 hours). The spans will + align to local time boundaries. +
+ + +The following template uses a Cheetah for loop to iterate over all months in +a year, printing out each month's min and max temperature. The iteration loop +is ==highlighted==. + +```hl_lines="2 4" +Min, max temperatures by month +#for $month in $year.months +$month.dateTime.format("%B"): Min, max temperatures: $month.outTemp.min $month.outTemp.max +#end for +``` + +The result is: + +``` +Min, max temperatures by month +January: Min, max temperatures: 30.1°F 51.5°F +February: Min, max temperatures: 24.4°F 58.6°F +March: Min, max temperatures: 27.3°F 64.1°F +April: Min, max temperatures: 33.2°F 52.5°F +May: Min, max temperatures: N/A N/A +June: Min, max temperatures: N/A N/A +July: Min, max temperatures: N/A N/A +August: Min, max temperatures: N/A N/A +September: Min, max temperatures: N/A N/A +October: Min, max temperatures: N/A N/A +November: Min, max temperatures: N/A N/A +December: Min, max temperatures: N/A N/A +``` + +The following template again uses a Cheetah `for` loop, this time to iterate +over 3-hour spans over the last 24 hours, displaying the averages in each +span. The iteration loop is ==highlighted==. + +```html hl_lines="6 12" +

3 hour averages over the last 24 hours

+ + + + +#for $time_band in $span($day_delta=1).spans(interval=10800) + + + + + +#end for +
Date/timeoutTempoutHumidity
$time_band.start.format("%d/%m %H:%M")$time_band.outTemp.avg$time_band.outHumidity.avg
+``` + +The result is: + +
+

3 hour averages over the last 24 hours

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date/timeoutTempoutHumidity
21/01 18:5033.4°F95%
21/01 21:5032.8°F96%
22/01 00:5033.2°F96%
22/01 03:5033.2°F96%
22/01 06:5033.8°F96%
22/01 09:5036.8°F95%
22/01 12:5039.4°F91%
22/01 15:5035.4°F93%
+ +
+ + +See the NOAA template files `NOAA/NOAA-YYYY.txt.tmpl` and +`NOAA/NOAA-YYYY-MM.txt.tmpl`, both included in the _Seasons_ skin, for other +examples using iteration and explicit formatting. + +### Comprehensive example + +This example is designed to put together a lot of the elements described +above, including iteration, aggregation period starts and ends, formatting, +and overriding units. [Click here](../examples/tag.htm) for the results. + +```html + + + + + + + + + + + + +#for $hour in $day($days_ago=1).hours + + + + + +#end for + +
Time intervalMax temperatureTime
$hour.start.format("%H:%M")-$hour.end.format("%H:%M")$hour.outTemp.max ($hour.outTemp.max.degree_C)$hour.outTemp.maxtime.format("%H:%M")
+

+ Hourly max temperatures yesterday
+ $day($days_ago=1).start.format("%d-%b-%Y") +

+
+ + +``` + +### Support for series + +!!! Note + This is an experimental API that could change. + +WeeWX V4.5 introduced some experimental tags for producing _series_ of data, +possibly aggregated. This can be useful for creating the JSON data needed for +JavaScript plotting packages, such as +[HighCharts](https://www.highcharts.com/), +[Google Charts](https://developers.google.com/chart), +or [C3.js](https://c3js.org/). + +For example, suppose you need the maximum temperature for each day of the +month. This tag + + $month.outTemp.series(aggregate_type='max', aggregate_interval='1d', time_series='start').json + +would produce the following: + + [[1614585600, 58.2], [1614672000, 55.8], [1614758400, 59.6], [1614844800, 57.8], ... ] + +This is a list of (time, temperature) for each day of the month, in JSON, +easily consumed by many of these plotting packages. + +Many other combinations are possible. See the Wiki article +[_Tags for series_](https://github.com/weewx/weewx/wiki/Tags-for-series). + +### Helper functions + +WeeWX includes a number of helper functions that may be useful when writing +templates. + +#### $rnd(x, ndigits=None) + +Round `x` to `ndigits` decimal digits. The argument `x` can be a `float` or a list of `floats`. Values of `None` are passed through. + +#### $jsonize(seq) + +Convert the iterable `seq` to a JSON string. + +#### $to_int(x) + +Convert `x` to an integer. The argument `x` can be of type `float` or `str`. Values of `None` are passed through. + +#### $to_bool(x) + +Convert `x` to a boolean. The argument `x` can be of type `int`, `float`, or `str`. If lowercase `x` is 'true', 'yes', or 'y' the function returns `True`. If it is 'false', 'no', or 'n' it returns `False`. Other string values raise a `ValueError`. In case of a numeric argument, 0 means `False`, all other values `True`. + +#### $to_list(x) + +Convert `x` to a list. If `x` is already a list, nothing changes. If it is a single value it is converted to a list with this value as the only list element. Values of `None` are passed through. + +#### $getobs(plot_name) + +For a given plot name, this function will return the set of all observation types used by the plot. + +For example, consider a plot that is defined in `[ImageGenerator]` as + +```ini +[[[daytempleaf]]] + [[[[leafTemp1]]]] + [[[[leafTemp2]]]] + [[[[temperature]]]] + data_type = outTemp +``` + +The tag `$getobs('daytempleaf')` would return the set `{'leafTemp1', 'leafTemp2', 'outTemp'}`. + +### General tags + +There are some general tags that do not reflect observation data, but +technical information about the template files. They are frequently useful +in `#if` expressions to control how Cheetah processes the template. + +#### $encoding + +Character encoding, to which the file is converted after creation. Possible values are `html_entities`, `strict_ascii`, `normalized_ascii`, and `utf-8`. + +#### $filename + +Name of the file to be created including relative path. Can be used to set the canonical URL for search engines. + + + +#### $lang + +Language code set by the `lang` option for the report. For example, `fr`, or `gr`. + +#### $month_name + +For templates listed under `SummaryByMonth`, this will contain the localized month name (_e.g._, "_Sep_"). + +#### $page + +The section name from `skin.conf` where the template is described. + +#### $skin + +The value of option `skin` in `weewx.conf`. + +#### $SKIN_NAME + +All skin included with WeeWX, version 4.6 or later, include the tag `$SKIN_NAME`. For example, for the _Seasons_ skin, `$SKIN_NAME` would return `Seasons`. + +#### $SKIN_VERSION + +All skin included with WeeWX, version 4.6 or later, include the tag `$SKIN_VERSION`, which returns the WeeWX version number of when the skin was installed. Because skins are not touched during the upgrade process, this shows the origin of the skin. + +#### $SummaryByDay + +A list of year-month-day strings (_e.g._, `["2018-12-31", "2019-01-01"]`) for which a summary-by-day has been generated. The `[[SummaryByDay]]` section must have been processed before this tag will be valid, otherwise it will be empty. + +#### $SummaryByMonth + +A list of year-month strings (_e.g._, `["2018-12", "2019-01"]`) for which a summary-by-month has been generated. The `[[SummaryByMonth]]` section must have been processed before this tag will be valid, otherwise it will be empty. + +#### $SummaryByYear + +A list of year strings (_e.g._, `["2018", "2019"]`) for which a summary-by-year has been generated. The `[[SummaryByYear]]` section must have been processed before this tag will be valid, otherwise it will be empty. + +#### $year_name + +For templates listed under `SummaryByMonth` or `SummaryByYear`, this will contain the year (_e.g._, "2018"). + + +### `$gettext` - Internationalization + +Pages generated by WeeWX not only contain observation data, but also static +text. The WeeWX tag `$gettext` provides internationalization support for these +kinds of texts. It is structured very similarly to the +[GNU gettext facility](https://www.gnu.org/software/gettext/), but its +implementation is very different. To support internationalization of your +template, do not use static text in your templates, but rather use `$gettext`. +Here's how. + +Suppose you write a skin called "YourSkin", and you want to include a headline +labelled "Current Conditions" in English, "aktuelle Werte" in German, +"Conditions actuelles" in French, etc. Then the template file could contain: + +``` +

$gettext("Current Conditions")

+``` + +The section of `weewx.conf` configuring your skin would look something like +this: + +``` +[StdReport] + [[YourSkinReport]] + skin = YourSkin + lang = fr +``` + +With `lang = fr` the report is in French. To get it in English, replace the +language code `fr` by the code for English `en`. And to get it in German +use `de`. + +To make this all work, a language file has to be created for each supported +language. The language files reside in the `lang` subdirectory of the skin +directory that is defined by the skin option. The file name of the language +file is the language code appended by `.conf`, for example `en.conf`, +`de.conf`, or `fr.conf`. + +The language file has the same layout as `skin.conf`, _i.e._ you can put +language specific versions of the labels there. Additionally, a section +`[Texts]` can be defined to hold the static texts used in the skin. For +the example above the language files would contain the following: + +`en.conf` + +``` +[Texts] + "Current Conditions" = Current Conditions +``` + +`de.conf` + +``` +[Texts] + "Current Conditions" = Aktuelle Werte +``` + +`fr.conf` + +``` +[Texts] + "Current Conditions" = Conditions actuelles +``` + +While it is not technically necessary, we recommend using the whole English +text for the key. This makes the template easier to read, and easier for the +translator. In the absence of a translation, it will also be the default, so +the skin will still be usable, even if a translation is not available. + +See the subdirectory `SKIN_ROOT/Seasons/lang` for examples of language files. + + +### $pgettext - Context sensitive lookups + +A common problem is that the same string may have different translations, +depending on its context. For example, in English, the word "Altitude" is +used to mean both height above sea level, and the angle of a heavenly body +from the horizon, but that's not necessarily true in other languages. For +example, in Thai, "ระดับความสูง" is used to mean the former, "อัลติจูด" the latter. +The function `pgettext()` (the "p" stands for _particular_) allows you to +distinguish between the two. Its semantics are very similar to the +[GNU](https://www.gnu.org/software/gettext/manual/gettext.html#Contexts) and +[Python](https://docs.python.org/3/library/gettext.html#gettext.pgettext) +versions of the function. + +Here's an example: + +``` +

$pgettext("Geographical","Altitude"): $station.altitude

+

$pgettext("Astronomical","Altitude"): $almanac.moon.alt

+``` + +The `[Texts]` section of the language file should then contain a subsection +for each context. For example, the Thai language file would include: + +`th.conf` + +``` +[Texts] + [[Geographical]] + "Altitude" = "ระดับความสูง" # As in height above sea level + [[Astronomical]] + "Altitude" = "อัลติจูด" # As in angle above the horizon +``` + +## Almanac + +If module [`ephem`](https://rhodesmill.org/pyephem) has been installed, then +WeeWX can generate extensive almanac information for the Sun, Moon, Venus, +Mars, Jupiter, and other heavenly bodies, including their rise, transit and +set times, as well as their azimuth and altitude. Other information is also +available. + +Here is an example template: + +``` +Current time is $current.dateTime +#if $almanac.hasExtras + Sunrise, transit, sunset: $almanac.sun.rise $almanac.sun.transit $almanac.sun.set + Moonrise, transit, moonset: $almanac.moon.rise $almanac.moon.transit $almanac.moon.set + Mars rise, transit, set: $almanac.mars.rise $almanac.mars.transit $almanac.mars.set + Azimuth, altitude of Mars: $almanac.mars.azimuth $almanac.mars.altitude + Next new, full moon: $almanac.next_new_moon; $almanac.next_full_moon + Next summer, winter solstice: $almanac.next_summer_solstice; $almanac.next_winter_solstice +#else + Sunrise, sunset: $almanac.sunrise $almanac.sunset +#end if +``` + +If `ephem` is installed this would result in: + +
+Current time is 03-Sep-2010 11:00 + Sunrise, transit, sunset: 06:29 13:05 19:40 + Moonrise, transit, moonset: 00:29 08:37 16:39 + Mars rise, transit, set: 10:12 15:38 21:04 + Azimuth, altitude of Mars: 111° 08° + Next new, full moon: 08-Sep-2010 03:29; 23-Sep-2010 02:17 + Next summer, winter solstice: 21-Jun-2011 10:16; 21-Dec-2010 15:38 +
+ +Otherwise, a fallback of basic calculations is used, resulting in: + +
+Current time is 29-Mar-2011 09:20
+Sunrise, sunset: 06:51 19:30 +
+ +As shown in the example, you can test whether this extended almanac +information is available with the value `$almanac.hasExtras`. + +The almanac information falls into three categories: + +* Calendar events +* Heavenly bodies +* Functions + +We will cover each of these separately. + +### Calendar events + +"Calendar events" do not require a heavenly body. They cover things such as +the time of the next solstice or next first quarter moon, or the sidereal +time. The syntax is: + + $almanac.next_solstice + +or + + $almanac.next_first_quarter_moon + +or + + $almanac.sidereal_angle + +Here is a table of the information that falls into this category: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Calendar events
previous_equinoxnext_equinox
previous_solsticenext_solstice
previous_autumnal_equinoxnext_autumnal_equinox
previous_vernal_equinoxnext_vernal_equinox
previous_winter_solsticenext_winter_solstice
previous_summer_solsticenext_summer_solstice
previous_new_moonnext_new_moon
previous_first_quarter_moonnext_first_quarter_moon
previous_full_moonnext_full_moon
previous_last_quarter_moonnext_last_quarter_moon
sidereal_angle
+ +!!! Note + The tag `$almanac.sidereal_angle` returns a value in decimal degrees + rather than a more customary value from 0 to 24 hours. + +### Heavenly bodies + +The second category does require a heavenly body. This covers queries such +as, "When does Jupiter rise?" or, "When does the sun transit?" Examples are + + $almanac.jupiter.rise + +or + + $almanac.sun.transit + +To accurately calculate these times, WeeWX automatically uses the present +temperature and pressure to calculate refraction effects. However, you can +override these values, which will be necessary if you wish to match the +almanac times published by the Naval Observatory [as explained in the PyEphem +documentation](https://rhodesmill.org/pyephem/rise-set.html). For example, +to match the sunrise time as published by the Observatory, instead of + + $almanac.sun.rise + +use + + $almanac(pressure=0, horizon=-34.0/60.0).sun.rise + +By setting pressure to zero we are bypassing the refraction calculations +and manually setting the horizon to be 34 arcminutes lower than the +normal horizon. This is what the Navy uses. + +If you wish to calculate the start of civil twilight, you can set the +horizon to -6 degrees, and also tell WeeWX to use the center of the sun +(instead of the upper limb, which it normally uses) to do the +calcuation: + + $almanac(pressure=0, horizon=-6).sun(use_center=1).rise + +The general syntax is: + +``` +$almanac(almanac_time=time, ## Unix epoch time + lat=latitude, lon=longitude, ## degrees + altitude=altitude, ## meters + pressure=pressure, ## mbars + horizon=horizon, ## degrees + temperature=temperature_C ## degrees C + ).heavenly_body(use_center=[01]).attribute + +``` + +As you can see, many other properties can be overridden besides pressure +and the horizon angle. + +PyEphem offers an extensive list of objects that can be used for the +_`heavenly_body`_ tag. All the planets and many stars are in the list. + +The possible values for the _`attribute`_ tag are listed in the following +table, along with the corresponding name used in the PyEphem documentation. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Attributes that can be used with heavenly bodies
WeeWX namePyEphem nameMeaning
azimuthazAzimuth
altitudealtAltitude
astro_raa_raAstrometric geocentric right ascension
astro_deca_decAstrometric geocentric declination
geo_rag_raApparent geocentric right ascension
topo_raraApparent topocentric right ascension
geo_decg_decApparent geocentric declination
topo_decdecApparent topocentric declination
elongationelongAngle with sun
radius_sizeradiusSize as an angle
hlongitudehlonAstrometric heliocentric longitude
hlatitudehlatAstrometric heliocentric latitude
sublatitudesublatGeocentric latitude
sublongitudesublonGeocentric longitude
next_risingnext_risingTime body will rise next
next_settingnext_settingTime body will set next
next_transitnext_transitTime body will transit next
next_antitransitnext_antitransitTime body will anti-transit next
previous_risingprevious_risingPrevious time the body rose
previous_settingprevious_settingPrevious time the body sat
previous_transitprevious_transitPrevious time the body transited
previous_antitransitprevious_antitransitPrevious time the body anti-transited
risenext_risingTime body will rise next
setnext_settingTime body will set next
transitnext_transitTime body will transit next
visibleN/AHow long body will be visible
visible_changeN/AChange in visibility from previous day
+ +!!! Note + The tags `topo_ra`, `astro__ra` and `geo_ra` return values in decimal + degrees rather than customary values from 0 to 24 hours. + +### Functions + +There is actually one function in this category: `separation`. It returns +the angular separation between two heavenly bodies. For example, to calculate +the angular separation between Venus and Mars you would use: + +``` tty +

The separation between Venus and Mars is + $almanac.separation(($almanac.venus.alt,$almanac.venus.az), ($almanac.mars.alt,$almanac.mars.az))

+``` + +This would result in: + +
+The separation between Venus and Mars is 55:55:31.8 +
+ +### Adding new bodies + +It is possible to extend the WeeWX almanac, adding new bodies that it was not +previously aware of. For example, say we wanted to add +[*433 Eros*](https://en.wikipedia.org/wiki/433_Eros), the first asteroid +visited by a spacecraft. Here is the process: + +1. Put the following in the file `user/extensions.py`: + + ``` tty + import ephem + eros = ephem.readdb("433 Eros,e,10.8276,304.3222,178.8165,1.457940,0.5598795,0.22258902,71.2803,09/04.0/2017,2000,H11.16,0.46") + ephem.Eros = eros + ``` + + This does two things: it adds orbital information about *433 Eros* + to the internal PyEphem database, and it makes that data available + under the name `Eros` (note the capital letter). + +2. You can then use *433 Eros* like any other body in your templates. + For example, to display when it will rise above the horizon: + + ``` tty + $almanac.eros.rise + ``` + +## Wind + +Wind deserves a few comments because it is stored in the database in two +different ways: as a set of scalars, and as a *vector* of speed and +direction. Here are the four wind-related scalars stored in the main +archive database: + + + + + + + + + + + + + + + + + + + + + + + + + + +
Archive typeMeaningValid contexts
windSpeedThe average wind speed seen during the archive period. + + $current, $latest, $hour, $day, $week, $month, $year, $rainyear +
windDir + If software record generation is used, this is the vector average + over the archive period. If hardware record generation is used, the + value is hardware dependent. +
windGustThe maximum (gust) wind speed seen during the archive period.
windGustDirThe direction of the wind when the gust was observed.
+ +Some wind aggregation types, notably `vecdir` and `vecavg`, require wind +speed *and* direction. For these, WeeWX provides a composite observation +type called `wind`. It is stored directly in the daily summaries, but +synthesized for aggregations other than multiples of a day. + +| Daily summary type | Meaning | Valid contexts | +|--------------------|---------------------------------|----------------------------------------------------------| +| wind | A vector composite of the wind. | `$hour`, `$day`, `$week`, `$month`, `$year`, `$rainyear` | + +Any of these can be used in your tags. Here are some examples: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagMeaning
$current.windSpeedThe average wind speed over the most recent archive interval. +
$current.windDir +If software record generation is used, this is the vector average over the +archive interval. If hardware record generation is used, the value is +hardware dependent. +
$current.windGustThe maximum wind speed (gust) over the most recent archive interval. +
$current.windGustDirThe direction of the gust.
$day.windSpeed.avg
$day.wind.avg
+The average wind speed since midnight. If the wind blows east at 5 m/s for 2 +hours, then west at 5 m/s for 2 hours, the average wind speed is 5 m/s. +
$day.wind.vecavg +The vector average wind speed since midnight. If the wind blows +east at 5 m/s for 2 hours, then west at 5 m/s for 2 hours, the vector +average wind speed is zero. +
$day.wind.vecdir +The direction of the vector averaged wind speed. If the wind blows northwest +at 5 m/s for two hours, then southwest at 5 m/s for two hours, the vector +averaged direction is west. +
$day.windGust.max
$day.wind.max
The maximum wind gust since midnight.
$day.wind.gustdirThe direction of the maximum wind gust.
$day.windGust.maxtime
$day.wind.maxtime
The time of the maximum wind gust.
$day.windSpeed.max +The max average wind speed. The wind is averaged over each of the archive +intervals. Then the maximum of these values is taken. Note that this is +not the same as the maximum wind gust. +
$day.windDir.avg +Not a very useful quantity. This is the strict, arithmetic average of all +the compass wind directions. If the wind blows at 350° for two hours +then at 10° for two hours, then the scalar average wind direction will +be 180° — probably not what you expect, nor want. +
+ + +## Defining new tags {#defining-new-tags} + +We have seen how you can change a template and make use of the various tags +available such as `$day.outTemp.max` for the maximum outside temperature for +the day. But, what if you want to introduce some new data for which no tag +is available? + +If you wish to introduce a static tag, that is, one that will not change with +time (such as a Google Analytics tracker ID, or your name), then this is very +easy: put it in section [`[Extras]`](../reference/skin-options/extras.md) in +the skin configuration file. More information on how to do this can be found +there. + +But, what if you wish to introduce a more dynamic tag, one that requires some +calculation, or perhaps uses the database? Simply putting it in the `[Extras]` +section won't do, because then it cannot change. + +The answer is to write a *search list extension*. Complete directioins on how +to do this are in the document +[*Writing search list extensions*](sle.md). diff --git a/dist/weewx-5.0.2/docs_src/custom/custom-reports.md b/dist/weewx-5.0.2/docs_src/custom/custom-reports.md new file mode 100644 index 0000000..1a25a33 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/custom-reports.md @@ -0,0 +1,512 @@ +# Customizing reports + +There are two general mechanisms for customizing reports: change options in +one or more configuration files, or change the template files. The former is +generally easier, but occasionally the latter is necessary. + +## How to specify options + +Options are used to control how reports will look and what they will contain. +For example, they determine which units to use, how to format dates and times, +which data should be in each plot, the colors of plot elements, _etc_. + +Options are read from three different types of _configuration files:_ + + + + + + + + + + + + + + + + + + + + + + + +
Configuration files
FileUse
weewx.conf +This is the application configuration file. It contains general configuration +information, such which drivers and services to load, as well as which reports +to run. Report options can also be specified in this file. +
skin.conf +This is the skin configuration file. It contains information specific to a +skin, in particular, which template files to process, and which plots +to generate. Typically, this file is supplied by the skin author. +
en.conf
de.conf
fr.conf
etc.
+These are internationalization files. They contain language and locale +information for a specific skin. +
+ +Configuration files are read and processed using the Python utility +[ConfigObj](https://configobj.readthedocs.io), using a format similar to the +MS-DOS ["INI" format](https://en.wikipedia.org/wiki/INI_file). Here's a +simple example: + +```ini +[Section1] + # A comment + key1 = value1 + [[SubSectionA]] + key2 = value2 +[Section2] + key3=value3 +``` + +This example uses two sections at root level (sections `Section1` and +`Section2`), and one subsection (`SubSectionA`), which is nested under +`Section1`. The option `key1` is nested under `Section1`, option `key3` is +nested under `Section2`, while option `key2` is nested under subsection +`SubSectionA`. + +Note that while this example indents subsections and options, this is +strictly for readability — this isn't Python! It's the number of brackets +that counts in determining nesting, not the indentation! It would torture +your readers, but the above example could be written + +```ini + [Section1] +# A comment +key1 = value1 +[[SubSectionA]] +key2 = value2 +[Section2] +key3=value3 +``` + +Configuration files take advantage of ConfigObj's ability to organize options +hierarchically into _stanzas_. For example, the `[Labels]` stanza contains the +text that should be displayed for each observation. The `[Units]` stanza +contains other stanzas, each of which contains parameters that control the +display of units. + + +## Processing order + +Configuration files and their sections are processed in a specific order. +Generally, the values from the skin configuration file (`skin.conf`) are +processed first, then options in the WeeWX configuration file (nominally +`weewx.conf`) are applied last. This order allows skin authors to specify the +basic look and feel of a report, while ensuring that users of the skin have +the final say. + +To illustrate the processing order, here are the steps for the skin _Seasons_: + +* First, a set of options defined in the Python module `weewx.defaults` serve +as the starting point. + +* Next, options from the configuration file for _Seasons_, located in +`skins/Seasons/skin.conf`, are merged. + +* Next, any options that apply to all skins, specified in the +`[StdReport] / [[Defaults]]` section of the WeeWX configuration file, are +merged. + +* Finally, any skin-specific options, specified in the +`[StdReport] / [[Seasons]]` section of the WeeWX configuration, are merged. +These options have the final say. + +At all four steps, if a language specification is encountered (option `lang`), +then the corresponding language file will be read and merged. If a unit +specification (option `unit_system`) is encountered, then the appropriate +unit groups are set. For example, if `unit_system=metricwx`, then the unit +for `group_pressure` will be set to `mbar`, etc. + +The result is the following option hierarchy, listed in order of increasing +precedence. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Option hierarchy, lowest to highest
FileExampleComments
weewx/defaults.py + [Units]
  [[Labels]]
    mbar=" mbar" +
+These are the hard-coded default values for every option. They are used when +an option is not specified anywhere else. These should not be modified unless +you propose a change to the WeeWX code; any changes made here will be lost +when the software is updated. +
skin.conf + [Units]
  [[Labels]]
    mbar=" hPa" +
+Supplied by the skin author, the skin configuration file, +skin.conf, contains options that define the baseline +behavior of the skin. In this example, for whatever reason, the skin author +has decided that the label for units in millibars should be +" hPa" (which is equivalent). +
weewx.conf + [StdReport]
  [[Defaults]]
    [[[Labels]]]
      [[[[Generic]]]]
+         rain=Rainfall +
+Options specified under [[Defaults]] apply to +all reports. This example indicates that the label +Rainfall should be used for the observation +rain, in all reports. +
weewx.conf + [StdReport]
  [[SeasonsReport]]
    [[[Labels]]]
      [[[[Generic]]]]
+         inTemp=Kitchen temperature +
+Highest precedence. Has the final say. Options specified here apply to a +single report. This example indicates that the label +Kitchen temperature should be used for the +observation inTemp, but only for the report +SeasonsReport. +
+ +!!! Note + When specifying options, you must pay attention to the number of brackets! + In the table above, there are two different nesting depths used: one for + `weewx.conf`, and one for `weewx/defaults.py` and `skin.conf`. This is + because the stanzas defined in `weewx.conf` start two levels down in the + hierarchy `[StdReport]`, whereas the stanzas defined in `skin.conf` and + `defaults.py` are at the root level. Therefore, options specified in + `weewx.conf` must use two extra sets of brackets. + +Other skins are processed in a similar manner although, of course, their name +will be something other than _Seasons_. + +Although it is possible to modify the options at any level, as the user of a +skin, it is usually best to keep your modifications in the WeeWX configuration +file (`weewx.conf`) if you can. That way you can apply any fixes or changes +when the skin author updates the skin, and your customizations will not be +overwritten. + +If you are a skin author, then you should provide the skin configuration file +(`skin.conf`), and put in it only the options necessary to make the skin render +the way you intend it. Any options that are likely to be localized for a +specific language (in particular, text), should be put in the appropriate +language file. + + +## Changing languages + +By default, the skins that come with WeeWX are set up for the English language, +but suppose you wish to switch to another language. How you do so will depend +on whether the skin you are using has been _internationalized_ and, if so, +whether it offers your local language. + +### Internationalized skins + +All the skins included with WeeWX have been internationalized, so if you're +working with one of them, this is the section you want. Next, you need to check +whether there is a _localization_ file for your particular language. To check, +look in the contents of subdirectory `lang` in the skin's directory. For +example, if you used a package installer and are using the _Seasons_ skin, you +will want to look in `/etc/weewx/skins/Seasons/lang`. Inside, you will see +something like this: + +``` +ls -l /etc/weewx/skins/Seasons/lang +total 136 +-rw-rw-r-- 1 tkeffer tkeffer 9844 Mar 13 12:31 cz.conf +-rw-rw-r-- 1 tkeffer tkeffer 9745 Mar 13 12:31 de.conf +-rw-rw-r-- 1 tkeffer tkeffer 9459 Mar 13 12:31 en.conf +-rw-rw-r-- 1 tkeffer tkeffer 10702 Mar 13 12:31 es.conf +-rw-rw-r-- 1 tkeffer tkeffer 10673 May 31 07:50 fr.conf +-rw-rw-r-- 1 tkeffer tkeffer 11838 Mar 13 12:31 gr.conf +-rw-rw-r-- 1 tkeffer tkeffer 9947 Mar 13 12:31 it.conf +-rw-rw-r-- 1 tkeffer tkeffer 9548 Mar 13 12:31 nl.conf +-rw-rw-r-- 1 tkeffer tkeffer 10722 Apr 15 14:52 no.conf +-rw-rw-r-- 1 tkeffer tkeffer 15356 Mar 13 12:31 th.conf +-rw-rw-r-- 1 tkeffer tkeffer 9447 Jul 1 11:11 zh.conf +``` + +This means that the _Seasons_ skin has been localized for the following +languages[^1]: + +| File | Language | +|---------|--------------------| +| cz.conf | Czeck | +| de.conf | German | +| en.conf | English | +| es.conf | Spanish | +| fr.conf | French | +| it.conf | Italian | +| gr.conf | Greek | +| nl.conf | Dutch | +| th.conf | Thai | +| zh.conf | Simplified Chinese | + +If you want to use the _Seasons_ skin and are working with one of these +languages, then you are in luck: you can simply override the `lang` option. +For example, to change the language displayed by the _Seasons_ skin from +English to German, edit `weewx.conf`, and change the highlighted line: + +```ini hl_lines="8" +[StdReport] + ... + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + lang = de +``` + +By contrast, if the skin has been internationalized, but there is no +localization file for your language, then you will have to supply one. See +the section [_Internationalized, but your language is missing_](localization.md#internationalized-missing-language). + + +## Changing date and time formats + +Date and time formats are specified using the same format strings used by +[strftime()](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior). +For example, `%Y` indicates the 4-digit year, and `%H:%M` indicates the time +in hours:minutes. The default values for date and time formats are generally +`%x %X`, which indicates "use the format for the locale of the computer". + +Since date formats default to the locale of the computer, a date might appear +with the format of "month/day/year". What if you prefer dates to have the +format "year.month.day"? How do you indicate 24-hour time format versus +12-hour? + +Dates and times generally appear in two places: in plots and in tags. + +### Date and time formats in images + +Most plots have a label on the horizontal axis that indicates when the plot +was generated. By default, the format for this label uses the locale of the +computer on which WeeWX is running, but you can modify the format by +specifying the option `bottom_label_format`. + +For example, this would result in a date/time string such as +"2021.12.13 12:45" no matter what the computer's locale: + +```ini +[StdReport] + ... + [[Defaults]] + [[[ImageGenerator]]] + [[[[day_images]]]] + bottom_label_format = %Y.%m.%d %H:%M + [[[[week_images]]]] + bottom_label_format = %Y.%m.%d %H:%M + [[[[month_images]]]] + bottom_label_format = %Y.%m.%d %H:%M + [[[[year_images]]]] + bottom_label_format = %Y.%m.%d %H:%M +``` + +### Date and time formats for tags + +Each aggregation period has a format for the times associated with that period. +These formats are defined in the `TimeFormats` section. The default formats +use the date and/or time for the computer of the locale on which WeeWX is +running. + +For example, this would result in a date/time string such as +"2021.12.13 12:45" no matter what the computer's locale: + +```ini +[StdReport] + ... + [[Defaults]] + [[[Units]]] + [[[[TimeFormats]]]] + hour = %H:%M + day = %Y.%m.%d + week = %Y.%m.%d (%A) + month = %Y.%m.%d %H:%M + year = %Y.%m.%d %H:%M + rainyear = %Y.%m.%d %H:%M + current = %Y.%m.%d %H:%M + ephem_day = %H:%M + ephem_year = %Y.%m.%d %H:%M +``` + + +## Changing unit systems + +Each _unit system_ is a set of units. For example, the `METRIC` unit system +uses centimeters for rain, kilometers per hour for wind speed, and degree +Celsius for temperature. The option +[`unit_system`](../reference/weewx-options/stdreport.md#unit_system) +controls which unit system will be used in your reports. The available choices +are `US`, `METRIC`, or `METRICWX`. The option is case-insensitive. See +[_Units_](../reference/units.md) for the units defined in each of these unit +systems. + +By default, WeeWX uses `US` (US Customary) system. Suppose you would rather +use the `METRICWX` system for all your reports? Then change this + +```ini hl_lines="7" +[StdReport] + ... + [[Defaults]] + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us +``` + +to this + +```ini hl_lines="7" +[StdReport] + ... + [[Defaults]] + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = metricwx +``` + +### Mixed units + +However, what if you want a mix? For example, suppose you generally want US +Customary units, but you want barometric pressures to be in millibars? This +can be done by _overriding_ the appropriate unit group. + +```ini hl_lines="12" +[StdReport] + ... + [[Defaults]] + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us + + # Override the units used for pressure: + [[[Units]]] + [[[[Groups]]]] + group_pressure = mbar +``` + +This says that you generally want the US systems of units for all reports, but +want pressure to be reported in _millibars_. Other units can be overridden in +a similar manner. + +### Multiple unit systems + +Another example. Suppose we want to generate _two_ reports, one in the `US` +system, the other using the `METRICWX` system. The first, call it +_SeasonsUSReport_, will go in the regular directory `HTML_ROOT`. However, the +latter, call it _SeasonsMetricReport_, will go in a subdirectory, +`HTML_ROOT/metric`. Here's how you would do it + +```ini +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + [[SeasonsUSReport]] + skin = Seasons + unit_system = us + enable = true + + [[SeasonsMetricReport]] + skin = Seasons + unit_system = metricwx + HTML_ROOT = public_html/metric + enable = true +``` + +Note how both reports use the same _skin_ (that is, skin _Seasons_), but +different unit systems, and different destinations. The first, +_SeasonsUSReport_ sets option unit_system to `us`, and uses the default +destination. By contrast, the second, _SeasonsMetricReport_, uses unit system +`metricwx`, and a different destination, `public_html/metric`. + +## Changing labels + +Every observation type is associated with a default _label_. For example, in +the English language, the default label for observation type `outTemp` is +generally "Outside Temperature". You can change this label by _overriding_ +the default. How you do so will depend on whether the skin you are using has +been _internationalized_ and, if so, whether it offers your local language. + +Let's look at an example. If you take a look inside the file +`skins/Seasons/lang/en.conf`, you will see it contains what looks like a big +configuration file. Among other things, it has two entries that look like this: + +```ini +... +[Labels] + ... + [[Generic]] + ... + inTemp = Inside Temperature + outTemp = Outside Temperature + ... +``` + +This tells the report generators that when it comes time to label the +observation variables `inTemp` and `outTemp`, use the strings +"Inside Temperature" and "Outside Temperature", respectively. + +However, let's say that we have actually located our outside temperature sensor +in the barn, and wish to label it accordingly. We need to _override_ the label +that comes in the localization file. We could just change the localization +file `en.conf`, but then if the author of the skin came out with a new version, +our change could get lost. Better to override the default by making the change +in `weewx.conf`. To do this, make the following changes in `weewx.conf`: + +```ini + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + lang = en + unit_system = US + enable = true + [[[Labels]]] + [[[[Generic]]]] + outTemp = Barn Temperature +``` + +This will cause the default label Outside Temperature to be replaced with the +new label "Barn Temperature" everywhere in your report. The label for type +`inTemp` will be untouched. + +[^1]: V5 uses two letter [ISO 639 language +codes](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) to signify +a language. It does not support four letter country codes (such as `en_NZ`). +Naturally, this simple model comes with limitations. For example, Simplified +Chinese is usually signified by `zh_CN`, while Traditional Chinese by `zh_TW`. +With only a two letter code available, we must choose which we mean. We have +chosen the former, Simplified Chinese. This two-letter limitation may be relaxed +in the future. \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/custom/database.md b/dist/weewx-5.0.2/docs_src/custom/database.md new file mode 100644 index 0000000..2fa53ad --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/database.md @@ -0,0 +1,377 @@ +# Customizing the database + +For most users the database defaults will work just fine. However, there +may be occasions when you may want to add a new observation type to your +database, or change its unit system. This section shows you how to do +this. + +Every relational database depends on a *schema* to specify which types +to include in the database. When a WeeWX database is first created, it +uses a Python version of the schema to initialize the database. However, +once the database has been created, the schema is read directly from the +database and the Python version is not used again — any changes to it +will have no effect. This means that the strategy for modifying the +schema depends on whether the database already exists. + +## Specifying a schema for a new database + +If the database does not exist yet, then you will want to pick an +appropriate starting schema. If it's not exactly what you want, you can +modify it to fit your needs before creating the database. + +### Picking a starting schema + +WeeWX gives you a choice of three different schemas to choose from when +creating a new database: + +| Name | Number of
observation types | Comment | +|------------------|---------------|----------------------------------------------------------| +| `schemas.wview.schema`| 49 | The original schema that came with wview. | +| `schemas.wview_extended.schema` | 111 | A version of the wview schema,
which has been extended with
many new types.
This is the default version. | +| `schemas.wview_small.schema` | 20 | A minimalist version of the wview schema. | + + +For most users, the default database schema, +`schemas.wview_extended.schema`, will work just fine. + +To specify which schema to use when creating a database, modify option +`schema` in section `[DataBindings]` in +`weewx.conf`. For example, suppose you wanted to use the classic +(and smaller) schema `schemas.wview.schema` instead of the +default `schemas.wview_extended.schema`. Then the section +`[DataBindings]` would look like: + +``` ini hl_lines="6" +[DataBindings] + [[wx_binding]] + database = archive_sqlite + table_name = archive + manager = weewx.manager.DaySummaryManager + schema = schemas.wview.schema +``` + +Now, when you start WeeWX, it will use this new choice instead of the +default. + +!!! Note + This only works when the database is *first created*. Thereafter, + WeeWX reads the schema directly from the database. Changing this option + will have no effect! + +### Modifying a starting schema {#modify-starting-schema} + +If none of the three starting schemas that come with WeeWX suits your purposes, +you can easily create your own. Just pick one of the three schemas as a +starting point, then modify it. Put the results in the `user` subdirectory, +where it will be safe from upgrades. For example, suppose you like the +`schemas.wview_small` schema, but you need to store the type `electricity` +from the example +[*Adding a second data source*](service-engine.md#add-data-source). The type +`electricity` does not appear in the schema, so you'll have to add it before +starting up WeeWX. We will call the resulting new schema +`user.myschema.schema`. + +If you did a Debian install, here's how you would do this: + +``` shell + # Copy the wview_small schema over to the user subdirectory and rename it myschema: +sudo cp /usr/share/weewx/schemas/wview_small.py /etc/weewx/bin/user/myschema.py + + # Edit it using your favorite text editor +sudo nano /etc/weewx/bin/user/myschema.py +``` + +If you did a pip install, it can be difficult to find the starting schema +because it can be buried deep in the Python library tree. It's easier to +just download from the git repository and start with that: + +``` shell + # Download the wview_small schema and rename it to myschema.py +cd ~/weewx-data/bin/user +wget https://raw.githubusercontent.com/weewx/weewx/master/bin/schemas/wview_small.py +mv wview_small.py myschema.py + + # Edit it using your favorite text editor +nano myschema.py +``` + +In `myschema.py` change this: + +``` tty + ... + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windSpeed', 'REAL'), + ] +``` + +to this + +``` tty hl_lines="7" + ... + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windSpeed', 'REAL'), + ('electricity', 'REAL'), + ] +``` + +The only change was the addition (==highlighted==) of `electricity` to the +list of observation names. + +Now change option `schema` under `[DataBindings]` in `weewx.conf` to use +your new schema: + +``` tty hl_lines="6" +[DataBindings] + [[wx_binding]] + database = archive_sqlite + table_name = archive + manager = weewx.manager.DaySummaryManager + schema = user.myschema.schema +``` + +Start WeeWX. When the new database is created, it will use your modified +schema instead of the default. + +!!! Note + This will only work when the database is first created! + Thereafter, WeeWX reads the schema directly from the database and your + changes will have no effect! + + +## Modify the schema of an existing database + +The previous section covers the case where you do not have an existing +database, so you modify a starting schema, then use it to initialize the +database. But, what if you already have a database, and you want to modify +its schema, perhaps by adding a column or two? Creating a new starting schema +is not going to work because it is only used when the database is first +created. Here is where the command +[`weectl database`](../utilities/weectl-database.md) can be useful. + +There are two ways to do this. Both are covered below. + +1. Modify the database *in situ*. This choice works best for small changes. + +2. Reconfigure the old database to a new one while modifying it along the + way, This choice is best for large modifications. + +!!! Warning + Before using `weectl database`, MAKE A BACKUP! + + Be sure to stop `weewxd` before making any changes to the database. + +### Modify the database *in situ* + +If you want to make some minor modifications to an existing database, perhaps +adding or removing a column, then this can easily be done using the command +`weectl database` with an appropriate action. We will cover the cases of +adding, removing, and renaming a type. See the documentation for [`weectl +database`](../utilities/weectl-database.md) for more details. + +#### Adding a type {#add-archive-type} + +Suppose you have an existing database, to which you want to add a type, such as +the type `electricity` from the example [*Adding a second data +source*](service-engine.md#add-data-source). This can be done in one easy +step using the action `weectl database add-column`: + +``` shell +weectl database add-column electricity +``` + +The tool not only adds `electricity` to the main archive table, but also to the +daily summaries. + +#### Removing a type {#remove-archive-type} + +In a similar manner, the tool can remove any unneeded types from an existing +database. For example, suppose you are using the `schemas.wview` schema, but +you're pretty sure you're not going to need to store soil moisture. You can +drop the unnecessary types this way: + +``` shell +weectl database drop-columns soilMoist1 soilMoist2 soilMoist3 soilMoist4 +``` + +Unlike the action `add-column`, the action `drop-columns` can take more than +one type. This is done in the interest of efficiency: adding new columns is +easy and fast with the SQLite database, but dropping columns requires copying +the whole database. By specifying more than one type, you can amortize the +cost over a single invocation of the utility. + +!!! Warning + Dropping types from a database means *you will lose any data + associated with them!* The data cannot be recovered. + +#### Renaming a type {#rename-archive-type} + +Suppose you just want to rename a type? This can be done using the action +`rename-column`. Here's an example where you rename `soilMoist1` to +`soilMoistGarden`: + +``` shell +weectl database rename-column soilMoist1 soilMoistGarden +``` + +Note how the action `rename-column` requires _two_ positional arguments: +the column being renamed, and its final name. + +### Reconfigure database using a new schema {#reconfigure-using-new-schema} + +If you are making major changes to your database, you may find it easier +to create a brand-new database using the schema you want, then transfer +all data from the old database into the new one. This approach is more +work, and takes more processing time than the *in situ* strategies +outlines above, but has the advantage that it leaves behind a record of +exactly the schema you are using. + +Here is the general strategy to do this. + +1. Create a new schema that includes exactly the types that you want. + +2. Specify this schema as the starting schema for the database. + +3. Make sure you have the necessary permissions to create the new database. + +4. Use the action + [`weectl database reconfigure`](../utilities/weectl-database.md/#reconfigure-a-database) + to create the new database and populate it with data from the old + database. + +5. Shuffle databases around so WeeWX will use the new database. + +Here are the details: + +1. **Create a new schema.** First step is to create a new schema with + exactly the types you want. See the instructions above [*Modify a + starting schema*](#modify-starting-schema). As an example, suppose + your new schema is called `user.myschema.schema`. + +2. **Set as starting schema.** Set your new schema as the starting + schema with whatever database binding you are working with + (generally, `wx_binding`). For example: + + ``` ini hl_lines="7" + [DataBindings] + + [[wx_binding]] + database = archive_sqlite + table_name = archive + manager = weewx.manager.DaySummaryManager + schema = user.myschema.schema + ``` + +3. **Check permissions.** The transfer action will create a new + database with the same name as the old, except with the suffix `_new` + attached to the end. Make sure you have the necessary permissions to do + this. In particular, if you are using MySQL, you will need `CREATE` + privileges. + +4. **Create and populate the new database.** Use the command + `weectl database` with the `reconfigure` action. + + ``` shell + weectl database reconfigure + ``` + + This will create a new database (nominally, `weewx.sdb_new` if you are + using SQLite, `weewx_new` if you are using MySQL), using the schema found + in `user.myschema.schema`, and populate it with data from the old database. + +5. **Shuffle the databases.** Now arrange things so WeeWX can find the + new database. + + !!! Warning + Make a backup of the data before doing any of the next steps! + + You can either shuffle the databases around so the new database has the + same name as the old database, or edit `weewx.conf` to use the new + database name. To do the former: + + === "SQLite" + + ``` shell + cd ~/weewx-data/archive + mv weewx.sdb_new weewx.sdb + ``` + + === "MySQL" + + ``` shell + mysql -u --password= + mysql> DROP DATABASE weewx; # Delete the old database + mysql> CREATE DATABASE weewx; # Create a new one with the same name + mysql> RENAME TABLE weewx_new.archive TO weewx.archive; # Rename to the nominal name + ``` + +6. It's worth noting that there's actually a hidden, last step: + rebuilding the daily summaries inside the new database. This will be + done automatically by `weewxd` at the next startup. Alternatively, it + can be done manually using the + [`weectl database rebuild-daily`](../utilities/weectl-database.md/) action: + + ``` shell + weectl database rebuild-daily + ``` + +## Changing the unit system in an existing database {#change-unit-system} + +Normally, data are stored in the databases using US Customary units, and you +shouldn't care; it is an "implementation detail". Data can always be displayed +using any set of units you want — the section [*Changing unit +systems*](custom-reports.md#changing-unit-systems) explains how to change +the reporting units. Nevertheless, there may be special situations where you +wish to store the data in Metric units. For example, you may need to allow +direct programmatic access to the database from another piece of software that +expects metric units. + +You should not change the database unit system midstream. That is, do +not start with one unit system then, some time later, switch to another. +WeeWX cannot handle databases with mixed unit systems — see the +section [`[StdConvert]`](../reference/weewx-options/stdconvert.md) in the +WeeWX User's Guide. However, you can reconfigure the database by +copying it to a new database, performing the unit conversion along the +way. You then use this new database. + +The general strategy is identical to the strategy outlined above in the section +[*Reconfigure database using new schema*](#reconfigure-using-new-schema). The +only difference is that instead of specifying a new starting schema, you specify +a different database unit system. This means that instead of steps 1 and 2 +above, you edit the configuration file and change option `target_unit` in +section [`[StdConvert]`](../reference/weewx-options/stdconvert.md) to reflect +your choice. For example, if you are switching to metric units, the option will +look like: + +``` ini +[StdConvert] + target_unit = METRICWX +``` + +After changing `target_unit`, you then go ahead with the rest of the steps. +That is run the action `weectl database reconfigure`, then shuffle the +databases. + +## Rebuilding the daily summaries + +The `weectl database` command can also be used to rebuild the daily +summaries: + +``` shell +weectl database rebuild-daily +``` + +In most cases this will be sufficient; however, if anomalies remain in the +daily summaries the daily summary tables may be dropped first before +rebuilding: + +``` shell +weectl database drop-daily +``` + +Then try again with `weectl database rebuild-daily`. diff --git a/dist/weewx-5.0.2/docs_src/custom/derived.md b/dist/weewx-5.0.2/docs_src/custom/derived.md new file mode 100644 index 0000000..1cb9d0f --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/derived.md @@ -0,0 +1,14 @@ +# Adding new, derived types + +In the section [*Adding a second data source*](service-engine.md#add-data-source), +we saw an example of how to create a new type for a new data source. But, +what if you just want to add a type that is a derivation of existing types? +The WeeWX type `dewpoint` is an example of this: it's a function of two +observables, `outTemp`, and `outHumidity`. WeeWX calculates it automatically +for you. + +Calculating new, derived types is the job of the WeeWX XTypes system. It +can also allow you to add new aggregation types. + +See the Wiki article [*Extensible types (XTypes)*](https://github.com/weewx/weewx/wiki/xtypes) +for complete details on how the XTypes system works. diff --git a/dist/weewx-5.0.2/docs_src/custom/drivers.md b/dist/weewx-5.0.2/docs_src/custom/drivers.md new file mode 100644 index 0000000..06e4653 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/drivers.md @@ -0,0 +1,221 @@ +# Porting to new hardware {#porting} + +Naturally, this is an advanced topic but, nevertheless, I'd like to +encourage any Python wizards out there to give it a try. Of course, I +have selfish reasons for this: I don't want to have to buy every +weather station ever invented, and I don't want my roof to look like a +weather station farm! + +A *driver* communicates with hardware. Each driver is a single python +file that contains the code that is the interface between a device and +WeeWX. A driver may communicate directly with hardware using a MODBus, +USB, serial, or other physical interface. Or it may communicate over a +network to a physical device or a web service. + +## General guidelines + +- The driver should emit data as it receives it from the hardware (no + caching). +- The driver should emit only data it receives from the hardware (no + "filling in the gaps"). +- The driver should not modify the data unless the modification is + directly related to the hardware (*e.g.*, decoding a + hardware-specific sensor value). +- If the hardware flags "bad data", then the driver should emit a + null value for that datum (Python `None`). +- The driver should not calculate any derived variables (such as + dewpoint). The service `StdWXService` will do that. +- However, if the hardware emits a derived variable, then the driver + should emit it. + +## Implement the driver + +Create a file in the user directory, say `mydriver.py`. This file +will contain the driver class as well as any hardware-specific code. Do +not put it in the `weewx/drivers` directory, or it will be deleted +when you upgrade WeeWX. + +Inherit from the abstract base class +`weewx.drivers.AbstractDevice`. Try to implement as many of its +methods as you can. At the very minimum, you must implement the first +three methods, `loader`, `hardware_name`, and +`genLoopPackets`. + +#### loader() + +This is a factory function that returns an instance of your driver. It +has two arguments: the configuration dictionary, and a reference to the +WeeWX engine. + +#### hardware_name + +This is an attribute that should return a string with a short nickname for the +hardware, such as `"ACME X90"` + +#### genLoopPackets() + +This should be a Python [generator +function](https://wiki.python.org/moin/Generators) that yields loop +packets, one after another. Don't worry about stopping it: the engine +will do this when an archive record is due. A "loop packet" is a +dictionary. At the very minimum it must contain keys for the observation +time and for the units used within the packet. + + + + + + + + + + + + + +
Required keys
dateTimeThe time of the observation in unix epoch time.
usUnits +The unit system used. weewx.US for US customary, +weewx.METRICWX, or +weewx.METRIC for metric. See the +Units for their exact definitions. +The dictionaries USUnits, +MetricWXUnits, and +MetricUnits in file +units.py, can also be useful. +
+ +Then include any observation types you have in the dictionary. Every +packet need not contain the same set of observation types. Different +packets can use different unit systems, but all observations within a +packet must use the same unit system. If your hardware is capable of +measuring an observation type but, for whatever reason, its value is bad +(maybe a bad checksum?), then set its value to `None`. If your +hardware is incapable of measuring an observation type, then leave it +out of the dictionary. + +A couple of observation types are tricky, in particular, `rain`. +The field `rain` in a LOOP packet should be the amount of rain +that has fallen *since the last packet*. Because LOOP packets are +emitted fairly frequently, this is likely to be a small number. If your +hardware does not provide this value, you might have to infer it from +changes in whatever value it provides, for example changes in the daily +or monthly rainfall. + +Wind is another tricky one. It is actually broken up into four different +observations: `windSpeed`, `windDir`, `windGust`, +and `windGustDir`. Supply as many as you can. The directions +should be compass directions in degrees (0=North, 90=East, etc.). + +Be careful when reporting pressure. There are three observations related +to pressure. Some stations report only the station pressure, others +calculate and report sea level pressures. + + + + + + + + + + + + + + + + + +
Pressure types
pressure +The Station Pressure (SP), which is the raw, absolute pressure +measured by the station. This is the true barometric pressure for the station. +
barometer +The Sea Level Pressure (SLP) obtained by correcting the Station +Pressure for altitude and local temperature. This is the pressure reading +most commonly used by meteorologist to track weather systems at the surface, +and this is the pressure that is uploaded to weather services by WeeWX. It is +the station pressure reduced to mean sea level using local altitude and local +temperature. +
altimeter +The Altimeter Setting (AS) obtained by correcting the Station +Pressure for altitude. This is the pressure reading most commonly heard +in weather reports. It is not the true barometric pressure of a station, but +rather the station pressure reduced to mean sea level using altitude and an +assumed temperature average. +
+ +#### genArchiveRecords() + +If your hardware does not have an archive record logger, then WeeWX can +do the record generation for you. It will automatically collect all the +types it sees in your loop packets then emit a record with the averages +(in some cases the sum or max value) of all those types. If it doesn't +see a type, then it won't appear in the emitted record. + +However, if your hardware does have a logger, then you should implement +method `genArchiveRecords()` as well. It should be a generator +function that returns all the records since a given time. + +#### archive_interval + +If you implement function `genArchiveRecords()`, then you should +also implement `archive_interval` as either an attribute, or as a +[property +function](https://docs.python.org/3/library/functions.html#property). It +should return the archive interval in seconds. + +#### getTime() + +If your hardware has an onboard clock and supports reading the time from +it, then you may want to implement this method. It takes no argument. It +should return the time in Unix Epoch Time. + +#### setTime() + +If your hardware has an onboard clock and supports *setting* it, then +you may want to implement this method. It takes no argument and does not +need to return anything. + +#### closePort() + +If the driver needs to close a serial port, terminate a thread, close a +database, or perform any other activity before the application +terminates, then you must supply this function. WeeWX will call it if it +needs to shut down your console (usually in the case of an error). + +## Define the configuration + +You then include a new section in the configuration file +`weewx.conf` that includes any options your driver needs. It +should also include an entry `driver` that points to where your +driver can be found. Set option `station_type` to your new +section type and your driver will be loaded. + +## Examples + +The `fileparse` driver is perhaps the simplest example of a WeeWX +driver. It reads name-value pairs from a file and uses the values as +sensor 'readings'. The code is actually packaged as an extension, +located in `examples/fileparse`, making it a good example of not +only writing a device driver, but also of how to package an extension. +The actual driver itself is in +`examples/fileparse/bin/user/fileparse.py`. + +Another good example is the simulator code located in +`weewx/drivers/simulator.py`. It's dirt simple, and you can +easily play with it. Many people have successfully used it as a starting +point for writing their own custom driver. + +The Ultimeter (`ultimeter.py`) and WMR100 (`wmr100.py`) +drivers illustrate how to communicate with serial and USB hardware, +respectively. They also show different approaches for decoding data. +Nevertheless, they are pretty straightforward. + +The driver for the Vantage series is by far the most complicated. It +actually multi-inherits from not only `AbstractDevice`, but also +`StdService`. That is, it also participates in the engine as a +service. + +Naturally, there are a lot of subtleties that have been glossed over in +this high-level description. If you run into trouble, look for help in +the [weewx-development](https://groups.google.com/g/weewx-development) group. diff --git a/dist/weewx-5.0.2/docs_src/custom/extensions.md b/dist/weewx-5.0.2/docs_src/custom/extensions.md new file mode 100644 index 0000000..6183a77 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/extensions.md @@ -0,0 +1,126 @@ +# Extensions + +A key feature of WeeWX is its ability to be extended by installing 3rd party +*extensions*. Extensions are a way to package one or more customizations so that +they can be installed and distributed as a functional group. + +Customizations typically fall into one of these categories: + +* search list extension +* template +* skin +* service +* generator +* driver + +Take a look at the [WeeWX wiki](https://github.com/weewx/weewx/wiki) for a +sampling of some of the extensions that are available. + +## Creating an extension + +Now that you have made some customizations, you might want to share those +changes with other WeeWX users. Put your customizations into an extension to +make installation, removal, and distribution easier. + +Here are a few guidelines for creating extensions: + +* Extensions should not modify or depend upon existing skins. An extension + should include its own, standalone skin to illustrate any templates, search + list extension, or generator features. + +* Extensions should not modify the database schemas. If it requires + data not found in the default databases, an extension should provide its own + database and schema. + +Although one extension might use another extension, take care to write the +dependent extension so that it fails gracefully. For example, a skin might use +data from the forecast extension, but what happens if the forecast extension is +not installed? Make the skin display a message about "forecast not installed" +but otherwise continue to function. + +## Packaging an extension + +The structure of an extension mirrors that of WeeWX itself. If the +customizations include a skin, the extension will have a `skins` directory. If +the customizations include python code, the extension will have a `bin/user` +directory. + +Each extension should also include: + +* `readme.txt` or `readme.md` - a summary of what the extension does, a list of + pre-requisites (if any), and instructions for installing the extension + manually + +* `changelog` - an enumeration of changes in each release + +* `install.py` - python code used by the WeeWX `ExtensionInstaller` + +For example, here is the structure of an extension called `basic`, which +installs a skin called `Basic`. You can find it in the `examples` subdirectory. + +``` +basic +├── changelog +├── install.py +├── readme.md +└── skins + └── Basic + ├── basic.css + ├── current.inc + ├── favicon.ico + ├── hilo.inc + ├── index.html.tmpl + ├── lang + │ ├── en.conf + │ └── fr.conf + └── skin.conf +``` + +Here is the structure of an extension called `xstats`, which implements a search +list extension, as well as a simple skin. You can also find it in the `examples` +subdirectory. + +``` +xstats +├── bin +│ └── user +│ └── xstats.py +├── changelog +├── install.py +├── readme.txt +└── skins + └── xstats + ├── index.html.tmpl + └── skin.conf +``` + +To distribute an extension, simply create a compressed archive of the +extension directory. + +For example, create the compressed archive for the `basic` skin +like this: + + tar cvfz basic.tar.gz basic + +Once an extension has been packaged, it can be installed using `weectl`: + + weectl extension install EXTENSION-LOCATION + +## Default values + +Whenever possible, an extension should *just work*, with a minimum of input from +the user. At the same time, parameters for the most frequently requested options +should be easily accessible and easy to modify. For skins, this might mean +parameterizing strings into `[Labels]` for easier customization. Or it might +mean providing parameters in `[Extras]` to control skin behavior or to +parameterize links. + +Some parameters *must* be specified, and no default value would be appropriate. +For example, an uploader may require a username and password, or a driver might +require a serial number or IP address. In these cases, use a default value in +the configuration that will obviously require modification. The *username* might +default to *REPLACE_ME*. Also be sure to add a log entry that indicates the +feature is disabled until the value has been specified. + +In the case of drivers, use the configuration editor to prompt for this type of +required value. diff --git a/dist/weewx-5.0.2/docs_src/custom/image-generator.md b/dist/weewx-5.0.2/docs_src/custom/image-generator.md new file mode 100644 index 0000000..1c01f8f --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/image-generator.md @@ -0,0 +1,362 @@ +# The Image generator + +WeeWX is configured to generate a set of useful plots. But, what if you don't +like how they look, or you want to generate different plots, perhaps with +different aggregation types? + +The Image generator is controlled by the configuration options in the +reference [_[ImageGenerator]_](../reference/skin-options/imagegenerator.md). + +These options are specified in the `[ImageGenerator]` section of a skin +configuration file. Let's take a look at the beginning part of this section. +It looks like this: + +``` ini +[ImageGenerator] + ... + image_width = 500 + image_height = 180 + image_background_color = #f5f5f5 + + chart_background_color = #d8d8d8 + chart_gridline_color = #a0a0a0 + ... +``` + +The options directly under the section name `[ImageGenerator]` will apply to +*all* plots, unless overridden in subsections. So, unless otherwise changed, +all plots will be 500 pixels in width, 180 pixels in height, and will have an +RGB background color of `#f5f5f5`, a very light gray (HTML color "WhiteSmoke"). +The chart itself will have a background color of `#d8d8d8` (a little darker +gray), and the gridlines will be `#a0a0a0` (still darker). The other options +farther down (not shown) will also apply to all plots. + +## Time periods + +After the "global" options at the top of section `[ImageGenerator]`, comes a +set of subsections, one for each time period (day, week, month, and year). +These subsections define the nature of aggregation and plot types for that +time period. For example, here is a typical set of options for subsection +`[[month_images]]`. It controls which "monthly" images will get generated, +and what they will look like: + +``` ini + [[month_images]] + x_label_format = %d + bottom_label_format = %m/%d/%y %H:%M + time_length = 2592000 # == 30 days + aggregate_type = avg + aggregate_interval = 3h + show_daynight = false +``` + +The option `x_label_format` gives a +[strftime()](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) +type format for the x-axis. In this example, it will only show days +(format option `%d`). The `bottom_label_format` is the +format used to time stamp the image at the bottom. In this example, it +will show the time as something like `10/25/09 15:35`. A plot +will cover a nominal 30 days, and all items included in it will use an +aggregate type of averaging over 3 hours. Finally, by setting option +`show_daynight` to `false`, we are requesting that +day-night, shaded bands not be shown. + +## Image files + +Within each time period subsection is another nesting, one for each +image to be generated. The title of each sub-sub-section is the filename +to be used for the image. Finally, at one additional nesting level (!) +are the logical names of all the line types to be drawn in the image. +Like elsewhere, the values specified in the level above can be +overridden. For example, here is a typical set of options for +sub-sub-section `[[[monthrain]]]`: + +``` ini + [[[monthrain]]] + plot_type = bar + yscale = None, None, 0.02 + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) +``` + +This will generate an image file with name `monthrain.png`. It +will be a bar plot. Option `yscale` controls the y-axis scaling +— if left out, the scale will be chosen automatically. However, in +this example we are choosing to exercise some degree of control by +specifying values explicitly. The option is a 3-way tuple +(`ylow`, `yhigh`, `min_interval`), where +`ylow` and `yhigh` are the minimum and maximum y-axis +values, respectively, and `min_interval` is the minimum tick +interval. If set to `None`, the corresponding value will be +automatically chosen. So, in this example, the setting + +``` ini +yscale = None, None, 0.02 +``` + +will cause WeeWX to pick sensible y minimum and maximum values, but +require that the tick increment (`min_interval`) be at least +0.02. + +Continuing on with the example above, there will be only one plot +"line" (it will actually be a series of bars) and it will have logical +name `rain`. Because we have not said otherwise, the database +column name to be used for this line will be the same as its logical +name, that is, `rain`, but this can be overridden. The +aggregation type will be summing (overriding the averaging specified in +subsection `[[month_images]]`), so you get the total rain +over the aggregate period (rather than the average) over an aggregation +interval of 86,400 seconds (one day). The plot line will be titled with +the indicated label of 'Rain (daily total)'. The result of all this is +the following plot: + +![Sample monthly rain plot](../images/sample_monthrain.png) + +## Including more than one type in a plot + +More than one observation can be included in a plot. For example, here +is how to generate a plot with the week's outside temperature as well +as dewpoint: + +``` ini +[[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] +``` + +This would create an image in file `monthtempdew.png` that +includes a line plot of both outside temperature and dewpoint: + +![Monthly temperature and dewpoint](../images/sample_monthtempdew.png) + +### Including a type more than once in a plot {#inclue-same-sql-type-2x} + +Another example. Suppose that you want a plot of the day's temperature, +overlaid with hourly averages. Here, you are using the same data type +(`outTemp`) for both plot lines, the first with averages, the +second without. If you do the obvious it won't work: + +``` ini +# WRONG ## +[[[daytemp_with_avg]]] + [[[[outTemp]]]] + aggregate_type = avg + aggregate_interval = 1h + [[[[outTemp]]]] # OOPS! The same section name appears more than once! +``` + +The option parser does not allow the same section name (`outTemp` +in this case) to appear more than once at a given level in the +configuration file, so an error will be declared (technical reason: +formally, the sections are an unordered dictionary). If you wish for the +same observation to appear more than once in a plot then there is a +trick you must know: use option `data_type`. This will override +the default action that the logical line name is used for the database +column. So, our example would look like this: + +``` ini +[[[daytemp_with_avg]]] + [[[[avgTemp]]]] + data_type = outTemp + aggregate_type = avg + aggregate_interval = 1h + label = Avg. Temp. + [[[[outTemp]]]] +``` + +Here, the first plot line has been given the name `avgTemp` to +distinguish it from the second line `outTemp`. Any name will do +— it just has to be different. We have specified that the first line +will use data type ` outTemp` and that it will use averaging over +a one-hour period. The second also uses `outTemp`, but will not +use averaging. + +The result is a nice plot of the day's temperature, overlaid with a one-hour +smoothed average: + +![Daytime temperature with running average](../images/daytemp_with_avg.png) + +One more example. This one shows daily high and low temperatures for a +year: + +``` ini +[[year_images]] + [[[yearhilow]]] + [[[[hi]]]] + data_type = outTemp + aggregate_type = max + label = High + [[[[low]]]] + data_type = outTemp + aggregate_type = min + label = Low Temperature +``` + +This results in the plot `yearhilow.png`: + +![Daily highs and lows](../images/yearhilow.png) + + +## Including arbitrary expressions {#arbitrary-expressions} + +The option `data_type` can actually be *any arbitrary SQL +expression* that is valid in the context of the available types in the +schema. For example, say you wanted to plot the difference between +inside and outside temperature for the year. This could be done with: + +``` ini +[[year_images]] + [[[yeardiff]]] + [[[[diff]]]] + data_type = inTemp-outTemp + label = Inside - Outside +``` + +Note that the option `data_type` is now an expression +representing the difference between `inTemp` and +`outTemp`, the inside and outside temperature, respectively. This +results in a plot `yeardiff.png`: + +![Inside - outside temperature](../images/yeardiff.png) + +## Changing the unit used in a plot + +Normally, the unit used in a plot is set by the unit group of the +observation types in the plot. For example, consider this plot of +today's outside temperature and dewpoint: + +``` ini + [[day_images]] + ... + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] +``` + +Both `outTemp` and `dewpoint` belong to unit group +`group_temperature`, so this plot will use whatever unit has been specified +for that group. See the section [*Mixed units*](custom-reports.md#mixed-units) +for details. + +However, supposed you'd like to offer both Metric and US Customary +versions of the same plot? You can do this by using option +[`unit`](../reference/skin-options/imagegenerator.md/#unit) +to override the unit used for individual plots: + +``` ini hl_lines="4 9" + [[day_images]] + ... + [[[daytempdewUS]]] + unit = degree_F + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[daytempdewMetric]]] + unit = degree_C + [[[[outTemp]]]] + [[[[dewpoint]]]] +``` + +This will produce two plots: file `daytempdewUS.png` will be in +degrees Fahrenheit, while file `dayTempMetric.png` will use +degrees Celsius. + +## Line gaps {#line-gaps} + +If there is a time gap in the data, the option +[`line_gap_fraction`](../reference/skin-options/imagegenerator.md/#line_gap_fraction) controls how line plots will be drawn. +Here's what a plot looks like without and with this option being specified: + +
+ ![Gap not shown](../images/day-gap-not-shown.png) +
No `line_gap_fraction` specified
+
+ +
+ ![Gap showing](../images/day-gap-showing.png) +
With `line_gap_fraction=0.01`.
Note how each line has been split into two lines.
+
+ +## Progressive vector plots + +WeeWX can produce progressive vector plots as well as the more +conventional x-y plots. To produce these, use plot type `vector`. +You need a vector type to produce this kind of plot. There are two: +`windvec`, and `windgustvec`. While they do not actually +appear in the database, WeeWX understands that they represent special +vector-types. The first, `windvec`, represents the average wind +in an archive period, the second, `windgustvec` the max wind in +an archive period. Here's how to produce a progressive vector for one +week that shows the hourly biggest wind gusts, along with hourly +averages: + +``` ini +[[[weekgustoverlay]]] + aggregate_interval = 1h + [[[[windvec]]]] + label = Hourly Wind + plot_type = vector + aggregate_type = avg + [[[[windgustvec]]]] + label = Gust Wind + plot_type = vector + aggregate_type = max +``` + +This will produce an image file with name `weekgustoverlay.png`. +It will consist of two progressive vector plots, both using hourly +aggregation (3,600 seconds). For the first set of vectors, the hourly +average will be used. In the second, the max of the gusts will be used: + +![hourly average wind vector overlaid with gust vectors](../images/weekgustoverlay.png) + +By default, the sticks in the progressive wind plots point towards the +wind source. That is, the stick for a wind from the west will point +left. If you have a chronic wind direction (as I do), you may want to +rotate the default direction so that all the vectors do not line up over +the x-axis, overlaying each other. Do this by using option +`vector_rotate`. For example, with my chronic westerlies, I set +`vector_rotate` to 90.0 for the plot above, so winds out of the +west point straight up. + +If you use this kind of plot (the out-of-the-box version of WeeWX +includes daily, weekly, monthly, and yearly progressive wind plots), a +small compass rose will be put in the lower-left corner of the image to +show the orientation of North. + +## Overriding values + +Remember that values at any level can override values specified at a +higher level. For example, say you want to generate the standard plots, +but for a few key observation types such as barometer, you want to also +generate some oversized plots to give you extra detail, perhaps for an +HTML popup. The standard `skin.conf` file specifies plot size of +300x180 pixels, which will be used for all plots unless overridden: + +``` ini +[ImageGenerator] + ... + image_width = 300 + image_height = 180 +``` + +The standard plot of barometric pressure will appear in +`daybarometer.png`: + +``` ini +[[[daybarometer]]] + [[[[barometer]]]] +``` + +We now add our special plot of barometric pressure, but specify a larger +image size. This image will be put in file +`daybarometer_big.png`. + +``` ini +[[[daybarometer_big]]] + image_width = 600 + image_height = 360 + [[[[barometer]]]] +``` diff --git a/dist/weewx-5.0.2/docs_src/custom/introduction.md b/dist/weewx-5.0.2/docs_src/custom/introduction.md new file mode 100644 index 0000000..173a5c4 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/introduction.md @@ -0,0 +1,650 @@ +# Customization Guide + +This document covers the customization of WeeWX. It assumes that you have read, +and are reasonably familiar with, the [_Users +Guide_](../usersguide/introduction.md). + +The introduction contains an overview of the architecture. If you are only +interested in customizing the generated reports you can probably skip the +introduction and proceed directly to the section +[_Customizing reports_](custom-reports.md). With this approach you can easily +add new plot images, change the titles of images, change the units used in +the reports, and so on. + +However, if your goal is a specialized application, such as adding alarms, +RSS feeds, _etc._, then it would be worth your while to read about the +internal architecture. + +Most of the guide applies to any hardware, but the exact data types are +hardware-specific. See the [_WeeWX Hardware Guide_](../hardware/drivers.md) +for details of how different observation types are handled by different types +hardware. + +!!! Warning + WeeWX is still an experimental system and, as such, its internal design is + subject to change. Future upgrades may break any customizations you have + done, particularly if they involve the API (skin customizations tend to + be more stable). + + +## Overall system architecture + +Below is a brief overview of the WeeWX system architecture, which is covered +in much more detail in the rest of this document. + +
+ ![The WeeWX pipeline](../images/pipeline.png) +
A typical WeeWX pipeline. The actual pipeline depends on what extensions are in use. Data, in the form of LOOP packets and archive records, flows from top to bottom.
+
+ +* A WeeWX process normally handles the monitoring of one station — _e.g._ a +weather station. The process is configured using options in a configuration +file, typically called `weewx.conf`. + +* A WeeWX process has at most one "driver" to communicate with the station +hardware and receive "high resolution" (_i.e._ every few seconds) measurement +data in the form of LOOP packets. The driver is single-threaded and blocking, +so no more than one driver can run in a WeeWX process. + +* LOOP packets may contain arbitrary data from the station/driver in the form +of a Python dictionary. Each LOOP packet must contain a time stamp and a unit +system, in addition to any number of observations, such as temperature or +humidity. For extensive types, such as rain, the packet contains the total +amount of rain that fell during the observation period. + +* WeeWX then compiles these LOOP packets into regularly spaced +"archive records". For most types, the archive record contains the average +value seen in all the LOOP packets over the archive interval (typically +5 minutes). For extensive types, such as rain, it is the sum of all values +over the archive interval. + +* Internally, the WeeWX engine uses a _pipeline_ architecture, consisting of +many _services_. Services bind to events of interest, such as new LOOP packets, +or new archive records. Events are then run down the pipeline in order — +services at the top of the pipeline act on the data before services farther +down the pipe. + +* Services can do things such as check the data quality, apply corrections, or +save data to a database. Users can easily add new services. + +* WeeWX includes an ability to customize behavior by installing _extensions_. +Extensions may consist of one or more drivers, services, and/or skins, all in +an easy-to-install package. + + +## Data architecture + +WeeWX is data-driven. When the sensors spit out some data, WeeWX does +something. The "something" might be to print out the data, or to generate an +HTML report, or to use FTP to copy a report to a web server, or to perform +some calculations using the data. + +A driver is Python code that communicates with the hardware. The driver reads +data from a serial port or a device on the USB or a network interface. It +handles any decoding of raw bits and bytes, and puts the resulting data into +LOOP packets. The drivers for some kinds of hardware (most notably, Davis +Vantage) are capable of emitting archive records as well. + +In addition to the primary observation types such as temperature, humidity, or +solar radiation, there are also many useful dependent types, such as wind +chill, heat index, or ET, which are calculated from the primary data. The +firmware in some weather stations are capable of doing many of these +calculations on their own. For the rest, should you choose to do so, the WeeWX +service [StdWXCalculate](../reference/weewx-options/stdwxcalculate.md) can +fill in the gaps. Sometimes the firmware simply does it wrong, and you may +choose to have WeeWX do the calculation, despite the type's presence in LOOP +packets. + + +## LOOP packets _vs._ archive records + +Generally, there are two types of data that flow through WeeWX: LOOP packets, +and archive records. Both are represented as Python dictionaries. + +### LOOP packets + +LOOP packets are the raw data generated by the device driver. They get their +name from the Davis Instruments documentation. For some devices they are +generated at rigid intervals, such as every 2 seconds for the Davis Vantage +series, for others, irregularly, every 20 or 30 seconds or so. LOOP packets +may or may not contain all the data types an instrument is capable of +measuring. For example, a packet may contain only temperature data, another +only barometric data, _etc_. These kinds of packet are called _partial record +packets_. By contrast, other types of hardware (notably the Vantage series) +include every data type in every LOOP packet. + +In summary, LOOP packets can be highly irregular in time and in what they +contain, but they come in frequently. + +### Archive records + +By contrast, archive records are highly regular. They are generated at regular +intervals (typically every 5 to 30 minutes), and all contain the same data +types. They represent an _aggregation_ of the LOOP packets over the archive +interval. The exact kind of aggregation depends on the data type. For example, +for temperature, it's generally the average temperature over the interval. For +rain, it's the sum of rain over the interval. For battery status it's the last +value in the interval. + +Some hardware is capable of generating their own archive records (the Davis +Vantage and Oregon Scientific WMR200, for example), but for hardware that +cannot, WeeWX generates them. + +It is the archive data that is put in the SQL database, although, occasionally, +the LOOP packets can be useful (such as for the Weather Underground's +"Rapidfire" mode). + + +## What to customize + +For configuration changes, such as which skins to use, or enabling posts to +the Weather Underground, simply modify the WeeWX configuration file, nominally +`weewx.conf`. Any changes you make will be preserved during an upgrade. + +Customization of reports may require changes to a skin configuration file +`skin.conf` or template files ending in `.tmpl` or `.inc`. Anything in the +`skins` subdirectory is also preserved across upgrades. + +You may choose to install one of the many +[third-party extensions](https://github.com/weewx/weewx/wiki#extensions-to-weewx) +that are available for WeeWX. These are typically installed in either the +skins or user subdirectories, both of which are preserved across upgrades. + +More advanced customizations may require new Python code or modifications of +example code. These should be placed in the `user` directory, where they will +be preserved across upgrades. For example, if you wish to modify one of the +examples that comes with WeeWX, copy it from the examples directory to the +user directory, then modify it there. This way, your modifications will not +be touched if you upgrade. + +For code that must run before anything else in WeeWX runs (for example, to set +up an environment), put it in the file `extensions.py` in the user directory. +It is always run before the WeeWX engine starts up. Because it is in the +`user` subdirectory, it is preserved between upgrades. + + +## Do I need to restart WeeWX? + +If you make a change in `weewx.conf`, you will need to restart `weewxd`. + +If you modify Python code in the `user` directory or elsewhere, you will need +to restart `weewxd`. + +If you install an extension, you will need to restart `weewxd`. + +If you make a change to a template or to a `skin.conf` file, then you do not +need to restart `weewxd`. The change will be adopted at the next reporting +cycle, typically at the end of an archive interval. + + +## Running reports on demand + +If you make changes, how do you know what the results will look like? You +could just run `weewxd` and wait until the next reporting cycle kicks off but, +depending on your archive interval, that could be a 30-minute wait or more. + +The utility [`weectl report +run`](../utilities/weectl-report.md#run-reports-on-demand) allows you to run a +report whenever you like. To use it, just run it from a command line. +Optionally, you can tell it what to use as the "Current" time, using either +option `--epoch`, or by using the combination of `--date` and `--time`. + + +## The WeeWX service architecture + +At a high-level, WeeWX consists of an engine class called `StdEngine`. It is +responsible for loading _services_, then arranging for them to be called when +key events occur, such as the arrival of LOOP or archive data. The default +install of WeeWX includes the following services: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
The standard WeeWX services
ServiceFunction
weewx.engine.StdTimeSynchArrange to have the clock on the station synchronized at regular intervals. +
weewx.engine.StdConvertConverts the units of the input to a target unit system (such as US or Metric). +
weewx.engine.StdCalibrateAdjust new LOOP and archive packets using calibration expressions. +
weewx.engine.StdQCCheck quality of incoming data, making sure values fall within a specified range. +
weewx.wxservices.StdWXCalculateDecide which derived observation types need to be calculated. +
weewx.wxxtypes.StdWXXTypes
weewx.wxxtypes.StdPressureCooker
weewx.wxxtypes.StdRainRater
weewx.wxxtypes.StdDelta
Calculate derived variables, such as ET, dewpoint, or rainRate. +
weewx.engine.StdArchiveArchive any new data to the SQL databases.
weewx.restx.StdStationRegistry
weewx.restx.StdWunderground
+ weewx.restx.StdPWSweather
weewx.restx.StdCWOP
weewx.restx.StdWOW
weewx.restx.StdAWEKAS +
Various RESTful services + (simple stateless client-server protocols), such as the Weather Underground, CWOP, etc. Each + launches its own, independent thread, which manages the post. +
weewx.engine.StdPrintPrint out new LOOP and archive packets on the console. +
weewx.engine.StdReportLaunch a new thread to do report processing after a new archive record arrives. Reports do things + such as generate HTML or CSV files, generate images, or transfer files using FTP/rsync. +
+ +It is easy to extend old services or to add new ones. The source distribution +includes an example new service called MyAlarm, which sends an email when an +arbitrary expression evaluates True. These advanced topics are covered later +in the section [_Customizing the WeeWX service engine_](service-engine.md). + + +## The standard reporting service `StdReport` + +For the moment, let us focus on the last service, `weewx.engine.StdReport`, +the standard service for creating reports. This will be what most users will +want to customize, even if it means just changing a few options. + +### Reports + +The standard reporting service, `StdReport`, runs zero or more _reports_. The +specific reports which get run are set in the configuration file `weewx.conf`, +in section `[StdReport]`. + +The default distribution of WeeWX includes six reports: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReportDefault functionality
SeasonsReport +Introduced with WeeWX V3.9, this report generates a single HTML file with day, +week, month and year "to-date" summaries, as well as the plot images to go +along with them. Buttons select which timescale the user wants. It also +generates HTML files with more details on celestial bodies and statistics. +Also generates NOAA monthly and yearly summaries. +
SmartphoneReport +A simple report that generates an HTML file, which allows "drill down" to show +more detail about observations. Suitable for smaller devices, such as +smartphones. +
MobileReport +A super simple HTML file that just shows the basics. Suitable for low-powere +d or bandwidth-constrained devices. +
StandardReport +This is an older report that has been used for many years in WeeWX. It +generates day, week, month and year "to-date" summaries in HTML, as well +as the plot images to go along with them. Also generates NOAA monthly and +yearly summaries. It typically loads faster than the SeasonsReport. +
FTP +Transfer everything in the HTML_ROOT directory +to a remote server using ftp. +
RSYNC +Transfer everything in the HTML_ROOT directory +to a remote server using the utility +rsync. +
+ +Note that the FTP and RSYNC "reports" are a funny kind of report in that +they do not actually generate anything. Instead, they use the reporting +service engine to transfer files and folders to a remote server. + +### Skins + +Each report has a _skin_ associated with it. For most reports, the +relationship with the skin is an obvious one: the skin contains the templates, +any auxiliary files such as background GIFs or CSS style sheets, files with +localization data, and a _skin configuration file_, `skin.conf`. If you will, +the skin controls the _look and feel_ of the report. Note that more than one +report can use the same skin. For example, you might want to run a report that +uses US Customary units, then run another report against the same skin, but +using metric units and put the results in a different place. All this is +possible by either overriding configuration options in the WeeWX configuration +file or the skin configuration file. + +Like all reports, the FTP and RSYNC "reports" also use a skin, and include a +skin configuration file, although they are quite minimal. + +Skins live in their own directory called `skins`, whose location is referred +to as _`SKIN_ROOT`_. + +!!! Note + The symbol _`SKIN_ROOT`_ is a symbolic name to the location of the + directory where your skins are located. It is not to be taken literally. + Consult the section [*Where to find things*](../usersguide/where.md) in the + *User's Guide* for its exact location, dependent on how you installed + WeeWX and what operating system you are using + +### Generators + +To create their output, skins rely on one or more _generators_, which are what +do the actual work, such as creating HTML files or plot images. Generators can +also copy files around or FTP/rsync them to remote locations. The default +install of WeeWX includes the following generators: + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GeneratorFunction
weewx.cheetahgenerator.CheetahGenerator +Generates files from templates, using the Cheetah template engine. Used to +generate HTML and text files. +
weewx.imagegenerator.ImageGeneratorGenerates graph plots.
weewx.reportengine.FtpGeneratorUploads data to a remote server using FTP.
weewx.reportengine.RsyncGeneratorUploads data to a remote server using rsync.
weewx.reportengine.CopyGeneratorCopies files locally.
+ +Note that the three generators `FtpGenerator`, `RsyncGenerator`, and +`CopyGenerator` do not actually generate anything having to do with the +presentation layer. Instead, they just move files around. + +Which generators are to be run for a given skin is specified in the skin's +configuration file, in the section [[Generators]](../reference/skin-options/generators.md). + +### Templates + +A template is a text file that is processed by a _template engine_ to create +a new file. WeeWX uses the [Cheetah](https://cheetahtemplate.org/) template +engine. The generator `weewx.cheetahgenerator.CheetahGenerator` is responsible +for running Cheetah at appropriate times. + +A template may be used to generate HTML, XML, CSV, Javascript, or any other +type of text file. A template typically contains variables that are replaced +when creating the new file. Templates may also contain simple programming +logic. + +Each template file lives in the skin directory of the skin that uses it. By +convention, a template file ends with the `.tmpl` extension. There are also +template files that end with the `.inc` extension. These templates are +included in other templates. + + +## The database + +WeeWX uses a single database to store and retrieve the records it needs. It +can be implemented by using either [SQLite](https://www.sqlite.org/), an +open-source, lightweight SQL database, or [MySQL](https://www.mysql.com/), an +open-source, full-featured database server. + +### Structure + +Inside this database are several tables. The most important is the +_archive table_, a big flat table, holding one record for each archive +interval, keyed by `dateTime`, the time at the end of the archive interval. +It looks something like this: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Structure of the archive database table +
dateTimeusUnitsintervalbarometerpressurealtimeterinTempoutTemp...
14139378001529.938nullnull71.256.0...
14139381001529.941nullnull71.255.9...
...........................
+ +The first three columns are _required._ Here's what they mean: + + + + + + + + + + + + + + + + + + +
NameMeaning
dateTime +The time at the end of the archive interval in +unix epoch time. This +is the primary key in the database. It must be unique, and it cannot +be null. +
usUnits +The unit system the record is in. It cannot be null. See the +Units for how these systems are +encoded. +
interval +The length of the archive interval in minutes. It cannot be null. +
+ +In addition to the archive table, there are a number of smaller tables inside +the database, one for each observation type, which hold _daily summaries_ of +the type, such as the minimum and maximum value seen during the day, and at +what time. These tables have names such as `archive_day_outTemp` or +`archive_day_barometer`. They are there to optimize certain types of queries +— their existence is generally transparent to the user. For more details, +see the section [_Daily summaries_](../devnotes.md#daily-summaries) in the +_Developer's Notes_. + +### Binding names + +While most users will only need the one weather database that comes with +WeeWX, the reporting engine allows you to use multiple databases in the same +report. For example, if you have installed the +[cmon](https://github.com/weewx/weewx/wiki/cmon) computer monitoring package +, which uses its own database, you may want to include some statistics or +graphs about your server in your reports, using that database. + +An additional complication is that WeeWX can use more than one database +implementation: SQLite or MySQL. Making users specify in the templates not +only which database to use, but also which implementation, would be +unreasonable. + +The solution, like so many other problems in computer science, is to introduce +another level of indirection, a _database binding_. Rather than specify which +database to use, you specify which _binding_. Bindings do not change with the +database implementation, so, for example, you know that `wx_binding` will +always point to the weather database, no matter if its implementation is a +sqlite database or a MySQL database. Bindings are listed in section +[`[DataBindings]`](../reference/weewx-options/data-bindings.md) in the +WeeWX configuration file. + +The standard weather database binding that WeeWX uses is `wx_binding`. This +is the binding that you will be using most of the time and, indeed, it is the +default. You rarely have to specify it explicitly. + +### Programming interface + +WeeWX includes a module called `weedb` that provides a single interface for +many of the differences between database implementations such as SQLite and +MySQL. However, it is not uncommon to make direct SQL queries within services +or search list extensions. In such cases, the SQL should be generic so that +it will work with every type of database. + +The database manager class provides methods to create, open, and query a +database. These are the canonical forms for obtaining a database manager. + +If you are opening a database from within a WeeWX service: + +```python +db_manager = self.engine.db_binder.get_manager(data_binding='name_of_binding', initialize=True) + + # Sample query: +db_manager.getSql("SELECT SUM(rain) FROM %s "\\ + "WHERE dateTime>? AND dateTime<=?" % db_manager.table_name, (start_ts, stop_ts)) +``` + +If you are opening a database from within a WeeWX search list extension, you +will be passed in a function `db_lookup()` as a parameter, which can then be +used to bind to a database. By default, it returns a manager bound to +`wx_binding`: + +```python +wx_manager = db_lookup() # Get default binding +other_manager = db_lookup(data_binding='some_other_binding') # Get an explicit binding + + # Sample queries: +wx_manager.getSql("SELECT SUM(rain) FROM %s "\\ + "WHERE dateTime>? AND dateTime<=?" % wx_manager.table_name, (start_ts, stop_ts)) +other_manager.getSql("SELECT SUM(power) FROM %s"\\ + "WHERE dateTime>? AND dateTime<=?" % other_manager.table_name, (start_ts, stop_ts)) +``` + +If opening a database from somewhere other than a service, and there is no +`DBBinder` available: + +```python +db_manager = weewx.manager.open_manager_with_config(config_dict, data_binding='name_of_binding') + + # Sample query: +db_manager.getSql("SELECT SUM(rain) FROM %s "\\ + "WHERE dateTime>? AND dateTime<=?" % db_manager.table_name, (start_ts, stop_ts)) +``` + +The `DBBinder` caches managers, and thus database connections. It cannot be +shared between threads. + + +## Units + +The unit architecture in WeeWX is designed to make basic unit conversions and +display of units easy. It is not designed to provide dimensional analysis, +arbitrary conversions, and indications of compatibility. + +The _driver_ reads observations from an instrument and converts them, as +necessary, into a standard set of units. The actual units used by each +instrument vary widely; some instruments use Metric units, others use US +Customary units, and many use a mixture. The driver can emit measurements in +any unit system, but it must use the same unit system for all values in a +LOOP packet or archive record. + +By default, and to maintain compatibility with wview, the default database +units are US Customary, although this can be changed. + +Note that whatever unit system is used in the database, data can be _displayed_ +using any unit system. So, in practice, it does not matter what unit system is +used in the database. + +Each _observation type_, such as `outTemp` or `pressure`, is associated with a +_unit group_, such as `group_temperature` or `group_pressure`. Each unit group +is associated with a _unit type_ such as `degree_F` or `mbar`. The reporting +service uses this architecture to convert observations into a target unit +system, to be displayed in your reports. + +With this architecture one can easily create reports with, say, wind measured +in knots, rain measured in mm, and temperatures in degree Celsius. Or one can +create a single set of templates, but display data in different unit systems +with only a few stanzas in a configuration file. diff --git a/dist/weewx-5.0.2/docs_src/custom/localization.md b/dist/weewx-5.0.2/docs_src/custom/localization.md new file mode 100644 index 0000000..53f45d6 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/localization.md @@ -0,0 +1,323 @@ +# Localization + +This section provides suggestions for localization, including +translation to different languages and display of data in formats +specific to a locale. + +## If the skin has been internationalized + +All the skins that come with WeeWX have been *internationalized*, +that is, they are capable of being *localized*, although there may or may not +be a localization available for your specific language. See the section +[Internationalized skins](custom-reports.md#internationalized-skins) for +how to tell. + +### Internationalized, your language is available + +This is the easy case: the skin has been internationalized, and your +locale is available. In this case, all you need to do is to select your +locale in `weewx.conf`. For example, to select German (code +`de`) for the *Seasons* skin, just add the highlighted line (or +change, if it's already there): + +``` ini hl_lines="7" +[StdReport] + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + lang = de +``` + +### Internationalized, but your language is missing {#missing-language} + +If the `lang` subdirectory is present in the skin directory, then +the skin has been internationalized. However, if your language code is +not included in the subdirectory, then you will have to *localize* it to +your language. To do so, copy the file `en.conf` and name it +according to the language code of your language. Then translate all the +strings on the right side of the equal signs to your language. For +example, say you want to localize the skin in the French language. Then +copy `en.conf` to `fr.conf` + +```shell +cp en.conf fr.conf +``` + +Then change things that look like this: + +``` ini +[Texts] + "Language" = "English" + + "7-day" = "7-day" + "24h" = "24h" + "About this weather station" = "About this weather station" +``` + +to something that looks like this: + +``` ini +[Texts] + Language = French + + "7-day" = "7-jours" + "24h" = "24h" + "About this weather station" = "A propos de cette station" +``` + +And so on. When you're done, the skin author may be interested in your +localization file to ship it together with the skin for the use of other +users. If the skin is one that came with WeeWX, contact the WeeWX team +via a post to the [weewx-user +group](https://groups.google.com/forum/#!forum/weewx-user) and, with +your permission, we may include your localization file in a future WeeWX +release. + +Finally, set the option `lang` in `weewx.conf` to your language code (`fr` in +this example) as described in the +[User's Guide](../reference/weewx-options/stdreport.md#lang). + +## How to internationalize a skin + +What happens when you come across a skin that you like, but it has not +been internationalized? This section explains how to convert the report +to local formats and language. + +Internationalization of WeeWX templates uses a pattern very similar to +the well-known GNU "[`gettext`](https://www.gnu.org/software/gettext/)" +approach. The only difference is that we have leveraged the `ConfigObj` +configuration library used throughout WeeWX. + +### Create the localization file + +Create a subdirectory called `lang` in the skin directory. Then create a file +named by the language code with the suffix `.conf` in this subdirectory. For +example, if you want to translate to Spanish, name the file `lang/es.conf`. +Include the following in the file: + +``` ini +[Units] + + [[Labels]] + + # These are singular, plural + meter = " meter", " meters" + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Altimeter # QNH + altimeterRate = Altimeter Change Rate + appTemp = Apparent Temperature + appTemp1 = Apparent Temperature + barometer = Barometer # QFF + barometerRate = Barometer Change Rate + cloudbase = Cloud Base + dateTime = Time + dewpoint = Dew Point + ET = ET + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + heatindex = Heat Index + inDewpoint = Inside Dew Point + inHumidity = Inside Humidity + inTemp = Inside Temperature + interval = Interval + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + outHumidity = Outside Humidity + outTemp = Outside Temperature + pressure = Pressure # QFE + pressureRate = Pressure Change Rate + radiation = Radiation + rain = Rain + rainRate = Rain Rate + THSW = THSW Index + UV = UV Index + wind = Wind + windchill = Wind Chill + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windgustvec = Gust Vector + windrun = Wind Run + windSpeed = Wind Speed + windvec = Wind Vector + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +[Texts] + + Language = Español # Replace with the language you are targeting + +``` + +Go through the file, translating all phrases on the right-hand side of +the equal signs to your target language (Spanish in this example). + +### Internationalize the template + +You will need to internationalize every HTML template (these typically +have a file suffix of `.html.tmpl`). This is most easily done by +opening the template and the language file in different editor windows. +It is much easier if you can change both files simultaneously. + +#### Change the HTML `lang` attribute + +At the top of the template, change the HTML `lang` attribute to a +configurable value, `$lang`. + +``` html + + + + + ... +``` + +The value `$lang` will get replaced by the actual language to be used. + +For reference, here are the ISO language and country codes: + +* [language codes](https://www.w3schools.com/tags/ref_language_codes.asp)
+* [country codes](https://www.w3schools.com/tags/ref_country_codes.asp) + +#### Change the body text + +The next step is to go through the templates and change all natural +language phrases into lookups using `$gettext`. For example, +suppose your skin has a section that looks like this: + +``` html +
+ Current Conditions + + + + + +
Outside Temperature$current.outTemp
+
+``` + +There are two natural language phrases here: *Current Conditions* and +*Outside Temperature*. They would be changed to: + +``` html +
+ $gettext("Current Conditions") + + + + + +
$obs.label.outTemp$current.outTemp
+
+``` + +We have done two replacements here. For the phrase *Current Conditions*, +we substituted `$gettext("Current Conditions")`. This will +cause the Cheetah Generator to look up the localized version of +"Current Conditions" in the localization file and substitute it. We +could have done something similar for *Outside Temperature*, but in this +case, we chose to use the localized name for type `outTemp`, +which you should have provided in your localization file, under section +`[Labels] / [[Generic]]`. + +In the localization file, include the translation for *Current +Conditions* under the `[Texts]` section: + +``` ini +... +[Texts] + + "Language" = "Español" + "Current Conditions" = "Condiciones Actuales" + ... +``` + +Repeat this process for all the strings that you find. Make sure not to +replace HTML tags and HTML options. + +### Think about time + +Whenever a time is used in a template, it will need a format. WeeWX +comes with the following set of defaults: + +``` ini +[Units] + [[TimeFormats]] + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X +``` + +The times for images are defined with the following defaults: + +``` ini +[ImageGenerator] + [[day_images]] + bottom_label_format = %x %X + [[week_images]] + bottom_label_format = %x %X + [[month_images]] + bottom_label_format = %x %X + [[year_images]] + bottom_label_format = %x %X +``` + +These defaults will give something readable in every locale, but they +may not be very pretty. Therefore, you may want to change them to +something more suitable for the locale you are targeting, using the +Python [`strftime()` specific +directives](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior). + +Example: the default time formatting for "Current" conditions is `%x +%x`, which will show today's date as "14/05/21 10:00:00" in the +Spanish locale. Suppose you would rather see "14-mayo-2021 10:00". You +would add the following to your Spanish localization file +`es.conf`: + +``` ini +[Units] + [[TimeFormats]] + current = %d-%B-%Y %H:%M +``` + +### Set the environment variable `LANG` {#environment-variable-LANG} + +Finally, you will need to set the environment variable `LANG` to +reflect your locale. For example, assuming you set + +``` shell +$ export LANG=es_ES.UTF-8 +``` + +before running WeeWX, then the local Spanish names for days of the week +and months of the year will be used. The decimal point for numbers will +also be modified appropriately. diff --git a/dist/weewx-5.0.2/docs_src/custom/multiple-bindings.md b/dist/weewx-5.0.2/docs_src/custom/multiple-bindings.md new file mode 100644 index 0000000..0f9a4c8 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/multiple-bindings.md @@ -0,0 +1,146 @@ +# Using multiple bindings + +It's easy to use more than one database in your reports. Here's an +example. In my office I have two consoles: a VantagePro2 connected to a +Dell Optiplex, and a WMR100N, connected to a Raspberry Pi. Each is +running WeeWX. The Dell is using SQLite, the RPi, MySQL. + +Suppose I wish to compare the inside temperatures of the two consoles. +How would I do that? + +It's easier to access MySQL across a network than SQLite, so let's run +the reports on the Dell, but access the RPi's MySQL database remotely. +Here's how the bindings and database sections of `weewx.conf` +would look on the Dell: + +``` ini hl_lines="14-22 31-34" +[DataBindings] + # This section binds a data store to an actual database + + [[wx_binding]] + # The database to be used - it should match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The class to manage the database + manager = weewx.manager.DaySummaryManager + # The schema defines to structure of the database contents + schema = schemas.wview_extended.schema + + [[wmr100_binding]] + # Binding for my WMR100 on the RPi + database = rpi_mysql + # The name of the table within the database + table_name = archive + # The class to manage the database + manager = weewx.manager.DaySummaryManager + # The schema defines to structure of the database contents + schema = schemas.wview_extended.schema + +[Databases] + # This section binds to the actual database to be used + + [[archive_sqlite]] + database_type = SQLite + database_name = weewx.sdb + + [[rpi_mysql]] + database_type = MySQL + database_name = weewx + host = rpi-bug + +[DatabaseTypes] + # This section defines defaults for the different types of databases. + + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = archive + + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name + password = weewx + +``` + +The two additions have been ==highlighted==. The first, `[[wmr100_binding]]`, +adds a new binding called `wmr100_binding`. It links ("binds") to the new +database, called `rpi_mysql`, through the option `database`. It also defines +some characteristics of the binding, such as which manager is to be used and +what its schema looks like. + +The second addition, `[[rpi-mysql]]`, defines the new database. Option +`database_type` is set to `MySQL`, indicating that it is a MySQL database. +Defaults for MySQL databases are defined in the section `[[MySQL]]`. The new +database accepts all of them, except for `host`, which as been set to the +remote host `rpi-bug`, the name of my Raspberry Pi. + +## Explicit binding in tags + +How do we use this new binding? First, let's do a text comparison, +using tags. Here's what our template looks like: + +``` html hl_lines="8" + + + + + + + + + +
Inside Temperature, Vantage$current.inTemp
Inside Temperature, WMR100$latest($data_binding='wmr100_binding').inTemp
+``` + +The explicit binding to `wmr100_binding` is highlighted. This tells the +reporting engine to override the default binding specifed in `[StdReport]`, +generally `wx_binding`, and use `wmr100_binding` instead. + +
+ Inside Temperature, Vantage 68.7°F
+ Inside Temperature, WMR100 68.9°F +
+ +## Explicit binding in images + +How would we produce a graph of the two different temperatures? Here's +what the relevant section of the `skin.conf` file would look +like. + +``` ini hl_lines="6" +[[[daycompare]]] + [[[[inTemp]]]] + label = Vantage inTemp + [[[[WMR100Temp]]]] + data_type = inTemp + data_binding = wmr100_binding + label = WMR100 inTemp +``` + +This will produce an image with name `daycompare.png`, with two plot lines. +The first will be of the temperature from the Vantage. It uses the default +binding, `wx_binding`, and will be labeled `Vantage inTemp`. The second line +explicitly uses the `wmr100_binding`. Because it uses the same variable name +(`inTemp`) as the first line, we had to explicitly specify it using option +`data_type`, in order to avoid using the same subsection name twice (see +the section *[Including a type more than once in a plot](image-generator.md#include-same-sql-type-2x)* +for details). It will be labeled `WMR100 inTemp`. The results look like this: + +![Comparing temperatures](../images/daycompare.png) + + +## Stupid detail {#stupid-detail} + +At first, I could not get this example to work. The problem turned out to be +that the RPi was processing things just a beat behind the Dell, so the +temperature for the "current" time wasn't ready when the Dell needed it. +I kept getting `N/A`. To avoid this, I introduced the tag `$latest`, which +uses the last available timestamp in the binding, which may or may not be +the same as what `$current` uses. That's why the example above uses `$latest` +instead of `$current`. diff --git a/dist/weewx-5.0.2/docs_src/custom/report-scheduling.md b/dist/weewx-5.0.2/docs_src/custom/report-scheduling.md new file mode 100644 index 0000000..01837fa --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/report-scheduling.md @@ -0,0 +1,360 @@ +# Scheduling report generation + +Normal WeeWX operation is to run each _[report](../reference/weewx-options/stdreport.md)_ +defined in `weewx.conf` every archive period. While this may suit most +situations, there may be occasions when it is desirable to run a report less +frequently than every archive period. For example, the archive interval might +be 5 minutes, but you only want to FTP files every 30 minutes, once per day, +or at a set time each day. + +There are two options to `[StdReport]` that provide the ability to control +when files are generated. The _`stale_age`_ option allows control over the +age of a file before it is regenerated, and the _`report_timing`_ option +allows precise control over when individual reports are run. + +WeeWX also includes a utility [`weectl report +run`](../utilities/weectl-report.md#run-reports-on-demand) for +those times when you need to run a report independent of the interval timing. +For example, you might not want to wait for an archive interval to see if +your customizations worked. + +!!! Note + Although `report_timing` specifies when a given report should be generated, + the generation of reports is still controlled by the WeeWX report cycle, + so reports can never be generated more frequently than once every archive + period. + + If your reports contain data that change more frequently that each archive + interval, then you could run `weectl report run` separately, or consider + uploading data to a real-time reporting solution such as MQTT. + +## The stale_age option + +The `stale_age` option applies to each file in a report. When `stale_age` +is specified, the file will be (re)generaed only when it is older than the +indicated age. The age is specified in seconds. + +Details for the `stale_age` option are in the +[`[CheetahGenerator]`](../reference/skin-options/cheetahgenerator.md#stale_age) reference. + +## The report_timing option + +The `report_timing` option applies to each report. It uses a CRON-like +format to control when a report is to be run. While a CRON-like format is used, +the control of WeeWX report generation using the report_timing option is +confined completely to WeeWX and has no interraction with the system CRON +service. + +The `report_timing` option consists of five parameters separated by +white-space: + +``` +report_timing = minutes hours day_of_month months day_of_week +``` + +The parameters are summarised in the following table: + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterFunctionAllowable values
minutesSpecifies the minutes of the hour when the report will be run*, or numbers in the range 0..59 inclusive
hoursSpecifies the hours of the day when the report will be run*, or numbers in the range 0..23 inclusive
day_of_monthSpecifies the days of the month when the report will be run*, or numbers in the range 1..31 inclusive
monthsSpecifies the months of the year when the report will be run +*, or numbers in the range 1..12 inclusive, or +abbreviated names in the range jan..dec inclusive +
day_of_weekSpecifies the days of the week when the report will be run +*, or +numbers in the range 0..7 inclusive (0,7 = Sunday, 1 = Monday etc.), or +abbreviated names in the range sun..sat inclusive +
+ +The `report_timing` option may only be used in `weewx.conf`. When set in the +`[StdReport]` section, the option will apply to all reports listed under +`[StdReport]`. When specified within a report section, the option will +override any setting in `[StdReport]`, for that report. In this manner it +is possible to have different reports run at different times. The following +excerpt illustrates this: + +``` +[StdReport] + + report_timing = 0 * * * * + + [[AReport]] + skin = SomeSkin + + [[AnotherReport]] + skin = SomeOtherSkin + report_timing = */10 * * * * +``` + +In this case, the report `AReport` would be run under control of the +`0 * * * *` setting (on the hour) and the report `AnotherReport` would be +run under control of the `*/10 * * * *` setting (every 10 minutes). + +### How report_timing controls reporting + +The syntax and interpretation of the report_timing parameters are largely the +same as those of the CRON service in many Unix and Unix-like operating systems. +The syntax and interpretation are outlined below. + +When the report_timing option is in use WeeWX will run a report when the +minute, hour and month of year parameters match the report time, and at least +one of the two-day parameters (day of month or day of week) match the report +time. This means that non-existent times, such as "missing hours" during +daylight savings changeover, will never match, causing reports scheduled +during the "missing times" not to be run. Similarly, times that occur more +than once (again, during daylight savings changeover) will cause matching +reports to be run more than once. + +!!! Note + Report time does not refer to the time at which the report is run, but + rather the date and time of the latest data the report is based upon. If + you like, it is the effective date and time of the report. For normal + WeeWX operation, the report time aligns with the dateTime of the most + recent archive record. When reports are run using the `weectl report run` utility, + the report time is either the dateTime of the most recent archive record + (the default) or the optional timestamp command line argument. + +!!! Note + The day a report is to be run can be specified by two parameters; day of + month and/or day of week. If both parameters are restricted (i.e., not an + asterisk), the report will be run when either field matches the current + time. For example, + ``` + report_timing = 30 4 1,15 * 5 + ``` + would cause the report to be run at 4:30am on the 1st and 15th of each + month as well as 4:30am every Friday. + +### The relationship between report_timing and archive period + +A traditional CRON service has a resolution of one minute, meaning that the +CRON service checks each minute whether to execute any commands. On the +other hand, the WeeWX report system checks which reports are to be run once +per archive period, where the archive period may be one minute, five minutes, +or some other user defined period. Consequently, the report_timing option may +specify a report to be run at some time that does not align with the WeeWX +archive period. In such cases the report_timing option does not cause a report +to be run outside the normal WeeWX report cycle, rather it will cause the +report to be run during the next report cycle. At the start of each report +cycle, and provided a report_timing option is set, WeeWX will check each +minute boundary from the current report time back until the report time of +the previous report cycle. If a match is found on **any** of these one minute +boundaries the report will be run during the report cycle. This may be best +described through some examples: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
report_timingArchive periodWhen the report will be run
0 * * * *5 minutesThe report will be run only during the report cycle commencing on the hour.
5 * * * *5 minutesThe report will be run only during the report cycle commencing at 5 minutes past the hour.
3 * * * *5 minutesThe report will be run only during the report cycle commencing at 5 minutes past the hour.
10 * * * *15 minutesThe report will be run only during the report cycle commencing at 15 minutes past the hour
10,40 * * * *15 minutesThe report will be run only during the report cycles commencing at 15 minutes past the hour and 45 minutes past the hour.
5,10 * * * *15 minutesThe report will be run once only during the report cycle commencing at 15 minutes past the hour.
+ + +### Lists, ranges and steps + +The report_timing option supports lists, ranges, and steps for all parameters. +Lists, ranges, and steps may be used as follows: + +* _Lists_. A list is a set of numbers (or ranges) separated by commas, for + example `1, 2, 5, 9` or `0-4, 8-12`. A match with any of the elements of the + list will result in a match for that particular parameter. If the examples + were applied to the minutes parameter, and subject to other parameters in + the report_timing option, the report would be run at minutes 1, 2, 5, and + 9 and 0, 1, 2, 3, 4, 8, 9, 10, 11, and 12 respectively. Abbreviated month + and day names cannot be used in a list. + +* _Ranges_. Ranges are two numbers separated with a hyphen, for example `8-11`. + The specified range is inclusive. A match with any of the values included + in the range will result in a match for that particular parameter. If the + example was applied to the hours parameter, and subject to other parameters + in the report_timing option, the report would be run at hours 8, 9, 10, and + 11. A range may be included as an element of a list. Abbreviated month and + day names cannot be used in a range. + +* _Steps_. A step can be used in conjunction with a range or asterisk and are + denoted by a '/' followed by a number. Following a range with a step + specifies skips of the step number's value through the range. For example, + `0-12/2` used in the hours parameter would, subject to other parameter in the + report_timing option, run the report at hours 0, 2, 4, 6, 8, and 12. Steps + are also permitted after an asterisk in which case the skips of the step + number's value occur through the all possible values of the parameter. For + example, `*/3` can be used in the hours parameter to, subject to other + parameter in the report_timing option, run the report at hours 0, 3, 6, + 9, 12, 15, 18, and 21. + +### Nicknames + +The report_timing option supports a number of time specification 'nicknames'. +These nicknames are prefixed by the '@' character and replace the five +parameters in the report_timing option. The nicknames supported are: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NicknameEquivalent settingWhen the report will be run
@yearly
@annually
0 0 1 1 *Once per year at midnight on 1 January.
@monthly0 0 1 * *Monthly at midnight on the 1st of the month.
@weekly0 0 * * 0Every week at midnight on Sunday.
@daily0 0 * * *Every day at midnight.
@hourly0 * * * *Every hour on the hour.
+ + +### Examples of report_timing + +Numeric settings for report_timing can be at times difficult to understand due +to the complex combinations of parameters. The following table shows a number +of example report_timing options and the corresponding times when the report +would be run. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
report_timingWhen the report will be run
* * * * *Every archive period. This setting is effectively the default WeeWX method of operation.
25 * * * *25 minutes past every hour.
0 * * * *Every hour on the hour.
5 0 * * *00:05 daily.
25 16 * * *16:25 daily.
25 16 1 * *16:25 on the 1st of each month.
25 16 1 2 *16:25 on the 1st of February.
25 16 * * 01:25 each Sunday.
*/10 * * * *On the hour and 10, 20, 30, 40 and 50 mnutes past the hour.
*/9 * * * *On the hour and 9, 18, 27, 36, 45 and 54 minutes past the hour.
*/10 */2 * * *0, 10, 20, 30, 40 and 50 minutes after the even hour.
* 6-17 * * *Every archive period from 06:00 (inclusive) up until, but excluding, 18:00.
* 1,4,14 * * *Every archive period in the hour starting 01:00 to 01:59, 04:00 to 04:59 amd 14:00 to 14:59 (Note excludes report times at 02:00, 05:00 and 15:00).
0 * 1 * 0,3On the hour on the first of the month and on the hour every Sunday and Wednesday.
* * 21,1-10/3 6 *Every archive period on the 1st, 4th, 7th, 10th and 21st of June.
@monthlyMidnight on the 1st of the month.
+ +### The `weectl report run` utility and the report_timing option + +The `report_timing` option is ignored when using the `weectl report run` utility. diff --git a/dist/weewx-5.0.2/docs_src/custom/service-engine.md b/dist/weewx-5.0.2/docs_src/custom/service-engine.md new file mode 100644 index 0000000..dcbe466 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/service-engine.md @@ -0,0 +1,495 @@ +# Customizing the service engine + +This is an advanced topic intended for those who wish to try their hand +at extending the internal engine in WeeWX. Before attempting these +examples, you should be reasonably proficient with Python. + +!!! Warning + Please note that the API to the service engine may change in future + versions! + +At a high level, WeeWX consists of an *engine* that is responsible for +managing a set of *services*. A service consists of a Python class which +binds its member functions to various *events*. The engine arranges to +have the bound member function called when a specific event happens, +such as a new LOOP packet arriving. + +The services are specified in lists in the +[`[Engine][[Services]]`](../reference/weewx-options/engine.md#services) +stanza of the configuration file. The `[[Services]]` section +lists all the services to be run, broken up into different *service +lists*. + +These lists are designed to orchestrate the data as it flows through the WeeWX +engine. For example, you want to make sure that data has been processed by the +quality control service, `StdQC`, before putting them in the database. +Similarly, the reporting system must come *after* the data has been put in the +database. These groups ensure that things happen in the proper sequence. + +See the table [The standard WeeWX services](introduction.md#the-weewx-service-architecture) +for a list of the services that are normally run. + + +## Modifying an existing service {#modify-service} + +The service `weewx.engine.StdPrint` prints out new LOOP and +archive packets to the console when they arrive. By default, it prints +out the entire record, which generally includes a lot of possibly +distracting information and can be rather messy. Suppose you do not like +this, and want it to print out only the time, barometer reading, and the +outside temperature whenever a new LOOP packet arrives. + +This could be done by subclassing the default print service `StdPrint` and +overriding member function `new_loop_packet()`. + +Create the file `user/myprint.py`: + +``` python +from weewx.engine import StdPrint +from weeutil.weeutil import timestamp_to_string + +class MyPrint(StdPrint): + + # Override the default new_loop_packet member function: + def new_loop_packet(self, event): + packet = event.packet + print("LOOP: ", timestamp_to_string(packet['dateTime']), + "BAR=", packet.get('barometer', 'N/A'), + "TEMP=", packet.get('outTemp', 'N/A')) +``` + +This service substitutes a new implementation for the member function +`new_loop_packet`. This implementation prints out the time, then +the barometer reading (or `N/A` if it is not available) and the +outside temperature (or `N/A`). + +You then need to specify that your print service class should be loaded +instead of the default `StdPrint` service. This is done by +substituting your service name for `StdPrint` in +`service_list`, located in `[Engine]/[[Services]]`: + +``` ini +[Engine] + [[Services]] + ... + report_services = user.myprint.MyPrint, weewx.engine.StdReport +``` + +Note that the `report_services` must be all on one line. +Unfortunately, the parser `ConfigObj` does not allow options to +be continued on to following lines. + + +## Creating a new service {#create-service} + +Suppose there is no service that can be easily customized for your +needs. In this case, a new one can easily be created by subclassing off +the abstract base class `StdService`, and then adding the +functionality you need. Here is an example that implements an alarm, +which sends off an email when an arbitrary expression evaluates +`True`. + +This example is included in the standard distribution as +`examples/alarm.py:` + +``` python linenums="1" hl_lines="45 61 64" +import logging +import smtplib +import socket +import threading +import time +from email.mime.text import MIMEText + +import weewx +from weeutil.weeutil import timestamp_to_string, option_as_list +from weewx.engine import StdService + +log = logging.getLogger(__name__) + + +# Inherit from the base class StdService: +class MyAlarm(StdService): + """Service that sends email if an arbitrary expression evaluates true""" + + def __init__(self, engine, config_dict): + # Pass the initialization information on to my superclass: + super().__init__(engine, config_dict) + + # This will hold the time when the last alarm message went out: + self.last_msg_ts = 0 + + try: + # Dig the needed options out of the configuration dictionary. + # If a critical option is missing, an exception will be raised and + # the alarm will not be set. + self.expression = config_dict['Alarm']['expression'] + self.time_wait = int(config_dict['Alarm'].get('time_wait', 3600)) + self.timeout = int(config_dict['Alarm'].get('timeout', 10)) + self.smtp_host = config_dict['Alarm']['smtp_host'] + self.smtp_user = config_dict['Alarm'].get('smtp_user') + self.smtp_password = config_dict['Alarm'].get('smtp_password') + self.SUBJECT = config_dict['Alarm'].get('subject', + "Alarm message from weewx") + self.FROM = config_dict['Alarm'].get('from', + 'alarm@example.com') + self.TO = option_as_list(config_dict['Alarm']['mailto']) + except KeyError as e: + log.info("No alarm set. Missing parameter: %s", e) + else: + # If we got this far, it's ok to start intercepting events: + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) # 1 + log.info("Alarm set for expression: '%s'", self.expression) + + def new_archive_record(self, event): + """Gets called on a new archive record event.""" + + # To avoid a flood of nearly identical emails, this will do + # the check only if we have never sent an email, or if we haven't + # sent one in the last self.time_wait seconds: + if (not self.last_msg_ts + or abs(time.time() - self.last_msg_ts) >= self.time_wait): + # Get the new archive record: + record = event.record + + # Be prepared to catch an exception in the case that the expression + # contains a variable that is not in the record: + try: # 2 + # Evaluate the expression in the context of the event archive + # record. Sound the alarm if it evaluates true: + if eval(self.expression, None, record): # 3 + # Sound the alarm! Launch in a separate thread, + # so it doesn't block the main LOOP thread: + t = threading.Thread(target=MyAlarm.sound_the_alarm, + args=(self, record)) + t.start() + # Record when the message went out: + self.last_msg_ts = time.time() + except NameError as e: + # The record was missing a named variable. Log it. + log.info("%s", e) + + def sound_the_alarm(self, record): + """Sound the alarm in a 'try' block""" + + # Wrap the attempt in a 'try' block so we can log a failure. + try: + self.do_alarm(record) + except socket.gaierror: + # A gaierror exception is usually caused by an unknown host + log.critical("Unknown host %s", self.smtp_host) + # Reraise the exception. This will cause the thread to exit. + raise + except Exception as e: + log.critical("Unable to sound alarm. Reason: %s", e) + # Reraise the exception. This will cause the thread to exit. + raise + + def do_alarm(self, record): + """Send an email out""" + + # Get the time and convert to a string: + t_str = timestamp_to_string(record['dateTime']) + + # Log the alarm + log.info('Alarm expression "%s" evaluated True at %s' + % (self.expression, t_str)) + + # Form the message text: + msg_text = 'Alarm expression "%s" evaluated True at %s\nRecord:\n%s' \ + % (self.expression, t_str, str(record)) + # Convert to MIME: + msg = MIMEText(msg_text) + + # Fill in MIME headers: + msg['Subject'] = self.SUBJECT + msg['From'] = self.FROM + msg['To'] = ','.join(self.TO) + + try: + # First try end-to-end encryption + s = smtplib.SMTP_SSL(self.smtp_host, timeout=self.timeout) + log.debug("Using SMTP_SSL") + except (AttributeError, socket.timeout, socket.error) as e: + log.debug("Unable to use SMTP_SSL connection. Reason: %s", e) + # If that doesn't work, try creating an insecure host, + # then upgrading + s = smtplib.SMTP(self.smtp_host, timeout=self.timeout) + try: + # Be prepared to catch an exception if the server + # does not support encrypted transport. + s.ehlo() + s.starttls() + s.ehlo() + log.debug("Using SMTP encrypted transport") + except smtplib.SMTPException as e: + log.debug("Using SMTP unencrypted transport. Reason: %s", e) + + try: + # If a username has been given, assume that login is required + # for this host: + if self.smtp_user: + s.login(self.smtp_user, self.smtp_password) + log.debug("Logged in with user name %s", self.smtp_user) + + # Send the email: + s.sendmail(msg['From'], self.TO, msg.as_string()) + # Log out of the server: + s.quit() + except Exception as e: + log.error("SMTP mailer refused message with error %s", e) + raise + + # Log sending the email: + log.info("Email sent to: %s", self.TO) + + +if __name__ == '__main__': + """This section is used to test alarm.py. It uses a record and alarm + expression that are guaranteed to trigger an alert. + + You will need a valid weewx.conf configuration file with an [Alarm] + section that has been set up as illustrated at the top of this file.""" + + from optparse import OptionParser + import weecfg + import weeutil.logger + + usage = """Usage: python alarm.py --help + python alarm.py [CONFIG_FILE|--config=CONFIG_FILE] + +Arguments: + + CONFIG_PATH: Path to weewx.conf """ + + epilog = """You must be sure the WeeWX modules are in your PYTHONPATH. + For example: + + PYTHONPATH=/home/weewx/bin python alarm.py --help""" + + # Force debug: + weewx.debug = 1 + + # Create a command line parser: + parser = OptionParser(usage=usage, epilog=epilog) + parser.add_option("--config", dest="config_path", metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + # Parse the arguments and options + (options, args) = parser.parse_args() + + try: + config_path, config_dict = weecfg.read_config(options.config_path, args) + except IOError as e: + exit("Unable to open configuration file: %s" % e) + + print("Using configuration file %s" % config_path) + + # Set logging configuration: + weeutil.logger.setup('alarm', config_dict) + + if 'Alarm' not in config_dict: + exit("No [Alarm] section in the configuration file %s" % config_path) + + # This is a fake record that we'll use + rec = {'extraTemp1': 1.0, + 'outTemp': 38.2, + 'dateTime': int(time.time())} + + # Use an expression that will evaluate to True by our fake record. + config_dict['Alarm']['expression'] = "outTemp<40.0" + + # We need the main WeeWX engine in order to bind to the event, + # but we don't need for it to completely start up. So get rid of all + # services: + config_dict['Engine']['Services'] = {} + # Now we can instantiate our slim engine, using the DummyEngine class... + engine = weewx.engine.DummyEngine(config_dict) + # ... and set the alarm using it. + alarm = MyAlarm(engine, config_dict) + + # Create a NEW_ARCHIVE_RECORD event + event = weewx.Event(weewx.NEW_ARCHIVE_RECORD, record=rec) + + # Use it to trigger the alarm: + alarm.new_archive_record(event) +``` + +This service expects all the information it needs to be in the configuration +file `weewx.conf` in a new section called `[Alarm]`. So, add the following +lines to your configuration file: + +``` ini +[Alarm] + expression = "outTemp < 40.0" + time_wait = 3600 + smtp_host = smtp.example.com + smtp_user = myusername + smtp_password = mypassword + mailto = auser@example.com, anotheruser@example.com + from = me@example.com + subject = "Alarm message from WeeWX!" +``` + +There are three important ==highlighted== points to be noted in this example. + +1. (Line 45) Here is where the binding happens between an event, + `weewx.NEW_ARCHIVE_RECORD`, and a member function, `self.new_archive_record`. + When the event `NEW_ARCHIVE_RECORD` occurs, the function + `self.new_archive_record` will be called. There are many other events that can + be intercepted. Look in the file `weewx/_init_.py`. + +2. (Line 61) Some hardware do not emit all possible observation types in every + record, so it's possible that a record may be missing some types that are + used in the expression. This try block will catch the `NameError` exception + that would be raised should this occur. + +3. (Line 64) This is where the test is done for whether to sound the alarm. The + `[Alarm]` configuration options specify that the alarm be sounded when + `outTemp < 40.0` evaluates `True`, that is when the outside temperature is + below 40.0 degrees. Any valid Python expression can be used, although the + only variables available are those in the current archive record. + +Another example expression could be: + +``` ini +expression = "outTemp < 32.0 and windSpeed > 10.0" +``` + +In this case, the alarm is sounded if the outside temperature drops below +freezing and the wind speed is greater than 10.0. + +Note that units must be the same as whatever is being used in your database, +that is, the same as what you specified in option +[`target_unit`](../reference/weewx-options/stdconvert.md#target_unit). + +Option `time_wait` is used to avoid a flood of nearly identical emails. The new +service will wait this long before sending another email out. + +Email will be sent through the SMTP host specified by option `smtp_host`. The +recipient(s) are specified by the comma separated option `mailto`. + +Many SMTP hosts require user login. If this is the case, the user and password +are specified with options `smtp_user` and `smtp_password`, respectively. + +The last two options, `from` and `subject` are optional. If not supplied, WeeWX +will supply something sensible. Note, however, that some mailers require a valid +"from" email address and the one that WeeWX supplies may not satisfy its +requirements. + +To make this all work, you must first copy the `alarm.py` file to the `user` +directory. Then tell the engine to load this new service by adding the service +name to the list `report_services`, located in `[Engine]/[[Services]]`: + +``` ini +[Engine] + [[Services]] + report_services = weewx.engine.StdPrint, weewx.engine.StdReport, user.alarm.MyAlarm +``` + +Again, note that the option `report_services` must be all on one line — +the `ConfigObj` parser does not allow options to be continued on to following +lines. + +In addition to this example, the distribution also includes a low-battery +alarm (`lowBattery.py`), which is similar, except that it intercepts LOOP +events instead of archiving events. + + +## Adding a second data source {#add-data-source} + +A very common problem is wanting to augment the data from your weather +station with data from some other device. Generally, you have two +approaches for how to handle this: + +- Run two instances of WeeWX, each using its own database and + `weewx.conf` configuration file. The results are then + combined in a final report, using WeeWX's ability [to use more than + one database](multiple-bindings.md). See the Wiki entry + [*How to run multiple instances of + WeeWX*](https://github.com/weewx/weewx/wiki/weewx-multi) for details + on how to do this. + +- Run one instance, but use a custom WeeWX service to augment the + records coming from your weather station with data from the other + device. + +This section covers the latter approach. + +Suppose you have installed an electric meter at your house, and you wish +to correlate electrical usage with the weather. The meter has some sort +of connection to your computer, allowing you to download the total power +consumed. At the end of every archive interval you want to calculate the +amount of power consumed during the interval, then add the results to +the record coming off your weather station. How would you do this? + +Here is the outline of a service that retrieves the electrical +consumption data and adds it to the archive record. It assumes that you +already have a function `download_total_power()` that, somehow, +downloads the amount of power consumed since time zero. + +File `user/electricity.py` + +``` python +import weewx +from weewx.engine import StdService + +class AddElectricity(StdService): + + def __init__(self, engine, config_dict): + + # Initialize my superclass first: + super(AddElectricity, self).__init__(engine, config_dict) + + # Bind to any new archive record events: + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + self.last_total = None + + def new_archive_record(self, event): + + total_power = download_total_power() + + if self.last_total: + net_consumed = total_power - self.last_total + event.record['electricity'] = net_consumed + + self.last_total = total_power +``` + +This adds a new key `electricity` to the record dictionary and +sets it equal to the difference between the amount of power currently +consumed and the amount consumed at the last archive record. Hence, it +will be the amount of power consumed over the archive interval. The unit +should be Watt-hours. + +As an aside, it is important that the function +`download_total_power()` does not delay very long because it will +sit right in the main loop of the WeeWX engine. If it's going to cause +a delay of more than a couple seconds you might want to put it in a +separate thread and feed the results to `AddElectricity` through +a queue. + +To make sure your service gets run, you need to add it to one of the +service lists in `weewx.conf`, section `[Engine]`, +subsection `[[Services]]`. + +In our case, the obvious place for our new service is in +`data_services`. When you're done, your section +`[Engine]` will look something like this: + +``` ini hl_lines="11" + # This section configures the internal WeeWX engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + prep_services = weewx.engine.StdTimeSynch + data_services = user.electricity.AddElectricity + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport +``` diff --git a/dist/weewx-5.0.2/docs_src/custom/sle.md b/dist/weewx-5.0.2/docs_src/custom/sle.md new file mode 100644 index 0000000..362d5ad --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/sle.md @@ -0,0 +1,813 @@ +# Writing search list extensions + +The intention of this document is to help you write new Search List +Extensions (SLE). Here's the plan: + +* We start by explaining how SLEs work. + +* Then we will look at an example that implements an extension `$seven_day()`. + +* Then we will look at another example, `$colorize()`, which allows you to +pick background colors on the basis of a value (for example, low temperatures +could show blue, while high temperatures show red). It will be implemented +using 3 different, increasingly sophisticated, ways: + + * A simple, hardwired version that works in only one unit system. + + * A version that can handle any unit system, but with the colors + still hardwared. + + * Finally, a version that can handle any unit system, and takes + its color bands from the configuration file. + + +## How the search list works + +Let's start by taking a look at how the Cheetah search list works. + +The Cheetah template engine finds tags by scanning a search list, a +Python list of objects. For example, for a tag `$foo`, the +engine will scan down the list, trying each object in the list in turn. +For each object, it will first try using `foo` as an attribute, +that is, it will try evaluating `obj.foo`. If that raises an +`AttributeError` exception, then it will try `foo` as a +key, that is `obj[key]`. If that raises a `KeyError` +exception, then it moves on to the next item in the list. The first +match that does not raise an exception is used. If no match is found, +Cheetah raises a `NameMapper.NotFound` exception. + +### A simple tag {#simple-tag} + +Now let's take a look at how the search list interacts with WeeWX tags. +Let's start by looking at a simple example: station altitude, available +as the tag + +``` html +$station.altitude +``` + +As we saw in the previous section, Cheetah will run down the search list, +looking for an object with a key or attribute `station`. In the default +search list, WeeWX includes one such object, an instance of the class +`weewx.cheetahgenerator.Station`, which has an attribute `station`, so +it gets a hit on this object. + +Cheetah will then try to evaluate the attribute `altitude` on this object. +Class `Station` has such an attribute, so Cheetah evaluates it. + +#### Return value + +What this attribute returns is not a raw value, say `700`, nor +even a string. Instead, it returns an instance of the class +[`ValueHelper`](../reference/valuehelper.md), a special class defined in module +`weewx.units`. Internally, it holds not only the raw value, but +also references to the formats, labels, and conversion targets you +specified in your configuration file. Its job is to make sure that the +final output reflects these preferences. Cheetah doesn't know anything +about this class. What it needs, when it has finished evaluating the +expression `$station.altitude`, is a *string*. In order to +convert the `ValueHelper` it has in hand into a string, it does +what every other Python object does when faced with this problem: it +calls the special method +[`__str__`](https://docs.python.org/3/reference/datamodel.html#object.__str__). +Class `ValueHelper` has a definition for this method. Evaluating +this function triggers the final steps in this process. Any necessary +unit conversions are done, then formatting occurs and, finally, a label +is attached. The result is a string something like + +
+700 feet +
+ +which is what Cheetah actually puts in the generated HTML file. This is +a good example of *lazy evaluation*. The tags gather all the information +they need, but don't do the final evaluation until the last final +moment, when the most context is understood. WeeWX uses this technique +extensively. + +### A slightly more complex tag {#complex-tag} + +Now let's look at a more complicated example, say the maximum +temperature since midnight: + +``` html +$day.outTemp.max +``` + +When this is evaluated by Cheetah, it actually produces a chain of +objects. At the top of this chain is class +`weewx.tags.TimeBinder`, an instance of which is included in the +default search list. Internally, this instance stores the time of the +desired report (usually the time of the last archive record), a cache to +the databases, a default data binding, as well as references to the +formatting and labelling options you have chosen. + +This instance is examined by Cheetah to see if it has an attribute +`day`. It does and, when it is evaluated, it returns the next +class in the chain, an instance of `weewx.tags.TimespanBinder`. +In addition to all the other things contained in its parent +`TimeBinder`, class `TimespanBinder` adds the desired time +period, that is, the time span from midnight to the current time. + +Cheetah then continues on down the chain and tries to find the next +attribute, `outTemp`. There is no such hard coded attribute (hard +coding all the conceivable different observation types would be +impossible!). Instead, class `TimespanBinder` defines the Python +special method +[`__getattr__`](https://docs.python.org/3/reference/datamodel.html#object.__getattr__). +If Python cannot find a hard coded version of an attribute, and the +method `__getattr__` exists, it will try it. The definition +provided by `TimespanBinder` returns an instance of the next +class in the chain, `weewx.tags.ObservationBinder`, which not +only remembers all the previous stuff, but also adds the observation +type, `outTemp`. + +Cheetah then tries to evaluate an attribute `max` of this class, and the +pattern repeats. Class `weewx.tags.ObservationBinder` does not have an +attribute `max`, but it does have a method `__getattr__`. This method +returns an instance of the next class in the chain, class `AggTypeBinder`, +which not only remembers all the previous information, but adds the +aggregation type, `max`. + +One final step needs to occur: Cheetah has an instance of +`AggTypeBinder` in hand, but what it really needs is a string to +put in the file being created from the template. It creates the string +by calling the method `__str__()` of `AggTypeBinder`. +Now, finally, the chain ends and everything comes together. The method +`__str__` triggers the actual calculation of the value, using +all the known parameters: the database binding to be hit, the time span +of interest, the observation type, and the type of aggregation, querying +the database as necessary. The database is not actually hit until the +last possible moment, after everything needed to do the evalation is +known. + +Like our previous example, the results of the evaluation are then +packaged up in an instance of `ValueHelper`, which does the final +conversion to the desired units, formats the string, then adds a label. +The results, something like + +
+12°C +
+ +are put in the generated HTML file. As you can see, a lot of machinery +is hidden behind the deceptively simple expression +`$day.outTemp.max`! + + +## Extending the list {#extending-the-list} + +As mentioned, WeeWX comes with a number of objects already in the search +list, but you can extend it. + +The general pattern is to create a new class that inherits from +`weewx.cheetahgenerator.SearchList`, which supplies the +functionality you need. You may or may not need to override its member +function `get_extension_list()`. If you do not, then a default is +supplied. + +### Adding tag `$seven_day` + +Let's look at an example. The regular version of WeeWX offers statistical +summaries by day, week, month, year, rain year, and all time. While WeeWX offers +the tag `$week`, this is statistics *since Sunday at midnight*. Suppose we would +like to have statistics for a full week, that is since midnight seven days ago. + +If you wish to use or modify this example, cut and paste the below to +`user/seven_day.py`. + +``` {.python .copy} +import datetime +import time + +from weewx.cheetahgenerator import SearchList +from weewx.tags import TimespanBinder +from weeutil.weeutil import TimeSpan + +class SevenDay(SearchList): # 1 + + def __init__(self, generator): # 2 + SearchList.__init__(self, generator) + + def get_extension_list(self, timespan, db_lookup): # 3 + """Returns a search list extension with two additions. + + Parameters: + timespan: An instance of weeutil.weeutil.TimeSpan. This will + hold the start and stop times of the domain of + valid times. + + db_lookup: This is a function that, given a data binding + as its only parameter, will return a database manager + object. + """ + + # Create a TimespanBinder object for the last seven days. First, + # calculate the time at midnight, seven days ago. The variable week_dt + # will be an instance of datetime.date. + week_dt = datetime.date.fromtimestamp(timespan.stop) \ + - datetime.timedelta(weeks=1) # 4 + # Convert it to unix epoch time: + week_ts = time.mktime(week_dt.timetuple()) # 5 + # Form a TimespanBinder object, using the time span we just + # calculated: + seven_day_stats = TimespanBinder(TimeSpan(week_ts, timespan.stop), + db_lookup, + context='week', + formatter=self.generator.formatter, + converter=self.generator.converter, + skin_dict=self.generator.skin_dict) # 6 + + # Now create a small dictionary with the key 'seven_day': + search_list_extension = {'seven_day' : seven_day_stats} # 7 + + # Finally, return our extension as a list: + return [search_list_extension] # 8 +``` + +Going through the example, line by line: + +1. Create a new class called `SevenDay`, which will inherit from + class `SearchList`. All search list extensions must inherit + from this class. +2. Create an initializer for our new class. In this case, the + initializer is not really necessary and does nothing except pass its + only parameter, `generator`, a reference to the calling + generator, on to its superclass, `SearchList`, which will + then store it in `self`. Nevertheless, we include the + initializer in case you wish to modify it. +3. Override member function `get_extension_list()`. This + function will be called when the generator is ready to accept your + new search list extension. The parameters that will be passed in + are: + - `self` Python's way of indicating the instance we are + working with; + - `timespan` An instance of the utility class + `TimeSpan`. This will contain the valid start and ending + times used by the template. Normally, this is all valid times, + but if your template appears under one of the + ["Summary By"](../reference/skin-options/cheetahgenerator.md#summarybyday) + sections in the `[CheetahGenerator]` section of `skin.conf`, then + it will contain the timespan of that time period. + - `db_lookup` This is a function supplied by the generator. + It takes a single argument, a name of a binding. When called, it + will return an instance of the database manager class for that + binding. The default for the function is whatever binding you + set with the option `data_binding` for this report, + usually `wx_binding`. +4. The object `timespan` holds the domain of all valid times for + the template, but in order to calculate statistics for the last + seven days, we need not the earliest valid time, but the time at + midnight seven days ago. So, we do a little Python date arithmetic + to calculate this. The object `week_dt` will be an instance + of `datetime.date`. +5. We convert it to unix epoch time and assign it to variable + `week_ts`. +6. The class `TimespanBinder` represents a statistical calculation over a + time period. We have [already met it](#complex-tag) in the introduction. + In our case, we will set it up to represent the statistics over the last + seven days. The class takes 6 parameters. + - The first is the timespan over which the calculation is to be + done, which, in our case, is the last seven days. In step 5, we + calculated the start of the seven days. The end is "now", that + is, the end of the reporting period. This is given by the end + point of `timespan`, `timespan.stop`. + - The second, `db_lookup`, is the database lookup function + to be used. We simply pass in `db_lookup`. + - The third, `context`, is the time *context* to be used + when formatting times. The set of possible choices is given by + sub-section [`[[TimeFormats]]`](../reference/skin-options/units.md#timeformats) + in the configuration file. Our new tag, `$seven_day` + is pretty similar to `$week`, so we will just use + `'week'`, indicating that we want a time format that is + suitable for a week-long period. + - The fourth, `formatter`, should be an instance of class + `weewx.units.Formatter`, which contains information about + how the results should be formatted. We just pass in the + formatter set up by the generator, + `self.generator.formatter`. + - The fifth, `converter`, should be an instance of + `weewx.units.Converter`, which contains information about + the target units (*e.g.*, `degree_C`) that are to be + used. Again, we just pass in the instance set up by the + generator, `self.generator.converter`. + - The sixth, `skin_dict`, is an instance of + `configobj.ConfigObj`, and contains the contents of the + skin configuration file. We pass it on in order to allow + aggregations that need information from the file, such as + heating and cooling degree-days. +7. Create a small dictionary with a single key, `seven_day`, + whose value will be the `TimespanBinder` that we just + constructed. +8. Return the dictionary in a list + +#### Registering {#register-seven-day} + +The final step that we need to do is to tell the template engine where +to find our extension. You do that by going into the skin configuration +file, `skin.conf`, and adding the option +`search_list_extensions` with our new extension. When you're +done, it will look something like this: + +``` ini hl_lines="8" +[CheetahGenerator] + # This section is used by the generator CheetahGenerator, and specifies + # which files are to be generated from which template. + + # Possible encodings include 'html_entities', 'strict_ascii', 'normalized_ascii', + # as well as those listed in https://docs.python.org/3/library/codecs.html#standard-encodings + encoding = html_entities + search_list_extensions = user.seven_day.SevenDay + + [[SummaryByMonth]] + ... +``` + +Our addition has been ==highlighted==. Note that it is in the +section `[CheetahGenerator]`. + +Now, if the Cheetah engine encounters the tag `$seven_day`, it +will scan the search list, looking for an attribute or key that matches +`seven_day`. When it gets to the little dictionary we provided, +it will find a matching key, allowing it to retrieve the appropriate +`TimespanBinder` object. + +With this approach, you can now include "seven day" statistics in your +HTML templates: + +``` html hl_lines="12" + + + + + + + + + + + + + +
Maximum temperature over the last seven days:$seven_day.outTemp.max
Minimum temperature over the last seven days:$seven_day.outTemp.min
Rain over the last seven days:$seven_day.rain.sum
+``` + +We put our addition `seven_day.py` in the "user" directory, which is +automatically included by WeeWX in the Python path. However, if you put the file +somewhere else, you may have to specify its location with the environment +variable [`PYTHONPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH) +when you start WeeWX: + +``` shell +export PYTHONPATH=/home/me/secret_location +``` + +### Adding tag `$colorize` + +Let's look at another example. This one will allow you to supply a +background color, depending on the temperature. For example, to colorize +an HTML table cell: + +``` html hl_lines="7" + + + ... + + + + + + + ... + +
Outside temperature$current.outTemp
+``` + +The highlighted expression will return a color, depending on the value +of its argument. For example, if the temperature was 30.9ºF, then the +output might look like: + + + + + + +
Outside temperature30.9°F
+ +#### A very simple implementation + +We will start with a very simple version. The code can be found in +`examples/colorize/colorize_1.py`. + +``` python +from weewx.cheetahgenerator import SearchList + +class Colorize(SearchList): # 1 + + def colorize(self, t_c): # 2 + """Choose a color on the basis of temperature + + Args: + t_c (float): The temperature in degrees Celsius + + Returns: + str: A color string + """ + + if t_c is None: # 3 + return "#00000000" + elif t_c < -10: + return "magenta" + elif t_c < 0: + return "violet" + elif t_c < 10: + return "lavender" + elif t_c < 20: + return "mocassin" + elif t_c < 30: + return "yellow" + elif t_c < 40: + return "coral" + else: + return "tomato" +``` + +The first thing that's striking about this version is just how simple +an SLE can be: just one class with a single function. Let's go through +the implementation line-by-line. + +1. Just like the first example, all search list extensions inherit from + `weewx.cheetahgenerator.SearchList` + +2. The class defines a single function, `colorize()`, with a + single argument that must be of type `float`. + + Unlike the first example, notice how we do not define an + initializer, `__init__()`, and, instead, rely on our + superclass to do the initialization. + +3. The function relies on a big if/else statement to pick a color on + the basis of the temperature value. Note how it starts by checking + whether the value could be Python `None`. WeeWX uses `None` to represent + missing or invalid data. One must be always vigilant in guarding + against a `None` value. If `None` is found, then the color `#00000000` is + returned, which is transparent and will have no effect. + +#### Registering {#register-colorize} + +As before, we must register our extension with the Cheetah engine. We do +this by copying the extension to the user directory, then adding its +location to option `search_list_extensions`: + +``` ini +[CheetahGenerator] + ... + search_list_extensions = user.colorize_1.Colorize + ... +``` + +#### Where is `get_extension_list()`? + +You might wonder, "What happened to the member function +`get_extension_list()`? We needed it in the first example; why +not now?" The answer is that we are inheriting from, and relying on, +the version in the superclass `SearchList`, which looks like +this: + +``` python + def get_extension_list(self, timespan, db_lookup): + return [self] +``` + +This returns a list, with itself (an instance of class +`Colorize`) as the only member. + +How do we know whether to include an instance of +`get_extension_list()`? Why did we include a version in the first +example, but not in the second? + +The answer is that many extensions, including `$seven_day`, need +information that can only be known when the template is being evaluated. +In the case of `$seven_day`, this was which database binding to +use, which will determine the results of the database query done in its +implementation. This information is not known until +`get_extension_list()` is called, which is just before template +evaluation. + +By constrast, `$colorize()` is pure static: it doesn't use the +database at all, and everything it needs it can get from its single +function argument. So, it has no need for the information in +`get_extension_list()`. + +#### Review + +Let's review the whole process. When the WeeWX Cheetah generator starts +up to evaluate a template, it first creates a search list. It does this +by calling `get_extension_list()` for each SLE that has been +registered with it. In our case, this will cause the function above to +put an instance of `Colorize` in the search list — we don't +have to do anything to make this happen. + +When the engine starts to process the template, it will eventually come +to + +``` html +$current.outTemp +``` + +It needs to evaluate the expression +`$colorize($current.outTemp.raw)`, so it starts scanning the +search list looking for something with an attribute or key +`colorize`. When it comes to our instance of `Colorize` it +gets a hit because, in Python, member functions are implemented as +attributes. The Cheetah engine knows to call it as a function because of +the parenthesis that follow the name. The engine passes in the value of +`$current.outTemp.raw` as the sole argument, where it appears +under the name `t_c`. + +As described above, the function `colorize()` then uses the +argument to choose an appropriate color, returning it as a string. + +#### Limitation + +This example has an obvious limitation: the argument to +`$colorize()` must be in degrees Celsius. We can guard against +passing in the wrong unit by always converting to Celsius first: + +``` html +$current.outTemp +``` + +but the user would have to remember to do this every time +`colorize()` is called. The next version gets around this +limitation. + +#### A slightly better version + +Here's an improved version that can handle an argument that uses any +unit, not just degrees Celsius. The code can be found in +`examples/colorize/colorize_2.py`. + +``` python +import weewx.units +from weewx.cheetahgenerator import SearchList + +class Colorize(SearchList): # 1 + + def colorize(self, value_vh): # 2 + """Choose a color string on the basis of a temperature value""" + + # Extract the ValueTuple part out of the ValueHelper + value_vt = value_vh.value_t # 3 + + # Convert to Celsius: + t_celsius = weewx.units.convert(value_vt, 'degree_C') # 4 + + # The variable "t_celsius" is a ValueTuple. Get just the value: + t_c = t_celsius.value # 5 + + # Pick a color based on the temperature + if t_c is None: # 6 + return "#00000000" + elif t_c < -10: + return "magenta" + elif t_c < 0: + return "violet" + elif t_c < 10: + return "lavender" + elif t_c < 20: + return "mocassin" + elif t_c < 30: + return "yellow" + elif t_c < 40: + return "coral" + else: + return "tomato" +``` + +Going through the example, line by line: + +1. Just like the other examples, we must inherit from + `weewx.cheetahgenerator.SearchList`. + +2. However, in this example, notice that the argument to + `colorize()` is an instance of class + [`ValueHelper`](../reference/valuehelper.md), instead of a + simple float. + + As before, we do not define an initializer, `__init__()`, + and, instead, rely on our superclass to do the initialization. + +3. The argument `value_vh` will contain many things, including + formatting and preferred units, but, for now, we are only interested + in the [`ValueTuple`](../reference/valuetuple.md) contained + within, which can be extracted with the attribute `value_t`. + +4. The variable `value_vt` could be in any unit that measures + temperature. Our code needs Celsius, so we convert to Celsius using + the convenience function `weewx.units.convert()`. The results + will be a new `ValueTuple`, this time in Celsius. + +5. We need just the temperature value, and not the other things in a + `ValueTuple`, so extract it using the attribute + `value`. The results will be a simple instance of + `float` or, possibly, Python `None`. + +6. Finally, we need a big if/else statement to choose which color to + return, while making sure to test for `None`. + +This version uses a `ValueHelper` as an argument instead of a +float. How do we call it? Here's an example: + +``` tty hl_lines="7" + + + ... + + + + + + + ... + +
Outside temperature$current.outTemp
+``` + +This time, we call the function with a simple `$current.outTemp` (without the +`.raw` suffix), which is actually an instance of class `ValueHelper`. When we +met this class earlier, the Cheetah engine needed a string to put in the +template, so it called the special member function `__str__()`. However, in this +case, the results are going to be used as an argument to a function, not as a +string, so the engine simply passes in the `ValueHelper` unchanged to +`colorize()`, where it appears as argument `value_vh`. + +Our new version is better than the original because it can take a +temperature in any unit, not just Celsius. However, it can still only +handle temperature values and, even then, the color bands are still +hardwired in. Our next version will remove these limitations. + +#### A more sophisticated version + +Rather than hardwire in the values and observation type, in this version +we will retrieve them from the skin configuration file, +`skin.conf`. Here's what a typical configuration might look like +for this version: + +``` ini +[Colorize] # 1 + [[group_temperature]] # 2 + unit_system = metricwx # 3 + default = tomato # 4 + None = lightgray # 5 + [[[upper_bounds]]] # 6 + -10 = magenta # 7 + 0 = violet # 8 + 10 = lavender + 20 = mocassin + 30 = yellow + 40 = coral + [[group_uv]] # 9 + unit_system = metricwx + default = darkviolet + [[[upper_bounds]]] + 2.4 = limegreen + 5.4 = yellow + 7.4 = orange + 10.4 = red +``` + +Here's what the various lines in the configuration stanza mean: + +1. All the configuration information needed by the SLE + `Colorize` can be found in a stanza with the heading + `[Colorize]`. Linking facility with a stanza of the same + name is a very common pattern in WeeWX. +2. We need a separate color table for each unit group that we are going + to support. This is the start of the table for unit group + `group_temperature`. +3. We need to specify what unit system will be used by the temperature + color table. In this example, we are using `metricwx`. +4. In case we do not find a value in the table, we need a default. We + will use the color `tomato`. +5. In case the value is Python `None`, return the color given by option + `None`. We will use `lightgray`. +6. The sub-subsecction `[[[upper_bounds]]]` lists the + upper (max) value of each of the color bands. +7. The first color band (magenta) is used for temperatures less than or + equal to -10°C. +8. The second band (violet) is for temperatures greater than -10°C and + less than or equal to 0°C. And so on. +9. The next subsection, `[[group_uv]]`, is very similar to + the one for `group_temperature`, except the values are for + bands of the UV index. + +Although `[Colorize]` is in `skin.conf`, there is +nothing special about it, and it can be overridden in +`weewx.conf`, just like any other configuration information. + +#### Annotated code + +Here's the alternative version of `colorize()`, which will use +the values in the configuration file. It can also be found in +`examples/colorize/colorize_3.py`. + +``` python +import weewx.units +from weewx.cheetahgenerator import SearchList + +class Colorize(SearchList): # 1 + + def __init__(self, generator): # 2 + SearchList.__init__(self, generator) + self.color_tables = self.generator.skin_dict.get('Colorize', {}) + + def colorize(self, value_vh): + + # Get the ValueTuple and unit group from the incoming ValueHelper + value_vt = value_vh.value_t # 3 + unit_group = value_vt.group # 4 + + # Make sure unit_group is in the color table, and that the table + # specifies a unit system. + if unit_group not in self.color_tables \ + or 'unit_system' not in self.color_tables[unit_group]: # 5 + return "#00000000" + + # Convert the value to the same unit used by the color table: + unit_system = self.color_tables[unit_group]['unit_system'] # 6 + converted_vt = weewx.units.convertStdName(value_vt, unit_system) # 7 + + # Check for a value of None + if converted_vt.value is None: # 8 + return self.color_tables[unit_group].get('none') \ + or self.color_tables[unit_group].get('None', "#00000000") + + # Search for the value in the color table: + for upper_bound in self.color_tables[unit_group]['upper_bounds']: # 9 + if converted_vt.value <= float(upper_bound): # 10 + return self.color_tables[unit_group]['upper_bounds'][upper_bound] + + return self.color_tables[unit_group].get('default', "#00000000") # 11 +``` + +1. As before, our class must inherit from `SearchList`. + +2. In this version, we supply an initializer because we are going to do + some work in it: extract our color table out of the skin + configuration dictionary. In case the user neglects to include a + `[Colorize]` section, we substitute an empty dictionary. + +3. As before, we extract the `ValueTuple` part out of the + incoming `ValueHelper` using the attribute `value_t`. + +4. Retrieve the unit group used by the incoming argument. This will be + something like "group_temperature". + +5. What if the user is requesting a color for a unit group that we + don't know anything about? We must check that the unit group is in + our color table. We must also check that a unit system has been + supplied for the color table. If either of these checks fail, then + return the color `#00000000`, which will have no effect in + setting a background color. + +6. Thanks to the checks we did in step 5, we know that this line will + not raise a `KeyError` exception. Get the unit system used by + the color table for this unit group. It will be something like + 'US', 'metric', or 'metricwx'. + +7. Convert the incoming value, so it uses the same units as the color + table. + +8. We must always be vigilant for values of Python None! The expression + + ``` python + self.color_tables[unit_group].get('none') or self.color_tables[unit_group].get('None', "#00000000") + ``` + + is just a trick to allow us to accept either "`none`" or + "`None`" in the configuration file. If neither is present, + then we return the color `#00000000`, which will have no + effect. + +9. Now start searching the color table to find a band that is less than + or equal to the value we have in hand. + +10. Two details to note. + + First, the variable `converted_vt` is a `ValueTuple`. + We need the raw value in order to do the comparison. We get this + through attribute `.value`. + + Second, WeeWX uses the utility `ConfigObj` to read + configuration files. When `ConfigObj` returns its results, + the values will be *strings*. We must convert these to floats before + doing the comparison. You must be constantly vigilant about this + when working with configuration information. + + If we find a band with an upper bound greater than our value, we + have a hit. Return the corresponding color. + +11. If we make it all the way through the table without a hit, then we + must have a value greater than anything in the table. Return the + default, or the color `#00000000` if there is no default. diff --git a/dist/weewx-5.0.2/docs_src/custom/units.md b/dist/weewx-5.0.2/docs_src/custom/units.md new file mode 100644 index 0000000..bdcd2a4 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/custom/units.md @@ -0,0 +1,135 @@ +# Customizing units and unit groups + +!!! Warning + This is an area that is changing rapidly in WeeWX. Presently, new units + and unit groups are added by manipulating the internal dictionaries in + WeeWX (as described below). In the future, they may be specified in + `weewx.conf`. + +## Assigning a unit group + +In the section [Customizing the database](database.md), we created a new +observation type, `electricity`, and added it to the database schema. Now we +would like to recognize that it is a member of the unit group +`group_energy` (which already exists), so it can enjoy the labels +and formats already provided for this group. This is done by extending +the dictionary `weewx.units.obs_group_dict`. + +Add the following to our new services file `user/electricity.py`, +just after the last import statement: + +``` python +import weewx +from weewx.engine import StdService + +import weewx.units +weewx.units.obs_group_dict['electricity'] = 'group_energy' + +class AddElectricity(StdService): + + # [...] +``` + +When our new service gets loaded by the WeeWX engine, these few lines +will get run. They tell WeeWX that our new observation type, +`electricity`, is part of the unit group `group_energy`. +Once the observation has been associated with a unit group, the unit +labels and other tag syntax will work for that observation. So, now a +tag like: + +``` +$month.electricity.sum +``` + +will return the total amount of electricity consumed for the month, in +Watt-hours. + +## Creating a new unit group + +That was an easy one, because there was already an existing group, +`group_energy`, that covered our new observation type. But, what +if we are measuring something entirely new, like force with time? There +is nothing in the existing system of units that covers things like +newtons or pounds. We will have to define these new units, as well as +the unit group they can belong to. + +We assume we have a new observation type, `rocketForce`, which we +are measuring over time, for a service named `Rocket`, located in +` user/rocket.py`. We will create a new unit group, +`group_force`, and new units, `newton` and `pound`. +Our new observation, `rocketForce`, will belong to +`group_force`, and will be measured in units of `newton` +or `pound`. + +To make this work, we need to add the following to +`user/rocket.py`. + +1. As before, we start by specifying what group our new observation + type belongs to: + + ``` python + import weewx.units + weewx.units.obs_group_dict['rocketForce'] = 'group_force' + ``` + +2. Next, we specify what unit is used to measure force in the three + standard unit systems used by weewx. + + ``` python + weewx.units.USUnits['group_force'] = 'pound' + weewx.units.MetricUnits['group_force'] = 'newton' + weewx.units.MetricWXUnits['group_force'] = 'newton' + ``` + +3. Then we specify what formats and labels to use for `newton` + and `pound`: + + ``` python + weewx.units.default_unit_format_dict['newton'] = '%.1f' + weewx.units.default_unit_format_dict['pound'] = '%.1f' + + weewx.units.default_unit_label_dict['newton'] = ' newton' + weewx.units.default_unit_label_dict['pound'] = ' pound' + ``` + +4. Finally, we specify how to convert between them: + + ``` python + weewx.units.conversionDict['newton'] = {'pound': lambda x : x * 0.224809} + weewx.units.conversionDict['pound'] = {'newton': lambda x : x * 4.44822} + ``` + +Now, when the service `Rocket` gets loaded, these lines of code +will get executed, adding the necessary unit extensions to WeeWX. + +## Using the new units + +Now you've added a new type of units. How do you use it? + +Pretty much like any other units. For example, to do a plot of the +month's electric consumption, totaled by day, add this section to the +`[[month_images]]` section of `skin.conf`: + +``` ini +[[[monthelectric]]] + [[[[electricity]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Electric consumption (daily total) +``` + +This will cause the generation of an image `monthelectric.png`, +showing a plot of each day's consumption for the past month. + +If you wish to use the new type in the templates, it will be available +using the same syntax as any other type. Here are some other tags that +might be useful: + +| Tag| Meaning | +|----|------------------------------------------------------------------------------------------------------------------------| +| `$day.electricity.sum`| Total consumption since midnight | + | `$year.electricity.sum`| Total consumption since the first of the year | +| `$year.electricity.max`| The most consumed during any archive period | +| `$year.electricity.maxsum`| The most consumed during a day | +| `$year.electricity.maxsumtime`| The day it happened. | +| `$year.electricity.sum_ge((5000.0, 'kWh', 'group_energy'))`| The number of days of the year where
more than 5.0 kWh of energy was consumed.
The argument is a `ValueTuple`. | diff --git a/dist/weewx-5.0.2/docs_src/devnotes.md b/dist/weewx-5.0.2/docs_src/devnotes.md new file mode 100644 index 0000000..e896915 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/devnotes.md @@ -0,0 +1,777 @@ +# Notes for WeeWX developers + +This guide is intended for developers contributing to the open source +project WeeWX. + +## Goals + +The primary design goals of WeeWX are: + +- Architectural simplicity. No semaphores, no named pipes, no + inter-process communications, no complex multi-threading to manage. + +- Extensibility. Make it easy for the user to add new features or to + modify existing features. + +- *Fast enough* In any design decision, architectural simplicity and + elegance trump speed. + +- One code base. A single code base should be used for all platforms, + all weather stations, all reports, and any combination of features. + Ample configuration and customization options should be provided so + the user does not feel tempted to start hacking code. At worse, the + user may have to subclass, which is much easier to port to newer + versions of the code base, than customizing the base code. + +- Minimal dependencies. The code should rely on a minimal number of + external packages, so the user does not have to go chase them down + all over the Web before getting started. + +- Simple data model. The implementation should use a very simple data + model that is likely to support many different types of hardware. + +- A *pythonic* code base. The code should be written in a style that + others will recognize. + +## Strategies + +To meet these goals, the following strategies were used: + +- A *micro-kernel* design. The WeeWX engine actually does very + little. Its primary job is to load and run *services* at runtime, + making it easy for users to add or subtract features. + +- A largely stateless design style. For example, many of the + processing routines read the data they need directly from the + database, rather than caching it and sharing with other routines. + While this means the same data may be read multiple times, it also + means the only point of possible cache incoherence is through the + database, where transactions are easily controlled. This greatly + reduces the chances of corrupting the data, making it much easier to + understand and modify the code base. + +- Isolated data collection and archiving. The code for collecting and + archiving data run in a single thread that is simple enough that it + is unlikely to crash. The report processing is where most mistakes + are likely to happen, so isolate that in a separate thread. If it + crashes, it will not affect the main data thread. + +- A powerful configuration parser. The + [ConfigObj](https://configobj.readthedocs.io) module, by Michael + Foord and Nicola Larosa, was chosen to read the configuration file. + This allows many options that might otherwise have to go in the + code, to be in a configuration file. + +- A powerful templating engine. The + [Cheetah](https://cheetahtemplate.org/) module was chosen for + generating html and other types of files from templates. Cheetah + allows *search list extensions* to be defined, making it easy to + extend WeeWX with new template tags. + +- Pure Python. The code base is 100% Python — no underlying C + libraries need be built to install WeeWX. This also means no + Makefiles are needed. + +While WeeWX is nowhere near as fast at generating images and HTML as its +predecessor, *wview* (this is partially because WeeWX uses +fancier fonts and a much more powerful templating engine), it is fast +enough for all platforms but the slowest. I run it regularly on a 500 +MHz machine where generating the 9 images used in the *Current +Conditions* page takes just under 2 seconds (compared with 0.4 seconds +for wview). + +All writes to the databases are protected by transactions. You can kill +the program at any time without fear of corrupting the databases. + +The code makes ample use of exceptions to insure graceful recovery from +problems such as network outages. It also monitors socket and console +timeouts, restarting whatever it was working on several times before +giving up. In the case of an unrecoverable console error (such as the +console not responding at all), the program waits 60 seconds then +restarts the program from the top. + +Any *hard* exceptions, that is those that do not involve network and +console timeouts and are most likely due to a logic error, are logged, +reraised, and ultimately cause thread termination. If this happens in +the main thread (not likely due to its simplicity), then this causes +program termination. If it happens in the report processing thread (much +more likely), then only the generation of reports will be affected — +the main thread will continue downloading data off the instrument and +putting them in the database. + +## Units + +In general, there are three different areas where the unit system makes +a difference: + +1. On the weather station hardware. Different manufacturers use + different unit systems for their hardware. The Davis Vantage series + use U.S. Customary units exclusively, Fine Offset and LaCrosse + stations use metric, while Oregon Scientific, Peet Bros, and Hideki + stations use a mishmash of US and metric. + +2. In the database. Either US or Metric can be used. + +3. In the presentation (i.e., html and image files). + +The general strategy is that measurements are converted by service +`StdConvert` as they come off the weather station into a target +unit system, then stored internally in the database in that unit system. +Then, as they come off the database to be used for a report, they are +converted into a target unit, specified by a combination of the +configuration file `weewx.conf` and the skin configuration file +`skin.conf`. + +## Value `None` + +The Python special value `None` is used throughout to signal an +invalid or bad data point. All functions must be written to expect it. + +Device drivers should be written to emit `None` if a data value +is bad (perhaps because of a failed checksum). If the hardware simply +doesn't support a data type, then the driver should not emit a value at +all. + +The same rule applies to derived values. If the input data for a derived +value are missing, then no derived value should be emitted. However, if +the input values are present, but have value `None`, then the +derived value should be set to `None`. + +However, the time value must never be `None`. This is because it +is used as the primary key in the SQL database. + +## Time + +WeeWX stores all data in UTC (roughly, *Greenwich* or *Zulu*) time. +However, usually one is interested in weather events in local time and +want image and HTML generation to reflect that. Furthermore, most +weather stations are configured in local time. This requires that many +data times be converted back and forth between UTC and local time. To +avoid tripping up over time zones and daylight savings time, WeeWX +generally uses Python routines to do this conversion. Nowhere in the +code base is there any explicit recognition of DST. Instead, its +presence is implicit in the conversions. At times, this can cause the +code to be relatively inefficient. + +For example, if one wanted to plot something every 3 hours in UTC time, +it would be very simple: to get the next plot point, just add 10,800 to +the epoch time: + +``` +next_ts = last_ts + 10800 +``` + +But, if one wanted to plot something for every 3 hours *in local time* +(that is, at 0000, 0300, 0600, etc.), despite a possible DST change in +the middle, then things get a bit more complicated. One could modify the +above to recognize whether a DST transition occurs sometime between +`last_ts` and the next three hours and, if so, make the necessary +adjustments. This is generally what `wview` does. WeeWX takes a +different approach and converts from UTC to local, does the arithmetic, +then converts back. This is inefficient, but bulletproof against changes +in DST algorithms, etc.: + +``` +time_dt = datetime.datetime.fromtimestamp(last_ts) +delta = datetime.timedelta(seconds=10800) +next_dt = time_dt + delta +next_ts = int(time.mktime(next_dt.timetuple())) +``` + +Other time conversion problems are handled in a similar manner. + +For astronomical calculations, WeeWX uses the latitude and longitude +specified in the configuration file. If that location does not +correspond to the computer's local time, reports with astronomical +times will probably be incorrect. + +## Archive records + +An archive record's timestamp, whether in software or in the database, +represents the *end time* of the record. For example, a record +timestamped 05-Feb-2016 09:35, includes data from an instant after +09:30, through 09:35. Another way to think of it is that it is exclusive +on the left, inclusive on the right. Schematically: + +``` +09:30 < dateTime <= 09:35 +``` + +Database queries should reflect this. For example, to find the maximum +temperature for the hour between timestamps 1454691600 and 1454695200, +the query would be: + +``` +SELECT MAX(outTemp) FROM archive + WHERE dateTime > 1454691600 and dateTime <= 1454695200; +``` + +This ensures that the record at the beginning of the hour (1454691600) +does not get included (it belongs to the previous hour), while the +record at the end of the hour (1454695200) does. + +One must be constantly be aware of this convention when working with +timestamped data records. + +Better yet, if you need this kind of information, use an +[xtypes](https://github.com/weewx/weewx/wiki/WeeWX-V4-user-defined-types) +call: + +``` +max_temp = weewx.xtypes.get_aggregate('outTemp', + (1454691600, 1454695200), + 'max', + db_manager) +``` + +It will not only make sure the limits of the query are correct, but will +also decide whether the daily summary optimization can be used +([details below](#daily-summaries)). If not, it will use the regular +archive table. + +## Internationalization + +Generally, WeeWX is locale aware. It will emit reports using the local +formatting conventions for date, times, and values. + +## Exceptions + +In general, your code should not simply swallow an exception. For +example, this is bad form: + +``` + try: + os.rename(oldname, newname) + except: + pass +``` + +While the odds are that if an exception happens it will be because the +file `oldname` does not exist, that is not guaranteed. It could +be because of a keyboard interrupt, or a corrupted file system, or +something else. Instead, you should test explicitly for any expected +exception, and let the rest go by: + +``` + try: + os.rename(oldname, newname) + except OSError: + pass +``` + +WeeWX has a few specialized exception types, used to rationalize all +the different types of exceptions that could be thrown by the underlying +libraries. In particular, low-level I/O code can raise a myriad of +exceptions, such as USB errors, serial errors, network connectivity +errors, *etc.* All device drivers should catch these exceptions and +convert them into an exception of type `WeeWxIOError` or one of +its subclasses. + +## Naming conventions + +How you name variables makes a big difference in code readability. In +general, long names are preferable to short names. Instead of this, + +``` +p = 990.1 +``` + +use this, + +``` +pressure = 990.1 +``` + +or, even better, this: + +``` +pressure_mbar = 990.1 +``` + +WeeWX uses a number of conventions to signal the variable type, although +they are not used consistently. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Variable suffix conventions
SuffixExampleDescription
_tsfirst_tsVariable is a timestamp in unix epoch time.
_dtstart_dtVariable is an instance of datetime.datetime, usually in local time.
_dend_dVariable is an instance of datetime.date, usually in local time.
_ttsod_ttVariable is an instance of time.struct_time (a time tuple), usually in local time.
_vhpressure_vhVariable is an instance of weewx.units.ValueHelper.
_vtspeed_vtVariable is an instance of weewx.units.ValueTuple.
+ +## Code style + +Generally, we try to follow the [PEP 8 style +guide](https://www.python.org/dev/peps/pep-0008/), but there are *many* +exceptions. In particular, many older WeeWX function names use +camelCase, but PEP 8 calls for snake_case. Please use snake_case for new +code. + +Most modern code editors, such as Eclipse, or PyCharm, have the ability +to automatically format code. Resist the temptation and *don't use this +feature!* Two reasons: + +- Unless all developers use the same tool, using the same settings, we + will just thrash back and forth between slightly different versions. + +- Automatic formatters play a useful role, but some of what they do + are really trivial changes, such as removing spaces in otherwise + blank lines. Now if someone is trying to figure out what real, + syntactic, changes you have made, s/he will have to wade through all + those extraneous *changed lines,* trying to find the important + stuff. + +If you are working with a file where the formatting is so ragged that +you really must do a reformat, then do it as a separate commit. This +allows the formatting changes to be clearly distinguished from more +functional changes. + +When invoking functions or instantiating classes, use the fully +qualified name. Don't do this: + +``` +from datetime import datetime +now = datetime() +``` + +Instead, do this: + +``` +import datetime +now = datetime.datetime() +``` + +## Git work flow + +We use Git as the source control system. If Git is still mysterious to +you, bookmark this: [*Pro Git*](https://git-scm.com/book/en/v2), then +read the chapter *Git Basics*. Also recommended is the article [*How to +Write a Git Commit Message*](https://cbea.ms/git-commit/). + +The code is hosted on [GitHub](https://github.com/weewx/weewx). Their +[documentation](https://docs.github.com/en/get-started) is very +extensive and helpful. + +We generally follow Vincent Driessen's [branching +model](http://nvie.com/posts/a-successful-git-branching-model/). Ignore +the complicated diagram at the beginning of the article, and just focus +on the text. In this model, there are two key branches: + +- 'master'. Fixes go into this branch. We tend to use fewer + *hot fix* branches and, instead, just incorporate any fixes + directly into the branch. Releases are tagged relative to this + branch. + +- 'development' (called `develop` in Vince's article). + This is where new features go. Before a release, they will be merged + into the `master` branch. + +What this means to you is that if you submit a pull request that +includes a new feature, make sure you commit your changes relative to +the *development* branch. If it is just a bug fix, it should be +committed against the `master` branch. + +### Forking the repository + +The WeeWX GitHub repository is configured to use +[GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions) +to run Continuous Integration (CI) workflows automatically if certain +`git` operations are done on branches under active development. + +This means that CI workflows will also be run on any forks that you may have +made if the configured `git` action is done. This can be confusing if you get +an email from GitHub if these tasks fail for some reason on your fork. + +To control GitHub Actions for your fork, see the recommended solutions in this +[GitHub discussion](https://github.com/orgs/community/discussions/26704#discussioncomment-3252979) +on this topic. + +## Tools + +### Python + +[JetBrain's PyCharm](http://www.jetbrains.com/pycharm/) is exellent, +and now there's a free Community Edition. It has many advanced +features, yet is structured that you need not be exposed to them until +you need them. Highly recommended. + +### HTML and Javascript + +For Javascript, [JetBrain's +WebStorm](http://www.jetbrains.com/webstorm/) is excellent, particularly +if you will be using a framework such as Node.js or Express.js. + +## Daily summaries {#daily-summaries} + +This section builds on the discussion [*The database*](custom/database.md) +in the *Customization Guide*. Read it first. + +The big flat table in the database (usually called table +`archive`) is the definitive table of record. While it includes a +lot of information, querying it can be slow. For example, to find the +maximum temperature of the year would require scanning the entire table, +which might include 100,000 or more records. To speed things up, WeeWX +includes *daily summaries* in the database as an optimization. + +In the daily summaries, each observation type gets its own table, which +holds a statistical summary for the day. For example, for outside +temperature observation type `outTemp`, this table would be +named `archive_day_outTemp`. This is what it would look like: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Structure of the archive_day_outTemp daily summary
dateTimeminmintimemaxmaxtimesumcountwsumsumtime
165242520044.7165251160056.0165247764038297.07632297820.045780
165251160044.1165253128066.7165257250076674.414334600464.085980
165259800050.3165261522059.8165267432032903.06111974180.036660
...........................
+ +This is what the table columns mean: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameMeaning
dateTimeThe time of the start of day in unix epoch time. This is the primary key in the database. It must be unique, and it cannot be null.
minThe minimum temperature seen for the day. The unit is whatever unit system the main archive table uses (generally given by the first record in the table).
mintimeThe time in unix epoch time of the minimum temperature.
maxThe maximum temperature seen for the day. The unit is whatever unit system the main archive table uses (generally given by the first record in the table).
maxtimeThe time in unix epoch time of the maximum temperature.
sumThe sum of all the temperatures for the day.
countThe number of records in the day.
wsumThe weighted sum of all the temperatures for the day. The weight is the + archive interval in seconds. That is, for each record, the temperature + is multiplied by the length of the archive record in seconds, then + summed up.
sumtimeThe sum of all the archive intervals for the day in seconds. If the + archive interval didn't change during the day, then this number would + be interval * 60 * count.
+ +Note how the average temperature for the day can be calculated as `wsum +/ sumtime`. This will be true even if the archive interval +changes during the day. + +Now consider an extensive variable such as `rain`. The total +rainfall for the day will be given by the field `sum`. So, +calculating the total rainfall for the year can be done by scanning and +summing only 365 records, instead of potentially tens, or even hundreds, +of thousands of records. This results in a dramatic speed up for report +generation, particularly on slower machines such as the Raspberry Pi, +working off an SD card. + +### Wind summaries + +The daily summary for wind includes six additional fields. This is what +they mean: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameMeaning
max_dirThe direction of the maximum wind seen for the day.
xsumThe sum of the x-component (east-west) of the wind for the day.
ysumThe sum of the y-component (north-south) of the wind for the day.
dirsumtimeThe sum of all the archive intervals for the day in seconds, + which contributed to xsum + and ysum.
squaresumThe sum of the wind speed squared for the day.
wsquaresumThe sum of the weighted wind speed squared for the day. That is the + wind speed is squared, then multiplied by the archive interval in + seconds, then summed for the day. This is useful for calculating + RMS wind speed.
+ +Note that the RMS wind speed can be calculated as + +``` +math.sqrt(wsquaresum / sumtime) +``` + +## Glossary + +This is a glossary of terminology used throughout the code. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Terminology used in WeeWX
NameDescription
archive interval +WeeWX does not store the raw data that comes off a weather station. Instead, +it aggregates the data over a length of time, the archive interval, +and then stores that. +
archive record +While packets are raw data that comes off the weather station, +records are data aggregated by time. For example, temperature may be +the average temperature over an archive interval. These are the data +stored in the SQL database +
config_dict +All configuration information used by WeeWX is stored in the configuration +file, usually with the name weewx.conf. By +convention, when this file is read into the program, it is called +config_dict, an instance of the class +configobj.ConfigObj. +
datetime +An instance of the Python object datetime.datetime. +Variables of type datetime usually have a suffix _dt. +
db_dict +A dictionary with all the data necessary to bind to a database. An example for +SQLite would be +
+{
+  'driver':'db.sqlite',
+  'SQLITE_ROOT':'/home/weewx/archive',
+  'database_name':'weewx.sdb'
+ }
+ An example for MySQL would be +
+ {
+   'driver':'db.mysql',
+   'host':'localhost',
+   'user':'weewx',
+   'password':'mypassword',
+   'database_name':'weewx'
+ }
+
epoch time +Sometimes referred to as "unix time," or "unix epoch time." +The number of seconds since the epoch, which is 1 Jan 1970 00:00:00 UTC. Hence, +it always represents UTC (well... after adding a few leap seconds... but, close +enough). This is the time used in the databases and appears as type +dateTime in the SQL schema, perhaps an unfortunate +name because of the similarity to the completely unrelated Python type datetime. Very easy to manipulate, but it is a big opaque +number. +
LOOP packet +The real-time data coming off the weather station. The terminology "LOOP" comes +from the Davis series of weather stations. A LOOP packet can contain all +observation types, or it may contain only some of them ("Partial packet"). +
observation type +A physical quantity measured by a weather station (e.g., +outTemp) or something derived from it (e.g., +dewpoint). +
skin_dict +All configuration information used by a particular skin is stored in the +skin configuration file, usually with the name +skin.conf. By convention, when this file is read +into the program, it is called skin_dict, an +instance of the class configobj.ConfigObj. +
SQL type +A type that appears in the SQL database. This usually looks something like +outTemp, barometer, +extraTemp1, and so on. +
standard unit system +A complete set of units used together. Either US, +METRIC, or METRICWX. +
time stamp +A variable in unix epoch time. Always in UTC. Variables carrying a time stamp +usually have a suffix _ts. +
tuple-time +An instance of the Python object + +time.struct_time. This is a 9-way tuple that represent a time. It +could be in either local time or UTC, though usually the former. See module +time +for more information. Variables carrying tuple time usually have a suffix +_tt. +
value tuple +A 3-way tuple. First element is a value, second element the unit type the value +is in, the third the unit group. An example would be (21.2, +'degree_C', 'group_temperature'). +
WEEWX_ROOT +The location of the station data area. Unfortunately, due to legacy reasons, +its value can be interpreted two different ways. In the +configuration file weewx.conf, its value +is relative to the location of the configuration file. In memory, in the +data structure config_dict, it is an absolute path. +
diff --git a/dist/weewx-5.0.2/docs_src/examples/tag.htm b/dist/weewx-5.0.2/docs_src/examples/tag.htm new file mode 100644 index 0000000..477762c --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/examples/tag.htm @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Time intervalMax temperatureTime
00:00-01:0022.9°F (-5.1°C)00:55
01:00-02:0023.5°F (-4.7°C)01:25
02:00-03:0024.1°F (-4.4°C)03:00
03:00-04:0025.5°F (-3.6°C)03:55
04:00-05:0025.7°F (-3.5°C)04:20
05:00-06:0025.4°F (-3.7°C)05:05
06:00-07:0025.1°F (-3.8°C)06:05
07:00-08:0025.1°F (-3.8°C)07:05
08:00-09:0025.6°F (-3.6°C)09:00
09:00-10:0026.5°F (-3.1°C)10:00
10:00-11:0029.2°F (-1.6°C)10:45
11:00-12:0030.6°F (-0.8°C)11:20
12:00-13:0031.4°F (-0.3°C)12:30
13:00-14:0030.6°F (-0.8°C)13:05
14:00-15:0030.3°F (-0.9°C)14:20
15:00-16:0030.0°F (-1.1°C)15:10
16:00-17:0029.7°F (-1.3°C)16:50
17:00-18:0029.7°F (-1.3°C)17:05
18:00-19:0029.0°F (-1.7°C)18:05
19:00-20:0029.4°F (-1.4°C)19:45
20:00-21:0030.4°F (-0.9°C)21:00
21:00-22:0031.1°F (-0.5°C)21:40
22:00-23:0031.3°F (-0.4°C)22:55
23:00-00:0032.1°F (0.1°C)23:50
Hourly max temperatures yesterday
+ 18-Jan-2017 +
+ + + diff --git a/dist/weewx-5.0.2/docs_src/hardware/acurite.md b/dist/weewx-5.0.2/docs_src/hardware/acurite.md new file mode 100644 index 0000000..89695c2 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/acurite.md @@ -0,0 +1,211 @@ +# AcuRite {id=acurite_notes} + +According to Acurite, the wind speed updates every 18 seconds. +The wind direction updates every 30 seconds. Other sensors update +every 60 seconds. + +In fact, because of the message structure and the data logging +design, these are the actual update frequencies: + + + + + + + + + + + +
AcuRite transmission periods
sensorperiod
Wind speed18 seconds
Outdoor temperature, outdoor humidity36 seconds
Wind direction, rain total36 seconds
Indoor temperature, pressure60 seconds
Indoor humidity12 minutes (only when in USB mode 3)
+ +The station emits partial packets, which may confuse some online +services. + +The AcuRite stations do not record wind gusts. + +Some consoles have a small internal logger. Data in the logger +are erased when power is removed from the station. + +The console has a sensor for inside humidity, but the values from +that sensor are available only by reading from the console logger. +Due to instability of the console firmware, the +WeeWX driver does not read the console +logger. + +## USB Mode {id=acurite_usb_mode} + +Some AcuRite consoles have a setting called "USB Mode" that controls +how data are saved and communicated: + + + + + + + + + + + + + + + +
AcuRite USB mode
ModeShow data
in display
Store data
in logger
Send data
over USB
1yesyes
2yes
3yesyesyes
4yesyes
+ +If the AcuRite console has multiple USB modes, it must be set to +USB mode 3 or 4 in order to work with the WeeWX driver. + +Communication via USB is disabled in modes 1 and 2. Mode 4 is more reliable +than mode 3; mode 3 enables logging of data, mode 4 does not. When the +console is logging it frequently causes USB communication +problems. + +The default mode is 2, so after a power failure one must use the +console controls to change the mode before +WeeWX can resume data collection. + +The 01025, 01035, 01036, 01525, and 02032 consoles have a USB mode setting. + +The 02064 and 01536 consoles do not have a mode setting; these +consoles are always in USB mode 4. + +## Configuring with `weectl device` {id=acurite_configuration} + +The [`weectl device`](../utilities/weectl-device.md) utility cannot be used to +configure AcuRite stations. + +## Station data {id=acurite_data} + +The following table shows which data are provided by the station +hardware and which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AcuRite station data
Database FieldObservationLoopArchive
barometerS
pressurepressureH
altimeterS
inTemptemperature_inH
outTemptemperature_outH
inHumidityhumidity_in
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
rainrainD
rain_totalH
rainRateS
dewpointS
windchillS
heatindexS
rxCheckPercentrssiH
outTempBatteryStatusbatteryH
+ +

+Each packet contains a subset of all possible readings. For example, one type +of packet contains windSpeed, +windDir and rain. +A different type of packet contains windSpeed, +outTemp and outHumidity. +

+ +

+H indicates data provided by Hardware
+D indicates data calculated by the Driver
+S indicates data calculated by the StdWXCalculate Service
+

+ diff --git a/dist/weewx-5.0.2/docs_src/hardware/cc3000.md b/dist/weewx-5.0.2/docs_src/hardware/cc3000.md new file mode 100644 index 0000000..0170820 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/cc3000.md @@ -0,0 +1,521 @@ +# RainWise CC3000 {id=cc3000_notes} + +The CC3000 data logger stores 2MB of records. + +When the logger fills up, it stops recording. + +When WeeWX starts up it will attempt to +download all records from the logger since the last record in the +archive database. + +The driver does not support hardware record generation. + +The CC3000 data logger may be configured to return data in METRIC or ENGLISH +units. These are then mapped to the WeeWX unit groups `METRICWX` or `US`, +respectively. + +## Configuring with `weectl device` {id=cc3000_configuration} + +The CC3000 can be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +!!! Note + Make sure you stop `weewxd` before running `weectl device`. + + +### --help {id=cc3000_help} + +Invoking `weectl device` with the `--help` option will produce something like +this: + + weectl device --help + +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +Usage: weectl device [config_file] [options] [-y] [--debug] [--help] + +Configuration utility for weewx devices. + +Options: +-h, --help show this help message and exit +--debug display diagnostic information while running +-y answer yes to every prompt +--info display weather station configuration +--current display current weather readings +--history=N display N records (0 for all records) +--history-since=N display records since N minutes ago +--clear-memory clear station memory +--get-header display data header +--get-rain get the rain counter +--reset-rain reset the rain counter +--get-max get the max values observed +--reset-max reset the max counters +--get-min get the min values observed +--reset-min reset the min counters +--get-clock display station clock +--set-clock set station clock to computer time +--get-interval display logger archive interval, in seconds +--set-interval=N set logging interval to N seconds +--get-units show units of logger +--set-units=UNITS set units to METRIC or ENGLISH +--get-dst display daylight savings settings +--set-dst=mm/dd HH:MM,mm/dd HH:MM,[MM]M +set daylight savings start, end, and amount +--get-channel display the station channel +--set-channel=CHANNEL +set the station channel +``` + +### `--info` {id=cc3000_info} + +Display the station settings with `--info` + + weectl device --info + +This will result in something like: + +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +Firmware: Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 +Time: 2020/01/04 15:38:14 +DST: 03/08 02:00,11/01 02:00,060 +Units: ENGLISH +Memory: 252242 bytes, 4349 records, 12% +Interval: 300 +Channel: 0 +Charger: CHARGER=OFF,BATTERIES=5.26V +Baro: 30.37 +Rain: 3.31 +HEADER: ['HDR', '"TIMESTAMP"', '"TEMP OUT"', '"HUMIDITY"', '"PRESSURE"', '"WIND DIRECTION"', '"WIND SPEED"',~ +MAX: ['MAX', '11:59 61.1', '02:47 99', '09:51 30.42', '13:32 337', '13:32 13.3', '00:00 0.00', '09:20 ~ +MIN: ['MIN', '02:19 40.6', '14:42 66', '00:34 30.24', '00:00 67', '00:00 0.0', '00:00 0.00', '06:48 ~ +``` + +### `--current` {id=cc3000_current} + +Returns the current values in a comma-delimited format. The order of +the data values corresponds to the output of the +`--get-header` command. `NO DATA` is returned if the unit has not +received a transmission from the weather station. + + weectl device --current + +This will result in something like: + +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +{'dateTime': 1578175453.0, 'outTemp': 58.6, 'outHumidity': 71.0, 'pressure': 30.36, 'windDir': 315.0, ~ +``` + +### `--history=N` {id=cc3000_history} + +Display the latest `N` records from the CC3000 logger memory. Use a value of +`0` to display all records. Note: because there may be other records +mixed in with the archive records, this command will display an +extra seven records per day (or partial day). + + weectl device --history=2 + +This will result in something like: + +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +['REC', '2020/01/04 13:25', ' 58.9', ' 71', '30.36', '344', ' 4.9', ' 10.7', ' 0.00', ' 7.44', ' 5.26', ~ +['REC', '2020/01/04 13:30', ' 59.0', ' 71', '30.36', '327', ' 3.6', ' 10.0', ' 0.00', ' 7.32', ' 5.26', ~ +['REC', '2020/01/04 13:35', ' 59.1', ' 70', '30.36', '305', ' 5.5', ' 13.3', ' 0.00', ' 7.44', ' 5.26', ~ +['REC', '2020/01/04 13:40', ' 59.1', ' 70', '30.36', '330', ' 3.4', ' 8.9', ' 0.00', ' 7.08', ' 5.26', ~ +['REC', '2020/01/04 13:45', ' 58.9', ' 70', '30.36', '318', ' 2.6', ' 7.2', ' 0.00', ' 7.17', ' 5.26', ~ +['REC', '2020/01/04 13:50', ' 58.8', ' 71', '30.36', '312', ' 3.6', ' 7.9', ' 0.00', ' 7.14', ' 5.26', ~ +['REC', '2020/01/04 13:55', ' 58.9', ' 71', '30.36', '330', ' 4.5', ' 10.0', ' 0.00', ' 7.20', ' 5.26', ~ +['REC', '2020/01/04 14:00', ' 58.8', ' 71', '30.36', '331', ' 4.6', ' 9.6', ' 0.00', ' 7.38', ' 5.26', ~ +['REC', '2020/01/04 14:05', ' 58.6', ' 71', '30.36', '331', ' 4.0', ' 9.3', ' 0.00', ' 7.29', ' 5.26', ~ +``` + +### `--history-since=N` {id=cc3000_history_since} + +Display all CC3000 logger memory records created in +the last `N` minutes. + + weectl device --history-since=10 + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +{'dateTime': 1578175800.0, 'outTemp': 58.6, 'outHumidity': 70.0, 'pressure': 30.36, 'windDir': 316.0, ~ +{'dateTime': 1578176100.0, 'outTemp': 58.7, 'outHumidity': 70.0, 'pressure': 30.36, 'windDir': 317.0, ~ +``` + +### `--clear-memory` {id=cc3000_clear_memory} + +Use --clear-memory` to erase all records +from the logger memory. + + weectl device --clear-memory + +### `--get-header` {id=cc3000_get_header} + +Returns a series of comma delimited text descriptions. These +descriptions are used to identify the type and order of the returned +data in both `--get-current, +`--download=N and +`--download-since=N commands. + + weectl device --get-header + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +['HDR', '"TIMESTAMP"', '"TEMP OUT"', '"HUMIDITY"', '"PRESSURE"', '"WIND DIRECTION"', '"WIND SPEED"', ~ +``` + +### `--get-rain` {id=cc3000_get_rain} + +Display the rain counter. + +The CC-3000 maintains a rainfall counter that is only reset by a reboot or by +issuing the `reset` command. The counter counts in 0.01 inch increments and +rolls over at 65536 counts. Issuing the rainfall reset command will clear all +rainfall counters including the current daily rainfall. + + weectl device --get-rain + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +3.31 +``` + +### `--reset-rain` {id=cc3000_reset_rain} + +Reset the rain counter to zero. + + weectl device --reset-rain + +### `--get-max` {id=cc3000_get_max} + +Get the maximum values observed since the last time. + +Output parameter order: Outside temperature, humidity, pressure, +wind direction, wind speed, rainfall (daily total), station +voltage, inside temperature. If any optional sensors have been +enabled they will also be displayed. + + weectl device --get-max + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +['MAX', '11:59 61.1', '02:47 99', '09:51 30.42', '13:32 337', '13:32 13.3', '00:00 0.00', '09:20 ~ +``` + +### `--reset-max` {id=cc3000_reset_max} + +Reset the maximum values. + + weectl device --reset-max + +### `--get-min` {id=cc3000_get_min} + +Get the minimum values observed since the last time. + +Output parameter order: Outside temperature, humidity, pressure, +wind direction, wind speed, rainfall (ignore), station +voltage, inside temperature. If any optional sensors have been +enabled they will also be displayed. + + weectl device --get-min + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +['MIN', '02:19 40.6', '14:42 66', '00:34 30.24', '00:00 67', '00:00 0.0', '00:00 0.00', '06:48 ~ +``` + +### `--reset-min` {id=cc3000_reset_min} + +Reset the minimum values. + + weectl device --reset-min + +### `--get-clock` {id=cc3000_get_clock} + +Get the time. + + weectl device --get-clock + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +2020/01/04 15:01:34 +``` + +### `--set-clock` {id=cc3000_set_clock} + +Set the station clock to match the date/time of the computer. + + weectl device --set-clock + +### `--get-interval` {id=cc3000_get_interval} + +Returns the current logging interval (in seconds). + + weectl device --get-interval + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +300 +``` + +### `--set-interval=N` {id=cc3000_set_interval} + +Set the archive interval. + +CC3000 loggers ship from the factory with an archive interval of +1 minutes (60 seconds). To change the +station's interval to 300 seconds (5 minutes), do the following: + + weectl device --set-interval=5 + +### `--get-units` {id=cc3000_get_units} + +Returns the current measurement units. + + weectl device --get-units + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +ENGLISH +``` + +### `--set-units` {id=cc3000_set_units} + +The CC3000 can display data in either ENGLISH or METRIC unit systems. Use +`--set-units` to specify one or the other. + +The CC3000 driver automatically converts the units to maintain consistency with +the units in WeeWX. + + weectl device --set-units=ENGLISH + +### `--get-dst` {id=cc3000_get_dst} + +Return the dates and times when the clock will change due to daylight +saving and the number of minutes that the clock will change. + +This will result in something like: +``` +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +03/08 02:00,11/01 02:00,060 +``` + +### `--set-dst=N` {id=cc3000_set_dst} + +Set the station start, end, and amount of daylight savings. + +The schedule can be set by adding the three parameters, forward date and time, +back date and time and number of minutes to change (120 max). + + weectl device --set-dst="03/08 02:00,11/01 02:00,060" + +Daylight saving can be disabled by setting the daylight-saving to zero. + + weectl device --set-dst=0 + +### `--get-channel` {id=cc3000_get_channel} + +Displays the station channel (0-3). + +This will result in something like: +``` +Using configuration file /home/weewx/weewx.conf +Using CC3000 driver version 0.30 (weewx.drivers.cc3000) +0 +``` + +### `--set-channel=N` {id=cc3000_set_channel} + +Rainwise stations transmit on one of four channels. If you have +multiple instrument clusters within a kilometer or so of each +other, you should configure each to use a different channel. +In the instrument cluster, remove the cover and set the DIP +switches 0 and 1. Use `--set-channel` +to a value of 0-3 to match that of the instrument cluster. + + weectl device --set-channel=0 + +## Station data {id=cc3000_data} + +The following table shows which data are provided by the station +hardware and which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CC3000 station data
Database FieldObservationLoopArchive
barometerSS
pressurePRESSUREHH
altimeterSS
inTempTEMP INHH
outTempTEMP OUTHH
outHumidityHUMIDITYHH
windSpeedWIND SPEEDHH
windDirWIND DIRECTIONHH
windGustWIND GUSTHH
rainrain_deltaDD
RAINHH
rainRateSS
dewpointSS
windchillSS
heatindexSS
radiation1SOLAR RADIATIONHH
UV1UV INDEXHH
supplyVoltageSTATION BATTERYH
consBatteryVoltageBATTERY BACKUPH
extraTemp12TEMP 1HH
extraTemp22TEMP 2HH
+ +

+1 The radiation and +UV +data are available only with the optional solar radiation sensor. +

+ +

+2 The extraTemp1 and +extraTemp2 +data are available only with the optional additional temperature +sensors. +

+ +

+H indicates data provided by Hardware
+D indicates data calculated by the Driver
+S indicates data calculated by the StdWXCalculate Service
+

+ diff --git a/dist/weewx-5.0.2/docs_src/hardware/drivers.md b/dist/weewx-5.0.2/docs_src/hardware/drivers.md new file mode 100644 index 0000000..cca4a00 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/drivers.md @@ -0,0 +1,805 @@ +# Hardware guide + +## Core hardware + +Drivers for these stations are included in the core WeeWX distribution. Each +station type provides a different set of observations, at different sampling +rates. These are enumerated in a *Station data* table in the page for each +station. + + + + + + + + + + + + + + + + + + + + +
AcuRiteTE923WMR100WS1
CC3000UltimeterWMR300WS23xx
FineOffsetVantageWMR9x8WS28xx
+ + +## Driver status + +The following table enumerates many of the weather stations that are known to +work with WeeWX. If your station is not in the table, check the pictures at the +supported hardware page — +it could be a variation of one of the supported models. You can also check the +station comparison table — +sometimes new models use the same communication protocols as older hardware. + +The maturity column indicates the degree of confidence in the driver. For +stations marked Tested, the station is routinely tested as part of +the release process and should work as documented. For stations not marked +at all, they are "known to work" using the indicated driver, but are not +routinely tested. For stations marked Experimental, we are still +working on the driver. There can be problems. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Weather hardware supported by WeeWX
VendorModelHardware
Interface
Required
Package
DriverMaturity
AcuRite01025USBpyusbAcuRite12
01035USBpyusbAcuRite12Tested
01036USBpyusbAcuRite12
01525USBpyusbAcuRite12
02032USBpyusbAcuRite12
02064USBpyusbAcuRite12
06037USBpyusbAcuRite12
06039USBpyusbAcuRite12
Argent Data SystemsWS1SerialpyusbWS19
AercusWS2083USBpyusbFineOffsetUSB5
WS3083USBpyusbFineOffsetUSB5
Ambient WeatherWS1090USBpyusbFineOffsetUSB5Tested
WS2080USBpyusbFineOffsetUSB5Tested
WS2080AUSBpyusbFineOffsetUSB5Tested
WS2090USBpyusbFineOffsetUSB5
WS2095USBpyusbFineOffsetUSB5
CrestaWRX815USBpyusbTE9238
PWS720USBpyusbTE9238
DAZADZ-WH1080USBpyusbFineOffsetUSB5
DZ-WS3101USBpyusbFineOffsetUSB5
DZ-WS3104USBpyusbFineOffsetUSB5
DavisVantagePro2Serial or USBpyserialVantage1Tested
VantagePro2WeatherLink IP Vantage1Tested
VantageVueSerial or USBpyserialVantage1Tested
Elecsa6975USBpyusbFineOffsetUSB5
6976USBpyusbFineOffsetUSB5
ExcelvanExcelvanUSBpyusbFineOffsetUSB5
Fine OffsetWH1080USBpyusbFineOffsetUSB5
WH1081USBpyusbFineOffsetUSB5
WH1091USBpyusbFineOffsetUSB5
WH1090USBpyusbFineOffsetUSB5
WS1080USBpyusbFineOffsetUSB5
WA2080USBpyusbFineOffsetUSB5
WA2081USBpyusbFineOffsetUSB5
WH2080USBpyusbFineOffsetUSB5
WH2081USBpyusbFineOffsetUSB5
WH3080USBpyusbFineOffsetUSB5
WH3081USBpyusbFineOffsetUSB5
FroggitWS8700USBpyusbTE9238
WH1080USBpyusbFineOffsetUSB5
WH3080USBpyusbFineOffsetUSB5
General ToolsWS831DLUSBpyusbTE9238
HidekiDV928USBpyusbTE9238
TE821USBpyusbTE9238
TE827USBpyusbTE9238
TE831USBpyusbTE9238
TE838USBpyusbTE9238
TE923USBpyusbTE9238
HugerWM918SerialpyserialWMR9x84
IROXPro XUSBpyusbTE9238
La CrosseC86234USBpyusbWS28xx7Tested
WS-1640USBpyusbTE9238
WS-23XXSerialfcntl/selectWS23xx6Tested
WS-28XXUSBpyusbWS28xx7
MaplinN96GYUSBpyusbFineOffsetUSB5
N96FYUSBpyusbFineOffsetUSB5
MeadeTE923WUSBpyusbTE9238Tested
TE923W-MUSBpyusbTE9238
TE924WUSBpyusbTE9238
MebusTE923USBpyusbTE9238
National Geographic265USBpyusbFineOffsetUSB5
Oregon ScientificWMR88USBpyusbWMR1002
WMR88AUSBpyusbWMR1002
WMR100USBpyusbWMR1002
WMR100NUSBpyusbWMR1002Tested
WMR180USBpyusbWMR1002
WMR180AUSBpyusbWMR1002
WMRS200USBpyusbWMR1002
WMR300USBpyusbWMR3003Experimental
WMR300AUSBpyusbWMR3003Experimental
WMR918SerialpyserialWMR9x84
WMR928NSerialpyserialWMR9x84Tested
WMR968SerialpyserialWMR9x84Tested
PeetBrosUltimeter 100SerialpyserialUltimeter10
Ultimeter 800SerialpyserialUltimeter10
Ultimeter 2000SerialpyserialUltimeter10
Ultimeter 2100SerialpyserialUltimeter10
RainWiseMark IIISerialpyserialCC300011
CC3000SerialpyserialCC300011Tested
Radio Shack63-256USBpyusbWMR1002
63-1016SerialpyserialWMR9x84
SinometerWS1080 / WS1081USBpyusbFineOffsetUSB5
WS3100 / WS3101USBpyusbFineOffsetUSB5
TechnoLineWS-2300Serialfcntl/selectWS23xx6
WS-2350Serialfcntl/selectWS23xx6
TFAMatrixSerialfcntl/selectWS23xx6
NexusUSBpyusbTE9238
OpusUSBpyusbWS28xx7
PrimusUSBpyusbWS28xx7
SinusUSBpyusbTE9238
TyconTP1080WCUSBpyusbFineOffsetUSB5
WatsonW-8681USBpyusbFineOffsetUSB5
WX-2008USBpyusbFineOffsetUSB5
VellemanWS3080USBpyusbFineOffsetUSB5
VentusW831USBpyusbTE9238
W928USBpyusbTE9238
+ +Davis "Vantage" series of weather stations, +including the VantagePro2™ +and VantageVue™, +using serial, USB, or WeatherLinkIP™ connections. Both the "Rev +A" (firmware dated before 22 April 2002) and "Rev B" versions are +supported. + +Oregon Scientific WMR-100 stations. Tested on the +Oregon + Scientific WMR100N. + +Oregon Scientific WMR-300 stations. Tested on the +Oregon + Scientific WMR300A. + +Oregon Scientific WMR-9x8 stations. Tested on the +Oregon Scientific WMR968. + +Fine Offset 10xx, 20xx, and 30xx stations. +Tested on the Ambient Weather WS2080. + +La Crosse WS-23xx stations. Tested on the +La Crosse 2317. + +La Crosse WS-28xx stations. Tested on the +La Crosse C86234. + +Hideki Professional Weather Stations. Tested on the Meade +TE923. + +ADS WS1 Stations. Tested on the +WS1. + +PeetBros Ultimeter Stations. Tested on the +Ultimeter 2000. + +RainWise Mark III Stations. Tested on the +CC3000 +(firmware "Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016"). + +AcuRite Weather Stations. Tested on the 01036RX. diff --git a/dist/weewx-5.0.2/docs_src/hardware/fousb.md b/dist/weewx-5.0.2/docs_src/hardware/fousb.md new file mode 100644 index 0000000..2c1048d --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/fousb.md @@ -0,0 +1,358 @@ +# FineOffset (USB) {id=fousb_notes} + +The station clock can only be set manually via buttons on the console, or (if +the station supports it) by WWVB radio. The FineOffsetUSB driver ignores the +station clock since it cannot be trusted. + +The station reads data from the sensors every 48 seconds. The 30xx stations +read UV data every 60 seconds. + +The 10xx and 20xx stations can save up to 4080 historical readings. That is +about 85 days of data with the default recording interval of 30 minutes, or +about 14 days with a recording interval of 5 minutes. The 30xx stations can +save up to 3264 historical readings. + +When WeeWX starts up it will attempt to download all records from the console +since the last record in the archive database. + +## Polling mode and interval + +When reading 'live' data, WeeWX can read as fast as possible, or at a +user-defined period. This is controlled by the option `polling_mode` in the +WeeWX configuration file. + + + + + + + + + + + + + + + + + + +
Polling modes for Fine Offset stations
ModeConfigurationNotes
ADAPTIVE +
[FineOffsetUSB]
+polling_mode = ADAPTIVE
+
+In this mode, WeeWX reads data from the station as often as possible, +but at intervals that avoid communication between the console and the sensors. Nominally this +results in reading data every 48 seconds. +
PERIODIC +
[FineOffsetUSB]
+polling_mode = PERIODIC
+polling_interval = 60
+
+In this mode, WeeWX reads data from the station every polling_interval seconds. + +The console reads the sensors every 48 seconds (60 seconds for UV), so setting the polling_interval to a value less than 48 will result in duplicate +readings. +
+ + +## Data format {id=fousb_data_format} + +The 10xx/20xx consoles have a data format that is different from +the 30xx consoles. All the consoles recognize wind, rain, +temperature, and humidity from the same instrument clusters. +However, some instrument clusters also include a luminosity sensor. +Only the 30xx consoles recognize the luminosity and UV output +from these sensors. As a consequence, the 30xx consoles also have +a different data format. + +Since WeeWX cannot reliably determine the data format by communicating with +the station, the `data_format` configuration option indicates the station +type. Possible values are `1080` and `3080`. Use `1080` for the 10xx and +20xx consoles. + +The default value is `1080`. + +For example, this would indicate that the station is a 30xx console: + +``` +[FineOffsetUSB] + data_format = 3080 +``` + +## Configuring with `weectl device` {id=fousb_configuration} + +The Fine Offset stations can be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +!!! Note + Make sure you stop `weewxd` before running `weectl device`. + + +### `--help` {id=fousb_help} + +Invoking `weectl device` with the `--help` option + + weectl device --help + +will produce something like this: + +``` +FineOffsetUSB driver version 1.7 +Usage: weectl device [config_file] [options] [--debug] [--help] + +Configuration utility for weewx devices. + +Options: +-h, --help show this help message and exit +--debug display diagnostic information while running +-y answer yes to every prompt +--info display weather station configuration +--current get the current weather conditions +--history=N display N records +--history-since=N display records since N minutes ago +--clear-memory clear station memory +--set-time set station clock to computer time +--set-interval=N set logging interval to N minutes +--live display live readings from the station +--logged display logged readings from the station +--fixed-block display the contents of the fixed block +--check-usb test the quality of the USB connection +--check-fixed-block monitor the contents of the fixed block +--format=FORMAT format for output, one of raw, table, or dict + +Mutating actions will request confirmation before proceeding. +``` + +### `--info` {id=fousb_info} + +Display the station settings with the `--info` option. + + weectl device --info + +This will result in something like: +
Fine Offset station settings:
+local time: 2013.02.11 18:34:28 CET
+polling_mode: ADAPTIVE
+
+abs_pressure: 933.3
+current_pos: 592
+data_changed: 0
+data_count: 22
+date_time: 2007-01-01 22:49
+hum_in_offset: 18722
+hum_out_offset: 257
+id: None
+lux_wm2_coeff: 0
+magic_1: 0x55
+magic_2: 0xaa
+model: None
+rain_coef: None
+read_period: 30
+rel_pressure: 1014.8
+temp_in_offset: 1792
+temp_out_offset: 0
+timezone: 0
+unknown_01: 0
+unknown_18: 0
+version: 255
+wind_coef: None
+wind_mult: 0
+ +Highlighted values can be modified. + + +### `--set-interval=N` {id=fousb_set_interval} + +Set the archive interval. Fine Offset stations ship from the +factory with an archive interval (read_period) of 30 minutes (1800 +seconds). To change the station's interval to 5 minutes, do the +following: + + weectl device --set-interval=5 + +### `--history=N` {id=fousb_history} + +Fine Offset stations store records in a circular buffer — once the buffer +fills, the oldest records are replaced by newer records. The 1080 and 2080 +consoles store up to 4080 records. The 3080 consoles store up to 3264 records. +The `data_count` indicates how many records are in memory. The `read_period` +indicates the number of minutes between records. `weectl device` can display +these records in space-delimited, raw bytes, or dictionary format. + +For example, to display the most recent 30 records from the console memory: + + weectl device --history=30 + +### `--clear-memory` {id=fousb_clear_memory} + +To clear the console memory: + + weectl device --clear-memory + +### `--check-usb` {id=fousb_check_usb} + +This command can test the quality of the USB connection between the computer +and console. Poor quality USB cables, under-powered USB hubs, and other +devices on the bus can interfere with communication. + +To test the quality of the USB connection to the console: + + weectl device --check-usb + +Let the utility run for at least a few minutes, or possibly an hour or two. +It is not unusual to see a few bad reads in an hour, but if you see many bad +reads within a few minutes, consider replacing the USB cable, +USB hub, or removing other devices from the bus. + +## Station data {id=fousb_data} + +The following table shows which data are provided by the station +hardware and which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fine Offset station data
Database FieldObservationLoopArchive
barometerSS
pressurepressureHH
altimeterSS
inTemptemperature_inHH
outTemptemperature_outHH
inHumidityhumidity_inHH
outHumidityhumidity_outHH
windSpeedwind_speedHH
windDirwind_dirHH
windGustwind_gustHH
rainrainDD
rain_totalHH
rainRateSS
dewpointdewpointHS
windchillwindchillHS
heatindexheatindexHS
radiation1radiationDD
luminosity1luminosityHH
rxCheckPercentsignalH
outTempBatteryStatusbatteryH
+ +

+1 The radiation data are available +only from 30xx stations. +These stations include a luminosity sensor, from which the radiation is +approximated. +

+ +

+H indicates data provided by Hardware
+D indicates data calculated by the Driver
+S indicates data calculated by the StdWXCalculate Service
+

diff --git a/dist/weewx-5.0.2/docs_src/hardware/te923.md b/dist/weewx-5.0.2/docs_src/hardware/te923.md new file mode 100644 index 0000000..d5dbe94 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/te923.md @@ -0,0 +1,456 @@ +# TE923 {id=te923_notes} + +Some station models will recognize up to 5 remote +temperature/humidity sensor units. Use the hardware switch +in each sensor unit to identify sensors. Use the +`sensor_map` in +`weewx.conf` +to associate each sensor with a database field. + +The station has either 208 or 3442 history records, depending on +the model. With an archive interval set to 5 minutes, that is just +over a day (208 records) or about 23 days (3442 records). + +The TE923 driver will read history records from the station when +WeeWX starts up, but it does not support hardware record +generation. + +## Configuring with `weectl device` {id=te923_configuration} + +The TE923 can be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +!!! Note + Make sure you stop `weewxd` before running `weectl device`. + + +### `--help` {id=te923_help} + +Invoking `weectl device` with the `--help` option + + weectl device /home/weewx/weewx.conf --help + +will produce something like this: + +``` +Using configuration file /home/weewx/weewx.conf +Using TE923 driver version 0.21 (weewx.drivers.te923) +Usage: weectl device [config_file] [options] [--debug] [--help] + +Configuration utility for weewx devices. + +Options: +-h, --help show this help message and exit +--debug display diagnostic information while running +-y answer yes to every prompt +--info display weather station configuration +--current get the current weather conditions +--history=N display N history records +--history-since=N display history records since N minutes ago +--minmax display historical min/max data +--get-date display station date +--set-date=YEAR,MONTH,DAY +set station date +--sync-date set station date using system clock +--get-location-local display local location and timezone +--set-location-local=CITY|USR,LONG_DEG,LONG_MIN,E|W,LAT_DEG,LAT_MIN,N|S,TZ,DST +set local location and timezone +--get-location-alt display alternate location and timezone +--set-location-alt=CITY|USR,LONG_DEG,LONG_MIN,E|W,LAT_DEG,LAT_MIN,N|S,TZ,DST +set alternate location and timezone +--get-altitude display altitude +--set-altitude=ALT set altitude (meters) +--get-alarms display alarms +--set-alarms=WEEKDAY,SINGLE,PRE_ALARM,SNOOZE,MAXTEMP,MINTEMP,RAIN,WIND,GUST +set alarm state +--get-interval display archive interval +--set-interval=INTERVAL +set archive interval (minutes) +--format=FORMAT formats include: table, dict + +Be sure to stop weewx first before using. Mutating actions will request +confirmation before proceeding. + +``` + +### `--info` {id=te923_info} + +Use `--info` to display the station +configuration: + + weectl device --info + +This will result in something like: + +``` +Querying the station for the configuration... +altitude: 16 +bat_1: True +bat_2: True +bat_3: True +bat_4: True +bat_5: True +bat_rain: True +bat_uv: False +bat_wind: True +latitude: 43.35 +longitude: -72.0 +version_bar: 23 +version_rcc: 16 +version_sys: 41 +version_uv: 20 +version_wind: 38 +``` + +### `--current` {id=te923_current} + +Use `--current` to display the current status of each sensor: + + weectl device --current + +This will result in something like: + +``` +Querying the station for current weather data... +dateTime: 1454615168 +forecast: 5 +h_1: 41 +h_1_state: ok +h_2: 48 +h_2_state: ok +h_3: None +h_3_state: no_link +h_4: None +h_4_state: no_link +h_5: None +h_5_state: no_link +h_in: 44 +h_in_state: ok +rain: 2723 +rain_state: ok +slp: 1012.4375 +slp_state: ok +storm: 0 +t_1: 13.9 +t_1_state: ok +t_2: 21.5 +t_2_state: ok +t_3: None +t_3_state: no_link +t_4: None +t_4_state: no_link +t_5: None +t_5_state: no_link +t_in: 22.85 +t_in_state: ok +uv: None +uv_state: no_link +windchill: None +windchill_state: invalid +winddir: 12 +winddir_state: invalid +windgust: None +windgust_state: invalid +windspeed: None +windspeed_state: invalid +``` + +### `--set-interval` {id=te923_set_interval} + +TE923 stations ship from the factory with an archive interval of 1 +hour (3600 seconds). To change the station's interval to 5 minutes +(300 seconds), do the following: + + weectl device --set-interval=300 + +### `--history=N` {id=te923_history} + +Use the `--history` action to display +records from the logger in tabular or dictionary format. + +For example, to display the most recent 30 records in dictionary +format: + + weectl device --history=30 --format=dict + +### `--clear-memory` {id=te923_clear_memory} + +Use `--clear-memory` to erase all records from the logger memory. + +## Station data {id=te923_data} + +The following table shows which data are provided by the station +hardware and which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TE923 station data
Database FieldObservationLoopArchive
barometerbarometerHH
pressureSS
altimeterSS
inTempt_inHH
inHumidityh_inHH
outTempt_1HH
outHumidityh_1HH
outTempBatteryStatusbat_1H
outLinkStatuslink_1H
windSpeedwindspeedHH
windDirwinddirHH
windGustwindgustHH
windBatteryStatusbat_windH
windLinkStatuslink_windH
rainrainDD
rain_totalHH
rainBatteryStatusbat_rainH
rainLinkStatuslink_rainH
rainRateSS
dewpointSS
windchillwindchillHH
heatindexSS
UV1uvHH
uvBatteryStatusbat_uvH
uvLinkStatuslink_uvH
extraTemp1t_2HH
extraHumid1h_2HH
extraBatteryStatus1bat_2H
extraLinkStatus1link_2H
extraTemp2t_3HH
extraHumid2h_3HH
extraBatteryStatus2bat_3H
extraLinkStatus2link_3H
extraTemp3t_4HH
extraHumid3h_4HH
extraBatteryStatus3bat_4H
extraLinkStatus3link_4H
extraTemp4t_5HH
extraHumid4h_5HH
extraBatteryStatus4bat_5H
extraLinkStatus4link_5H
+ +

+Some stations support up to 5 remote temperature/humidity sensors. +

+ +

+1 The UV data are available +only with the optional solar radiation sensor. +

+ +

+H indicates data provided by Hardware
+D indicates data calculated by the Driver
+S indicates data calculated by the StdWXCalculate Service
+

diff --git a/dist/weewx-5.0.2/docs_src/hardware/ultimeter.md b/dist/weewx-5.0.2/docs_src/hardware/ultimeter.md new file mode 100644 index 0000000..abc738d --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/ultimeter.md @@ -0,0 +1,135 @@ +# Ultimeter {id=ultimeter_notes} + +The Ultimeter driver operates the Ultimeter in Data Logger Mode, which results in sensor readings +every 1/2 second or so. + +The Ultimeter driver ignores the maximum, minimum, and average values recorded by the station. + +## Configuring with `weectl device` {id=ultimeter_configuration} + +The Ultimeter stations cannot be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +## Station data {id=ultimeter_data} + +The following table shows which data are provided by the station hardware and which are calculated +by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Ultimeter station data
Database FieldObservationLoopArchive
barometer2barometerH
pressure2S
altimeter2S
inTemp2temperature_inH
outTemptemperature_outH
inHumidity2humidity_inH
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
rain1rainD
rain_totalH
rainRate1S
dewpointS
windchillS
heatindexS
+

+ 1 The rain and + rainRate are + available only on stations with the optional rain sensor. +

+ +

+ 2 Pressure, inside temperature, and inside humidity + data are not available on all types of Ultimeter stations. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ diff --git a/dist/weewx-5.0.2/docs_src/hardware/vantage.md b/dist/weewx-5.0.2/docs_src/hardware/vantage.md new file mode 100644 index 0000000..54187d4 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/vantage.md @@ -0,0 +1,762 @@ +# Vantage {id=vantage_notes} + +The Davis Vantage stations include a variety of models and configurations. +The WeeWX driver can communicate with a console or envoy using serial, USB, +or TCP/IP interface. + +## Configuring with `weectl device` {id=vantage_configuration} + +The Vantage stations can be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +!!! NOTE + + Make sure you stop `weewxd` before running `weectl device`. + +### `--help` {id=vantage_help} + +Invoking `weectl device` with the `--help` option + + weectl device /home/weewx/weewx.conf --help + +will produce something like this: + +``` +Using configuration file /home/weewx-user/weewx-data/weewx.conf +Using driver weewx.drivers.vantage. +Using Vantage driver version 3.6.2 (weewx.drivers.vantage) +Usage: weectl device --help + weectl device --info [config_file] + weectl device --current [config_file] + weectl device --clear-memory [config_file] [-y] + weectl device --set-interval=MINUTES [config_file] [-y] + weectl device --set-latitude=DEGREE [config_file] [-y] + weectl device --set-longitude=DEGREE [config_file] [-y] + weectl device --set-altitude=FEET [config_file] [-y] + weectl device --set-barometer=inHg [config_file] [-y] + weectl device --set-wind-cup=CODE [config_file] [-y] + weectl device --set-bucket=CODE [config_file] [-y] + weectl device --set-rain-year-start=MM [config_file] [-y] + weectl device --set-offset=VARIABLE,OFFSET [config_file] [-y] + weectl device --set-transmitter-type=CHANNEL,TYPE,TEMP,HUM,REPEATER_ID [config_file] [-y] + weectl device --set-retransmit=[OFF|ON|ON,CHANNEL] [config_file] [-y] + weectl device --set-temperature-logging=[LAST|AVERAGE] [config_file] [-y] + weectl device --set-time [config_file] [-y] + weectl device --set-dst=[AUTO|ON|OFF] [config_file] [-y] + weectl device --set-tz-code=TZCODE [config_file] [-y] + weectl device --set-tz-offset=HHMM [config_file] [-y] + weectl device --set-lamp=[ON|OFF] [config_file] + weectl device --dump [--batch-size=BATCH_SIZE] [config_file] [-y] + weectl device --logger-summary=FILE [config_file] [-y] + weectl device [--start | --stop] [config_file] + +Configures the Davis Vantage weather station. + +Options: + -h, --help show this help message and exit + --debug display diagnostic information while running + -y answer yes to every prompt + --info To print configuration, reception, and barometer + calibration information about your weather station. + --current To print current LOOP information. + --clear-memory To clear the memory of your weather station. + --set-interval=MINUTES + Sets the archive interval to the specified number of + minutes. Valid values are 1, 5, 10, 15, 30, 60, or + 120. + --set-latitude=DEGREE + Sets the latitude of the station to the specified + number of tenth degree. + --set-longitude=DEGREE + Sets the longitude of the station to the specified + number of tenth degree. + --set-altitude=FEET Sets the altitude of the station to the specified + number of feet. + --set-barometer=inHg Sets the barometer reading of the station to a known + correct value in inches of mercury. Specify 0 (zero) + to have the console pick a sensible value. + --set-wind-cup=CODE Set the type of wind cup. Specify '0' for small size; + '1' for large size + --set-bucket=CODE Set the type of rain bucket. Specify '0' for 0.01 + inches; '1' for 0.2 mm; '2' for 0.1 mm + --set-rain-year-start=MM + Set the rain year start (1=Jan, 2=Feb, etc.). + --set-offset=VARIABLE,OFFSET + Set the onboard offset for VARIABLE inTemp, outTemp, + extraTemp[1-7], inHumid, outHumid, extraHumid[1-7], + soilTemp[1-4], leafTemp[1-4], windDir) to OFFSET + (Fahrenheit, %, degrees) + --set-transmitter-type=CHANNEL,TYPE,TEMP,HUM,REPEATER_ID + Set the transmitter type for CHANNEL (1-8), TYPE + (0=iss, 1=temp, 2=hum, 3=temp_hum, 4=wind, 5=rain, + 6=leaf, 7=soil, 8=leaf_soil, 9=sensorlink, 10=none), + as extra TEMP station and extra HUM station (both 1-7, + if applicable), REPEATER_ID (A-H, or 0=OFF) + --set-retransmit=OFF|ON|ON,CHANNEL + Turn ISS retransmit 'ON' or 'OFF', using optional CHANNEL. + --set-temperature-logging=LAST|AVERAGE + Set console temperature logging to either 'LAST' or + 'AVERAGE'. + --set-time Set the onboard clock to the current time. + --set-dst=AUTO|ON|OFF + Set DST to 'ON', 'OFF', or 'AUTO' + --set-tz-code=TZCODE Set timezone code to TZCODE. See your Vantage manual + for valid codes. + --set-tz-offset=HHMM Set timezone offset to HHMM. E.g. '-0800' for U.S. + Pacific Time. + --set-lamp=ON|OFF Turn the console lamp 'ON' or 'OFF'. + --dump Dump all data to the archive. NB: This may result in + many duplicate primary key errors. + --batch-size=BATCH_SIZE + Use with option --dump. Pages are read off the console + in batches of BATCH_SIZE. A BATCH_SIZE of zero means + dump all data first, then put it in the database. This + can improve performance in high-latency environments, + but requires sufficient memory to hold all station + data. Default is 1 (one). + --logger-summary=FILE + Save diagnostic summary to FILE (for debugging the + logger). + --start Start the logger. + --stop Stop the logger. + +Be sure to stop weewxd first before using. Mutating actions will request +confirmation before proceeding. +``` + +### `--info` {id=vantage_info} + +Use the `--info` option to display the current EEPROM settings: + + weectl device --info + +This will result in something like: + +
+Using configuration file /home/weewx/weewx.conf
+Using driver weewx.drivers.vantage.
+Using Vantage driver version 3.6.2 (weewx.drivers.vantage)
+Querying...
+Davis Vantage EEPROM settings:
+
+    CONSOLE TYPE:                   Vantage Pro2
+
+    CONSOLE FIRMWARE:
+      Date:                         Dec 11 2012
+      Version:                      3.12
+
+    CONSOLE SETTINGS:
+      Archive interval:             300 (seconds)
+      Altitude:                     700 (foot)
+      Wind cup type:                large
+      Rain bucket type:             0.01 inches
+      Rain year start:              10
+      Onboard time:                 2023-05-01 07:35:25
+
+    CONSOLE DISPLAY UNITS:
+      Barometer:                    mbar
+      Temperature:                  degree_F
+      Rain:                         inch
+      Wind:                         mile_per_hour
+
+    CONSOLE STATION INFO:
+      Latitude (onboard):           +46.0
+      Longitude (onboard):          -121.6
+      Use manual or auto DST?       AUTO
+      DST setting:                  N/A
+      Use GMT offset or zone code?  ZONE_CODE
+      Time zone code:               4
+      GMT offset:                   N/A
+      Temperature logging:          AVERAGE
+
+    TRANSMITTERS:
+      Channel   Receive     Retransmit  Repeater  Type
+         1      inactive    N           NONE      iss
+         2      inactive    N           NONE      (N/A)
+         3      active      N           NONE      temp (as extra temperature 1)
+         4      active      N           8         temp_hum (as extra temperature 4 and extra humidity 1)
+         5      inactive    N           NONE      (N/A)
+         6      inactive    Y           NONE      (N/A)
+         7      inactive    N           NONE      (N/A)
+         8      inactive    N           NONE      (N/A)
+
+    RECEPTION STATS:
+      Total packets received:       2895
+      Total packets missed:         82
+      Number of resynchronizations: 0
+      Longest good stretch:         330
+      Number of CRC errors:         134
+
+    BAROMETER CALIBRATION DATA:
+      Current barometer reading:    29.821 inHg
+      Altitude:                     700 feet
+      Dew point:                    43 F
+      Virtual temperature:          56 F
+      Humidity correction factor:   1.7
+      Correction ratio:             1.026
+      Correction constant:          +0.036 inHg
+      Gain:                         0.000
+      Offset:                       -47.000
+
+    OFFSETS:
+      Wind direction:               +0 deg
+      Inside Temperature:           +0.0 F
+      Inside Humidity:              +0 %
+      Outside Temperature:          +0.0 F
+      Outside Humidity:             +0 %
+      Extra Temperature 1:          +0.0 F
+      Extra Temperature 4:          +0.0 F
+      Extra Humidity 1:             +0.0 F
+
+ +The console version number is available only on consoles with firmware dates +after about 2006. + +Highlighted values can be modified using the +commands below. + +### `--current` {id=vantage_current} + +This command will print a single LOOP packet. + +### `--clear-memory` {id=vantage_clear_console_memory} + +This command will clear the logger memory after asking for confirmation. + +### `--set-interval=N` {id=vantage_archive_interval} + +Use this command to change the archive interval of the internal logger. Valid +intervals are 1, 5, 10, 15, 30, 60, or 120 minutes. However, if you are +ftp'ing lots of files to a server, setting it to one minute may not give +enough time to have them all uploaded before the next archive record is +due. If this is the case, you should pick a longer archive interval, or +trim the number of files you are using. + +An archive interval of five minutes works well for the Vantage stations. +Because of the large amount of onboard memory they carry, going to a larger +interval does not have any real advantages. + +Example: change the archive interval to 10 minutes: + + weectl device --set-interval=10 + +### `--set-altitude=N` + +Use this command to set the console's stored altitude. The altitude must be in +_feet_. + +Example: change the altitude to 700 feet: + + weectl device --set-altitude=700 + +### `--set-barometer=N` + +Use this command to calibrate the barometer in your Vantage weather station. +To use it, you must have a known correct barometer reading _for your altitude_. +In practice, you will either have to move your console to a known-correct +station (perhaps a nearby airport) and perform the calibration there, or +reduce the barometer reading to your altitude. Otherwise, specify the value +zero and the station will pick a sensible value. + +The barometer value must be in _inches of Mercury_. + +### `--set-bucket` {id=vantage_rain_bucket_type} + +Normally, this is set by Davis, but if you have replaced your bucket with a +different kind, you might want to reconfigure. For example, to change to a +0.1 mm bucket (bucket code `2`), use the following: + + weectl device --set-bucket=2 + +### `--set-rain-year-start` + +The Davis Vantage series allows the start of the rain year to be something +other than 1 January. + +For example, to set it to 1 October: + + weectl device --set-rain-year-start=10 + +### `--set-offset` {id=vantage_setting_offsets} + +The Davis instruments can correct sensor errors by adding an _offset_ to their +emitted values. This is particularly useful for Southern Hemisphere users. +Davis fits the wind vane to the Integrated Sensor Suite (ISS) in a position +optimized for Northern Hemisphere users, who face the solar panel to the south. +Users south of the equator must orient the ISS's solar panel to the north to +get maximal insolation, resulting in a 180° error in the wind direction. The +solution is to add a 180° offset correction. You can do this with the +following command: + + weectl device --set-offset=windDir,180 + +### `--set-transmitter-type` {id=vantage_configuring_additional_sensors} + +If you have additional sensors and/or repeaters for your Vantage station, you +can configure them using your console. However, if you have a [Davis Weather +Envoy](https://www.davisinstruments.com/product/wireless-weather-envoy/), +it will not have a console! As an alternative, `weectl device` can do this using +the command `--set-transmitter-type`. + +For example, to add an extra temperature sensor to channel 3 and no repeater +is used, do the following: + + weectl device --set-transmitter-type=3,1,2 + +This says to turn on channel 3, set its type to 1 ("Temperature only"), and it +will show up in the database as `extraTemp2`. No repeater id was specified, so +it defaults to "no repeater." + +Here's another example, this time for a combined temperature / humidity sensor +retransmitted via repeater A: + + weectl device --set-transmitter-type=5,3,2,4,a + +This will add the combined sensor to channel 5, set its type to 3 +("Temperature and humidity"), via Repeater A, and it will +show up in the database as `extraTemp2` and `extraHumid4`. + +The `--help` option will give you the code for each sensor type and repeater +id. + +If you have to use repeaters with your Vantage Pro2 station, please take a +look at [Installing Repeater Networks for Vantage +Pro2](https://support.davisinstruments.com/article/t9nvrc8c1u-app-notes-installing-repeater-networks-for-vantage-pro-2) +for how to set them up. + +### `--set-retransmit` {id=vantage_retransmit} + +Use this command to tell your console whether to act as a retransmitter of ISS +data. + +Example: Tell your console to retransmit ISS data using the first available +channel: + + weectl device --set-retransmit=on + +Example: Tell your console to retransmit ISS data on channel 4: + + weectl device --set-retransmit=on,4 + +!!! Warning + + You can use only channels not actively used for reception. The command + checks for this and will not accept channel numbers actively used for + reception of sensor stations. + +Example: Tell your console to turn retransmission 'OFF': + + weectl device --set-retransmit=off + +### `--set-dst` + +Use the command to tell your console whether daylight savings time is in +effect, or to have it set automatically based on the time zone. + +### `--set-tz-code` {id=vantage_time_zone} + +This command can be used to change the time zone. Consult the Vantage manual +for the code that corresponds to your time zone. + +For example, to set the time zone code to Central European Time (code 20): + + weectl device --set-tz-code=20 + +!!! Warning + + You can set either the time zone code _or_ the time zone offset, but + not both. + +### `--set-tz-offset` + +If you live in an odd time zone that is perhaps not covered by the "canned" +Davis time zones, you can set the offset from UTC using this command. + +For example, to set the time zone offset for Newfoundland Standard +Time (UTC-03:30), use the following: + + weectl device --set-tz-offset=-0330 + +!!! Warning + + You can set either the time zone code _or_ the time zone offset, but + not both. + +### `--set-lamp` + +Use this command to turn the console lamp on or off. + +### `--dump` {id=vantage_dumping_the_logger_memory} + +Generally, WeeWX downloads only new archive records from the on-board logger +in the Vantage. However, occasionally the memory in the Vantage will get +corrupted, making this impossible. See the section [_WeeWX generates HTML +pages, but it does not update them_](https://github.com/weewx/weewx/wiki/Troubleshooting-the-Davis-Vantage-station#weewx-generates-html-pages-but-it-does-not-update-them) +in the Wiki. The fix involves clearing the memory but, unfortunately, this +means you may lose any data which might have accumulated in the logger memory, +but not yet downloaded. By using the `--dump` command before clearing the +memory, you might be able to save these data. + +Stop WeeWX first, then + + weectl device --dump + +This will dump all data archived in the Vantage memory directly to the +database, without regard to whether or not they have been seen before. +Because the command dumps _all_ data, it may result in many duplicate primary +key errors. These can be ignored. + +### `--logger-summary` + +This command is useful for debugging the console logger. It will scan the +logger memory, recording the timestamp in each page and index slot to the +file `FILE`. + +Example: + + weectl device --logger-summary=/var/tmp/summary.txt + +### `--start` + +Use this command to start the logger. There are occasions when an +out-of-the-box logger needs this command. + +### `--stop` + +Use this command to stop the logger. This can be useful when servicing your +weather station, and you don't want any bad data to be stored in the logger. + +## Station data {id=vantage_data} + +The following table shows which data are provided by the station hardware and +which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Vantage station data
Database FieldObservationLoopArchive
barometerbarometerHH
pressureSS
altimeterSS
inTempinTempHH
outTempoutTempHH
inHumidityinHumidityHH
outHumidityoutHumidityHH
windSpeedwindSpeedHH
windDirwindDirHH
windGustwindGustHH
windGustDirwindGustDirHH
rainrainDH
monthRainHH
rainRaterainRateHH
dewpointSS
windchillSS
heatindexSS
radiationradiationHH
UVUVHH
extraTemp1extraTemp1HH
extraTemp2extraTemp2HH
extraTemp3extraTemp3HH
extraTemp4extraTemp4H
extraTemp5extraTemp5H
extraTemp6extraTemp6H
extraTemp7extraTemp7H
soilTemp1soilTemp1HH
soilTemp2soilTemp2HH
soilTemp3soilTemp3HH
soilTemp4soilTemp4HH
leafTemp1leafTemp1HH
leafTemp2leafTemp2HH
leafTemp3leafTemp3HH
leafTemp4leafTemp4HH
extraHumid1extraHumid1HH
extraHumid2extraHumid2HH
extraHumid3extraHumid3H
extraHumid4extraHumid4H
extraHumid5extraHumid5H
extraHumid6extraHumid6H
extraHumid7extraHumid7H
soilMoist1soilMoist1HH
soilMoist2soilMoist2HH
soilMoist3soilMoist3HH
soilMoist4soilMoist4HH
leafWet1leafWet1HH
leafWet2leafWet2HH
leafWet3leafWet3HH
leafWet4leafWet4HH
txBatteryStatustxBatteryStatusHH
consBatteryVoltageconsBatteryVoltageHH
wind_samplesH
+ +

+H indicates data provided by Hardware
+D indicates data calculated by the Driver
+S indicates data calculated by the StdWXCalculate Service
+

diff --git a/dist/weewx-5.0.2/docs_src/hardware/wmr100.md b/dist/weewx-5.0.2/docs_src/hardware/wmr100.md new file mode 100644 index 0000000..96b2d9d --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/wmr100.md @@ -0,0 +1,299 @@ +# WMR100 {id=wmr100_notes} + +The station emits partial packets, which may confuse some online services. + +## Configuring with `weectl device` {id=wmr100_configuration} + +The WMR100 stations cannot be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + + +## Station data {id=wmr100_data} + +The following table shows which data are provided by the station hardware and which are calculated +by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WMR100 station data
Database FieldObservationLoopArchive
barometerS
pressurepressureH
altimeterS
inTemptemperature_0H
outTemptemperature_1H
inHumidityhumidity_0H
outHumidityhumidity_1H
windSpeedwind_speedH
windDirwind_dirH
windGustwind_gustH
rainrainD
rain_totalH
rainRaterain_rateH
dewpointS
windchillS
heatindexS
extraTemp1temperature_2H
extraTemp2temperature_3H
extraTemp3temperature_4H
extraTemp4temperature_5H
extraTemp5temperature_6H
extraTemp6temperature_7H
extraTemp7temperature_8H
extraHumid1humidity_2H
extraHumid2humidity_3H
extraHumid3humidity_4H
extraHumid4humidity_5H
extraHumid5humidity_6H
extraHumid6humidity_7H
extraHumid7humidity_8H
UVuvH
inTempBatteryStatusbattery_status_0H
outTempBatteryStatusbattery_status_1H
rainBatteryStatusrain_battery_statusH
windBatteryStatuswind_battery_statusH
uvBatteryStatusuv_battery_statusH
extraBatteryStatus1battery_status_2H
extraBatteryStatus2battery_status_3H
extraBatteryStatus3battery_status_4H
extraBatteryStatus4battery_status_5H
extraBatteryStatus5battery_status_6H
extraBatteryStatus6battery_status_7H
extraBatteryStatus7battery_status_8H
+ +

+Each packet contains a subset of all possible readings. For +example, a temperature packet contains +temperature_N and +battery_status_N, a rain packet contains +rain_total and +rain_rate. +

+ +

+H indicates data provided by Hardware
+D indicates data calculated by the Driver
+S indicates data calculated by the StdWXCalculate Service
+

diff --git a/dist/weewx-5.0.2/docs_src/hardware/wmr300.md b/dist/weewx-5.0.2/docs_src/hardware/wmr300.md new file mode 100644 index 0000000..e976899 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/wmr300.md @@ -0,0 +1,331 @@ +# WMR300 {id=wmr300_notes} + + +A single WMR300 console supports 1 wind, 1 rain, 1 UV, and up to 8 temperature/humidity sensors. + +The WMR300 sensors send at different rates: + + + + + + + + + +
WMR300 transmission periods
sensorperiod
Wind2.5 to 3 seconds
T/H10 to 12 seconds
Rain20 to 24 seconds
+ +The console contains the pressure sensor. The console reports pressure every 15 minutes. + +The station emits partial packets, which may confuse some online services. + +The rain counter has a limit of 400 inches (10160 mm). The counter does not wrap around; it must +be reset when it hits the limit, otherwise additional rain data will not be recorded. + +The logger stores about 50,000 records. When the logger fills up, it stops recording data. + +When WeeWX starts up it will attempt to download all records from the console since the last record +in the archive database. This can take as much as couple of hours, depending on the number of +records in the logger and the speed of the computer and disk. + +## Configuring with `weectl device` {id=wrm300_configuration} + +The WMR300 stations cannot be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +## Station data {id=wmr300_data} + +The following table shows which data are provided by the station hardware and which are calculated +by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WMR300 station data
Database FieldObservationLoopArchive
barometerbarometerHH
pressurepressureHS
altimeterSS
inTemptemperature_0HH
inHumidityhumidity_0HH
windSpeedwind_avgHH
windDirwind_dirHH
windGustwind_gustHH
windGustDirwind_gust_dirHH
rainrainDD
rain_totalHH
rainRaterain_rateHH
outTemptemperature_1HH
outHumidityhumidity_1HH
dewpointdewpoint_1HH
heatindexheatindex_1HH
windchillwindchillHH
extraTemp1temperature_2HH
extraHumid1humidity_2HH
extraDewpoint1dewpoint_2HH
extraHeatindex1heatindex_2HH
extraTemp2temperature_3HH
extraHumid2humidity_3HH
extraDewpoint2dewpoint_3HH
extraHeatindex2heatindex_3HH
extraTemp3temperature_4HH
extraHumid3humidity_4HH
extraDewpoint3dewpoint_4HH
extraHeatindex3heatindex_4HH
extraTemp4temperature_5HH
extraHumid4humidity_5HH
extraDewpoint4dewpoint_5HH
extraHeatindex4heatindex_5HH
extraTemp5temperature_6HH
extraHumid5humidity_6HH
extraDewpoint5dewpoint_6HH
extraHeatindex5heatindex_6HH
extraTemp6temperature_7HH
extraHumid6humidity_7HH
extraDewpoint6dewpoint_7HH
extraHeatindex6heatindex_7HH
extraTemp7temperature_8HH
extraHumid7humidity_8HH
extraDewpoint7dewpoint_8HH
extraHeatindex7heatindex_8HH
+

+ Each packet contains a subset of all possible readings. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ diff --git a/dist/weewx-5.0.2/docs_src/hardware/wmr9x8.md b/dist/weewx-5.0.2/docs_src/hardware/wmr9x8.md new file mode 100644 index 0000000..0401605 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/wmr9x8.md @@ -0,0 +1,325 @@ +# WMR9x8 {id=wmr9x8_notes} + +The station includes a data logger, but the driver does not read records from the station. + +The station emits partial packets, which may confuse some online services. + +## Configuring with `weectl device` {id=wmr9x8_configuration} + +The WMR9x8 stations cannot be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + + +## Station data {id=wmr9x8_data} + +The following table shows which data are provided by the station hardware and which are calculated +by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WMR9x8 station data
Database FieldObservationLoopArchive
barometerbarometerH
pressurepressureH
altimeterS
inTemptemperature_inH
outTemptemperature_outH
inHumidityhumidity_inH
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
windGustwind_gustH
windGustDirwind_gust_dirH
rainrainD
rain_totalH
rainRaterain_rateH
inDewpointdewpoint_inH
dewpointdewpoint_outH
windchillwindchillH
heatindexS
UVuvH
extraTemp1temperature_1H
extraTemp2temperature_2H
extraTemp3temperature_3H
extraTemp4temperature_4H
extraTemp5temperature_5H
extraTemp6temperature_6H
extraTemp7temperature_7H
extraTemp8temperature_8H
extraHumid1humidity_1H
extraHumid2humidity_3H
extraHumid3humidity_3H
extraHumid4humidity_4H
extraHumid5humidity_5H
extraHumid6humidity_6H
extraHumid7humidity_7H
extraHumid8humidity_8H
inTempBatteryStatusbattery_status_inH
outTempBatteryStatusbattery_status_outH
rainBatteryStatusrain_battery_statusH
windBatteryStatuswind_battery_statusH
extraBatteryStatus1battery_status_1H
extraBatteryStatus2battery_status_2H
extraBatteryStatus3battery_status_3H
extraBatteryStatus4battery_status_4H
extraBatteryStatus5battery_status_5H
extraBatteryStatus6battery_status_6H
extraBatteryStatus7battery_status_7H
extraBatteryStatus8battery_status_8H
+

+ Each packet contains a subset of all possible readings. For + example, a temperature packet contains + temperature_N and + battery_status_N, a rain packet contains + rain_total and + rain_rate. +

+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ diff --git a/dist/weewx-5.0.2/docs_src/hardware/ws1.md b/dist/weewx-5.0.2/docs_src/hardware/ws1.md new file mode 100644 index 0000000..0778b28 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/ws1.md @@ -0,0 +1,123 @@ +# WS1 {id=ws1_notes} + + +The WS1 stations produce data every 1/2 second or so. + +## Configuring with `weectl device` {id=ws1_configuration} + +The WS1 stations cannot be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + + +## Station data {id=ws1_data} + +The following table shows which data are provided by the station hardware and which are calculated +by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WS1 station data
Database FieldObservationLoopArchive
barometerS
pressurepressureH
altimeterS
inTemptemperature_inH
outTemptemperature_outH
inHumidityhumidity_inH
outHumidityhumidity_outH
windSpeedwind_speedH
windDirwind_dirH
rainrainD
rain_totalH
rainRateS
dewpointS
windchillS
heatindexS
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

diff --git a/dist/weewx-5.0.2/docs_src/hardware/ws23xx.md b/dist/weewx-5.0.2/docs_src/hardware/ws23xx.md new file mode 100644 index 0000000..9895d24 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/ws23xx.md @@ -0,0 +1,254 @@ +# WS23xx {id=ws23xx_notes} + +The hardware interface is a serial port, but USB-serial converters +can be used with computers that have no serial port. Beware that +not every type of USB-serial converter will work. Converters based +on ATEN UC-232A chipset are known to work. + +The station does not record wind gust or wind gust direction. + +The hardware calculates windchill and dewpoint. + +Sensors can be connected to the console with a wire. If not wired, +the sensors will communicate via a wireless interface. When +connected by wire, some sensor data are transmitted every 8 +seconds. With wireless, data are transmitted every 16 to 128 +seconds, depending on wind speed and rain activity. + + + + + + + + + + + +
WS23xx transmission periods
sensorperiod
Wind32 seconds when wind > 22.36 mph (wireless)
+ 128 seconds when wind > 22.36 mph (wireless)
+ 10 minutes (wireless after 5 failed attempts)
+ 8 seconds (wired) +
Temperature15 seconds
Humidity20 seconds
Pressure15 seconds
+ +The station has 175 history records. That is just over 7 days of data with the +factory default history recording interval of 60 minutes, or about 14 hours with +a recording interval of 5 minutes. + +When WeeWX starts up it will attempt to download all records from the console +since the last record in the archive database. + + +## Configuring with `weectl device` {id=ws23xx_configuration} + +The WS23xx stations can be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +!!! Note + Make sure you stop `weewxd` before running `weectl device`. + +### `--help` {id=ws23xx_help} + +Invoking `weectl device` with the `--help` option + + weectl device --help + +will produce something like this: + +``` + WS23xx driver version 0.21 + Usage: weectl device [config_file] [options] [--debug] [--help] + + Configuration utility for weewx devices. + + Options: + -h, --help show this help message and exit + --debug display diagnostic information while running + -y answer yes to every prompt + --info display weather station configuration + --current get the current weather conditions + --history=N display N history records + --history-since=N display history records since N minutes ago + --clear-memory clear station memory + --set-time set the station clock to the current time + --set-interval=N set the station archive interval to N minutes + + Mutating actions will request confirmation before proceeding. +``` + +### `--info` {id=ws23xx_info} + +Display the station settings with the `--info` option. + + weectl device --info + +This will result in something like: + +``` + buzzer: 0 + connection time till connect: 1.5 + connection type: 15 + dew point: 8.88 + dew point max: 18.26 + dew point max alarm: 20.0 + dew point max alarm active: 0 + dew point max alarm set: 0 + dew point max when: 978565200.0 + dew point min: -2.88 + dew point min alarm: 0.0 + dew point min alarm active: 0 + dew point min alarm set: 0 + dew point min when: 978757260.0 + forecast: 0 + history interval: 5.0 + history last record pointer: 8.0 + history last sample when: 1385564760.0 + history number of records: 175.0 + history time till sample: 5.0 + icon alarm active: 0 + in humidity: 48.0 +``` + + +The line `history number of records` indicates how many records are in memory. +The line `history interval` indicates the number of minutes between records. + +### `--set-interval` {id=ws23xx_set_interval} + +WS23xx stations ship from the factory with an archive interval of 60 minutes +(3600 seconds). To change the interval to 5 minutes, do the following: + + weectl device --set-interval=5 + +!!! Warning + Changing the interval will clear the station memory. + +### `--history` {id=ws23xx_history} + +WS23xx stations store records in a circular buffer — once the +buffer fills, the oldest records are replaced by newer records. +The console stores up to 175 records. + +For example, to display the latest 30 records from the console memory: + + weectl device --history=30 + +### `--clear-memory` {id=ws23xx_clear_memory} + +To clear the console memory: + + weectl device --clear-memory + +## Station data {id=ws23xx_data} + +The following table shows which data are provided by the station hardware +and which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WS23xx station data
Database FieldObservationLoopArchive
barometerSS
pressurepressureHH
altimeterSS
inTemptemperature_inHH
outTemptemperature_outHH
inHumidityhumidity_inHH
outHumidityhumidity_outHH
windSpeedwind_speedHH
windDirwind_dirHH
rainrainDD
rain_totalHH
rainRaterain_rateHH
dewpointdewpointHH
windchillwindchillHH
heatindexSS
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

+ diff --git a/dist/weewx-5.0.2/docs_src/hardware/ws28xx.md b/dist/weewx-5.0.2/docs_src/hardware/ws28xx.md new file mode 100644 index 0000000..4478c91 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/hardware/ws28xx.md @@ -0,0 +1,371 @@ +# WS28xx {id=ws28xx_notes} + +WeeWX communicates with a USB transceiver, which communicates with the station +console, which in turn communicates with the sensors. The transceiver and +console must be paired and synchronized. + +The sensors send data at different rates: + + + + + + + + + + +
WS28xx transmission periods
sensorperiod
Wind17 seconds
T/H13 seconds
Rain19 seconds
Pressure15 seconds
+ +The wind and rain sensors transmit to the temperature/humidity device, then +the temperature/humidity device retransmits to the weather station console. +Pressure is measured by a sensor in the console. + +The station has 1797 history records. That is just over 6 days of data with +an archive interval of 5 minutes. + +When WeeWX starts up it will attempt to download all records from the console +since the last record in the archive database. + +The WS28xx driver sets the station archive interval to 5 minutes. + +The WS28xx driver does not support hardware archive record generation. + +## Pairing {id=ws28xx_pairing} + +The console and transceiver must be paired. Pairing ensures that your +transceiver is talking to your console, not your neighbor's console. Pairing +should only have to be done once, although you might have to pair again after +power cycling the console, for example after you replace the batteries. + +There are two ways to pair the console and the transceiver: + +* _The WeeWX way._ Be sure that WeeWX is not running. Run the configuration + utility, press and hold the [v] button on the console until you see 'PC' + in the display, then release the button. If the console pairs with the + transceiver, 'PC' will go away within a second or two. +``` +weectl device --pair + +Pairing transceiver with console... +Press and hold the [v] key until "PC" appears (attempt 1 of 3) +Transceiver is paired to console +``` + +* _The HeavyWeather way._ Follow the pairing instructions that came with the + station. You will have to run HeavyWeather on a Windows computer with the + USB transceiver. After HeavyWeather indicates the devices are paired, put + the USB transceiver in your WeeWX computer and start WeeWX. Do not power + cycle the station console, or you will have to start over. + +If the console does not pair, you will see messages in the log such as this: + + ws28xx: RFComm: message from console contains unknown device ID (id=165a resp=80 req=6) + +Either approach to pairing may require multiple attempts. + +## Synchronizing {id=ws28xx_synchronizing} + +After pairing, the transceiver and console must be synchronized in order to +communicate. Synchronization will happen automatically at the top of each +hour, or you can force synchronization by pressing the [SET] button +momentarily. Do not press and hold the [SET] button — that modifies +the console alarms. + +When the transceiver and console are synchronized, you will see lots of +`ws28xx: RFComm` messages in the log when `debug=1`. When the devices are +not synchronized, you will see messages like this about every 10 minutes: + + Nov 7 19:12:17 raspi weewx[2335]: ws28xx: MainThread: no contact with console + +If you see this, or if you see an extended gap in the weather data in the +WeeWX plots, press momentarily the [SET] button, or wait until the top of +the hour. + +When the transceiver has not received new data for awhile, you will see +messages like this in the log: + + Nov 7 19:12:17 raspi weewx[2335]: ws28xx: MainThread: no new weather data + +If you see 'no new weather data' messages with the 'no contact with console' +messages, it simply means that the transceiver has not been able to +synchronize with the console. If you see only the 'no new weather data' +messages, then the sensors are not communicating with the console, or the +console may be defective. + +## Alarms {id=ws28xx_alarms} + +When an alarm goes off, communication with the transceiver stops. The WS28xx +driver clears all alarms in the station. It is better to create alarms in +WeeWX, and the WeeWX alarms can do much more than the console alarms anyway. + +## Configuring with `weectl device` {id=ws28xx_configuration} + +The WS28xx stations can be configured with the utility +[`weectl device`](../utilities/weectl-device.md). + +!!! Note + Make sure you stop `weewxd` before running `weectl device`. + +### `--help` {id=ws28xx_help} + +Invoking `weectl device` with the `--help` option + + weectl device --help + +will produce something like this: + +``` + WS28xx driver version 0.33 + Usage: weectl device [config_file] [options] [--debug] [--help] + + Configuration utility for weewx devices. + + Options: + -h, --help show this help message and exit + --debug display diagnostic information while running + -y answer yes to every prompt + --check-transceiver check USB transceiver + --pair pair the USB transceiver with station console + --info display weather station configuration + --set-interval=N set logging interval to N minutes + --current get the current weather conditions + --history=N display N history records + --history-since=N display history records since N minutes ago + --maxtries=MAXTRIES maximum number of retries, 0 indicates no max + + Mutating actions will request confirmation before proceeding. +``` + +### `--pair` + +The console and transceiver must be paired. This can be done either +by using this command, or by running the program HeavyWeather on a +Windows PC. + +Be sure that WeeWX is not running. Run the command: + +``` +weectl device --pair + +Pairing transceiver with console... +Press and hold the [v] key until "PC" appears (attempt 1 of 3) +Transceiver is paired to console +``` + +Press and hold the [v] button on the console until you see 'PC' in +the display, then release the button. If the console pairs with +the transceiver, 'PC' will go away within a second or two. + +If the console does not pair, you will see messages in the log such as this: + + ws28xx: RFComm: message from console contains unknown device ID (id=165a resp=80 req=6) + +Pairing may require multiple attempts. + +After pairing, the transceiver and console must be synchronized in +order to communicate. This should happen automatically. + +### `--info` + +Display the station settings with the `--info` option. + + weectl device --info + +This will result in something like: + +``` + alarm_flags_other: 0 + alarm_flags_wind_dir: 0 + checksum_in: 1327 + checksum_out: 1327 + format_clock: 1 + format_pressure: 0 + format_rain: 1 + format_temperature: 0 + format_windspeed: 4 + history_interval: 1 + indoor_humidity_max: 70 + indoor_humidity_max_time: None + indoor_humidity_min: 45 + indoor_humidity_min_time: None + indoor_temp_max: 40.0 + indoor_temp_max_time: None + indoor_temp_min: 0.0 + indoor_temp_min_time: None + lcd_contrast: 4 + low_battery_flags: 0 + outdoor_humidity_max: 70 + outdoor_humidity_max_time: None + outdoor_humidity_min: 45 + outdoor_humidity_min_time: None + outdoor_temp_max: 40.0 + outdoor_temp_max_time: None + outdoor_temp_min: 0.0 + outdoor_temp_min_time: None + pressure_max: 1040.0 + pressure_max_time: None + pressure_min: 960.0 + pressure_min_time: None + rain_24h_max: 50.0 + rain_24h_max_time: None + threshold_storm: 5 + threshold_weather: 3 + wind_gust_max: 12.874765625 + wind_gust_max_time: None +``` + +## Station data {id=ws28xx_data} + +The following table shows which data are provided by the station +hardware and which are calculated by WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WS28xx station data
Database FieldObservationLoopArchive
barometerSS
pressurepressureHH
altimeterSS
inTemptemperature_inHH
outTemptemperature_outHH
inHumidityhumidity_inHH
outHumidityhumidity_outHH
windSpeedwind_speedHH
windDirwind_dirHH
windGustwind_gustHH
windGustDirwind_gust_dirHH
rainrainDD
rain_totalHH
rainRaterain_rateH
dewpointSS
windchillwindchillHH
heatindexheatindexHH
rxCheckPercentrssiH
windBatteryStatuswind_battery_statusH
rainBatteryStatusrain_battery_statusH
outTempBatteryStatusbattery_status_outH
inTempBatteryStatusbattery_status_inH
+ +

+ H indicates data provided by Hardware
+ D indicates data calculated by the Driver
+ S indicates data calculated by the StdWXCalculate Service
+

diff --git a/dist/weewx-5.0.2/docs_src/images/antialias.gif b/dist/weewx-5.0.2/docs_src/images/antialias.gif new file mode 100644 index 0000000..29f412f Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/antialias.gif differ diff --git a/dist/weewx-5.0.2/docs_src/images/day-gap-not-shown.png b/dist/weewx-5.0.2/docs_src/images/day-gap-not-shown.png new file mode 100644 index 0000000..dfba9ae Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/day-gap-not-shown.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/day-gap-showing.png b/dist/weewx-5.0.2/docs_src/images/day-gap-showing.png new file mode 100644 index 0000000..5868a49 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/day-gap-showing.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/daycompare.png b/dist/weewx-5.0.2/docs_src/images/daycompare.png new file mode 100644 index 0000000..effbe13 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/daycompare.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/daytemp_with_avg.png b/dist/weewx-5.0.2/docs_src/images/daytemp_with_avg.png new file mode 100644 index 0000000..530e29a Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/daytemp_with_avg.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/dayvaporp.png b/dist/weewx-5.0.2/docs_src/images/dayvaporp.png new file mode 100644 index 0000000..104b6d8 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/dayvaporp.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/daywindvec.png b/dist/weewx-5.0.2/docs_src/images/daywindvec.png new file mode 100644 index 0000000..caefe92 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/daywindvec.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/favicon.png b/dist/weewx-5.0.2/docs_src/images/favicon.png new file mode 100644 index 0000000..4026396 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/favicon.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/ferrites.jpg b/dist/weewx-5.0.2/docs_src/images/ferrites.jpg new file mode 100644 index 0000000..d6626ae Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/ferrites.jpg differ diff --git a/dist/weewx-5.0.2/docs_src/images/funky_degree.png b/dist/weewx-5.0.2/docs_src/images/funky_degree.png new file mode 100644 index 0000000..d22b2ee Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/funky_degree.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/image_parts.png b/dist/weewx-5.0.2/docs_src/images/image_parts.png new file mode 100644 index 0000000..79d5a68 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/image_parts.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/image_parts.xcf b/dist/weewx-5.0.2/docs_src/images/image_parts.xcf new file mode 100644 index 0000000..3b8708a Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/image_parts.xcf differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-apple.png b/dist/weewx-5.0.2/docs_src/images/logo-apple.png new file mode 100644 index 0000000..a9c1617 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-apple.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-centos.png b/dist/weewx-5.0.2/docs_src/images/logo-centos.png new file mode 100644 index 0000000..5afb7b4 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-centos.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-debian.png b/dist/weewx-5.0.2/docs_src/images/logo-debian.png new file mode 100644 index 0000000..7897b80 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-debian.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-fedora.png b/dist/weewx-5.0.2/docs_src/images/logo-fedora.png new file mode 100644 index 0000000..d8c5094 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-fedora.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-linux.png b/dist/weewx-5.0.2/docs_src/images/logo-linux.png new file mode 100644 index 0000000..d86f9dc Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-linux.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-mint.png b/dist/weewx-5.0.2/docs_src/images/logo-mint.png new file mode 100644 index 0000000..d4a9513 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-mint.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-opensuse.png b/dist/weewx-5.0.2/docs_src/images/logo-opensuse.png new file mode 100644 index 0000000..b14d575 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-opensuse.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-pypi.svg b/dist/weewx-5.0.2/docs_src/images/logo-pypi.svg new file mode 100644 index 0000000..6b8b45d --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/images/logo-pypi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/images/logo-redhat.png b/dist/weewx-5.0.2/docs_src/images/logo-redhat.png new file mode 100644 index 0000000..4ff65f4 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-redhat.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-rpi.png b/dist/weewx-5.0.2/docs_src/images/logo-rpi.png new file mode 100644 index 0000000..738cf3d Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-rpi.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-suse.png b/dist/weewx-5.0.2/docs_src/images/logo-suse.png new file mode 100644 index 0000000..7fb9047 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-suse.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-ubuntu.png b/dist/weewx-5.0.2/docs_src/images/logo-ubuntu.png new file mode 100644 index 0000000..0c5b4e7 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-ubuntu.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/logo-weewx.png b/dist/weewx-5.0.2/docs_src/images/logo-weewx.png new file mode 100644 index 0000000..0221ca9 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/logo-weewx.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/pipeline.png b/dist/weewx-5.0.2/docs_src/images/pipeline.png new file mode 100644 index 0000000..c9e20c0 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/pipeline.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/sample_monthrain.png b/dist/weewx-5.0.2/docs_src/images/sample_monthrain.png new file mode 100644 index 0000000..0282144 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/sample_monthrain.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/sample_monthtempdew.png b/dist/weewx-5.0.2/docs_src/images/sample_monthtempdew.png new file mode 100644 index 0000000..c6a4287 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/sample_monthtempdew.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/weekgustoverlay.png b/dist/weewx-5.0.2/docs_src/images/weekgustoverlay.png new file mode 100644 index 0000000..cb9a652 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/weekgustoverlay.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/weektempdew.png b/dist/weewx-5.0.2/docs_src/images/weektempdew.png new file mode 100644 index 0000000..00041c8 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/weektempdew.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/yeardiff.png b/dist/weewx-5.0.2/docs_src/images/yeardiff.png new file mode 100644 index 0000000..aa0c377 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/yeardiff.png differ diff --git a/dist/weewx-5.0.2/docs_src/images/yearhilow.png b/dist/weewx-5.0.2/docs_src/images/yearhilow.png new file mode 100644 index 0000000..0776a87 Binary files /dev/null and b/dist/weewx-5.0.2/docs_src/images/yearhilow.png differ diff --git a/dist/weewx-5.0.2/docs_src/index.md b/dist/weewx-5.0.2/docs_src/index.md new file mode 100644 index 0000000..8470be9 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/index.md @@ -0,0 +1,102 @@ +# Introduction to WeeWX + +The WeeWX weather system is written in Python and runs on Linux, MacOSX, +Solaris, and *BSD. It collects data from many types of weather stations and +sensors, then generates plots, HTML pages, and monthly and yearly summary +reports. It can push plots, pages, and reports to a web server, and data to +many online weather services. + +Initial development began in the winter of 2008-2009, with the first release in +2009. WeeWX is about 25,000 lines of code, plus another 15,000 for the hardware +drivers. + +The source code is hosted on [GitHub](https://github.com/weewx/weewx). +Installation instructions and releases are available at +[weewx.com/downloads](http://weewx.com/downloads). + +See the [hardware list](https://weewx.com/hardware.html) for a complete list +of supported stations, and for pictures to help identify your hardware! The +[hardware comparison](https://weewx.com/hwcmp.html) shows specifications for +many types of hardware, including some not yet supported by WeeWX. + +The WeeWX distribution includes drivers for many types of hardware. These +are listed in the driver list in the [Hardware Guide](hardware/drivers.md). +See the [WeeWX Wiki](https://github.com/weewx/weewx/wiki) for additional +drivers and other extensions. + + +## Installation + +If you're an old hand at installing software on Unix systems, you may be able +to use one of our _Quickstart guides_: + +* [Debian](quickstarts/debian.md) - including Ubuntu, Mint, Raspberry Pi + OS, Devuan. Uses `apt`. +* [Redhat](quickstarts/redhat.md) - including Fedora, CentOS, Rocky. Uses + `yum`. +* [SUSE](quickstarts/suse.md) - including openSUSE. Uses `zypper`. +* [pip](quickstarts/pip.md) - any operating system. Uses `pip` +* [git](quickstarts/git.md) - any operating system. Run directly from + repository. + +Otherwise, see the section [_Installing WeeWX_](usersguide/installing.md) in +the _User's Guide_. + +## Documentation + +WeeWX includes extensive documentation, and the WeeWX developers work hard to +keep it relevant and up to date. If you have questions, please consult the +documentation first. + +* [User's Guide](usersguide/introduction.md) - installation, getting started, + where to find things, backup/restore, troubleshooting +* [Customization Guide](custom/introduction.md) - instructions for customizing + reports and plots, localization, formatting, writing extensions +* [Utilities Guide](utilities/weewxd.md) - tools to manage stations, reports, + and data +* [Hardware Guide](hardware/drivers.md) - how to configure hardware, features + of supported hardware +* [Upgrade Guide](upgrade.md) - detailed changes in each release +* [Reference](reference/weewx-options/introduction.md) - application options, + skin options, definition of units and unit systems +* [Notes for developers](devnotes.md) - things you should know if you write + drivers or skins + +## Support + +Please first try to solve any problems yourself by reading the documentation. +If that fails, check the answers to frequently-asked questions, browse the +latest guides and software in the WeeWX Wiki, or post a question to the WeeWX +user group. + +### FAQ + +The Frequently Asked Questions (FAQ) is contributed by WeeWX users. It contains +pointers to more information for problems and questions most frequently asked +in the WeeWX forums. + +https://github.com/weewx/weewx/wiki/WeeWX-Frequently-Asked-Questions + +### Wiki + +The wiki content is contributed by WeeWX users. It contains suggestions and +experiences with different types of hardware, skins, extensions to WeeWX, and +other useful tips and tricks. + +https://github.com/weewx/weewx/wiki + +### Forums + +[weewx-user](https://groups.google.com/group/weewx-user) is for general issues +such as installation, sharing skins and templates, reporting unexpected +behavior, and suggestions for improvement. + +[weewx-development](https://groups.google.com/group/weewx-development) is for +discussions about developing drivers, extensions, or working on the core code. + +## Licensing and Copyright + +WeeWX is licensed under the GNU Public License v3. + +© [Copyright](copyright.md) 2009-2024 Thomas Keffer, Matthew Wall, and Gary +Roderick diff --git a/dist/weewx-5.0.2/docs_src/quickstarts/debian.md b/dist/weewx-5.0.2/docs_src/quickstarts/debian.md new file mode 100644 index 0000000..4cfa8a3 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/quickstarts/debian.md @@ -0,0 +1,188 @@ +# Installation on Debian systems + +This is a guide to installing WeeWX from a DEB package on systems based on +Debian, including Ubuntu, Mint, and Raspberry Pi OS. + +WeeWX V5 requires Python 3.6 or greater, which is only available as a Debian +package, with required modules, on Debian 10 or later. For older systems, +install Python 3 then [install WeeWX using pip](pip.md). + + +## Configure `apt` + +The first time you install WeeWX, you must configure `apt` so that it will +trust weewx.com, and know where to find the WeeWX releases. + +1. Tell your system to trust weewx.com. + + ```{.shell .copy} + sudo apt install -y wget gnupg + wget -qO - https://weewx.com/keys.html | \ + sudo gpg --dearmor --output /etc/apt/trusted.gpg.d/weewx.gpg + ``` + +2. Tell `apt` where to find the WeeWX repository. + + ```{.shell .copy} + echo "deb [arch=all] https://weewx.com/apt/python3 buster main" | \ + sudo tee /etc/apt/sources.list.d/weewx.list + ``` + + +## Install + +Use `apt` to install WeeWX. The installer will prompt for a location, +latitude/longitude, altitude, station type, and parameters specific to your +station hardware. When you are done, WeeWX will be running in the background. + +```{.shell .copy} +sudo apt update +sudo apt install weewx +``` + + +## Verify + +After about 5 minutes (the exact length of time depends on your archive +interval), copy the following and paste into a web browser. You should see +your station information and data. + +```{.copy} +/var/www/html/weewx/index.html +``` + +If things are not working as you think they should, check the status: +```{.shell .copy} +sudo systemctl status weewx +``` +and check the [system log](../usersguide/monitoring.md#log-messages): +```{.shell .copy} +sudo journalctl -u weewx +``` +See the [*Troubleshooting*](../usersguide/troubleshooting/what-to-do.md) +section of the [*User's guide*](../usersguide/introduction.md) for more help. + + +## Configure + +If you chose the simulator as your station type, then at some point you will +probably want to switch to using real hardware. This is how to reconfigure. + +```{.shell .copy} +# Stop the daemon +sudo systemctl stop weewx +# Reconfigure to use your hardware +weectl station reconfigure +# Delete the old database +rm /var/lib/weewx/weewx.sdb +# Start the daemon +sudo systemctl start weewx +``` + + +## Customize + +To enable uploads, or to enable other reports, modify the configuration file +`/etc/weewx/weewx.conf` using any text editor such as `nano`: + +```{.shell .copy} +nano /etc/weewx/weewx.conf +``` + +The reference +[*Application options*](../reference/weewx-options/introduction.md) +contains an extensive list of the configuration options, with explanations for +what they do. For more advanced customization, see the [*Customization +Guide*](../custom/introduction.md), as well as the reference [*Skin +options*](../reference/skin-options/introduction.md). + +To install new skins, drivers, or other extensions, use the [extension +utility](../utilities/weectl-extension.md). + +WeeWX must be restarted for the changes to take effect. +```{.shell .copy} +sudo systemctl restart weewx +``` + + +## Upgrade + +Upgrade to the latest version like this: + +```{.shell .copy} +sudo apt update +sudo apt install weewx +``` + +The upgrade process will only upgrade the WeeWX software; it does not modify +the configuration file, database, or any extensions you may have installed. + +If modifications have been made to the configuration file or the skins that +come with WeeWX, you will be prompted whether you want to keep the existing, +modified files, or accept the new files. Either way, a copy of the option you +did not choose will be saved. + +For example, if `/etc/weewx/weewx.conf` was modified, you will see a message +something like this: + +``` +Configuration file `/etc/weewx/weewx.conf' + ==> Modified (by you or by a script) since installation. + ==> Package distributor has shipped an updated version. + What would you like to do about it ? Your options are: + Y or I : install the package maintainer's version + N or O : keep your currently-installed version + D : show the differences between the versions + Z : start a shell to examine the situation + The default action is to keep your current version. +*** weewx.conf (Y/I/N/O/D/Z) [default=N] ? +``` + +Choosing `Y` or `I` (install the new version) will place the old +configuration in `/etc/weewx/weewx.conf.dpkg-old`, where it can be +compared with the new version in `/etc/weewx/weewx.conf`. + +Choosing `N` or `O` (keep the current version) will place the new +configuration in `/etc/weewx/weewx.conf.X.Y.Z`, where `X.Y.Z` is the +new version number. It can then be compared with your old version which +will be in `/etc/weewx/weewx.conf`. + +!!! Note + In most cases you should choose `N` (the default). Since WeeWX releases + are almost always backward-compatible with configuration files and skins, + choosing to keep the currently-installed version will ensure that your + system works as it did before the upgrade. After the upgrade, you can + compare the new files to your existing, operational files at your leisure. + + +## Uninstall + +To uninstall WeeWX, but retain configuration files and data: + +```{.shell .copy} +sudo apt remove weewx +``` + +To uninstall WeeWX, deleting configuration files but retaining data: + +```{.shell .copy} +sudo apt purge weewx +``` + +When you use `apt` to uninstall WeeWX, it does not touch WeeWX data, logs, +or any changes you might have made to the WeeWX configuration. It also leaves +the `weewx` user, because data and configuration files were owned by that user. +To remove every trace of WeeWX: + +```{.shell .copy} +sudo apt purge weewx +sudo rm -r /var/www/html/weewx +sudo rm -r /var/lib/weewx +sudo rm -r /etc/weewx +sudo rm /etc/default/weewx +sudo rm /etc/apt/trusted.gpg.d/weewx.gpg +sudo rm /etc/apt/sources.list.d/weewx.list +sudo userdel weewx +sudo gpasswd -d $USER weewx +sudo groupdel weewx +``` diff --git a/dist/weewx-5.0.2/docs_src/quickstarts/git.md b/dist/weewx-5.0.2/docs_src/quickstarts/git.md new file mode 100644 index 0000000..f5d499e --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/quickstarts/git.md @@ -0,0 +1,188 @@ +# Running WeeWX from a git repository + +Because WeeWX is pure-Python and does not need to be compiled, it can be run +directly from a git repository without "installing" it first. This approach is +perhaps most appropriate for developers, but it is also useful on older +operating systems or on platforms with tight memory and/or storage constraints. + +This technique can also be used to run from a source directory expanded from a +zip/tar file. + +Although you do not need root privileges to run WeeWX this way, you will need +them to set up a daemon and, perhaps, to change device permissions. + +## Install pre-requisites + +Before starting, you must install the pre-requisite Python and Python modules. + +1. Ensure that Python 3.6 or later is installed. + +2. Ensure that `pip` and `venv` are installed. + +3. Create and activate a virtual environment in your home directory: + + ``` {.shell .copy} + python3 -m venv ~/weewx-venv + source ~/weewx-venv/bin/activate + ``` + +4. Install the minimum WeeWX dependencies: + + ``` {.shell .copy} + python3 -m pip install CT3 + python3 -m pip install configobj + python3 -m pip install Pillow + ``` + +5. If you are running Python 3.6, you must install a backport to allow +importation of package resources: + + ``` {.shell .copy} + # for Python 3.6 only: + python3 -m pip install importlib-resources + ``` + +6. Depending on your situation, you may want to install these additional +dependencies: + + ``` {.shell .copy} + # If your hardware uses a serial port + python3 -m pip install pyserial + # If your hardware uses a USB port + python3 -m pip install pyusb + # If you want extended celestial information: + python3 -m pip install ephem + # If you use MySQL or Maria + python3 -m pip install "PyMySQL[rsa]" + ``` + +## Get the code + +Use `git` to clone the WeeWX repository into a directory called `weewx` in +your home directory: + +```{.shell .copy} +git clone https://github.com/weewx/weewx ~/weewx +``` + +!!! Note + For systems with very little space, you may want to create a *shallow* + clone: + ``` {.shell .copy} + git clone --depth 1 https://github.com/weewx/weewx ~/weewx + ``` + +!!! Note + Of course, the directory you clone into does not have to be `~/weewx`. + It can be any directory. Just be sure to replace `~/weewx` with your + directory's path in the rest of the instructions. + + +## Provision a new station + +Now that you have the prerequisites and the WeeWX code, you can provision a +new station: + +```{.shell .copy} +# If necessary, activate the WeeWX virtual environment +source ~/weewx-venv/bin/activate +# Provision a new station +python3 ~/weewx/src/weectl.py station create +``` + +The tool `weectl` will ask you a series of questions, then create a directory +`weewx-data` in your home directory with a new configuration file. It will also +install skins, utilitiy files, and examples in the same directory. The database +and reports will also go into that directory, but only after you run `weewxd`, +as shown in the following step. + + +## Run `weewxd` + +The program `weewxd` does the data collection, archiving, uploading, and report +generation. You can run it directly, or as a daemon. + +When you run `weewxd` directly, it will print data to the screen. It will +stop when you log out, or when you terminate it with `control-c`. + +```{.shell .copy} +# If necessary, activate the WeeWX virtual environment +source ~/weewx-venv/bin/activate +# Run weewxd +python3 ~/weewx/src/weewxd.py +``` + +To run `weewxd` as a daemon, install an init configuration that is appropriate +to your operating system, e.g., systemd service unit, SysV init script, or +launchd control file. Be sure to use the full path to the Python interpreter +and `weewxd.py` - the paths in the virtual environment. Examples are included +in the directory `~/weewx-data/util`. + + +## Verify + +After about 5 minutes (the exact length of time depends on your archive +interval), copy the following and paste into a web browser. You should see +your station information and data. + + ~/weewx-data/public_html/index.html + +!!! Note + Not all browsers understand the tilde ("`~`") mark. You may + have to substitute an explicit path to your home directory, + for example, `file:///home/jackhandy` instead of `~`. + +If you have problems, check the [system +log](../usersguide/monitoring.md#log-messages). See the +[*Troubleshooting*](../usersguide/troubleshooting/what-to-do.md) section of the +[*User's guide*](../usersguide/introduction.md) for more help. + + +## Customize + +To enable uploads, or to enable other reports, modify the configuration file +`~/weewx-data/weewx.conf` using any text editor such as `nano`: + +```{.shell .copy} +nano ~/weewx-data/weewx.conf +``` + +The reference +[*Application options*](../reference/weewx-options/introduction.md) +contains an extensive list of the configuration options, with explanations for +what they do. For more advanced customization, see the [*Customization +Guide*](../custom/introduction.md), as well as the reference [*Skin +options*](../reference/skin-options/introduction.md). + +To install new skins, drivers, or other extensions, use the [extension +utility](../utilities/weectl-extension.md). + +The executable `weewxd` must be restarted for the changes to take effect. + + +## Upgrade + +Update the code by pulling the latest: + +```{.shell .copy} +cd ~/weewx && git pull +``` + +Then restart `weewxd` + + +## Uninstall + +Before you uninstall, be sure that `weewxd` is not running. + +Then simply delete the git clone: + +```shell +rm -rf ~/weewx +``` + +If desired, delete the data directory: + +```shell +rm -r ~/weewx-data +``` diff --git a/dist/weewx-5.0.2/docs_src/quickstarts/index.md b/dist/weewx-5.0.2/docs_src/quickstarts/index.md new file mode 100644 index 0000000..f17b658 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/quickstarts/index.md @@ -0,0 +1,13 @@ +# Quickstarts + +If you're an old hand at installing software on Unix systems, you may be able to +use one of our _Quickstart guides_: + +* [Debian](debian.md) - including Ubuntu, Mint, Raspberry Pi + OS, Devuan. Uses `apt`. +* [Redhat](redhat.md) - including Fedora, CentOS, Rocky. Uses `yum`. +* [SUSE](suse.md) - including openSUSE. Uses `zypper`. +* [pip](pip.md) - any operating system. Uses `pip` +* [git](git.md) - any operating system. Run directly from repository. + +For detailed installation instructions, see the [*Users guide*](../usersguide/installing.md) diff --git a/dist/weewx-5.0.2/docs_src/quickstarts/pip.md b/dist/weewx-5.0.2/docs_src/quickstarts/pip.md new file mode 100644 index 0000000..2cde5d9 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/quickstarts/pip.md @@ -0,0 +1,394 @@ +# Installation using pip + +This is a guide to installing WeeWX using [`pip`](https://pip.pypa.io), the +*Preferred Installer Program* for Python. It can be used on almost any +operating system that offers Python v3.6 or greater. Python 2, or earlier +versions of Python 3, will not work. + +Although you do not need root privileges to install and configure WeeWX using +`pip`, you will need them to set up a daemon, and you may need them to change +device permissions. + +## Install `pip` and `venv` + +While there are many ways to install WeeWX using `pip` (see the wiki document +[_`pip` install strategies_](https://github.com/weewx/weewx/wiki/pip-install-strategies) +for a partial list), we recommend creating a _Python virtual environment_, +because it is the least likely to disturb the rest of your system. It is +worth reading about [`venv`](https://docs.python.org/3/library/venv.html), +the module used to create a virtual environment, in the Python3 documentation. + +To ensure that your Python has both `pip` and `venv`, follow the instructions +below for your system. For details, see the document [Installing `pip` with +Linux Package Managers](https://packaging.python.org/en/latest/guides/installing-using-linux-tools/). + + +=== "Debian" + + ```{ .shell .copy } + sudo apt update + sudo apt install python3-pip -y + sudo apt install python3-venv -y + ``` + _Tested with Debian 10, 12, RPi OS 32-bit, Ubuntu 20.04, and 22.04._ + +=== "Redhat 8" + + ```{ .shell .copy } + sudo yum update + sudo yum install python3-importlib-resources + sudo yum install python3-pip -y + sudo yum install python3-venv -y + ``` + _Tested with Rocky 8.7._ + +=== "Redhat 9" + + ```{ .shell .copy } + sudo yum update + sudo yum install python3-pip -y + ``` + _Tested with Rocky 9.1 and 9.2._ + +=== "openSUSE" + + ```{ .shell .copy } + sudo zypper refresh + sudo zypper install python3-importlib_resources + sudo zypper install python3-pip -y + sudo zypper install python3-venv -y + ``` + _Tested with openSUSE Leap 15.5._ + +=== "FreeBSD" + + ```{ .shell .copy } + sudo pkg install py39-pip + sudo pkg install py39-sqlite3 + sudo pkg install py39-Pillow + ``` + _Tested with FreeBSD 13.2 and 14.0_ + + _On BSD systems, it is easier to use the packaged py39-Pillow than it is + to do a pip install of Pillow into a virtual environment, since the latter + requires many other development packages_ + +=== "macOS" + + ```{ .shell .copy } + # There is no step 1! + # The python3 included with macOS already has pip and venv installed + ``` + _Tested on macOS 13.4 (Ventura)_ + + +## Install in a virtual environment + +To install WeeWX in a virtual environment, follow the directions below. + +```{ .shell .copy } +# Create the virtual environment +python3 -m venv ~/weewx-venv +# Activate the WeeWX virtual environment +source ~/weewx-venv/bin/activate +# Install WeeWX into the virtual environment +python3 -m pip install weewx +``` + +When you are finished, the WeeWX executables and dependencies will have been +installed inside the virtual environment. + +If you have any problems, see the wiki article +[_Troubleshooting pip installs_](https://github.com/weewx/weewx/wiki/pip-troubleshooting) +for help. + +## Provision a new station + +While the instructions above install WeeWX, they do not set up the +configuration specific to your station, nor do they set up the reporting +skins. That is the job of the tool `weectl`. + +```{ .shell .copy } +# Activate the WeeWX virtual environment +source ~/weewx-venv/bin/activate +# Create the station data +weectl station create +``` + +The tool `weectl` will ask you a series of questions, then create a directory +`weewx-data` in your home directory with a new configuration file, skins, +utility files, and examples. The database and reports will also go into that +directory, but only after you run `weewxd`, as shown in the following step. + + +## Run `weewxd` + +The program `weewxd` does the data collection, archiving, uploading, and +report generation. You can run it directly, or as a daemon. + + +### Run directly + +When you run WeeWX directly, it will print data to the screen. WeeWX will +stop when you log out, or when you terminate it with `control-c`. + +```{ .shell .copy } +# Activate the WeeWX virtual environment +source ~/weewx-venv/bin/activate +# Run weewxd +weewxd +``` + +### Run as a daemon + +To make WeeWX start when the system is booted, you will want to run `weewxd` +as a daemon. Run the daemon setup script to configure your system. This script +installs the startup/shutdown configuration appropriate to your operating +system. You will need root privileges to do this. + +```{ .shell .copy } +sudo sh ~/weewx-data/scripts/setup-daemon.sh +``` + +Then follow the directions below to start `weewxd` as a daemon. + +=== "systemd" + + ```{ .shell .copy } + # For Linux systems that use systemd, e.g., Debian, Redhat, and SUSE. + sudo systemctl start weewx + ``` + +=== "SysV" + + ```{ .shell .copy } + # For Linux systems that use SysV init, e.g., Slackware, Devuan, and Puppy. + sudo /etc/init.d/weewx start + ``` + +=== "BSD" + + ```{ .shell .copy } + # For BSD systems, e.g., FreeBSD and OpenBSD. + sudo service weewx start + ``` + +=== "macOS" + + ```{ .shell .copy } + # For macOS systems. + sudo launchctl load /Library/LaunchDaemons/com.weewx.weewxd.plist + ``` + + +## Verify + +After about 5 minutes (the exact length of time depends on your archive +interval), copy the following and paste into a web browser. You should +see your station information and data. + + ~/weewx-data/public_html/index.html + +!!! Note + Not all browsers understand the tilde ("`~`") mark. You may + have to substitute an explicit path to your home directory, + for example, `file:///home/jackhandy` instead of `~`. + +If you have problems, check the +[system log](../usersguide/monitoring.md#log-messages). +See the [*Troubleshooting*](../usersguide/troubleshooting/what-to-do.md) +section of the [*User's guide*](../usersguide/introduction.md) for more help. + + +## Configure + +If you chose the simulator as your station type, then at some point you will +probably want to switch to using real hardware. This is how to reconfigure. + +=== "systemd" + + ```{ .shell .copy } + # Stop the weewx daemon: + sudo systemctl stop weewx + # Activate the WeeWX virtual environment + source ~/weewx-venv/bin/activate + # Reconfigure to use your hardware + weectl station reconfigure + # Remove the old database + rm ~/weewx-data/archive/weewx.sdb + # Start the weewx daemon + sudo systemctl start weewx + ``` + +=== "SysV" + + ```{ .shell .copy } + # Stop the weewx daemon: + sudo /etc/init.d/weewx stop + # Activate the WeeWX virtual environment + source ~/weewx-venv/bin/activate + # Reconfigure to use your hardware + weectl station reconfigure + # Remove the old database + rm ~/weewx-data/archive/weewx.sdb + # Start the weewx daemon + sudo /etc/init.d/weewx start + ``` + +=== "BSD" + + ```{ .shell .copy } + # Stop the weewx daemon: + sudo service weewx stop + # Activate the WeeWX virtual environment + source ~/weewx-venv/bin/activate + # Reconfigure to use your hardware + weectl station reconfigure + # Remove the old database + rm ~/weewx-data/archive/weewx.sdb + # Start the weewx daemon + sudo service weewx start + ``` + +=== "macOS" + + ```{ .shell .copy } + # Stop the weewx daemon: + sudo launchctl unload /Library/LaunchDaemons/com.weewx.weewxd.plist + # Activate the WeeWX virtual environment + source ~/weewx-venv/bin/activate + # Reconfigure to use your hardware + weectl station reconfigure + # Remove the old database + rm ~/weewx-data/archive/weewx.sdb + # Start the weewx daemon + sudo launchctl load /Library/LaunchDaemons/com.weewx.weewxd.plist + ``` + + +## Customize + +To enable uploads, or to enable other reports, modify the configuration file +`~/weewx-data/weewx.conf` using any text editor such as `nano`: + +```{.shell .copy} +nano ~/weewx-data/weewx.conf +``` + +The reference +[*Application options*](../reference/weewx-options/introduction.md) +contains an extensive list of the configuration options, with explanations for +what they do. For more advanced customization, see the [*Customization +Guide*](../custom/introduction.md), as well as the reference [*Skin +options*](../reference/skin-options/introduction.md). + +To install new skins, drivers, or other extensions, use the [extension +utility](../utilities/weectl-extension.md). + +WeeWX must be restarted for the changes to take effect. + +=== "systemd" + + ```{ .shell .copy } + sudo systemctl restart weewx + ``` + +=== "SysV" + + ```{ .shell .copy } + sudo /etc/init.d/weewx stop + sudo /etc/init.d/weewx start + ``` + +=== "BSD" + + ```{ .shell .copy } + sudo service weewx stop + sudo service weewx start + ``` + +=== "macOS" + + ```{ .shell .copy } + sudo launchctl unload /Library/LaunchDaemons/com.weewx.weewxd.plist + sudo launchctl load /Library/LaunchDaemons/com.weewx.weewxd.plist + ``` + + +## Upgrade + +Get the latest release using `pip`: + +```{ .shell .copy } +# Activate the WeeWX virtual environment +source ~/weewx-venv/bin/activate +# Upgrade the WeeWX code +python3 -m pip install weewx --upgrade +``` + +Optional: You may want to upgrade examples and utility files: +``` +weectl station upgrade --what examples util +``` + +Optional: You may want to upgrade your skins, although this may break or +remove modifications you have made to them. Your old skins will be saved +in a timestamped directory. +``` +weectl station upgrade --what skins +``` + +Optional: You may want to upgrade your configuration file. This is only +necessary in the rare case that a new WeeWX release is not backward +compatible with older configuration files. +``` +weectl station upgrade --what config +``` + + +## Uninstall + +Before you uninstall, be sure that `weewxd` is not running. + +=== "systemd" + + ```{ .shell .copy } + sudo systemctl stop weewx + ``` + +=== "SysV" + + ```{ .shell .copy } + sudo /etc/init.d/weewx stop + ``` + +=== "BSD" + + ```{ .shell .copy } + sudo service weewx stop + ``` + +=== "macOS" + + ```{ .shell .copy } + sudo launchctl unload /Library/LaunchDaemons/com.weewx.weewxd.plist + ``` + +If you installed a daemon configuration, remove it: + +```{ .shell .copy } +sudo sh ~/weewx-data/scripts/setup-daemon.sh uninstall +``` + +To delete the applications and code, remove the WeeWX virtual environment: + +```{ .shell .copy } +rm -r ~/weewx-venv +``` + +Finally, if desired, to delete the database, skins, and other utilities, +remove the data directory: + +```{ .shell .copy } +rm -r ~/weewx-data +``` diff --git a/dist/weewx-5.0.2/docs_src/quickstarts/redhat.md b/dist/weewx-5.0.2/docs_src/quickstarts/redhat.md new file mode 100644 index 0000000..957f232 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/quickstarts/redhat.md @@ -0,0 +1,169 @@ +# Installation on Redhat systems + +This is a guide to installing WeeWX from an RPM package on systems based on +Redhat, including Fedora, CentOS, or Rocky. + +WeeWX V5 requires Python 3.6 or greater, which is only available as a Redhat +package, with required modules, on Redhat 8 or later. For older systems, +install Python 3 then [install WeeWX using pip](pip.md). + + +## Configure `yum` + +The first time you install WeeWX, you must configure `yum` so that it will +trust weewx.com, and know where to find the WeeWX releases. + +1. Configure `yum` to use `epel-release`, since some of the Python modules + required by WeeWX are in that respository. + ```{.shell .copy} + sudo dnf config-manager --set-enabled crb + sudo dnf -y install epel-release + ``` + +2. Tell your system to trust weewx.com: + + ```{.shell .copy} + sudo rpm --import https://weewx.com/keys.html + ``` + +3. Tell `yum` where to find the WeeWX repository. + + ```{.shell .copy} + curl -s https://weewx.com/yum/weewx.repo | \ + sudo tee /etc/yum.repos.d/weewx.repo + ``` + +!!! Note + If you are using Fedora, specify the repository that corresponds to your + Fedora release. + + For example, Fedora 28 should use Redhat 8 + ```{.shell .copy} + curl -s https://weewx.com/yum/weewx-el8.repo | \ + sudo tee /etc/yum.repos.d/weewx.repo + ``` + Fedora 34 should use Redhat 9 + ```{.shell .copy} + curl -s https://weewx.com/yum/weewx-el9.repo | \ + sudo tee /etc/yum.repos.d/weewx.repo + ``` + +## Install + +Install WeeWX using `yum` or `dnf`. When you are done, WeeWX will be running +the `Simulator` in the background. + +```{.shell .copy} +sudo yum install weewx +``` + + +## Verify + +After 5 minutes, copy the following and paste into a web browser. You should +see simulated data. + +```{.copy} +/var/www/html/weewx/index.html +``` + +If things are not working as you think they should, check the status: +```{.shell .copy} +sudo systemctl status weewx +``` +and check the [system log](../usersguide/monitoring.md#log-messages): +```{.shell .copy} +sudo journalctl -u weewx +``` +See the [*Troubleshooting*](../usersguide/troubleshooting/what-to-do.md) +section of the [*User's guide*](../usersguide/introduction.md) for more help. + + +## Configure + +To switch from the `Simulator` to real hardware, reconfigure the driver. + +```{.shell .copy} +# Stop the daemon +sudo systemctl stop weewx +# Reconfigure to use your hardware +weectl station reconfigure +# Delete the old database +rm /var/lib/weewx/weewx.sdb +# Start the daemon: +sudo systemctl start weewx +``` + + +## Customize + +To enable uploads, or to enable other reports, modify the configuration file +`/etc/weewx/weewx.conf` using any text editor such as `nano`: + +```{.shell .copy} +nano /etc/weewx/weewx.conf +``` + +The reference +[*Application options*](../reference/weewx-options/introduction.md) +contains an extensive list of the configuration options, with explanations for +what they do. For more advanced customization, see the [*Customization +Guide*](../custom/introduction.md), as well as the reference [*Skin +options*](../reference/skin-options/introduction.md). + +To install new skins, drivers, or other extensions, use the [extension +utility](../utilities/weectl-extension.md). + +WeeWX must be restarted for the changes to take effect. +```{.shell .copy} +sudo systemctl restart weewx +``` + + +## Upgrade + +Upgrade to the latest version like this: +```{.shell .copy} +sudo yum update weewx +``` + +The upgrade process will only upgrade the WeeWX software; it does not modify +the configuration file, database, or any extensions you may have installed. + +If modifications have been made to the configuration file or the skins that +come with WeeWX, you will see a message about any differences between the +modified files and the new files. Any new changes from the upgrade will be +noted as files with a `.rpmnew` extension, and the modified files will be left +untouched. + +For example, if `/etc/weewx/weewx.conf` was modified, you will see a message +something like this: + +``` +warning: /etc/weewx/weewx.conf created as /etc/weewx/weewx.conf.rpmnew +``` + + +## Uninstall + +To uninstall WeeWX, deleting configuration files but retaining data: + +```{.shell .copy} +sudo yum remove weewx +``` + +When you use `yum` to uninstall WeeWX, it does not touch WeeWX data, logs, +or any changes you might have made to the WeeWX configuration. It also leaves +the `weewx` user, since data and configuration files were owned by that user. +To remove every trace of WeeWX: + +```{.shell .copy} +sudo yum remove weewx +sudo rm -r /var/www/html/weewx +sudo rm -r /var/lib/weewx +sudo rm -r /etc/weewx +sudo rm /etc/default/weewx +sudo userdel weewx +sudo gpasswd -d $USER weewx +sudo groupdel weewx +``` diff --git a/dist/weewx-5.0.2/docs_src/quickstarts/suse.md b/dist/weewx-5.0.2/docs_src/quickstarts/suse.md new file mode 100644 index 0000000..ed85b88 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/quickstarts/suse.md @@ -0,0 +1,147 @@ +# Installation on SUSE systems + +This is a guide to installing WeeWX from an RPM package systems based on SUSE, +such as openSUSE Leap. + +WeeWX V5 requires Python 3.6 or greater, which is only available on SUSE-15 or +later. For older systems, install Python 3 then +[install WeeWX using pip](pip.md). + + +## Configure `zypper` + +The first time you install WeeWX, you must configure `zypper` so that it will +trust weewx.com, and know where to find the WeeWX releases. + +1. Tell your system to trust weewx.com: + + ```{.shell .copy} + sudo rpm --import https://weewx.com/keys.html + ``` + +2. Tell `zypper` where to find the WeeWX repository. + + ```{.shell .copy} + curl -s https://weewx.com/suse/weewx.repo | \ + sudo tee /etc/zypp/repos.d/weewx.repo + ``` + + +## Install + +Install WeeWX using `zypper`. When you are done, WeeWX will be running the +`Simulator` in the background. + +```{.shell .copy} +sudo zypper install weewx +``` + + +## Verify + +After 5 minutes, copy the following and paste into a web browser. You should +see simulated data. + +```{.copy} +/var/www/html/weewx/index.html +``` + +If things are not working as you think they should, check the status: +```{.shell .copy} +sudo systemctl status weewx +``` +and check the [system log](../usersguide/monitoring.md#log-messages): +```{.shell .copy} +sudo journalctl -u weewx +``` +See the [*Troubleshooting*](../usersguide/troubleshooting/what-to-do.md) +section of the [*User's guide*](../usersguide/introduction.md) for more help. + + +## Configure + +To switch from the `Simulator` to real hardware, reconfigure the driver. + +```{.shell .copy} +# Stop the daemon +sudo systemctl stop weewx +# Reconfigure to use your hardware +weectl station reconfigure +# Delete the old database +rm /var/lib/weewx/weewx.sdb +# Start the daemon +sudo systemctl start weewx +``` + + +## Customize + +To enable uploads, or to enable other reports, modify the configuration file +`/etc/weewx/weewx.conf` using any text editor such as `nano`: + +```{.shell .copy} +nano /etc/weewx/weewx.conf +``` + +The reference +[*Application options*](../reference/weewx-options/introduction.md) +contains an extensive list of the configuration options, with explanations for +what they do. For more advanced customization, see the [*Customization +Guide*](../custom/introduction.md), as well as the reference [*Skin +options*](../reference/skin-options/introduction.md). + +To install new skins, drivers, or other extensions, use the [extension +utility](../utilities/weectl-extension.md). + +WeeWX must be restarted for the changes to take effect. +```{.shell .copy} +sudo systemctl restart weewx +``` + + +## Upgrade + +Upgrade to the latest version like this: +```{.shell .copy} +sudo zypper update weewx +``` + +The upgrade process will only upgrade the WeeWX software; it does not modify +the configuration file, database, or any extensions you may have installed. + +If modifications have been made to the configuration file or the skins that +come with WeeWX, you will see a message about any differences between the +modified files and the new files. Any new changes from the upgrade will be +noted as files with a `.rpmnew` extension, and the modified files will be left +untouched. + +For example, if `/etc/weewx/weewx.conf` was modified, you will see a message +something like this: + +``` +warning: /etc/weewx/weewx.conf created as /etc/weewx/weewx.conf.rpmnew +``` + + +## Uninstall + +To uninstall WeeWX, deleting configuration files but retaining data: + +```{.shell .copy} +sudo zypper remove weewx +``` + +When you use `zypper` to uninstall WeeWX, it does not touch WeeWX data, logs, +or any changes you might have made to the WeeWX configuration. It also leaves +the `weewx` user, since data and configuration files were owned by that user. +To remove every trace of WeeWX: + +```{.shell .copy} +sudo zypper remove weewx +sudo rm -r /var/www/html/weewx +sudo rm -r /var/lib/weewx +sudo rm -r /etc/weewx +sudo rm /etc/default/weewx +sudo userdel weewx +sudo groupdel weewx +``` diff --git a/dist/weewx-5.0.2/docs_src/reference/aggtypes.md b/dist/weewx-5.0.2/docs_src/reference/aggtypes.md new file mode 100644 index 0000000..cbdfd7b --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/aggtypes.md @@ -0,0 +1,207 @@ +# Aggregation types + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aggregation types
Aggregation typeMeaning
avgThe average value in the aggregation period.
avg_ge(val)The number of days when the average value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
avg_le(val)The number of days when the average value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
countThe number of non-null values in the aggregation period. +
diffThe difference between the last and first value in the aggregation period. +
existsReturns True if the observation type exists in the database.
firstThe first non-null value in the aggregation period.
firsttimeThe time of the first non-null value in the aggregation period. +
gustdirThe direction of the max gust in the aggregation period. +
has_dataReturns True if the observation type + exists either in the database or as an xtype and is non-null. +
lastThe last non-null value in the aggregation period.
lasttimeThe time of the last non-null value in the aggregation period. +
maxThe maximum value in the aggregation period.
maxminThe maximum daily minimum in the aggregation period. Aggregation period must be one day or longer. +
maxmintimeThe time of the maximum daily minimum.
maxsumThe maximum daily sum in the aggregation period. Aggregation period must be one day or longer. +
maxsumtimeThe time of the maximum daily sum.
maxtimeThe time of the maximum value.
max_ge(val)The number of days when the maximum value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
max_le(val)The number of days when the maximum value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
meanmaxThe average daily maximum in the aggregation period. Aggregation period must be one day or longer. +
meanminThe average daily minimum in the aggregation period. Aggregation period must be one day or longer. +
minThe minimum value in the aggregation period.
minmaxThe minimum daily maximum in the aggregation period. Aggregation period must be one day or longer. +
minmaxtimeThe time of the minimum daily maximum.
minsumThe minimum daily sum in the aggregation period. Aggregation period must be one day or longer. +
minsumtimeThe time of the minimum daily sum.
mintimeThe time of the minimum value.
min_ge(val)The number of days when the minimum value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
min_le(val)The number of days when the minimum value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
not_null + Returns truthy if any value over the aggregation period is non-null. +
rmsThe root mean square value in the aggregation period. +
sumThe sum of values in the aggregation period.
sum_ge(val)The number of days when the sum of value is greater than or equal to val. Aggregation + period must be one day or longer. The argument val is a + ValueTuple. +
sum_le(val)The number of days when the sum of value is less than or equal to val. Aggregation period + must be one day or longer. The argument val is a + ValueTuple. +
tderiv + The time derivative between the last and first value in the aggregation period. This is the + difference in value divided by the difference in time. +
vecavgThe vector average speed in the aggregation period.
vecdirThe vector averaged direction during the aggregation period. +
diff --git a/dist/weewx-5.0.2/docs_src/reference/durations.md b/dist/weewx-5.0.2/docs_src/reference/durations.md new file mode 100644 index 0000000..9cd9e87 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/durations.md @@ -0,0 +1,31 @@ +# Durations + +Rather than give a value in seconds, many durations can be expressed using a shorthand notation. +For example, in a skin configuration file `skin.conf`, a value for `aggregate_interval` can be +given as either + + aggregate_interval = 3600 + +or + + aggregate_interval = 1h + +The same notation can be used in trends. For example: + +

Barometer trend over the last 2 hours: $trend(time_delta='2h').barometer

+ +Here is a summary of the notation: + +| Example | Meaning | +|---------|--------------------| +| `10800` | 3 hours | +| `3h` | 3 hours | +| `1d` | 1 day | +| `2w` | 2 weeks | +| `1m` | 1 month | +| `1y` | 1 year | +| `hour` | Synonym for `1h` | +| `day` | Synonym for `1d` | +| `week` | Synonym for `1w` | +| `month` | Synonym for `1m` | +| `year` | Synonym for `1y` | diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/almanac.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/almanac.md new file mode 100644 index 0000000..a14f277 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/almanac.md @@ -0,0 +1,10 @@ +# [Almanac] + +This section controls what text to use for the almanac. + +#### moon_phases + +This option is a comma separated list of labels to be used for the eight +phases of the moon. + +Default is `New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent`. diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/cheetahgenerator.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/cheetahgenerator.md new file mode 100644 index 0000000..2dbb672 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/cheetahgenerator.md @@ -0,0 +1,114 @@ +# [CheetahGenerator] + +This section contains the options for the Cheetah generator. It applies +to `skin.conf` only. + +#### search_list + +This is the list of search list objects that will be scanned by the +template engine, looking for tags. See the section *[Defining new +tags](../../custom/cheetah-generator.md#defining-new-tags)* and the [Cheetah +documentation](https://cheetahtemplate.org/) for details on search +lists. If no `search_list` is specified, a default list will be used. + +#### search_list_extensions + +This defines one or more search list objects that will be appended to +the `search_list`. For example, if you are using the +"seven day" and "forecast" search list extensions, the option would +look like + +``` ini +search_list_extensions = user.seven_day.SevenDay, user.forecast.ForecastVariables +``` + +#### encoding + +As Cheetah goes through the template, it substitutes strings for all tag +values. This option controls which encoding to use for the new strings. +The encoding can be chosen on a per-file basis. All the encodings +listed in the Python documentation [*Standard +Encodings*](https://docs.python.org/3/library/codecs.html#standard-encodings) +are available, as well as these WeeWX-specific encodings: + + + + + + + + + + + + + + + + + + + + +
EncodingComments
html_entities +Non 7-bit characters will be represented as HTML entities (e.g., the +degree sign will be represented as "&#176;") +
strict_asciiNon 7-bit characters will be ignored.
normalized_ascii +Replace accented characters with non-accented analogs (e.g., 'ö' will +be replaced with 'o'). +
+ +The encoding `html_entities` is the default. Other common choices are `utf8`, +`cp1252` (*a.k.a.* Windows-1252), and `latin1`. + +#### template + +The name of a template file. A template filename must end with `.tmpl`. +Filenames are case-sensitive. If the template filename has the letters `YYYY`, +`MM`, `WW` or `DD` in its name, these will be substituted for the year, month, +week and day of month, respectively. So, a template with the name +`summary-YYYY-MM.html.tmpl` would have name `summary-2010-03.html` for the +month of March 2010. + +#### generate_once + +When set to `True`, the template is processed only on the first +invocation of the report engine service. This feature is useful for +files that do not change when data values change, such as HTML files +that define a layout. The default is `False`. + +#### stale_age + +File staleness age, in seconds. If the file is older than this age it +will be generated from the template. If no `stale_age` is +specified, then the file will be generated every time the generator +runs. + +!!! Note + Precise control over when a run is available through use of the + `report_timing` option. The `report_timing` option uses a CRON-like + syntax to specify precisely when a report should be run. See the guide + *[Scheduling report generation](../../custom/report-scheduling.md)* + for details. + +## [[SummaryByDay]] + +The `SummaryByDay` section defines some special behavior. Each +template in this section will be used multiple times, each time with a +different per-day timespan. Be sure to include `YYYY`, +`MM`, and `DD` in the filename of any template in this +section. + +## [[SummaryByMonth]] + +The `SummaryByMonth` section defines some special behavior. Each +template in this section will be used multiple times, each time with a +different per-month timespan. Be sure to include `YYYY` and +`MM` in the filename of any template in this section. + +## [[SummaryByYear]] + +The `SummaryByYear` section defines some special behavior. Each +template in this section will be used multiple times, each time with a +different per-year timespan. Be sure to include `YYYY` in the +filename of any template in this section. diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/copygenerator.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/copygenerator.md new file mode 100644 index 0000000..2827e1e --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/copygenerator.md @@ -0,0 +1,35 @@ +# [CopyGenerator] + +This section is used by generator `weewx.reportengine.CopyGenerator` and +controls which files are to be copied over from the skin directory to the +destination directory. Think of it as "file generation," except that rather +than going through the template engine, the files are simply copied over. +It is useful for making sure CSS and Javascript files are in place. + +#### copy_once + +This option controls which files get copied over on the first invocation +of the report engine service. Typically, this is things such as style +sheets or background GIFs. Wildcards can be used. + +#### copy_always + +This is a list of files that should be copied on every invocation. +Wildcards can be used. + +Here is the `[CopyGenerator]` section from the Standard `skin.conf` + +``` ini +[CopyGenerator] + # This section is used by the generator CopyGenerator + + # List of files to be copied only the first time the generator runs + copy_once = backgrounds/*, weewx.css, mobile.css, favicon.ico + + # List of files to be copied each time the generator runs + # copy_always = +``` + +The Standard skin includes some background images, CSS files, and icons +that need to be copied once. There are no files that need to be copied +every time the generator runs. diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/extras.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/extras.md new file mode 100644 index 0000000..fb58fb1 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/extras.md @@ -0,0 +1,71 @@ +# [Extras] + +This section is available to add any static tags you might want to use +in your templates. + +As an example, the `skin.conf` file for the *Seasons* skin +includes three options: + + +| Skin option | Template tag | +|---------------------|-----------------------------| +| `radar_img` | `$Extras.radar_img` | +| `radar_url` | `$Extras.radar_url` | +| `googleAnalyticsId` | `$Extras.googleAnalyticsId` | + +If you take a look at the template `radar.inc` you will see +examples of testing for these tags. + +#### radar_img + +Set to an URL to show a local radar image for your region. + +#### radar_url + +If the radar image is clicked, the browser will go to this URL. This is +usually used to show a more detailed, close-up, radar picture. + +For me in Oregon, setting the two options to: + +``` ini +[Extras] + radar_img = http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif + radar_url = http://radar.weather.gov/ridge/radar.php?product=NCR&rid=RTX&loop=yes +``` + +results in a nice image of a radar centered on Portland, Oregon. When +you click on it, it gives you a detailed, animated view. If you live in +the USA, take a look at the [NOAA radar website](http://radar.weather.gov/) +to find a nice one that will work for you. In other countries, you will have +to consult your local weather +service. + +#### googleAnalyticsId + +If you have a [Google Analytics ID](https://www.google.com/analytics/), +you can set it here. The Google Analytics Javascript code will then be +included, enabling analytics of your website usage. If commented out, +the code will not be included. + +### Extending `[Extras]` + +Other tags can be added in a similar manner, including subsections. For +example, say you have added a video camera, and you would like to add a +still image with a hyperlink to a page with the video. You want all of +these options to be neatly contained in a subsection. + +``` ini +[Extras] + [[video]] + still = video_capture.jpg + hyperlink = http://www.eatatjoes.com/video.html + +``` + +Then in your template you could refer to these as: + +``` html + + Video capture + +``` diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/generators.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/generators.md new file mode 100644 index 0000000..8d7afa7 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/generators.md @@ -0,0 +1,17 @@ +# [Generators] + +This section defines the list of generators that should be run. + +#### generator_list + +This option controls which generators get run for this skin. It is a +comma separated list. The generators will be run in this order. + +For example, the Standard skin uses three generators: `CheetahGenerator`, +`ImageGenerator`, and `CopyGenerator`. Here is the `[Generators]` section +from the Standard `skin.conf` + +``` ini +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator +``` diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/imagegenerator.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/imagegenerator.md new file mode 100644 index 0000000..15966b3 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/imagegenerator.md @@ -0,0 +1,346 @@ +# [ImageGenerator] + +This section describes the various options available to the image +generator. + +
+ ![Part names in a WeeWX image](../../images/image_parts.png) +
Parts of a WeeWX plot image
+
+ +## General options + +These are options that affect the overall image. + +#### anti_alias + +Setting to 2 or more might give a sharper image, with fewer jagged +edges. Experimentation is in order. Default is `1`. + +
+ ![Effect of anti_alias option](../../images/antialias.gif) +
A GIF showing the same image
with `anti_alias=1`, `2`, and `4`.
+
+ +#### chart_background_color + +The background color of the chart itself. Optional. Default is +`#d8d8d8`. + +#### chart_gridline_color + +The color of the chart grid lines. Optional. Default is `#a0a0a0` + +
+ ![Example of day/night bands](../../images/weektempdew.png) +
Example of day/night bands in a one week image
+
+ +#### daynight_day_color + +The color to be used for the daylight band. Optional. Default is +`#ffffff`. + +#### daynight_edge_color + +The color to be used in the transition zone between night and day. +Optional. Default is `#efefef`, a mid-gray. + +#### daynight_night_color + +The color to be used for the nighttime band. Optional. Default is +`#f0f0f0`, a dark gray. + +#### image_background_color + +The background color of the whole image. Optional. Default is +`#f5f5f5` ("SmokeGray") + +#### image_width +#### image_height + +The width and height of the image in pixels. Optional. Default is 300 x +180 pixels. + +#### show_daynight + +Set to `true` to show day/night bands in an image. Otherwise, set +to false. This only looks good with day or week plots. Optional. Default +is `false`. + +#### skip_if_empty + +If set to `true`, then skip the generation of the image if all +data in it are null. If set to a time period, such as `month` or +`year`, then skip the generation of the image if all data in that +period are null. Default is `false`. + +#### stale_age + +Image file staleness age, in seconds. If the image file is older than +this age it will be generated. If no `stale_age` is specified, +then the image file will be generated every time the generator runs. + +#### unit + +Normally, the unit used in a plot is set by whatever [unit group the +types are in](../../custom/custom-reports.md#mixed-units). However, +this option allows overriding the unit used in a specific plot. + +## Label options + +These are options for the various labels used in the image. + +#### axis_label_font_color + +The color of the x- and y-axis label font. Optional. Default is +`black`. + +#### axis_label_font_path + +The path to the font to be used for the x- and y-axis labels. Optional. +If not given, or if WeeWX cannot find the font, then the default PIL +font will be used. + +#### axis_label_font_size + +The size of the x- and y-axis labels in pixels. Optional. The default is +`10`. + +#### bottom_label_font_color + +The color of the bottom label font. Optional. Default is `black`. + +#### bottom_label_font_path + +The path to the font to be used for the bottom label. Optional. If not +given, or if WeeWX cannot find the font, then the default PIL font will +be used. + +#### bottom_label_font_size + +The size of the bottom label in pixels. Optional. The default is +`10`. + +#### bottom_label_format + +The format to be used for the bottom label. It should be a [strftime +format](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior). +Optional. Default is `'%m/%d/%y %H:%M'`. + +#### bottom_label_offset + +The margin of the bottom label from the bottom of the plot. Default is +3. + +#### top_label_font_path + +The path to the font to be used for the top label. Optional. If not +given, or if WeeWX cannot find the font, then the default PIL font will +be used. + +#### top_label_font_size + +The size of the top label in pixels. Optional. The default is +`10`. + +#### unit_label_font_color + +The color of the unit label font. Optional. Default is `black`. + +#### unit_label_font_path + +The path to the font to be used for the unit label. Optional. If not +given, or if WeeWX cannot find the font, then the default PIL font will +be used. + +#### unit_label_font_size + +The size of the unit label in pixels. Optional. The default is +`10`. + +#### x_interval + +The time interval in seconds between x-axis tick marks. Optional. If not +given, a suitable default will be chosen. + +#### x_label_format + +The format to be used for the time labels on the x-axis. It should be a +[strftime +format](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior). +Optional. If not given, a sensible format will be chosen automatically. + +#### x_label_spacing + +Specifies the ordinal increment between labels on the x-axis: For +example, 3 means a label every 3rd tick mark. Optional. The default is +`2`. + +#### y_label_side + +Specifies if the y-axis labels should be on the left, right, or both +sides of the graph. Valid values are `left`, `right` or `both`. Optional. +Default is `left`. + +#### y_label_spacing + +Specifies the ordinal increment between labels on the y-axis: For +example, 3 means a label every 3rd tick mark. Optional. The default is +`2`. + +#### y_nticks + +The nominal number of ticks along the y-axis. The default is +`10`. + +## Plot scaling options + +#### time_length + +The nominal length of the time period to be covered in seconds. Alternatively, +a [duration notation](../durations.md) can be used. The exact length of the +x-axis is chosen by the plotting engine to cover this period. Optional. +Default is `86400` (one day). + +#### yscale + +A 3-way tuple (`ylow`, `yhigh`, `min_interval`), where `ylow` and `yhigh` are +the minimum and maximum y-axis values, respectively, and `min_interval` is the +minimum tick interval. If set to `None`, the corresponding value will be +automatically chosen. Optional. Default is `None, None, None`. (Choose the +y-axis minimum, maximum, and minimum increment automatically.) + +## Compass rose options + +
+ ![Example of a progressive vector plot](../../images/daywindvec.png) +
Example of a vector plot with a compass rose
in the lower-left
+
+ +#### rose_label + +The label to be used in the compass rose to indicate due North. +Optional. Default is `N`. + +#### rose_label_font_path + +The path to the font to be used for the rose label (the letter "N," +indicating North). Optional. If not given, or if WeeWX cannot find the +font, then the default PIL font will be used. + +#### rose_label_font_size + +The size of the compass rose label in pixels. Optional. The default is +`10`. + +#### rose_label_font_color + +The color of the compass rose label. Optional. Default is the same color +as the rose itself. + +#### vector_rotate + +Causes the vectors to be rotated by this many degrees. Positive is +clockwise. If westerly winds dominate at your location (as they do at +mine), then you may want to specify `+90` for this option. This +will cause the average vector to point straight up, rather than lie flat +against the x-axis. Optional. The default is `0`. + +## Plot line options + +These are options shared by all the plot lines. + +#### chart_line_colors + +Each chart line is drawn in a different color. This option is a list of +those colors. If the number of lines exceeds the length of the list, +then the colors wrap around to the beginning of the list. Optional. In +the case of bar charts, this is the color of the outline of the bar. +Default is `#0000ff, #00ff00, #ff0000`. +Individual line color can be overridden by using option [`color`](#color). + +#### chart_fill_colors + +A list of the color to be used as the fill of the bar charts. Optional. +The default is to use the same color as the outline color (option +[`chart_line_colors`](#chart_line_colors)). + +#### chart_line_width + +Each chart line can be drawn using a different line width. This option +is a list of these widths. If the number of lines exceeds the length of +the list, then the widths wrap around to the beginning of the list. +Optional. Default is `1, 1, 1`. +Individual line widths can be overridden by using option [`width`](#width). + +## Individual line options + +These are options that are set for individual lines. + +#### aggregate_interval + +The time period over which the data should be aggregated, in seconds. +Alternatively, a [duration notation](../durations.md) can be used. +Required if `aggregate_type` has been set. + +#### aggregate_type + +The default is to plot every data point, but this is probably not a good +idea for any plot longer than a day. By setting this option, you can +*aggregate* data by a set time interval. Available aggregation types +include `avg`, `count`, `cumulative`, `diff`, `last`, `max`, `min`, `sum`, +and `tderiv`. + +#### color + +This option is to override the color for an individual line. Optional. +Default is to use the color in [`chart_line_colors`](#chart_line_colors). + +#### data_type + +The SQL data type to be used for this plot line. For more information, +see the section *[Including a type more than once in a +plot](../../custom/image-generator.md#include-same-sql-type-2x)*. +Optional. The default is to use the section name. + +#### fill_color + +This option is to override the fill color for a bar chart. Optional. +Default is to use the color in [`chart_fill_colors`](#chart_fill_colors). + +#### label + +The label to be used for this plot line in the top label. Optional. The +default is to use the SQL variable name. + +#### line_gap_fraction + +If there is a gap between data points bigger than this fractional amount +of the x-axis, then a gap will be drawn, rather than a connecting line. +See Section *[Line gaps](../../custom/image-generator.md#line-gaps)*. +Optional. The default is to always draw the line. + +#### line_type + +The type of line to be used. Choices are `solid` or +`none`. Optional. Default is `solid`. + +#### marker_size + +The size of the marker. Optional. Default is `8`. + +#### marker_type + +The type of marker to be used to mark each data point. Choices are +`cross`, `x`, `circle`, `box`, or `none`. Optional. Default is `none`. + +#### plot_type + +The type of plot for this line. Choices are `line`, `bar`, +or `vector`. Optional. Default is `line`. + +#### width + +This option is to override the line width for an individual line. +Optional. Default is to use the width in [`chart_line_width`](#chart_line_width). diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/introduction.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/introduction.md new file mode 100644 index 0000000..8b7f3d5 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/introduction.md @@ -0,0 +1,18 @@ +# Skin options + +The skin configuration options control report behavior, including: + +* how files and plots are generated +* how files and plots look +* how data are displayed +* how units are displayed +* which language is used + +Options that control the behavior of a skin are specified in the skin +configuration file `skin.conf`. + +Language-dependent labels and texts are specified in the language files, +located in the skin's `lang` directory. + +UTF-8 is used throughout the skin configuration file, just as it is +throughout the WeeWX configuration file. diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/labels.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/labels.md new file mode 100644 index 0000000..7a1cf89 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/labels.md @@ -0,0 +1,35 @@ +# [Labels] + +This section defines various labels. + +#### hemispheres + +Comma separated list for the labels to be used for the four hemispheres. +The default is `N, S, E, W`. + +#### latlon_formats + +Comma separated list for the formatting to be used when converting +latitude and longitude to strings. There should be three elements: + +1. The format to be used for whole degrees of latitude +2. The format to be used for whole degrees of longitude +3. The format to be used for minutes. + +This allows you to decide whether you want leading zeroes. The +default includes leading zeroes and is `"%02d", "%03d", "%05.2f"`. + +## [[Generic]] + +This section specifies default labels to be used for each +observation type. For example, options + +``` ini +inTemp = Temperature inside the house +outTemp = Outside Temperature +UV = UV Index +``` + +would cause the given labels to be used for plots of `inTemp` and +`outTemp`. If no option is given, then the observation type +itself will be used (*e.g.*, `outTemp`). diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/texts.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/texts.md new file mode 100644 index 0000000..54407cc --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/texts.md @@ -0,0 +1,21 @@ +# [Texts] + +The section `[Texts]` holds static texts that are used in the +templates. Generally there are multiple language files, one for each +supported language, named by the language codes defined in +[ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). +The entries give the translation of the texts to the target language. +For example, + +``` ini +[Texts] + "Current Conditions" = "Aktuelle Werte" +``` + +would cause "Aktuelle Werte" to be used whereever `$gettext("Current +Conditions"` appeared. See the section on +[`$gettext`](../../custom/cheetah-generator.md#gettext-internationalization). + +!!! Note + Strings that include commas must be included in single or double quotes. + Otherwise, they will be misinterpreted as a list. diff --git a/dist/weewx-5.0.2/docs_src/reference/skin-options/units.md b/dist/weewx-5.0.2/docs_src/reference/skin-options/units.md new file mode 100644 index 0000000..dc24c52 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/skin-options/units.md @@ -0,0 +1,234 @@ +# [Units] + +This section controls how units are managed and displayed. + +## [[Groups]] + +This section lists all the *Unit Groups* and specifies which measurement unit is +to be used for each one of them. + +As there are many different observational measurement types (such as `outTemp`, +`barometer`, etc.) used in WeeWX (more than 50 at last count), it would be +tedious, not to say possibly inconsistent, to specify a different measurement +system for each one of them. At the other extreme, requiring all of them to be +"U.S. Customary" or "Metric" seems overly restrictive. WeeWX has taken a middle +route and divided all the different observation types into 12 different *unit +groups*. A unit group is something like `group_temperature`. It represents the +measurement system to be used by all observation types that are measured in +temperature, such as inside temperature (type `inTemp`), outside temperature +(`outTemp`), dewpoint (`dewpoint`), wind chill (`windchill`), and so on. If you +decide that you want unit group `group_temperature` to be measured in `degree_C` +then you are saying *all* members of its group will be reported in degrees +Celsius. + +Note that the measurement unit is always specified in the singular. That is, +specify `degree_C` or `foot`, not `degrees_C` or `feet`. See the reference +section *[Units](../units.md)* for more information, including a concise summary +of the groups, their members, and which options can be used for each group. + +#### group_altitude + +Which measurement unit to be used for altitude. Possible options are `foot` or +`meter`. + +#### group_direction + +Which measurement unit to be used for direction. The only option is +`degree_compass`. + +#### group_distance + +Which measurement unit to be used for distance (such as for wind run). +Possible options are `mile` or `km`. + +#### group_moisture + +The measurement unit to be used for soil moisture. The only option is +`centibar`. + +#### group_percent + +The measurement unit to be used for percentages. The only option is +`percent`. + +#### group_pressure + +The measurement unit to be used for pressure. Possible options are one of `inHg` +(inches of mercury), `mbar`, `hPa`, or `kPa`. + +#### group_pressurerate + +The measurement unit to be used for rate of change in pressure. Possible options +are one of `inHg_per_hour` (inches of mercury per hour), `mbar_per_hour`, +`hPa_per_hour`, or `kPa_per_hour`. + +#### group_radiation + +The measurement unit to be used for radiation. The only option is +`watt_per_meter_squared`. + +#### group_rain + +The measurement unit to be used for precipitation. Options are `inch`, `cm`, or +`mm`. + +#### group_rainrate + +The measurement unit to be used for rate of precipitation. Possible options are +one of `inch_per_hour`, `cm_per_hour`, or `mm_per_hour`. + +#### group_speed + +The measurement unit to be used for wind speeds. Possible options are one of +`mile_per_hour`, `km_per_hour`, `knot`, `meter_per_second`, or `beaufort`. + +#### group_speed2 + +This group is similar to `group_speed`, but is used for calculated wind speeds +which typically have a slightly higher resolution. Possible options are one +`mile_per_hour2`, `km_per_hour2`, `knot2`, or `meter_per_second2`. + +#### group_temperature + +The measurement unit to be used for temperatures. Options are `degree_C`, +[`degree_E`](https://xkcd.com/1923/), `degree_F`, or `degree_K`. + +#### group_volt + +The measurement unit to be used for voltages. The only option is `volt`. + +## [[StringFormats]] + +This section is used to specify what string format is to be used for each unit +when a quantity needs to be converted to a string. Typically, this happens with +y-axis labeling on plots and for statistics in HTML file generation. For +example, the options + +``` ini +degree_C = %.1f +inch = %.2f +``` + +would specify that the given string formats are to be used when formatting any +temperature measured in degrees Celsius or any precipitation amount measured in +inches, respectively. The [formatting codes are those used by +Python](https://docs.python.org/library/string.html#format-specification-mini-language), +and are very similar to C's `sprintf()` codes. + +You can also specify what string to use for an invalid or unavailable +measurement (value `None`). For example, + +``` ini +NONE = " N/A " +``` + +## [[Labels]] + +This section specifies what label is to be used for each measurement unit type. +For example, the options + +``` ini +degree_F = °F +inch = ' in' +``` + +would cause all temperatures to have unit labels `°F` and all precipitation to +have labels `in`. If any special symbols are to be used (such as the degree +sign) they should be encoded in UTF-8. This is generally what most text editors +use if you cut-and-paste from a character map. + +If the label includes two values, then the first is assumed to be the singular +form, the second the plural form. For example, + +``` ini +foot = " foot", " feet" +... +day = " day", " days" +hour = " hour", " hours" +minute = " minute", " minutes" +second = " second", " seconds" +``` + +## [[TimeFormats]] + +This section specifies what time format to use for different time *contexts*. +For example, you might want to use a different format when displaying the time +in a day, versus the time in a month. It uses +[strftime()](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) +formats. The default looks like this: + +``` ini + [[TimeFormats]] + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X +``` + +The specifiers `%x`, `%X`, and `%A` code locale dependent date, time, and +weekday names, respectively. Hence, if you set an appropriate environment +variable `LANG`, then the date and times should follow local conventions (see +section [Environment variable +LANG](../../custom/localization.md#environment-variable-LANG) for details on +how to do this). However, the results may not look particularly nice, and you +may want to change them. For example, I use this in the U.S.: + +``` ini + [[TimeFormats]] + # + # More attractive formats that work in most Western countries. + # + day = %H:%M + week = %H:%M on %A + month = %d-%b-%Y %H:%M + year = %d-%b-%Y %H:%M + rainyear = %d-%b-%Y %H:%M + current = %d-%b-%Y %H:%M + ephem_day = %H:%M + ephem_year = %d-%b-%Y %H:%M +``` + +The last two formats, `ephem_day` and `ephem_year` allow the formatting to be +set for almanac times The first, `ephem_day`, is used for almanac times within +the day, such as sunrise or sunset. The second, `ephem_year`, is used for +almanac times within the year, such as the next equinox or full moon. + +## [[Ordinates]] + +#### directions + +Set to the abbreviations to be used for ordinal directions. By default, this is +`N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N`. + +## [[DegreeDays]] + +#### heating_base +#### cooling_base +#### growing_base + +Set to the base temperature for calculating heating, cooling, and growing +degree-days, along with the unit to be used. Examples: + +``` ini +heating_base = 65.0, degree_F +cooling_base = 20.0, degree_C +growing_base = 50.0, degree_F +``` + +## [[Trend]] + +#### time_delta + +Set to the time difference over which you want trends to be calculated. +Alternatively, a [duration notation](../durations.md) can be used. The default +is 3 hours. + +#### time_grace + +When searching for a previous record to be used in calculating a trend, a record +within this amount of `time_delta` will be accepted. Default is 300 seconds. diff --git a/dist/weewx-5.0.2/docs_src/reference/units.md b/dist/weewx-5.0.2/docs_src/reference/units.md new file mode 100644 index 0000000..fc6e48e --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/units.md @@ -0,0 +1,359 @@ +# Units + +WeeWX offers three different *unit systems*: + + + + + + + + + + + + + + + + + + + + + + + +
The standard unit systems used within WeeWX
NameEncoded valueDescription
US0x01U.S. Customary
METRICWX0x11Metric, with rain related measurements in mm and speeds in m/s +
METRIC0x10Metric, with rain related measurements in cm and speeds in km/hr +
+ +The table below lists all the unit groups, their members, which units +are options for the group, and what the defaults are for each standard +unit system. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Unit groups, members and options
GroupMembersUnit optionsUSMETRICWXMETRIC
group_altitudealtitude
cloudbase +
foot
meter +
footmetermeter
group_ampampampampamp
group_angledegree_angle
radian
degree_angledegree_angledegree_angle
group_booleanbooleanbooleanbooleanboolean
group_concentrationno2
pm1_0
pm2_5
pm10_0
microgram_per_meter_cubedmicrogram_per_meter_cubedmicrogram_per_meter_cubedmicrogram_per_meter_cubed
group_countleafWet1
leafWet2
lightning_disturber_count
lightning_noise_count
lightning_strike_count
countcountcountcount
group_databyte
bit
bytebytebyte
group_dbnoisedBdBdBdB
group_deltatimedaySunshineDur
rainDur
sunshineDurDoc
second
minute
hour
day
secondsecondsecond
group_degree_daycooldeg
heatdeg
growdeg +
degree_F_day
degree_C_day
degree_F_daydegree_C_daydegree_C_day
group_directiongustdir
vecdir
windDir
windGustDir
degree_compassdegree_compassdegree_compassdegree_compass
group_distancewindrun
lightning_distance
mile
km
milekmkm
group_energykilowatt_hour
mega_joule
watt_hour
watt_second
watt_hourwatt_hourwatt_hour
group_energy2kilowatt_hour
watt_hour
watt_second
watt_secondwatt_secondwatt_second
group_fractionco
co2
nh3
o3
pb
so2
ppmppmppmppm
group_frequencyhertzhertzhertzhertz
group_illuminanceilluminanceluxluxluxlux
group_intervalintervalminuteminuteminuteminute
group_lengthinch
cm +
inchcmcm
group_moisturesoilMoist1
soilMoist2
soilMoist3
soilMoist4 +
centibarcentibarcentibarcentibar
group_percent + cloudcover
extraHumid1
extraHumid2
inHumidity
outHumidity
pop
+ rxCheckPercent
snowMoisture +
percentpercentpercentpercent
group_powerkilowatt
watt
wattwattwatt
group_pressure + barometer
altimeter
pressure +
+ inHg
mbar
hPa
kPa +
inHgmbarmbar
group_pressurerate + barometerRate
altimeterRate
pressureRate +
+ inHg_per_hour
mbar_per_hour
hPa_per_hour
kPa_per_hour +
inHg_per_hourmbar_per_hourmbar_per_hour
group_radiationmaxSolarRad
radiation
watt_per_meter_squaredwatt_per_meter_squaredwatt_per_meter_squaredwatt_per_meter_squared
group_rainrain
ET
hail
snowDepth
snowRate
inch
cm
mm +
inchmmcm
group_rainraterainRate
hailRate +
inch_per_hour
cm_per_hour
mm_per_hour +
inch_per_hourmm_per_hourcm_per_hour
group_speedwind
windGust
windSpeed
windgustvec
windvec +
mile_per_hour
km_per_hour
knot
meter_per_second
beaufort +
mile_per_hourmeter_per_secondkm_per_hour
group_speed2rms
vecavg +
mile_per_hour2
km_per_hour2
knot2
meter_per_second2 +
mile_per_hour2meter_per_second2km_per_hour2
group_temperatureappTemp
dewpoint
extraTemp1
extraTemp2
extraTemp3
heatindex
+ heatingTemp
humidex
inTemp
leafTemp1
leafTemp2
outTemp
soilTemp1 +
soilTemp2
soilTemp3
soilTemp4
windchill
THSW +
degree_C
degree_F
degree_E
degree_K +
degree_Fdegree_Cdegree_C
group_timedateTimeunix_epoch
dublin_jd +
unix_epochunix_epochunix_epoch
group_uvUVuv_indexuv_indexuv_indexuv_index
group_voltconsBatteryVoltage
heatingVoltage
referenceVoltage
supplyVoltage +
voltvoltvoltvolt
group_volumecubic_foot
gallon
liter +
gallonliterliter
group_NONENONENONENONENONENONE
diff --git a/dist/weewx-5.0.2/docs_src/reference/valuehelper.md b/dist/weewx-5.0.2/docs_src/reference/valuehelper.md new file mode 100644 index 0000000..726b263 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/valuehelper.md @@ -0,0 +1,37 @@ +# Class ValueHelper + +Class `ValueHelper` contains all the information necessary to do +the proper formatting of a value, including a unit label. + +### Instance attribute + +#### ValueHelper.value_t + +Returns the `ValueTuple` instance held internally. + +### Instance methods + +#### ValueHelper.__str__() + +Formats the value as a string, including a unit label, and returns it. + +#### ValueHelper.format(format_string=None, None_string=None, add_label=True, localize=True) + +Format the value as a string, using various specified options, and +return it. Unless otherwise specified, a label is included. + +Its parameters: + +- `format_string` A string to be used for formatting. It must +include one, and only one, [format +specifier](https://docs.python.org/3/library/string.html#formatspec). + +- `None_string` In the event of a value of Python `None`, this string +will be substituted. If `None`, then a default string from +`skin.conf` will be used. + +- `add_label` If truthy, then an appropriate unit label will be +attached. Otherwise, no label is attached. + +- `localize` If truthy, then the results will be localized. For +example, in some locales, a comma will be used as the decimal specifier. diff --git a/dist/weewx-5.0.2/docs_src/reference/valuetuple.md b/dist/weewx-5.0.2/docs_src/reference/valuetuple.md new file mode 100644 index 0000000..5b03ac2 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/valuetuple.md @@ -0,0 +1,53 @@ +# Class ValueTuple + +A value, along with the unit it is in, can be represented by a 3-way +tuple called a "value tuple". They are used throughout WeeWX. All +WeeWX routines can accept a simple unadorned 3-way tuple as a value +tuple, but they return the type `ValueTuple`. It is useful +because its contents can be accessed using named attributes. You can +think of it as a unit-aware value, useful for converting to and from +other units. + +The following attributes, and their index, are present: + + + + + + + + + + + + + + + + + + + + + + +
IndexAttributeMeaning
0valueThe data value(s). Can be a series (e.g., [20.2, 23.2, ...]) or a scalar + (e.g., 20.2) +
1unitThe unit it is in ("degree_C")
2groupThe unit group ("group_temperature")
+ +It is valid to have a datum value of `None`. + +It is also valid to have a unit type of `None` (meaning there is no +information about the unit the value is in). In this case, you won't be +able to convert it to another unit. + +Here are some examples: + +``` python +from weewx.units import ValueTuple + +freezing_vt = ValueTuple(0.0, "degree_C", "group_temperature") +body_temperature_vt = ValueTuple(98.6, "degree_F", group_temperature") +station_altitude_vt = ValueTuple(120.0, "meter", "group_altitude") + +``` diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/data-bindings.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/data-bindings.md new file mode 100644 index 0000000..cd38491 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/data-bindings.md @@ -0,0 +1,54 @@ +# [DataBindings] + +A "data binding" associates storage characteristics with a specific database. +Each binding contains a database from the [`[Databases]`](databases.md) +section plus parameters such as schema, table name, and mechanism for +aggregating data. + +## [[wx_binding]] + +This is the binding normally used for weather data. A typical `[[wx_binding]]` +section looks something like this: + +``` ini +[[wx_binding]] + database = archive_sqlite + table_name = archive + manager = weewx.manager.DaySummaryManager + schema = schemas.wview_extended.schema +``` + +What follows is more detailed information about each of the binding options. + +#### database + +The actual database to be used — it should match one of the sections in +[`[Databases]`](databases.md). Should you decide to use a MySQL database, +instead of the default SQLite database, this is the place to change it. See +the section [*Configuring MySQL/MariaDB*](../../usersguide/mysql-mariadb.md) +for details. + +Required. + +#### table_name + +Internally, the archive data is stored in one, long, flat table. This is the +name of that table. Normally this does not need to be changed. + +Optional. Default is `archive`. + +#### manager + +The name of the class to be used to manage the table. + +Optional. Default is class `weewx.manager.DaySummaryManager`. This class +stores daily summaries in the database. Normally, this does not need to be +changed. + +#### schema + +A Python structure holding the schema to be used to initialize the database. +After initialization, it is not used. + +Optional. Default is `schemas.wview_extended.schema`, which is a superset of +the schema used by the _wview_ weather system. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/database-types.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/database-types.md new file mode 100644 index 0000000..eea7076 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/database-types.md @@ -0,0 +1,92 @@ +# [DatabaseTypes] + +This section defines default parameters for various databases. + +## [[SQLite]] + +This section defines default values for SQLite databases. They can be +overridden by individual databases. + +#### driver + +The sqlite driver name. Required. + +#### SQLITE_ROOT + +The location of the directory that contains the SQLite database files. If this +is a relative path, it is relative to [`WEEWX_ROOT`](general.md#weewx_root). + +#### timeout + +When the database is accessed by multiple threads and one of these threads +modifies the database, the SQLite database is locked until the transaction +is completed. The timeout option specifies how long other threads should wait +in seconds for the lock to go away before raising an exception. + +The default is `5`. + +#### isolation_level + +Set the current isolation level. See the pysqlite documentation on +[isolation levels](https://docs.python.org/2.7/library/sqlite3.html#sqlite3.Connection.isolation_level) +for more information. There is no reason to change this, but it is here for +completeness. + +Default is `None` (autocommit). + +## [[MySQL]] + +This section defines default values for MySQL databases. They can be +overridden by individual databases. + +!!! Note + If you choose the [MySQL](https://www.mysql.com/) database, it is assumed + that you know how to administer it. In particular, you will have to set + up a user with appropriate create and modify privileges. + +!!! Tip + In what follows, if you wish to connect to a MySQL server using a Unix + socket instead of a TCP/IP connection, set `host` to an empty string + (`''`), then add an option `unix_socket` with the socket address. + ```ini + [[MySQL]] + ... + host = '' + unix_socket = /var/run/mysqld/mysqld.sock + ... + ``` + +#### driver + +The MySQL driver name. Required. + +#### host + +The name of the server on which the database is located. + +Default is `localhost`. + +#### user + +The username to be used to log into the server. + +Required. + +#### password + +The password. + +Required. + +#### port + +The port number to be used. + +Optional. + +Default is `3306`. + +#### engine + +The type of MySQL database storage engine to be used. This should not be +changed without a good reason. Default is `INNODB`. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/databases.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/databases.md new file mode 100644 index 0000000..b2ce657 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/databases.md @@ -0,0 +1,44 @@ +# [Databases] + +This section lists actual databases. The name of each database is given in +double brackets, for example, `[[archive_sqlite]]`. Each database section +contains the parameters necessary to create and manage the database. The +number of parameters varies depending on the type of database. + +## [[archive_sqlite]] + +This definition uses the [SQLite](https://sqlite.org/) database engine to +store data. SQLite is open-source, simple, lightweight, highly portable, and +memory efficient. For most purposes it serves nicely. + +#### database_type + +Set to `SQLite` to signal that this is a SQLite database. The definitions that +go with type `SQLite` are defined in section +[`[DatabaseTypes] / [[SQLite]]`](database-types.md#sqlite). + +#### database_name + +The path to the SQLite file. If the path is relative, it is relative to +[`SQLITE_ROOT`](database-types.md#sqlite_root). Default is `weewx.sdb`. + +#### timeout + +How many seconds to wait before raising an error when a table is locked. +Default is `5`. + +## [[archive_mysql]] + +This definition uses the MySQL database engine to store data. It is free, +highly-scalable, but more complicated to administer. + +#### database_type + +Set to `MySQL` to signal that this is a MySQL database. The definitions that +go with type `MySQL` are defined in section +[`[DatabaseTypes] / [[MySQL]]`](database-types.md#mysql). + +#### database_name + +The name of the database. Default is `weewx`. + diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/engine.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/engine.md new file mode 100644 index 0000000..a35c900 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/engine.md @@ -0,0 +1,86 @@ +# [Engine] + +This section is used to configure the internal service engine in WeeWX. It is +for advanced customization. Details on how to do this can be found in the section +[*Customizing the service engine*](../../custom/service-engine.md) of the *Customization Guide*. + +## [[Services]] + +Internally, WeeWX consists of many services, each responsible for some aspect +of the program's functionality. After an event happens, such as the arrival of +a new LOOP packet, any interested service gets a chance to do some useful work +on the event. For example, a service might manipulate the packet, print it +out, store it in a database, *etc*. This section controls which services are +loaded and in what order they get their opportunity to do that work. Before +WeeWX v2.6, this section held one, long, option called `service_list`, which +held the names of all the services that should be run. Since then, this list +has been broken down into smaller lists. + +Service lists are run in the order given below. + +| Service list | Function | +|--------------------|-------------------------------------------------------| +| `prep_services` | Perform any actions before the main loop is run. | +| `data_services` | Augment data, before it is processed. | +| `process_services` | Process, filter, and massage the data. | +| `xtype_services` | Add derived types to the data stream. | +| `archive_services` | Record the data in a database. | +| `restful_services` | Upload processed data to an external RESTful service. | +| `report_services` | Run any reports. | + +For reference, here is the standard set of services that are run with the +default distribution. + +| Service list | Function | +|--------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `prep_services` | `weewx.engine.StdTimeSynch` | +| `data_services` | | +| `process_services` | `weewx.engine.StdConvert`
`weewx.engine.StdCalibrate`
`weewx.engine.StdQC`
`weewx.wxservices.StdWXCalculate` | +| `xtype_services` | `weewx.wxxtypes.StdWXXTypes`
`weewx.wxxtypes.StdPressureCooker`
`weewx.wxxtypes.StdRainRater`
`weewx.wxxtypes.StdDelta` | +| `archive_services` | `weewx.engine.StdArchive` | +| `restful_services` | `weewx.restx.StdStationRegistry`
`weewx.restx.StdWunderground`
`weewx.restx.StdPWSweather`
`weewx.restx.StdCWOP`
`weewx.restx.StdWOW`
`weewx.restx.StdAWEKAS` | +| `report_services` | `weewx.engine.StdPrint`
`weewx.engine.StdReport` | + +If you're the type who likes to clean out your car trunk after every use, then +you may also be the type who wants to pare this down to the bare minimum. +However, this will only make a slight difference in execution speed and memory +use. + +#### prep_services + +These services get called before any others. They are typically used to +prepare the console. For example, the service `weewx.wxengine.StdTimeSynch`, +which is responsible for making sure the console's clock is up-to-date, is a +member of this group. + +#### data_services + +Augment data before processing. Typically, this means adding fields to a LOOP +packet or archive record. + +#### process_services + +Services in this group tend to process any incoming data. They typically do +things like quality control, or unit conversion, or sensor calibration. + +#### xtype_services + +These are services that use the +[WeeWX XTypes](https://github.com/weewx/weewx/wiki/xtypes) system to augment +the data. Typically, they calculate derived variables such as `dewpoint`, +`ET`, `rainRate`, *etc*. + +#### archive_services + +Once data have been processed, services in this group archive them. + +#### restful_services + +RESTful services, such as the Weather Underground, or CWOP, are in this group. +They need processed data that have been archived, hence they are run after the +preceeding groups. + +#### report_services + +The various reporting services run in this group, including the standard +reporting engine. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/general.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/general.md new file mode 100644 index 0000000..c3614c0 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/general.md @@ -0,0 +1,65 @@ +# General options + +The options declared at the top are not part of any section. + +#### ==debug== + +Set to `1` to have the program perform extra debug checks, as well as emit extra +information in the log file. This is strongly recommended if you are having +trouble. Set to `2` for even more information. Otherwise, set to `0`. Default is +`0` (no debug). + +#### ==log_success== + +If set to `true`, the default will be to log a successful operation (for +example, the completion of a report, or uploading to the Weather Underground, +etc.) to the system log. Default is `true`. + +#### ==log_failure== + +If set to `true`, the default will be to log an unsuccessful operation (for +example, failure to generate a report, or failure to upload to the Weather +Underground, etc.) to the system log. Default is `true`. + +#### WEEWX_ROOT + +`WEEWX_ROOT` is the path to the root directory of the station data area. + +If not specified, its default value is the directory of the configuration file. +For example, if the configuration file is `/etc/weewx/weewx.conf`, then the +default value will be `/etc/weewx`. + +If a relative path is specified, then it will be relative to the directory of +the configuration file. + +The average user rarely needs to specify a value. + + +#### USER_ROOT + +The location of the user package, relative to `WEEWX_ROOT`. WeeWX will look for +any user-installed extensions in this directory. Default is `bin/user`. + +#### socket_timeout + +Set to how long to wait in seconds before declaring a socket time out. This is +used by many services such as FTP, or the uploaders. Twenty (20) seconds is +reasonable. Default is `20`. + +#### gc_interval + +Set to how often garbage collection should be performed in seconds by the Python +runtime engine. Default is every `10800` (3 hours). + +#### loop_on_init + +Normally, if a hardware driver fails to load, WeeWX will exit, on the assumption +that there is likely a configuration problem and so retries are useless. +However, in some cases, drivers can fail to load for intermittent reasons, such +as a network failure. In these cases, it may be useful to have WeeWX do a retry. +Setting this option to `true` will cause WeeWX to keep retrying indefinitely. +Default is `false`. + +#### retry_wait + +If a retry is called for, how long to wait in seconds. Default is `60`. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/introduction.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/introduction.md new file mode 100644 index 0000000..4b97fdd --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/introduction.md @@ -0,0 +1,74 @@ +# The configuration file weewx.conf + +Application options are specified in a configuration file, nominally called +`weewx.conf`. This is a big text file, which holds the configuration +information about your installation of WeeWX. This includes things such as: + +* The type of hardware you have. +* The name of your station. +* What kind of database to use and where is it located. +* How to recognize out-of-range observations, etc. + +!!! note + The location of the configuration file will depend on your installation + method. For example, if you installed using pip, then the nominal location + is `~/weewx-data/weewx.conf`. For other installation methods, the location + depends on your operating system. See the section + [*Where to find things*](../../usersguide/where.md). + + +!!! note + There is another configuration file for presentation-specific options. + This file is called `skin.conf`, and there is one for each skin. It is + described in the reference guide + [*Skin options*](../skin-options/introduction.md). + + +The following sections are the definitive guide to the many configuration +options available. There are many more options than you are likely to need +— you can safely ignore most of them. The truly important ones, the +ones you are likely to have to customize for your station, are ==highlighted==. + +Default values are provided for many options, meaning that if they are not +listed in the configuration file at all, WeeWX will pick sensible values. When +the documentation gives a "default value" this is what it means. + + +## Option hierarchy + +In general, options closer to the "root" of weewx.conf are overridden by +options closer to the leaves. Here's an example: + +``` +log_success = false +... +[StdRESTful] + log_success = true + ... + [[Wunderground]] + log_success = false # Wunderground will not be logged + ... + [[WOW]] + log_success = true # WOW will be logged + ... + [[CWOP]] + # CWOP will be logged (inherits from [StdRESTful]) + ... +``` + +In this example, at the top level, `log_success` is set to false. So, unless +set otherwise, successful operations will not be logged. However, for +`StdRESTful` operations, it is set to true, so for these services, successful +operations _will_ be logged, unless set otherwise by an individual service. +Looking at the individual services, successful operations for + +* `Wunderground` will not be logged (set explicitly) +* `WOW` will be logged (set explicitly) +* `CWOP` will be logged (inherits from `StdRESTful`) + +## Boolean values + +The following will evaluate **True**: `true`, `True`, `yes`, `Yes`, `1`. + +The following will evaluate **False**: `false`, `False`, `no`, `No`, `0`. + diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stations.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stations.md new file mode 100644 index 0000000..253df92 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stations.md @@ -0,0 +1,564 @@ +# [Station] + +This section covers options relating to your weather station setup. + +## General Settings + +These options apply to every type of station. + +#### ==location== + +The station location should be a UTF-8 string that describes the geography of +where your weather station is located. Required. No default. + +``` ini +location = "A small ranch in Kentucky" +``` + +#### ==latitude== +#### ==longitude== + +The lat/lon should be set in decimal degrees, negative for southern and western +hemispheres, respectively. Required. No default. + +``` ini +latitude = 38.8977 +longitude = -77.0366 +``` + +#### ==altitude== + +Normally the altitude is downloaded from your hardware, but not all stations +support this. Set to the altitude of your console and the unit used to measure +that altitude. + +!!! Note + + If you live in a high-rise building, the altitude of the console (which is + where the pressure gauge is located), can be considerably different from + the ground altitude. You want the altitude of the console. + +Example: + +``` ini +altitude = 700, foot +``` + +An example in meters: +``` ini +altitude = 220, meter +``` + +#### ==station_type== + +Set to the type of hardware you are using. + +``` ini +station_type = Simulator +``` + +!!! note + Whatever option you choose, **you must have a matching section** in your + configuration file. For example, if you choose `station_type = Simulator`, + then you will need a **[Simulator]** section. While you can do this by + hand, it is tedious and error-prone. The better way to add the needed + section is by using the utility [`weectl station reconfigure`](../../utilities/weectl-station.md#reconfigure-an-existing-station). + If the needed section is missing, this utility will automatically inject + it into the configuration file. + +Valid station types include: + +| Option | Description | +|-------------------|-------------------------------------------------------------------------| +| **Simulator** | A software weather station simulator. Useful for testing and debugging. | +| **AcuRite** | AcuRite 5-in-1 stations with USB interface. | +| **CC3000** | RainWise CC3000 data logger. | +| **FineOffsetUSB** | Fine Offset 10xx, 20xx, and 30xx stations. | +| **TE923** | Hideki TE923 stations. | +| **Ultimeter** | PeetBros Ultimeter stations | +| **Vantage** | Davis Vantage weather stations. | +| **WMR100** | Oregon Scientific WMR100 series stations. | +| **WMR300** | Oregon Scientific WMR300 series stations. | +| **WMR9x8** | Oregon Scientific WMR-918/968 series stations | +| **WS1** | Argent Data Systems WS1 stations. | +| **WS23xx** | La Crosse 23xx stations. | +| **WS28xx** | La Crosse 28xx stations. | + + +#### ==station_url== + +If you have a website, you may optionally specify an URL for its HTML server. +It will be included in the RSS file generated by WeeWX and, if you choose to +opt into the [station registry](stdrestful.md#stationregistry), it will also +be included in the [map of WeeWX stations](https://www.weewx.com/stations.html). +It must be a valid URL. In particular, it must start with either `http://` +or `https://`. + +Example: + +``` ini +station_url = https://www.mywebsite.com +``` +#### rain_year_start + +Normally the start of the rain year is downloaded from your hardware, but not +all stations support this. Set to the start of your rain year, for example, if +your rain year starts in October, set it to `10`. Default is `1`. + +``` ini +rain_year_start = 1 +``` + +#### week_start + +Start of the week. `0`=Monday, `1`= Tuesday, ... , `6` = Sunday. Default is +`6` (Sunday). + +``` ini +week_start = 6 +``` + + +## [Simulator] + +This section is for options relating to the software weather station simulator +that comes with WeeWX. + +#### loop_interval + +The time (in seconds) between emitting loop packets. Default is `2.5`. + +#### mode + +One of either `simulator` or `generator`. Default is `simulator`. + +| mode | Description | +|-------------|--------------------------------------------------------------| +| `simulator` | Real-time simulator. It will sleep between emitting packets. | +| `generator` | Emit packets as fast as it can. Useful for testing. | + +#### start + +The local start time for the generator in the format `YYYY-mm-ddTHH:MM`. An +example would be `2012-03-30T18:30`. This would code 30-March-2012, at 6:30pm, +local time. Optional. Default is the present time. + + +## [AcuRite] + +This section is for options relating to the AcuRite 5-in-1 series of weather +stations with USB connectors. + +#### model + +Set to the station model. For example, "AcuRite 01035", "AcuRite 01036", or +"02032C" + +#### use_constants + +Some stations (01035, 01036) use the HP038 sensor, which contains constants +that define how the pressure and temperature should be interpreted. Other +stations (02032, 02064) use the MS5607 sensor, which requires a different +calculation to determine the pressure and temperature from the sensor. When +`use_constants=True`, the driver will use the constants to determine which +type of sensor is in the station and will adjust the calculation accordingly. +A value of `False` causes the driver to use a linear approximation, regardless +of the type of sensors. The default is `True`. + + +## [CC3000] + +This section is for options relating to the RainWise Mark III weather stations +and CC3000 data logger. + +#### port + +The serial port, e.g., `/dev/ttyS0`. When using a USB-Serial converter, the +port will be something like `/dev/ttyUSB0`. Default is `/dev/ttyUSB0` + +#### debug_serial +#### debug_openclose +#### debug_checksum + +The `debug_serial`, `debug_openclose`, and `debug_checksum` can be set to one +to produce debugging information in the WeeWX log. The defaults for these +options are zero. + +#### logger_threshold + +The `logger_threshold` specifies the number of records in the CC3000 station's +memory at which the station memory's should be cleared. If logger_threshold is +not specified, or if zero is specified, the station's memory will never be +cleared. In this case, when the memory fills up, new archive records will no +longer be saved in memory. Note: the CC3000's memory will hold about 35,000 +records. The default is 0 (i.e., never clear memory). + +#### max_tries + +The `max_tries` option specifies how many times WeeWX should retry +communicating with the CC3000 station when a serial communications error +occurs. The default is `5`. + +#### model + +The option `model` specifies the name of the hardware reported to WeeWX. The +default is `CC3000`. + +#### polling_interval + +The `polling_interval` determines how often WeeWX will query the station for +data in seconds. The default is `2`. Note: 2 seconds is a good choice as this +is the rate at which the CC3000 receives updates from the Rainwise Mark III +Weather Station. + +#### sensor_map + +This option defines the mapping between temperature values in the database and +the remote sensors. Two additional temperature sensors are supported. + +For example, this would associate `extraTemp1` with the second optional +temperature sensor: + +``` +[[sensor_map]] + extraTemp1 = TEMP 2 +``` + +See the [CC3000 Station data table](../../hardware/cc3000.md#cc3000_data) +in the *Hardware guide* for a complete listing of sensor names and the default +database fields for each sensor. + +#### use_station_time + +The `use_station_time` specifies whether the loop packets read by weewx should +use the time specified in the CC3000 station packet or the computer time. The +default is `True` (*i.e.*, to use CC3000 station time). + + +## [FineOffsetUSB] + +This section is for options relating to the Fine Offset series of weather stations with USB connectors. + +!!! warning + The following settings are highly recommended for Fine Offset stations. + Using hardware record generation or adaptive polling is more likely to + cause USB communication failure. Using hardware record generation will + cause delays in report generation. + +``` +[FineOffsetUSB] + polling_mode = PERIODIC + polling_interval = 60 +[StdArchive] + record_generation = software +``` + +#### model + +Set to the station model. For example, `WH1080`, `WS2080`, `WH3081`, *etc.* + +#### polling_mode + +One of `PERIODIC` or `ADAPTIVE`. In `PERIODIC` mode, WeeWX queries the console +at regular intervals determined by the polling_interval. In `ADAPTIVE` mode, +WeeWX attempts to query the console at times when it is not reading data from +the sensors or writing data to memory. See the section [*Polling mode and +interval*](../../hardware/fousb.md#polling-mode-and-interval) +in the *Hardware Guide* for more details. The default is `PERIODIC`. + + +#### polling_interval + +The frequency, in seconds, at which WeeWX will poll the console for data. This +setting applies only when the polling_mode is `PERIODIC`. Default is `60`. + +#### data_format + +There are two classes of hardware, the 10xx/20xx consoles and the 30xx +consoles. Unlike the 10xx/20xx consoles, the 30xx consoles record luminosity +and UV, so they have a different data format. Use the `data_format` to +indicate which type of hardware you have. Possible values are `1080` (for the +10xx and 20xx consoles) and `3080` (for the 30xx consoles). Default is `1080`. + + +## [TE923] + +This section is for options relating to the Hideki TE923 series of weather +stations. + +#### model + +Set to the station model. For example, Meade TE923W or TFA Nexus. Default is +`TE923`. + +#### sensor_map + +This option defines the mapping between temperature / humidity values in the +database and the remote sensors. Up to 5 remote sensors are supported. A +switch on each sensor determines which of 5 channels that sensor will use. For +example, if the switch on the sensor is set to 3, the temperature from that +sensor will be `t_3` and the humidity from that sensor will be `h_3`. + +For example, this would associate `outTemp` and `outHumidity` with sensor 4: + +``` ini +[[sensor_map]] + outTemp = t_4 + outHumidity = h_4 +``` + +See the [*TE923*](../../hardware/te923.md) section of the *Hardware Guide* +for a complete listing of sensor names and their corresponding default +database field. + + +## [Ultimeter] + +This section is for options relating to the PeetBros Ultimeter weather +stations. + +#### port + +The serial port, e.g., `/dev/ttyS0`. When using a USB-Serial converter, the +port will be something like `/dev/ttyUSB0`. Default is `/dev/ttyUSB0` + +#### model + +Set to the station model. For example, `Ultimeter 2000` or `Ultimeter 800`. +Default is `Ultimeter`. + + +## [Vantage] + +This section is for options relating to the Davis Vantage series of hardware +(VantagePro, VantagePro2 or VantageVue). + +#### type + +Set to either `serial`, for a serial or USB connection to the VantagePro (by +far the most common), or to `ethernet` for the WeatherLinkIP. No default. + +#### port + +If you chose `serial`, for `type`, then set to the serial port name used by +the station. For example, `/dev/ttyUSB0` is a common location for USB ports, +`/dev/ttyS0` for serial ports. Otherwise, not required. No default. + +#### host + +If you chose `ethernet`, then specify either the IP address (_e.g._, +`192.168.0.1`) or hostname (_e.g._, `console.mydomain.com`) to the console. +Otherwise, not required. No default. + +#### baudrate + +Set to the baudrate of the station. The default is `19200`. + +#### tcp_port + +The port where WeatherLinkIP will be listening. Default is `22222`. + +#### tcp_send_delay + +How long to wait in seconds after sending a socket packet to the WeatherLinkIP +before looking for input. Default is `0.5`. + +#### loop_request + +The type of LOOP packet to use for LOOP data. Options are `1` (type **LOOP1**); +`2` (type **LOOP2**); or `3` (alternate between). + +Type **LOOP2** packets have the advantage of including gauge pressure, +altimeter, heat index, THSW, and a few other types, so they do not have to be +calculated in software. On the other hand, they do not include any of the +extra Davis sensors, such as soil moisture, leaf temperature, *etc*. + +If you decide to alternate between packet types (option `3`), then the console +will send a type **LOOP1** packet, followed by a type **LOOP2** packet. This +means that for certain types, and depending on the options specified in +`[StdWXCalculate]`, the value that is used by WeeWX can flip between hardware +and software values with every packet. In this case, you should be sure to +specify option `hardware` for types `pressure`, `altimeter`, `dewpoint`, +`windchill`, and `heatindex`. This way, only hardware values will be used. + +!!! info + Not all stations support LOOP2 data. You need firmware version 1.90 or + later. + +Default is `1` (use **LOOP1** type packets). + +#### iss_id + +Set to the ID number of the Integrated Sensor Suite (ISS). This is used in the +formula to calculate reception quality for wireless stations. Default is `1`. + +#### timeout + +How many seconds to wait for a response from the station before giving up. +Default is `4`. + +#### wait_before_retry + +How many seconds to wait before retrying. Unless you have a good reason to +change it, this value should be left at the default, as it is long enough for +the station to offer new data, but not so long as to go into a new loop packet +(which arrive every 2 seconds). Default is `1.2`. + +#### max_tries + +How many times to try again before giving up. Default is `4`. + + +## [WMR100] + +This section is for options relating to the Oregon Scientific WMR100 series +of weather stations with USB connectors. + +#### model + +Set to the station model. For example, `WMR100` or `WMRS200`. + +#### sensor_map + +This option defines the mapping between observations from remote sensors and +the fields in the database. + +For example, this would associate `extraTemp1` with the remote T/H sensor on channel 5: + +``` +[[sensor_map]] + extraTemp1 = temperature_5 +``` + +See the [*WMR100*](../../hardware/wmr100.md) section of the *Hardware guide* +for a complete listing of sensor names and the default database fields for +each sensor. + + +## [WMR300] + +This section is for options relating to the Oregon Scientific WMR300 series +of weather stations with USB connectors. + +#### model + +Set to the station model. For example, `WMR300` or `WMR300A`. + +#### sensor_map + +This option defines the mapping between temperature / humidity values in the +database and the remote sensors. Up to 8 remote sensors are supported. + +For example, this would associate `outTemp` and `outHumidity` with sensor 4: + +``` +[[sensor_map]] + outTemp = temperature_4 + outHumidity = humidity_4 +``` + +See the [*WMR300*](../../hardware/wmr300.md) section of the *Hardware guide* +for a complete listing of sensor names and the default database fields for +each sensor. + + +## [WMR9x8] + +This section is for options relating to the Oregon Scientific WMR-918/968 +series of weather stations with serial connectors. + +#### type + +For the moment, only `serial` is supported. + +#### port + +Along with the serial option above, you must set the serial port name used by +the station. For example, `/dev/ttyUSB0` is a common location for USB ports, +`/dev/ttyS0` for serial ports. No default. + +#### model + +Set to the station model. For example, `WMR968` or `WMR918`. + +#### sensor_map + +This option defines the mapping between observations from remote sensors and +the fields in the database. + +For example, this would associate `extraTemp1` with the remote T/H sensor on +channel 5: + +``` +[[sensor_map]] + extraTemp1 = temperature_5 + extraHumid1 = humidity_5 +``` + +See the [*WMR9x8*](../../hardware/wmr9x8.md) sectioin of the *Hardware guide* +for a complete listing of sensor names and the default database fields for +each sensor. + + +## [WS1] + +This section is for options relating to the Argent Data Systems WS1 weather +stations. + +#### mode + +Select whether to connect via a serial line (option `serial`), or via an IP +connection using TCP (option `tcp`), or UDP (option `udp`). Default is +`serial`. + +#### port + +The port to be used. For a serial connection, this will be something like +`/dev/ttyUSB0`. For a TCP or UDP connection, it will be an IP address and +port, such as `192.168.1.12:3000` Default is `/dev/ttyUSB0`. + +#### polling_interval + +The `polling_interval` determines how often in seconds that WeeWX will query +the station for data. The default is `1`. + + +## [WS23xx] + +This section is for options relating to the La Crosse WS-23xx series of +weather stations. + +#### port + +The serial port, *e.g.*, `/dev/ttyS0`. When using a USB-Serial converter, the +port will be something like `/dev/ttyUSB0`. Default is `/dev/ttyUSB0` + +#### model + +Set to the station model. For example, `WS-2315`, `LaCrosse WS2317`, *etc*. +Default is `LaCrosse WS23xx`. + +#### polling_interval + +The `polling_interval` determines how often WeeWX will query the station for +data. If `polling_interval` is not specified (the default), WeeWX will +automatically use a polling interval based on the type of connection between +the station and the sensors (wired or wireless). When connected with a wire, +the console updates sensor data every 8 seconds. When connected wirelessly, +the console updates from 16 to 128 seconds, depending on sensor activity. + + +## [WS28xx] + +This section is for options relating to the La Crosse WS-28xx series of +weather stations. + +#### transceiver_frequency + +Radio frequency to use between USB transceiver and console. Specify either +`US` or `EU`. `US` uses 915 MHz, `EU` uses 868.3 MHz. Default is `US`. + +#### model + +Set to the station model. For example, `LaCrosse WS2810`, `TFA Primus`, *etc*. +Default is `LaCrosse WS28xx`. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdarchive.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdarchive.md new file mode 100644 index 0000000..aa11968 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdarchive.md @@ -0,0 +1,60 @@ +# [StdArchive] + +The `StdArchive` service stores data into a database. + +#### ==archive_interval== + +If your station hardware supports data logging then the archive interval will +be downloaded from the station. Otherwise, you must specify it here in +seconds, and it must be evenly divisible by 60. Optional. Default is `300`. + +#### archive_delay + +How long to wait in seconds after the top of an archiving interval before +fetching new data off the station. For example, if your archive interval is +5 minutes and archive_delay is set to 15, then the data will be fetched at +00:00:15, 00:05:15, 00:10:15, etc. This delay is to give the station a few +seconds to archive the data internally, and in case your server has any other +tasks to do at the top of the minute. Default is `15`. + +#### record_generation + +Set to whether records should be downloaded off the hardware (recommended), +or generated in software. If set to `hardware`, then WeeWX tries to download +archive records from your station. However, not all types of stations support +this, in which case WeeWX falls back to software generation. A setting of +`hardware` will work for most users. A notable exception is [users who have +cobbled together homebrew serial interfaces](https://www.wxforum.net/index.php?topic=10315.0) +for the Vantage stations that do not include memory for a logger. These users +should set this option to `software`, forcing software record generation. +Default is `hardware`. + +#### record_augmentation + +When performing hardware record generation, this option will attempt to +augment the record with any additional observation types that it can extract +out of the LOOP packets. Default is `true`. + +#### loop_hilo + +Set to `true` to have LOOP data and archive data to be used for high / low +statistics. Set to `false` to have only archive data used. If your sensor +emits lots of spiky data, setting to `false` may help. Default is `true`. + +#### log_success + +If you set a value for `log_success` here, it will override the value set at +the [top-level](general.md#log_success) and will apply only to archiving +operations. + +#### log_failure + +If you set a value for `log_failure` here, it will override the value set at +the [top-level](general.md#log_failure) and will apply only to archiving +operations. + +#### data_binding + +The data binding to be used to store the data. This should match one of the +bindings in the [`[DataBindings]`](data-bindings.md) section. Optional. +Default is `wx_binding`. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdcalibrate.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdcalibrate.md new file mode 100644 index 0000000..0abd151 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdcalibrate.md @@ -0,0 +1,92 @@ +# [StdCalibrate] + +The `StdCalibrate` service offers an opportunity to correct for any +calibration errors in your instruments. It is very general and flexible. + +Because this service is normally run after `StdConvert`, the units to be used +should be the same as the target unit system chosen in +[`StdConvert`](stdconvert.md). It is also important that this service be run +before the archiving service `StdArchive`, so that it is the corrected data +that are stored. + +In a default configuration, calibrations are applied to all LOOP packets. +They are applied to archive records only if the records were not software +generated (because, presumably, the correction was already applied in the +LOOP packets). + +Because `StdCalibrate` runs _before_ `StdWXCalculate`, correction are also not +applied to derived calculations. + +## [[Corrections]] + +In this section you list all correction expressions. The section looks like +this: +```ini +[StdCalibrate] + [[Corrections]] + obs_type = expression[, loop][, archive] +``` + +Where: + +_`expression`_ is a valid Python expression involving any observation types +in the same record, or functions in the [`math` +module](https://docs.python.org/3/library/math.html). More below. + +_`loop`_ is a directive that tells `StdCalibrate` to always apply the +correction to LOOP packets. + +_`archive`_ is a directive that tells `StdCalibrate` to always apply the +correction to archive records. + +Details below. + +### Expressions + +For example, say that you know your outside thermometer reads high by 0.2°F. You +could use the expression: + + outTemp = outTemp - 0.2 + +Perhaps you need a linear correction around a reference temperature of 68°F: + + outTemp = outTemp + (outTemp-68) * 0.02 + +or perhaps a non-linear correction, using the math function `math.pow()`: + + radiation = math.pow(radiation, 1.02) + +It is also possible to do corrections involving more than one variable. Suppose +you have a temperature sensitive barometer: + + barometer = barometer + (outTemp-32) * 0.0091 + +All correction expressions are run in the order given. + +### Directives + +Directives are separated by a comma from the expression, and tell `StdCalibrate` +whether to apply the correction to LOOP packets, archive records, or both. + +If not supplied, the correction will be applied to all LOOP packets. They will +also be applied to archive records, but only if they came from hardware[^1]. +This is usually what you want. + +Here are examples: + + humidity = humidity - 3 # 1 + outTemp = outTemp + 0.4, loop # 2 + barometer = barometer + .3, archive # 3 + windSpeed = windSpeed * 1.05, loop, archive # 4 + +1. Apply the correction to all LOOP packets. Apply the correction to archive + records only if they came from hardware. This is usually what you want. +2. Apply the correction only to LOOP packets. Do not apply to archive records. +3. Apply the correction only to archive records. Do not apply to LOOP packets. +4. Apply the correction to both LOOP packets and archive records all the time, + even if the archive record came from software. + + +[^1]: + Corrections are not applied to software-generated reocords because the + correction has already been applied to its constitutent LOOP packets. \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdconvert.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdconvert.md new file mode 100644 index 0000000..26e669f --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdconvert.md @@ -0,0 +1,40 @@ +# [StdConvert] + +The `StdConvert` service acts as a filter for units, converting the unit +system coming off your hardware to a target output unit system. All downstream +services, including the archiving service, will then see this unit system. +Hence, your data will be stored in the database using whatever unit system +you specify here. + +*Once chosen, the unit system in the database cannot be changed!* + +WeeWX does not allow you to mix unit systems within the databases. You must +choose a unit system and then stick with it. This means that users coming from +wview (which uses US Customary) should not change the default setting. Having +said this, there is a way of reconfiguring the database to use another unit +system. See the section [*Changing the unit system in an existing +database*](../../custom/database.md#change-unit-system) in the +*Customization Guide*. + +!!! note + This service only affects the units used in the databases. In particular, + it has nothing to do with what units are displayed in plots or files. + Those units are specified in the skin configuration file, as described + in the *Customization Guide*, under section + [*Changing unit systems*](../../custom/custom-reports.md#changing-unit-systems). + Because of this, unless you have a special purpose application, there is + really no good reason to change from the default, which is `US`. + +!!! Warning + If, despite these precautions, you do decide to change the units of data + stored in the database, be sure to read the sections `[StdCalibrate]` and + `[StdQC]`, and change the units there as well! + +#### target_unit + +Set to either `US`, `METRICWX`, or `METRIC`. The difference between `METRICWX` +and `METRIC` is that the former uses `mm` instead of `cm` for rain, and `m/s` +instead of `km/hr` for wind speed. See the reference section +[*Units*](../units.md) for the exact differences beween each of these options. + +Default is `US`. \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdqc.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdqc.md new file mode 100644 index 0000000..8b53817 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdqc.md @@ -0,0 +1,59 @@ +# [StdQC] + +The `StdQC` service offers a very simple _Quality Control_ that only checks +that values are within a minimum and maximum range. + +Because this service is normally run after `StdConvert`, the units to be used +should be the same as the target unit system chosen in `StdConvert`. It is +also important that it be run after the calibration service, `StdCalibrate` +and before the archiving service `StdArchive`, so that it is the calibrated +and corrected data that are stored. + +In a default configuration, quality control checks are applied to observations +from the hardware. They are not applied to derived calculations since the +`StdWXCalculate` service runs after the quality control. + +## [[MinMax]] + +In this section you list the observation types you wish to have checked, along +with their minimum and maximum values. If not specified, the units should are +in the same unit system as specified in [`[StdConvert]`](stdconvert.md). + +For example, + +``` ini +[[MinMax]] + outTemp = -40, 120 + barometer = 28, 32.5 + outHumidity = 0, 100 +``` + +With `target_unit=US` (the default), if a temperature should fall outside +the inclusive range -40 °F through 120 °F, then it will be set to the null +value, `None`, and ignored. In a similar manner, the acceptable values for +barometric pressure would be 28 through 32.5 inHg, for humidity 0 through 100%. + +You can also specify units. + +For example, + +``` ini +[[MinMax]] + outTemp = -40, 60, degree_C + barometer = 28, 32.5, inHg +``` + +In this example, if a temperature should fall outside the inclusive range +-40 °C through 60 °C, then it will be set to the null value, `None`, and +ignored. In a similar manner, the acceptable values for barometric pressure +would be 28 through 32.5 inHg. Since the units have been specified, these +values apply no matter what the `target_unit`. + +Both LOOP and archive data will be checked. + +Knowing the details of how your hardware encodes data helps to minimize the +number of observations that need to be checked. For example, the VP2 devotes +only one unsigned byte to storing wind speed, and even then `0xff` is devoted +to a bad value, so the only possible values that could appear are 0 through +126 mph, a reasonable range. So, for the VP2, there is no real point in +checking wind speed. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdreport.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdreport.md new file mode 100644 index 0000000..7dd5154 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdreport.md @@ -0,0 +1,291 @@ +# [StdReport] + +This section is for configuring the `StdReport` service, which controls what +reports are to be generated. While it can be highly customized for your +individual situation, this documentation describes the section as shipped in +the standard distribution. + +Each report is represented by a subsection, marked with double brackets (e.g., +`[[MyReport]]`). Any options for the report should be placed under it. The +standard report service will go through the subsections, running each report +in order. + +WeeWX ships with the following subsections: + +| subsection | Description | +| ----------- | ----------- | +| [[SeasonsReport]] | A full-featured single-page skin. Statistics and plots are revealed by touch or button press.| +| [[SmartphoneReport]] | A skin formatted for smaller screens, with a look-and-feel reminiscent of first-generation Apple iPhone.| +| [[MobileReport]] | A static skin formatted for very small screens, with a look-and-feel reminiscent of WindowsCE or PalmOS.| +| [[StandardReport]] | The original skin that shipped for many years as the default report. It uses static HTML and images, and requires few resources to generate and display.| +| [[FTP]] | No presentation elements. Uses the reporting machinery to transfer files to a remote server using FTP.| +| [[RSYNC]] | No presentation elements. Uses the reporting machinery to transfer files to a remote server using rsync.| + +Order matters. The reports that generate HTML and images, that is, +`SeasonsReport`, `SmartphoneReport`, `MobileReport`, and `StandardReport`, +are run _first_, then the reports that move them to a webserver, `FTP` and +`RSYNC`, are run. This insures that report generation is done before the +results are sent off. + +Details for how to customize reports are in the section +[*Customizing reports*](../../custom/custom-reports.md), in the +*Customization Guide*. + +#### SKIN_ROOT + +The directory where the skins live. + +If a relative path is specified, it is relative to +[`WEEWX_ROOT`](general.md#weewx_root). + +#### HTML_ROOT + +The target directory for the generated files. Generated files and images wil +l be put here. + +If a relative path is specified, it is relative to +[`WEEWX_ROOT`](general.md#weewx_root). + +#### log_success + +If you set a value for `log_success` here, it will override the value set at +the [top-level](general.md#log_success) and will apply only to reporting. +In addition, `log_success` can be set for individual reports by putting them +under the appropriate subsection (*e.g.*, under `[[Seasons]]`). + +#### log_failure + +If you set a value for log_failure here, it will override the value set at +the [top-level](general.md#log_failure) and will apply only to reporting. +In addition, `log_failure` can be set for individual reports by putting them +under the appropriate subsection (*e.g.*, under `[[Seasons]]`). + +#### data_binding + +The data source to be used for the reports. It should match a binding given +in section [`[DataBindings]`](data-bindings.md). The binding can be +overridden in individual reports. Optional. Default is `wx_binding`. + +#### report_timing + +This parameter uses a cron-like syntax that determines when a report will be +run. The setting can be overridden in individual reports, so it is possible +to run each report with a different schedule. Refer to the separate document +[_Scheduling report generation_](../../custom/report-scheduling.md) for how +to control when reports are run. Optional. By default, a value is missing, +which causes each report to run on each archive interval. + +## Standard WeeWX reports + +These are the four reports that are included in the standard distribution of +WeeWX, and which actually generate HTML files and plots. They all use US +Customary units by default (but this can be changed by setting the option +`unit_system`). + +#### [[SeasonsReport]] + +#### [[SmartphoneReport]] + +#### [[MobileReport]] + +#### [[StandardReport]] + +They all have the following options in common: + +#### lang + +Which language the skin should be localized in. The value is a two-character +language code as defined in [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). +This option only works with skins that have been internationalized. All skins +that ship with WeeWX have been internationalized, but only a handful of +languages are included. To see which language a skin supports, look in the +subdirectory `lang` in the skin's directory. For example, if you see a file +`fr.conf`, then the skin can be localized in French. + +#### unit_system + +Which unit system to use with the skin. Choices are `US`, `METRIC`, or +`METRICWX`. See the reference section [*Units*](../units.md) for definitions of +these unit systems. Individual units can be overridden. See the section +[*Changing unit systems*](../../custom/custom-reports.md#changing-unit-systems) +in the *Customization Guide* for more details. + +#### enable + +Set to `true` to enable the processing of this skin. Set to `false` to +disable. If this option is missing, `true` is assumed. + +#### skin + +Where to find the skin. This should be a directory under `SKIN_ROOT`. +Inside the directory should be any templates used by the skin and a skin +configuration file, `skin.conf`. + +#### HTML_ROOT + +If you put a value for `HTML_ROOT` here, it will override the +[value](#html_root) directly under `[StdReport]`. + + +## [[FTP]] + +While this "report" does not actually generate anything, it uses the report +machinery to upload files from directory `HTML_ROOT` to a remote webserver. +It does an incremental update, that is, it only FTPs any files that have +changed, saving the outgoing bandwidth of your Internet connection. + +#### enable + +Set to `true` (the default) to enable FTP. Set to `false` to disable. + +#### user + +Set to the username you use for your FTP connection to your web server. +Required. No default. + +#### password + +Set to the password you use for your FTP connection to your web server. +Required. No default. + +#### server + +Set to the name of your web server (*e.g.*, `www.acme.com`). Required. +No default + +#### path + +Set to the path where the weather data will be stored on your webserver +(*e.g.*, `/weather`). Required. No default. + +!!! Note + Some FTP servers require a leading slash ('`/`'), some do not. + +#### secure_ftp + +Set to `true` to use FTP (FTPS) over TLS. This is an extension to the FTP +protocol that uses a Secure Socket Layer (SSL) protocol, not to be confused +with SFTP, which uses a Secure Socket Shell protocol. Not all FTP servers +support this. In particular, the Microsoft FTP server seems to do a poor +job of it. Optional. Default is `false` + +#### secure_data + +If a secure session is requested (option `secure_ftp=true`), should we attempt +a secure data connection as well? This option is useful due to a bug in the +Python FTP client library. See WeeWx GitHub +[Issue #284](https://github.com/weewx/weewx/issues/284). Optional. Default +is `true`. + +#### reuse_ssl + +Some FTP servers (notably PureFTP) reuse ssl connections with FTPS. +Unfortunately, the Python library has a bug that prematurely closes such +connections. See [https://bit.ly/2Lrywla](https://bit.ly/2Lrywla). Symptom is an +exception *OSError: [Errno 0]*, or a 425 error ("*425 Unable to build data +connection: Operation not permitted*"). This option activates a workaround for +Python versions greater than 3.6. It won't work for earlier versions. Optional. +Default is `false`. + +#### port + +Set to the port ID of your FTP server. Default is `21`. + +#### passive + +Set to `1` if you wish to use the more modern, FTP passive mode, `0` if you +wish to use active mode. Passive mode generally works better through firewalls, +but not all FTP servers do a good job of supporting it. See [Active FTP vs. +Passive FTP, a Definitive Explanation](https://slacksite.com/other/ftp.html) +for a good explanation of the difference. Default is `1` (passive mode). + +#### max_tries + +WeeWX will try up to this many times to FTP a file up to your server before +giving up. Default is `3`. + +#### ftp_encoding + +The vast majority of FTP servers send their responses back using UTF-8 +encoding. However, there are a few oddballs that respond using Latin-1. This +option allows you to specify an alternative encoding. + +#### ciphers + +Some clients require a higher cipher level than the FTP server is capable of +delivering. The symptom is an error something like: + + ssl.SSLError: [SSL: DH_KEY_TOO_SMALL] dh key too small (_ssl.c:997)` + +This option allows you to specify a custom level. For example, in this case, +you might want to specify: + +``` ini +ciphers='DEFAULT@SECLEVEL=1' +``` + +However, if possible, you are always better off upgrading the FTP server. + + +## [[RSYNC]] + +While this "report" does not actually generate anything, it uses the report +machinery to upload files from directory `HTML_ROOT` to a remote webserver +using [rsync](https://rsync.samba.org/). Fast, efficient, and secure, it does +an incremental update, that is, it only synchronizes those parts of a file +that have changed, saving the outgoing bandwidth of your Internet connection. + +If you wish to use rsync, you must configure passwordless ssh using +public/private key authentication from the user account that WeeWX runs, to +the user account on the remote machine where the files will be copied. + +#### enable + +Set to `true` (the default) to enable rsync. Set to `false` to disable. + +#### server + +Set to the name of your server. This name should appear in your `.ssh/config` +file. Required. No default + +#### user + +Set to the ssh username you use for your rsync connection to your web server. +The local user that WeeWX runs as must have [passwordless ssh](https://www.tecmint.com/ssh-passwordless-login-using-ssh-keygen-in-5-easy-steps/) +configured for _user@server_. Required. No default. + +#### path + +Set to the path where the weather data will be stored on your webserver +(_e.g._, `/var/www/html/weather`). Make sure `user` has write privileges in +this directory. Required. No default. + +#### port + +The port to use for the ssh connection. Default is to use the default port for +the `ssh` command (generally 22). + +#### delete + +Files that don't exist in the local report are removed from the remote +location. + +!!! warning + USE WITH CAUTION! If you make a mistake in setting the path, this can + cause unexpected files to be deleted on the remote server. + +Valid values are `1` to enable and `0` to disable. Required. Default is `0`. + + +## [[Defaults]] + +This section defines default values for all reports. You can set: + +* The unit system to be used +* Overrides for individual units +* Which language to be used +* Number and time formats +* Labels to be used +* Calculation options for some derived values. + +See the section [*Processing order*](../../custom/custom-reports.md#processing-order) in the *Customization Guide* for more details. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdrestful.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdrestful.md new file mode 100644 index 0000000..9d76788 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdrestful.md @@ -0,0 +1,401 @@ +# [StdRESTful] + +This section is for configuring the StdRESTful services, which upload to simple +[RESTful](https://en.wikipedia.org/wiki/Representational_State_Transfer) +services such as: + +* [Weather Underground](https://www.wunderground.com/) +* [PWSweather.com](https://www.pwsweather.com/) +* [CWOP](http://www.wxqa.com/) +* [British Weather Observations Website (WOW)](https://wow.metoffice.gov.uk/) +* [Automatisches Wetterkarten System (AWEKAS)](https://www.awekas.at/) + + +## General options for each RESTful Service + +#### log_success + +If you set a value for `log_success` here, it will override the value set at +the [top-level](general.md#log_success) and will apply only to RESTful +services. In addition, `log_success` can be set for individual services by +putting them under the appropriate subsection (*e.g.*, under `[[CWOP]]`). + +#### log_failure + +If you set a value for `log_failure` here, it will override the value set at +the [top-level](general.md#log_failure) and will apply only to RESTful +services. In addition, `log_failure` can be set for individual services by +putting them under the appropriate subsection (*e.g.*, under `[[CWOP]]`). + + +## [[StationRegistry]] + +A registry of WeeWX weather stations is maintained at `weewx.com`. Stations +are displayed on a map and a list at +[https://weewx.com/stations.html](https://weewx.com/stations.html). + +How does the registry work? Individual weather stations periodically contact +the registry. Each station provides a unique URL to identify itself, plus +other information such as the station type, Python version, WeeWX version +and installation method. No personal information, nor any meteorological data, +is sent. + +To add your station to this list, you must do two things: + +1. Enable the station registry by setting option `register_this_station` to +`true`. Your station will contact the registry once per day. If your station +does not contact the registry for about a month, it will be removed from the +list. + +2. Provide a value for option `station_url`. This value must be unique, so +choose it carefully. + +``` ini +[StdRestful] + [[StationRegistry]] + register_this_station = True + description="Beach side weather" + station_url = https://acme.com +``` + +#### ==register_this_station== + +Set this to `true` to register the weather station. + +#### description + +A description of the station. If no description is specified, the +[`location`](stations.md#location) from section `[Station]` will be used. + +#### station_url + +The URL to the weather station. If no URL is specified, the +[`station_url`](stations.md#station_url) from section `[Station]` +will be used. It must be a valid URL. In particular, it must start with either +`http://` or `https://`. + +#### log_success + +If you set a value here, it will apply only to the station registry. + +#### log_failure + +If you set a value here, it will apply only to the station registry. + + +## [[AWEKAS]] + +WeeWX can send your current data to the [Automatisches Wetterkarten System +(AWEKAS)](https://www.awekas.at/). If you wish to do this, set the option +`enable` to `true`, then set options `username` and `password` appropriately. +When you are done, it will look something like this: + +``` ini +[StdRestful] + [[AWEKAS]] + enable = true + username = joeuser + password = XXX +``` + +#### enable + +Set to `true` to enable posting to AWEKAS. Optional. Default is `false`. + +#### username + +Set to your AWEKAS username (e.g., `joeuser`). Required. + +#### password + +Set to your AWEKAS password. Required. + +#### language + +Set to your preferred language. Default is `en`. + +#### log_success + +If you set a value here, it will apply only to logging for AWEKAS. + +#### log_failure + +If you set a value here, it will apply only to logging for AWEKAS. + +#### retry_login + +How long to wait in seconds before retrying a bad login. If set to zero, no +retry will be attempted. Default is `3600`. + +#### post_interval + +The interval in seconds between posts. Setting this value to zero will cause +every archive record to be posted. Optional. Default is zero. + + + +## [[CWOP]] + +WeeWX can send your current data to the +[Citizen Weather Observer Program](http://www.wxqa.com/). If you wish to do +this, set the option `enable` to `true`, then set the option `station` to +your CWOP station code. If your station is an amateur radio APRS station, +you will have to set `passcode` as well. When you are done, it will look +something like + +``` ini +[StdRestful] + [[CWOP]] + enable = true + station = CW1234 + passcode = XXX # Replace with your passcode (APRS stations only) + post_interval = 600 +``` + +#### enable + +Set to `true` to enable posting to the CWOP. Optional. Default is `false`. + +#### station + +Set to your CWOP station ID (e.g., CW1234) or amateur radio callsign (APRS). +Required. + +#### passcode + +This is used for APRS (amateur radio) stations only. Set to the passcode given +to you by the CWOP operators. Required for APRS stations, ignored for others. + +#### post_interval + +The interval in seconds between posts. Because CWOP is heavily used, the +operators discourage very frequent posts. Every 5 minutes (300 seconds) is +fine, but they prefer every 10 minutes (600 s) or even longer. Setting this +value to zero will cause every archive record to be posted. Optional. Default +is `600`. + +#### stale + +How old a record can be in seconds before it will not be used for a catch-up. +CWOP does not use the timestamp on a posted record. Instead, they use the wall +clock time that it came in. This means that if your station is off the air for +a long period of time, then when WeeWX attempts a catch-up, old data could be +interpreted as the current conditions. Optional. Default is `600`. + +#### server_list + +A comma-delimited list of the servers that should be tried for uploading data. +Optional. Default is: `cwop.aprs.net:14580, cwop.aprs.net:23` + +#### log_success + +If you set a value here, it will apply only to logging for CWOP. + +#### log_failure + +If you set a value here, it will apply only to logging for CWOP. + + +## [[PWSweather]] + +WeeWX can send your current data to the +[PWSweather.com](https://www.pwsweather.com/) service. If you wish to do this, +set the option `enable` to `true`, then set the options `station` and +`password` appropriately. When you are done, it will look something like this: + +``` ini +[StdRestful] + [[PWSweather]] + enable = true + station = BOISE + password = XXX +``` + +#### enable + +Set to `true` to enable posting to the PWSweather. Optional. Default is +`false`. + +#### station + +Set to your PWSweather station ID (e.g., `BOISE`). Required. + +#### password + +Set to your PWSweather password. Required. + +#### log_success + +If you set a value here, it will apply only to logging for PWSweather. + +#### log_failure + +If you set a value here, it will apply only to logging for PWSweather. + +#### retry_login + +How long to wait in seconds before retrying a bad login. Default is `3600` +(one hour). + +#### post_interval + +The interval in seconds between posts. Setting this value to zero will cause +every archive record to be posted. Optional. Default is zero. + + +## [[WOW]] +WeeWX can send your current data to the +[British Weather Observations Website (WOW)](https://wow.metoffice.gov.uk/) +service. If you wish to do this, set the option `enable` to `true`, then set +options `station` and `password` appropriately. Read [Importing Weather Data +into WOW](https://wow.metoffice.gov.uk/support/dataformats#automatic) on how +to find your site's username and how to set the password for your site. When +you are done, it will look something like this: + +``` ini +[StdRestful] + [[WOW]] + enable = true + station = 12345678 + password = XXX +``` + +#### enable + +Set to `true` to enable posting to WOW. Optional. Default is `false`. + +#### station + +Set to your WOW station ID (e.g., `12345678` for Pre June 1996 sites, or +`6a571450-df53-e611-9401-0003ff5987fd` for later ones). Required. + +#### password + +Set to your WOW Authentication Key. Required. This is not the same as your +WOW user password. It is a 6 digit numerical PIN, unique for your station. + +#### log_success + +If you set a value here, it will apply only to logging for WOW. + +#### log_failure + +If you set a value here, it will apply only to logging for WOW. + +#### retry_login + +How long to wait in seconds before retrying a bad login. Default is `3600` +(one hour). + +#### post_interval + +The interval in seconds between posts. Setting this value to zero will cause +every archive record to be posted. Optional. Default is zero. + + +## [[Wunderground]] + +WeeWX can send your current data to the +[Weather Underground](https://www.wunderground.com/). If you wish to post to +do this, set the option `enable` to `true`, then specify a station (e.g., +`KORBURNS99`). Use the station key for the password. + +When you are done, it will look something like this: + +``` ini +[StdRestful] + [[Wunderground]] + enable = true + station = KORBURNS99 + password = A331D1SIm + rapidfire = false +``` + +#### enable + +Set to `true` to enable posting to the Weather Underground. Optional. Default +is `false`. + +#### station + +Set to your Weather Underground station ID (e.g., `KORBURNS99`). Required. + +#### password + +Set to the station "key". You can find this at: + +https://www.wunderground.com/member/devices. + +#### rapidfire + +Set to `true` to have WeeWX post using the [Weather Underground's "Rapidfire" +protocol](https://www.wunderground.com/weatherstation/rapidfirehelp.asp). This +will send a post to the WU site with every LOOP packet, which can be as often +as every 2.5 seconds in the case of the Vantage instruments. Not all +instruments support this. Optional. Default is `false`. + +#### rtfreq + +When rapidfire is set, the `rtfreq` parameter is sent, and should correspond +to "the frequency of updates in seconds". Optional. Default is `2.5`. + +#### archive_post + +This option tells WeeWX to post on every archive record, which is the normal +"PWS" mode for the Weather Underground. Because they prefer that you either +use their "Rapidfire" protocol, or their PWS mode, but not both, the default +for this option is the opposite for whatever you choose above for option +rapidfire. However, if for some reason you want to do both, then you may +set both options to `true`. + +#### post_indoor_observations + +In the interest of respecting your privacy, WeeWX does not post indoor +temperature or humidity to the Weather Underground unless you set this +option to `true`. Default is `false`. + +#### log_success + +If you set a value here, it will apply only to logging for the Weather +Underground. + +#### log_failure + +If you set a value here, it will apply only to logging for the Weather +Underground. + +#### retry_login + +How long to wait in seconds before retrying a bad login. Default is `3600` +(one hour). + +#### post_interval + +The interval in seconds between posts. Setting this value to zero will cause +every archive record to be posted. Optional. Default is zero. + +#### force_direction + +The Weather Underground has a bug where they will claim that a station is +"unavailable" if it sends a null wind direction, even when the wind speed is +zero. Setting this option to `true` causes the software to cache the last +non-null wind direction and use that instead of sending a null value. Default +is `False`. + +#### [[[Essentials]]] + +Occasionally (but not always!) when the Weather Underground is missing a data +point it will substitute the value zero (0.0), thus messing up statistics and +plots. For all observation types listed in this section, the post will be +skipped if that type is missing. For example: + +``` ini +[StdRestful] + [[Wunderground]] + ... + [[[Essentials]]] + outTemp = True +``` + +would cause the post to be skipped if there is no outside temperature +(observation type `outTemp`). diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdtimesynch.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdtimesynch.md new file mode 100644 index 0000000..84dff0d --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdtimesynch.md @@ -0,0 +1,14 @@ +# [StdTimeSynch] + +The `StdTymeSynch` service can synchronize the onboard clock of station with +your computer. Not all weather station hardware supports this. + +#### clock_check + +How often to check the clock on the weather station in seconds. Default is +`14400` (every 4 hours). + +#### max_drift + +The maximum amount of clock drift to tolerate, in seconds, before resetting +the clock. Default is `5`. diff --git a/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdwxcalculate.md b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdwxcalculate.md new file mode 100644 index 0000000..73c3a70 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/reference/weewx-options/stdwxcalculate.md @@ -0,0 +1,250 @@ +# [StdWXCalculate] + +Some hardware provides derived quantities, such as `dewpoint`, `windchill`, +and `heatindex`, while other hardware does not. This service can be used to +fill in any missing quantities, or to substitute a software-calculated value +for hardware that has unreliable or obsolete calculations. + +By default, the service can calculate the following values, although the list +can be extended: + +* altimeter +* appTemp +* barometer +* cloudbase +* dewpoint +* ET +* heatindex +* humidex +* inDewpoint +* maxSolarRad +* pressure +* rainRate +* windchill +* windrun + +The configuration section `[StdWXCalculate]` consists of two different parts: + +1. The subsection `[[Calculations]]`, which specifies which derived types are +to be calculated and under what circumstances. +2. Zero or more subsections, which specify what parameters are to be used for +the calculation. These are described below. + +The service `StdWXCalculate` can be extended by the user to add new, derived +types by using the XTypes system. See the wiki article +[*Extensible types (XTypes)*](https://github.com/weewx/weewx/wiki/xtypes) +for how to do this. + +#### data_binding + +The data source to be used for historical data when calculating derived +quantities. It should match a binding given in section `[DataBindings]`. +Optional. Default is `wx_binding`. + +!!! Note + The data binding used by the `StdWXCalculate` service should normally + match the data binding used by the `StdArchive` service. Users who use + custom or additional data bindings should take care to ensure the correct + data bindings are used by both services. + +## [[Calculations]] + +This section specifies which strategy is to be used to provide values for +derived variables. It consists of zero or more options with the syntax: + +```ini +obs_type = directive[, optional_bindings]... +``` +where `directive` is one of `prefer_hardware`, `hardware`, or `software`: + +| directive | Definition | +|-------------------|---------------------------------------------------------------------------| +| `prefer_hardware` | Calculate the value in software only if it is not provided by hardware. | +| `hardware` | Hardware values only are accepted: never calculate the value in software. | +| `software` | Always calculate the value in software. | + +The option `optional_binding` is optional, and consists of either `loop`, or +`archive`. If `loop`, then the calculation will be done only for LOOP packets. +If `archive`, then the calculation will be done only for archive records. If +no binding is specified, then it will be done for both LOOP packets and +archive records. + +Example 1: if your weather station calculates windchill using the pre-2001 +algorithm, and you prefer to have WeeWX calculate it using a modern algorithm, +specify the following: + +``` ini +[StdWXCalculate] + [[Calculations]] + windchill = software +``` + +This will force WeeWX to always calculate a value for `windchill`, +regardless of whether the hardware provides one. + +Example 2: suppose you want ET to be calculated, but only for archive records. +The option would look like: + +``` ini +[StdWXCalculate] + [[Calculations]] + ET = software, archive +``` + +### Defaults + +In the absence of a `[[Calculations]]` section, no values will be calculated! + +However, the version of `weewx.conf` that comes with the WeeWX distribution +includes a `[[Calculations]]` section that looks like this: + +``` ini +[StdWXCalculate] + [[Calculations]] + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware +``` + +## [[WXXTypes]] + +The `StdWXXTypes` class is responsible for calculating the following simple, +derived types: + +* appTemp +* cloudbase +* dewpoint +* ET +* heatindex +* humidex +* inDewpoint +* maxSolarRad +* windchill +* windDir +* windRun + +A few of these types have an option or two that can be set. These are +described below. + +### [[[ET]]] + +This subsection contains several options used when calculating ET +(evapotranspiration). See the document [*Step by Step Calculation of the Penman-Monteith Evapotranspiration*](https://www.agraria.unirc.it/documentazione/materiale_didattico/1462_2016_412_24509.pdf) +for the definitions of `cn` and `cd`. + +#### et_period + +The length of time in seconds over which evapotranspiration is calculated. +Default is `3600` (one hour). + +#### wind_height + +The height in meters of your anemometer. Default is `2.0`. + +#### albedo + +The albedo to be used in the calculations. Default is `0.23`. + +#### cn + +The numerator constant for the reference crop type and time step. Default +is `37`. + +#### cd + +The denominator constant for the reference crop type and time step. Default +is `0.34`. + +### [[[heatindex]]] + +#### algorithm + +Controls which algorithm will be used to calculate heat-index. Choices are +`new` (see https://www.wpc.ncep.noaa.gov/html/heatindex_equation.shtml), or +`old`. The newer algorithm will give results down to 40°F, which are sometimes +less than the sensible temperature. For this reason, some people prefer the +older algorithm, which applies only to temperatures above 80°F. Default is +`new`. + +### [[[maxSolarRad]]] + +This section is used for specifying options when calculating `maxSolarRad`, +the theoretical maximum solar radiation. + +#### algorithm + +Controls which algorithm will be used to calculate maxSolarRad. Choices are +`bras` [("Bras")](http://www.ecy.wa.gov/programs/eap/models.html), or `rs` +[(Ryan-Stolzenbach)](http://www.ecy.wa.gov/programs/eap/models.html). Default +is `rs`. + +#### atc + +The coefficient `atc` is the "atmospheric transmission coefficient" used by +the 'Ryan-Stolzenbach' algorithm for calculating maximum solar radiation. +Value must be between `0.7` and `0.91`. Default is `0.8`. + +#### nfac + +The coefficient `nfac` is "atmospheric turbidity" used by the 'Bras' algorithm +for calculating maximum solar radiation. Values must be between `2` (clear) +and `5` (smoggy). Default is `2`. + +### [[[windDir]]] + +#### force_null + +Indicates whether the wind direction should be undefined when the wind speed +is zero. The default value is `true`: when the wind speed is zero, the wind +direction will be set to undefined (Python `None`). + +To report the wind vane direction even when there is no wind speed, change +this to `false`: + +``` ini +[StdWXCalculate] + [[WXXTypes]] + [[[windDir]]] + force_null = false +``` + +### [[PressureCooker]] +This class is responsible for calculating pressure-related values. Given the +right set of input types, it can calculate `barometer`, `pressure`, and +`altimeter`. See the Wiki article [Barometer, pressure, and altimeter](https://github.com/weewx/weewx/wiki/Barometer,-pressure,-and-altimeter) +for the differences between these three types. + +#### max_delta_12h + +Some of the calculations require the temperature 12 hours ago (to compensate +for tidal effects), which requires a database lookup. There may or may not be +a temperature exactly 12 hours ago. This option sets how much of a time +difference in seconds is allowed. The default is `1800`. + +#### [[[altimeter]]] + +#### algorithm + +Which algorithm to use when calculating altimeter from gauge pressure. +Possible choices are `ASOS`, `ASOS2`, `MADIS`, `NOAA`, `WOB`, and `SMT`. +The default is `ASOS`. + +### [[RainRater]] + +This class calculates `rainRate` from recent rain events. + +#### rain_period + +The algorithm calculates a running average over a period of time in the past. +This option controls how far back to go in time in seconds. Default is `1800`. diff --git a/dist/weewx-5.0.2/docs_src/upgrade.md b/dist/weewx-5.0.2/docs_src/upgrade.md new file mode 100644 index 0000000..11dbd5b --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/upgrade.md @@ -0,0 +1,2200 @@ +# Upgrade guide + +!!! Warning + You must use the same upgrade technique as your initial install! For + example, if you used pip to install WeeWX, then you should use pip to + upgrade. If you used a DEB or RPM package to install, then you should + upgrade using the same package type. + +!!! Warning + If you are upgrading from an old `setup.py` installation, see the + instructions [_Migrating setup.py installs to Version 5.0_](https://github.com/weewx/weewx/wiki/v5-upgrade). + +The instructions for upgrading WeeWX are in the quick start guides: + +* [Upgrading using Debian DEB](quickstarts/debian.md#upgrade) +* [Upgrading using Redhat RPM](quickstarts/redhat.md#upgrade) +* [Upgrading using SUSE RPM](quickstarts/suse.md#upgrade) +* [Upgrading using pip](quickstarts/pip.md#upgrade) +* [Upgrading using git](quickstarts/git.md#upgrade) + +The rest of this document describes the changes in each WeeWX release. + + +## Upgrading the WeeWX configuration file + +It does not happen very often, but occasionally a new release will +_require_ changes to the WeeWX configuration file. When this happens, +the installer takes care of upgrading the file, and you do not need to worry +about it. + +However, there are some occasions when you may need to upgrade the file +yourself. In particular, this happens when you run several instances of +`weewxd` on the same machine, each with its own configuration file. The +configuration file named `weewx.conf` will get upgraded, but what about +the others? + +The answer is to use `weectl station upgrade` with the `--config` option +pointing to the configuration file to be upgraded. For example, this would +upgrade the configuration file `/etc/weewx/other.conf`: + +``` +sudo weectl station upgrade --config=/etc/weewx/other.conf +``` + +## Upgrading to V5.0 + +There have been many changes with Version 5, but only a handful are likely to +affect users. With that in mind, we have broken this section down into two +sub-sections: breaking changes, and [non-breaking changes](#non-breaking-changes). + +### Breaking changes + +Changes in this category may require your attention. + +#### Python 2.7 no longer supported + +It has now been over 4 years since the end-of-life of Python 2.7. It's time to +move on. WeeWX V5.x requires Python 3.6 (released 7 years ago) or greater. These +days, all but the most ancient of operating systems offer Python 3, although you +may have to install it. + +#### New utilities + +The old utilities have been collected and put under one master utility, +`weectl`. This make it easy to use `weectl --help` to see which one you want. + +| Old utility | New utility | +|-----------------|--------------------| +| `wee_database` | `weectl database` | +| `wee_debug` | `weectl debug` | +| `wee_device` | `weectl device` | +| `wee_extension` | `weectl extension` | +| `wee_import` | `weectl import` | +| `wee_reports` | `weectl report` | +| `wee_config` | `weectl station` | + +#### pip installs to new location + +The default location for a `setup.py` install was `/home/weewx`. The new pip +install method installs to a directory called `weewx-data` in the home directory +of the user doing the install. However, old installations can continue to use +`/home/weewx` by following the guide [_Migrating setup.py installs to Version +5.0_](https://github.com/weewx/weewx/wiki/v5-upgrade). + +#### WeeWX runs as non-root user + +For new installations and most upgrades, `weewxd` will now run as a non-root +user. The configuration files, skins, databases, and reports are no longer +owned by root. This means that root privilege is no longer required to modify +the WeeWX configuration and the WeeWX daemon no longer runs with escalated +privileges. Escalated privilege is still required to start/stop the daemon. + +For installations that use `apt`, `yum`, or `zypper` (installs that use the +DEB or RPM packages), the WeeWX user is `weewx` and the group is `weewx`. The +home directory of the `weewx` user is `/var/lib/weewx`. The installer puts the +user who does the installation into the `weewx` group. For upgrades, the +installer checks the ownership of `/var/lib/weewx`. If the ownership was other +than `root:root`, the installer will respect the previous configured user and +not change it. + +For installations that use `pip` or `git`, the WeeWX user is whoever created +the station data. + +There are a few places where this change to permissions may affect an existing +WeeWX installation: + +* access to USB and/or serial devices +* access to network ports, specifically binding to port 80 +* creation and updating of reports + +If you encounter problems when upgrading to V5, please see the wiki article +[*What you should know about permissions*](https://github.com/weewx/weewx/wiki/Understanding-permissions) + +#### Changes in the utility `import` + +The use of a field map by the `import` utility has been revised and is now +more consistent across import sources. The result is the `import` utility now +requires a field map definition stanza for all import sources with the field +map defining the mapping from import source field to WeeWX archive field in +addition to defining source field units and identifying cumulative and text +fields. The expanded field map has resulted in the `rain` and `[[Units]]` +import configuration file options being deprecated. Refer to the `import` +utility documentation and comments in the example import configuration files +for further details. + +#### Chinese language code changed to `zh` + +Previously, the non-standard code "`cn`" was used for Chinese language +translations. V5 changes this to `zh`. This will require manually changing any +option that looks like this + + lang = cn + +to this + + lang = zh + +### Non-breaking changes + +Changes in this category involve new functionality, or are not of interest to +existing users, or the upgrade process takes care of old configurations +transparently. + +#### Utility `wunderfixer` has been removed. + +The Weather Underground no longer allows past-dated posts, so the utility is +no longer useful. + +#### setup.py installs no longer possible + +The package `distutils`, and the imperative approach on which the WeeWX +`setup.py` install method depends, [has been +deprecated](https://peps.python.org/pep-0632/) and will ultimately be removed. +The future is clearly pip installs using `pyproject.toml`. See the wiki article +on [Version 5](https://github.com/weewx/weewx/wiki/Version-5) for the technical +reasons why. + +#### `WEEWX_ROOT` is now relative to the configuration file + +In previous versions, `WEEWX_ROOT` was specified in the configuration file as an +absolute path. That requirement has been relaxed and refined. In V5, +`WEEWX_ROOT` can be specified in the configuration file as a _relative_ path, +or not at all. If it is specified as a relative path, it will be relative to +the directory of the configuration file. If it is not specified at all (the +default for new installs), it defaults to the directory of the configuration +file. + +This has the advantage that it allows the entire station data area to be rooted +anywhere in the file tree — one need only specify the location of the +configuration file to locate everything, for example, using the `--config` to +`weectl` or `weewxd`. + +Old configuration files with an absolute path will continue to function as +before. + +#### `SQLITE_ROOT` is now relative to `WEEWX_ROOT` + +Previously, `SQLITE_ROOT` was expected to be an absolute path, but now relative +paths are accepted. A relative path is considered relative to `WEEWX_ROOT`. +Because this is _less restrictive_, it is not expected to affect any users. + +#### New script to configure a daemon + +This affects WeeWX installations that use `pip`. Installations that use `apt`, +`yum`, or `zypper` (installs that use the DEB or RPM packages) are not affected. + +There is a new script `setup-daemon.sh` that will install the files necessary to +run WeeWX as a daemon. This script recognizes many operating systems, including +Linux (with or without systemd), BSD, and macOS. + +This script is not run automatically - you must invoke it explicitly after you +install or upgrade using `pip`. + +For upgrades, if you already have a systemd unit in `/etc/systemd/system`, a +SysV script in `/etc/init.d/weewx`, a launchd plist in `/Library/LaunchDaemons`, +or BSD rc in `/usr/local/etc/rc.d`, the setup script will move that aside. + +#### New location for `user` directory + +This affects WeeWX installations that use `apt`, `yum`, or `zypper` (installs +that use the DEB or RPM packages). Installations that use a `setup.py` or `pip` +install are not affected. + +Previous versions of WeeWX would install code used by extensions alongside +other WeeWX code, usually in `/usr/share/weewx/user`. + +With version 5, the `user` directory is kept with other station data, separate +from the WeeWX code. For DEB and RPM installations, the `user` directory is +now `/etc/weewx/bin/user`. If you upgrade a DEB or RPM installation, any +extensions that were installed in `/usr/share/weewx/user` will be copied to +`/etc/weewx/bin/user`, and the old `user` directory will be moved aside. + +#### Use of systemd units for services + +This affects users who use a DEB or RPM package install, and are running on +operating systems that use systemd, Installations that use a `setup.py` or `pip` +install are not affected. + +For these package installers, two systemd unit files will be installed: the +standard service unit `weewx.service`, and the template service unit +`weewx@.service`. The template unit makes it very easy to run multiple +instances of `weewxd` (for example, if you have more than one weather station or +multiple sets of sensors). + +If you upgrade from V4, the init script `/etc/init.d/weewx` will remain - you +can safely delete it, since the new systemd unit files have precedence. If you +modified the init script, you will have to migrate your changes to the systemd +methods for running daemons. + +If you added a systemd unit file `/etc/systemd/system/weewx.service` to a V4 +installation, then you upgrade from V4, you might want to remove the unit file +then `sudo systemctl daemon-reload` followed by `sudo systemctl enable weewx`. +Until you do this, the unit file in `/etc/systemd` will have precedence over +the unit and unit template that are installed by the installer. If you just +want to override behavior of the units installed by the installer, you should +use the `.d` pattern instead. See the systemd documentation for details. + +#### udev rules installed for core hardware + +Version 5 includes a small change to the `udev` rules for hardware that is +supported by WeeWX. The rules include permissions that make any hardware that +uses USB or serial ports accessible to non-root users. + +If you install WeeWX using `apt`, `yum`, or `zypper`, the udev rules are +installed as part of the installation process. In this case, the rules make +the devices accessible to anyone in the `weewx` group. So if you put yourself +in the `weewx` group, you will not have to `sudo` to communicate with a +supported USB or serial device. + +If you install using `pip`, the rules are installed by the same script that +installs the files necessary to run as a daemon. + +## Upgrading to V4.10 + +### Breaking changes for skins that use delta times + +A "delta time" is the difference between two times, for example the amount of +uptime (difference between start and current time), or the amount of daylight +(difference between sunrise and sunset). In previous versions, delta times were +treated as a special case, which limited their flexibility. With PR #807, a delta time is +treated like any other scalar, which gives you access to all the regular +formatting tools. However, a side effect is that if you want to format the time +in the "long form", that is, so the results look like 4 +hours, 15 minutes, rather than 15300 seconds, +then you will have to say so explicitly. + +If you use the Seasons skin, you will have to make these four changes. +Add the text in green: + +
+ + + + + + + + + + + + + + + + + + + +
V4.9 and earlier Seasons skin4.10 Seasons skin
Seasons/about.inc starting at + line 31 +
+
<tr>
+  <td class="label">$gettext("Server uptime")</td>
+  <td class="data">$station.os_uptime</td>
+</tr>
+<tr>
+  <td class="label">$gettext("WeeWX uptime")</td>
+  <td class="data">$station.uptime</td>
+</tr>
+
+
<tr>
+  <td class="label">$gettext("Server uptime")</td>
+  <td class="data">$station.os_uptime.long_form</td>
+</tr>
+<tr>
+  <td class="label">$gettext("WeeWX uptime")</td>
+  <td class="data">$station.uptime.long_form</td>
+</tr>
+
Seasons/celestial.inc + starting at line 94 +
+
<tr>
+  <td class="label">$gettext("Total daylight")</td>
+  <td class="data">$almanac.sun.visible<br/>$sun_visible_change $change_str</td>
+</tr>
+
+
<tr>
+  <td class="label">$gettext("Total daylight")</td>
+  <td class="data">$almanac.sun.visible.long_form<br/>$sun_visible_change.long_form $change_str</td>
+</tr>
+
+
+ +

+ In a similar manner, if you use the Standard skin, you will have to + make these two changes. +

+ +
+ + + + + + + + + + + + +
V4.9 and earlier Standard skin4.10 Standard skin
Standard/index.html.tmpl + starting at line 309 +
+
<p>$gettext("WeeWX uptime"):  $station.uptime<br/>
+$gettext("Server uptime"): $station.os_uptime<br/>
+
+
<p>$gettext("WeeWX uptime"):  $station.uptime.long_form<br/>
+$gettext("Server uptime"): $station.os_uptime.long_form<br/>
+
+
+ +

+ In both cases, the new formatting directive .long_form + has been used to explicitly request that the delta time be formatted into its + constituent components. The advantage of this approach is that the delta time + can be treated like any other scalar. Here are some examples: +

+ + + + + + + + + + + + + + + + + + + + + + +
TagResults
+
$almanac.sun.visible
+
+
45000 seconds
+
+
$almanac.sun.visible.hour
+
+
12.5 hours
+
+
$almanac.sun.visible.hour.format("%.2f")
+
+
12.50 hours
+
+
$almanac.sun.visible.long_form
+
+
12 hours, 30 minutes, 0 seconds
+
+ +### Changes for custom delta time formats + +

+ Fixing issue #808 + required introducing a separate dictionary for members of group group_deltatime. This means that if you specified custom + formats under [Units]/[[TimeFormats]], then you will + have to move them to the new section [Units]/[[DeltaTimeFormats]]. Very few users should be + affected by this change because the ability to set custom delta time formats is + an undocumented feature. +

+ +

+ The good news is that the contexts they now use have more standard names. The + table below summarizes: +

+ +
+ + + + + + + + + +
V4.9 and earlier4.10
+
  [[TimeFormats]]
+    ...
+    current     = %x %X
+    ephem_day   = %X
+    ephem_year  = %x %X
+    brief_delta = "%(minute)d%(minute_label)s, %(second)d%(second_label)s"
+    short_delta = "%(hour)d%(hour_label)s, %(minute)d%(minute_label)s, %(second)d%(second_label)s"
+    long_delta  = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s"
+    delta_time  = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s"
+
+
  [[TimeFormats]]
+    ...
+    current     = %x %X
+    ephem_day   = %X
+    ephem_year  = %x %X
+
+    [[DeltaTimeFormats]]
+    current = "%(minute)d%(minute_label)s, %(second)d%(second_label)s"
+    hour    = "%(minute)d%(minute_label)s, %(second)d%(second_label)s"
+    day     = "%(hour)d%(hour_label)s, %(minute)d%(minute_label)s, %(second)d%(second_label)s"
+    week    = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s"
+    month   = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s"
+    year    = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s"
+
+
+ +

Elapsed time calls may have to be changed

+

+ The fix for Issue #808 also affects the API. +

+ + + + + + + + + + +
V4.9 and earlier4.10
+
# Create an elapsed time of 1655 seconds
+vt = ValueTuple(1655, 'second', 'group_deltatime')
+vh = ValueHelper(vt,
+                 formatter=self.generator.formatter,
+                 converter=self.generator.converter,
+                 context='short_delta')
+      
+
+
# Create an elapsed time of 1655 seconds
+vt = ValueTuple(1655, 'second', 'group_deltatime')
+vh = ValueHelper(vt,
+                 formatter=self.generator.formatter,
+                 converter=self.generator.converter,
+                 context='day')
+      
+
+ +

+ In summary, because group_deltatime now uses the same context names as any other + time, you will have to change the call to use one of the conventional contexts. +

+ + +## Upgrading to V4.9 +### wee_reports may require --epoch option. +

+ In previous versions, the utility wee_reports could take an optional position + argument that specified the reporting time in unix epoch time. For example, +

+
wee_reports /home/weewx/weewx.conf 1645131600
+

+ would specify that the reporting time should be 1645131600, or 17-Feb-2022 13:00 PST. +

+

+ Starting with V4.9, the unix epoch time must be specified using the --epoch flag, + so the command becomes +

+
wee_reports /home/weewx/weewx.conf --epoch=1645131600
+

+ Alternatively, the reporting time can be specified by using --date and --time flags: +

+
wee_reports /home/weewx/weewx.conf --date=2022-02-17 --time=13:00
+ +### Init function of class WXXTypes has changed +

+ In earlier versions, ET was calculated in class WXXTypes. Now it has its own + class, ETXType. As a result, some initialization parameters are no longer needed + and have been removed. +

+

+ Note also that the parameter heat_index_algo was changed to heatindex_algo + in keeping with the rest of the code. +

+

+ Unless you have been writing specialized code that required direct access to WXXTypes you are very unlikely to be affected. +

+ + + + + + + + + +
V4.8 and earlier4.9
+
def __init__(self, altitude_vt, latitude_f, longitude_f,
+        et_period=3600,
+        atc=0.8,
+        nfac=2,
+        wind_height=2.0,
+        force_null=True,
+        maxSolarRad_algo='rs',
+        heat_index_algo='new')
+      
+
+
def __init__(self, altitude_vt, latitude_f, longitude_f,
+        atc=0.8,
+        nfac=2,
+        force_null=True,
+        maxSolarRad_algo='rs',
+        heatindex_algo='new')
+      
+
+ +### Calling signature of some archive span functions has changed + +

+ The calling signature of the following functions has changed. In general, the parameter grace + has been removed in favor of a more robust method for figuring out which time span a timestamp belongs to. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
V4.8 and earlier4.9
+
def archiveHoursAgoSpan(time_ts, hours_ago=0, grace=1)
+
+
def archiveHoursAgoSpan(time_ts, hours_ago=0)
+
+
def archiveDaySpan(time_ts, grace=1, days_ago=0)
+
+
def archiveDaySpan(time_ts, days_ago=0)
+
+
def archiveWeekSpan(time_ts, startOfWeek=6, grace=1, weeks_ago=0)
+
+
def archiveWeekSpan(time_ts, startOfWeek=6, weeks_ago=0)
+
+
def archiveMonthSpan(time_ts, grace=1, months_ago=0)
+
+
def archiveMonthSpan(time_ts, months_ago=0)
+
+
def archiveYearSpan(time_ts, grace=1, years_ago=0)
+
+
def archiveYearSpan(time_ts, years_ago=0)
+
+
def archiveRainYearSpan(time_ts, sory_mon, grace=1)
+
+
def archiveRainYearSpan(time_ts, sory_mon)
+
+ +## Upgrading to V4.6 +### Ordering of search list changed +

+ In previous versions, user-supplied search list extensions were appended to the search list. + Starting with V4.6, they are now prepended to the list. This means that when Cheetah is evaluating + the list, looking for a tag, it will encounter user-supplied extensions first, allowing extensions to + override the built-in tags and change their behavior. +

+### Tag $alltime +

+ The tag $alltime, formerly included as an example, is now a part of the core of + WeeWX. This means you may no longer need to include the "stats" example as a Cheetah search list extension, + although leaving it in will do no harm. +

+ + +## Upgrading to V4.5 +

+ Version 4.5 changes are unlikely to affect anyone, except extension writers who are deep in the internals of + WeeWX. +

+ +### API changes +

+ There have been a number of changes in the WeeWX API. None of them are likely to affect end users. Overall, + the biggest change is that the data held internally in class ValueHelper is now + held in the target unit system, rather than being converted at time of use. +

+ +

Defaults for ValueHelper have changed

+

+ Before, when constructing a new ValueHelper, if no converter was included in the + constructor, a default converter, which converts to US Units, was supplied. Now, if no converter is included + in the constructor, no default converter at all is supplied, and the data remain in the original units. This + is unlikely to affect most extension writers because the usual case is to supply a converter to the ValueHelper constructor. +

+ +

Function as_value_tuple() may raise KeyError

+

+ Previously, if the function as_value_tuple() was asked to return a ValueTuple of a non-existent type, it returned a ValueTuple + with values (None, None, None). Now it raises a KeyError + exception. +

+ +## Upgrading to V4.4 + +### Auto patch of daily summaries + +

+ The V4.2 daily summary patch inadvertently introduced a bug which caused the wind + daily summary to be incorrectly weighted. See issue #642. V4.4 includes a patch to automatically fix the + defective daily summary. It is run only once on the first use of a database (usually by weewxd) + and takes only a few seconds. +

+ +## Upgrading to V4.3 + +### Auto patch of daily summaries + +

+ Version 4.2 inadvertently introduced a bug, which prevented the sums that are kept in the daily summaries + from being weighted by the archive interval. See issue #623. + V4.3 includes a patch, which will fix any defective daily summaries automatically. It is run only once on + the first use of a database (usually by weewxd) and takes only a few seconds. +

+ +### Option ignore_zero_wind has been renamed and moved + +

+ Normally, WeeWX sets wind direction to undefined when wind speed is zero. While it was never documented, + some users used option ignore_zero_wind to prevent this: if set to False, wind direction will not be set to undefined when wind speed is zero. However, the + option's location and name has changed. It is now called force_null, and it is now + located under [StdWXCalculate] / [[WXXTypes]] / [[[windDir]]]. The old name and + location has been deprecated, but still honored. +

+ + + + + + + + + + +
V4.2 and earlier4.3
+
[StdWXCalculate]
+    ignore_zero_wind = False
+    [[Calculations]]
+        ...
+
+
[StdWXCalculate]
+    [[Calculations]]
+        ...
+    [[WXXTypes]]
+    [[[windDir]]]
+        force_null = False
+
+ +### Option log_failure set to True +

+ The option log_failure under [StdReport] controls + whether to log failures into the system log. It was previously set to False. The + upgrade process will set it to True, so that users can better see failure modes. +

+ + + + + + + + + + +
V4.2 and earlier4.3
+
[StdReport]
+	...
+	log_failure = False
+	...
+
+
[StdReport]
+	...
+	log_failure = True
+	...
+
+ +## Upgrading to V4.2 + +

+ For the most part, V4.2 is backwards compatible with previous versions. There is one small exception. +

+ +### Type beaufort deprecated +

+ The type beaufort has been deprecated, although there are no immediate plans to + remove it — using it will put a warning in the log. Instead, it has become a new unit, which can be + used as part of group_speed. Here's an example: +

+ + + + + + + + + +
V4.1 and earlier4.2
+
<p>
+The current wind speed is $current.windSpeed,
+which is $current.beaufort on the beaufort scale.
+</p>
+
+
<p>
+The current wind speed is $current.windSpeed,
+which is $current.windSpeed.beaufort on the beaufort scale.
+</p>
+
+ +## Upgrading to V4.0 + +

+ For the most part, V4.0 is backwards compatible with previous versions. +

+### Python 2.5 and 2.6 +

+ Support for Python 2.5 and 2.6 has been dropped. It has now been well over 10 years since these versions + were introduced, and 6+ years since they were supported by the Python Software Foundation. If you are using + Python 2.5 or 2.6, then you should either upgrade your copy of Python, or stay with your old version of + WeeWX. +

+### [StdWXCalculate] +

+ In earlier versions of WeeWX, many derived types were calculated by default by the service StdWXCalculate. By contrast, in WeeWX V4, no derived types are calculated by default + — all desired types must be explicitly listed in weewx.conf. For most users, + this will not cause a problem because most types were already listed in weewx.conf. + However if you deleted one of the following types in the clause [StdWXCalculate], + and started to depend on the default calculations, then the type will no longer be calculated in V4. +

+ + + + + + + + + + + + + + + +
pressurebarometeraltimeter
windchillheatindexdewpoint
inDewpointrainRate
+

+ In this case, you should add the type to [StdWXCalculate]. For example, if for + some reason you deleted dewpoint, then you would need to add the following +

+
[StdWXCalculate]
+  [[Calculations]]
+    ...
+    dewpoint = prefer_hardware
+
+ +## Upgrading to V3.9 + +### New skins +

+ Version 3.9 introduces and installs a new skin, Seasons, and promotes two old skins, + Mobile and Smartphone, previously part of Standard, to first-class, independent, + skins. +

+ +
    +
  • + If you are installing fresh (not an upgrade), then all four skins, Seasons, Standard, + Mobile, and Smartphone will be installed, but only Seasons will be activated. +
  • +
  • + If you are upgrading, then the three new skins, Seasons, Mobile, and + Smartphone will be installed, but none will be activated. Your existing Standard will + be left untouched and activated. For most people, your website will continue to look as expected (the + exception is explained below in section Do you need to change anything?). +
  • +
+ +!!! Note + If you are upgrading, and you wish to try the new skin Seasons, + then activate it, but be sure to deactivate Standard. Otherwise, + both will get generated, and they will compete with each other for + placement in your HTML directory. + +### Skin defaults +

+ Version 3.9 introduces a new section, [StdReport]/[[Defaults]] in weewx.conf. Options in this section apply to all reports. For example, if you + set a unit group here, it will be applied everywhere. This makes it easy to set the units across all + reports, or to ensure that the labels for observations are the same in every report. +

+

+ It also adds a file defaults.py, in which the fallback values for every report + parameter are specified. Although the defaults file is currently useful only to developers, in the future it + may be extended to facilitate translations and localization. +

+ +### Do you need to change anything? + +

+ The introduction of the new section, [StdReport]/[[Defaults]] in weewx.conf, can change which units are applied to reports + because it has a higher precedence than what is in skin.conf. See the section How to specify options for + details of the ordering in which an option is considered. +

+ +

+ Most users will be unaffected by these changes because they depend on specific report overrides. There is, + however, one exception: +

+ +!!! Note + If your installation does not use overrides, and you changed to metric + units in your `skin.conf` file, you will be affected. + +

+ Your reports will start appearing in U.S. Customary units. The reason is that + the new section, [StdReport]/[[Defaults]] has higher + priority than options in skin.conf, and thus will + start asserting itself.

+ +

+ The fix is simple: modify the [StdReport]/[[Defaults]] to suit your preferences. +

+ +

+ However, users who specified what unit system to use as part of the automated install using a package + installer or setup.py, will have an override section, and therefore will be + unaffected. This is because the override section has the highest priority. +

+ +## Upgrading to V3.7 + + +### Changes to daily summaries +

Perhaps the most significant change in V3.7 is a fix for how daily summary values are calculated and stored. +

+ +

Daily summaries were introduced in V3.0 (December 2014), to speed up certain kinds of aggregation + calculations. They were designed to "weight" values depending on the time length of the archive record that + contributed them. For example, shorter intervals contributed less than longer intervals. This was intended + to enable changes to the length of the archive interval. +

+ +

Unfortunately, a bug caused the weightings to all be one (1), regardless of what the actual archive interval + length might be. This means all archive records, long and short, contribute the same amount, instead of + being weighted by their length. So, if the archive interval were to change, averages would be calculated + incorrectly. +

+ +

+ This bug will only affect you if you have changed the length of your archive interval, or if you plan to. If + you have not changed your archive interval length, and have no plans to do so, then there is nothing you + need to do. +

+ +!!! Note + Your archive may have interval values of different sizes if +
    +
  • you have imported data using `wee_import`
  • +
  • your hardware has a data logger, your configuration uses `record_generation=software`, and the `archive_interval` is different from that in the hardware
  • +
  • your archive contains data from more than one type of hardware
  • +
+ +

The weectl +database update utility can fix this problem by recalculating the +weights in the daily summary, on the basis of the actual archive interval +lengths. On a Raspberry Pi 1, model B, it takes about 10 minutes to fix a +database with 10 years of data. On faster machines, it takes much less time. +

+ +
weectl database update
+ +

+ If you have multiple databases, consider recalculating the weights in each database. Interval weighting will + only need to be applied to databases that have daily summaries, i.e., the binding uses +

+
manager = weewx.wxmanager.WXDaySummaryManager
+

+ To apply interval weighting to a database other than the default wx_binding, use + the --binding=BINDING_NAME option. +

+
weectl database update --binding=cmon_binding
+ +### Recalculation of windSpeed maximum values + +

Version 3.7 changes how maximum windSpeed is calculated for aggregations of a day + or more. Previously, if option use_hilo was set to True + (the usual case), maximum windSpeed for a day was set to the maximum value seen in + the LOOP packets for the day. In practice this is the same value as windGust. That + is, these two tags would supply the same value: +

+ +
$day.windSpeed.max
+  $day.windGust.max
+ +

+In Version 3.7, this has been changed so the maximum windSpeed is now set to the + maximum archive value seen during the day — usually a slightly smaller number. However, older + daily max values will still contain the max LOOP packet values. If you wish to update your older values and + have them use the max archive record value, use the utility weectl + database update . +

+ + +### Change in the name and locations of examples +

+ In earlier versions, examples were located with the WeeWX code base, and packaged as a Python module. This + is categorically and semantically incorrect. +

+

+ The examples now live in their own directory, whose location + is dependent on the installation method. If you use an example, you should copy it to the user subdirectory, modify it if necessary, then use it there. Your copy will be retained + across version upgrades. +

+ + + + + + + + + + +
3.63.7
+
examples.alarm.MyAlarm
+examples.lowBattery.MyAlarm
+examples.xsearch.MyXSearch
+
+
user.alarm.MyAlarm
+user.lowBattery.BatteryAlarm
+user.stats.MyStats
+
+ +

Finally, note that the name of one example has been changed:

+ + + + + + + + + + +
3.63.7
+
xsearch.py
+
+
stats.py
+
+ + +### Changes to sensor mapping +

+ The mapping of sensors to database fields was formalized in Version 3.7, resulting in changes to the + configuration for some of the drivers, including cc3000, te923, wmr100, wmr200, wmr300, and wmr9x8. +

+

+ For the wmr200 driver, until Version 3.6, the fields extraTemp1 and extraHumid1 + were not usable. Version 3.6.1 shifted the extraTemp and extraHumid mappings down one channel, so that + extraTemp1 corresponded to channel 2, extraTemp2 corresponded to channel 3, and so on. The mappings can now + be modified by a change to the configuration file, and the default mappings are as follows: +

+ + + + + + + + + +
3.63.7
+
** hard-coded in the driver **
+
+	outTemp = temperature_1
+	# extraTemp1 is not usable
+	extraTemp2 = temperature_2
+	extraTemp3 = temperature_3
+	extraTemp4 = temperature_4
+	extraTemp5 = temperature_5
+	extraTemp6 = temperature_6
+	extraTemp7 = temperature_7
+	extraTemp8 = temperature_8
+	outHumidity = humidity_1
+	# extraHumid1 is not usable
+	extraHumid2 = humidity_2
+	extraHumid3 = humidity_3
+	extraHumid4 = humidity_4
+	extraHumid5 = humidity_5
+	extraHumid6 = humidity_6
+	extraHumid7 = humidity_7
+	extraHumid8 = humidity_8
+
+
[WMR200]
+	[[sensor_map]]
+          altimeter = altimeter
+          pressure = pressure
+          windSpeed = wind_speed
+          windDir = wind_dir
+          windGust = wind_gust
+          windBatteryStatus = battery_status_wind
+          inTemp = temperature_0
+          outTemp = temperature_1
+          extraTemp1 = temperature_2
+          extraTemp2 = temperature_3
+          extraTemp3 = temperature_4
+          extraTemp4 = temperature_5
+          extraTemp5 = temperature_6
+          extraTemp6 = temperature_7
+          extraTemp7 = temperature_8
+          inHumidity = humidity_0
+          outHumidity = humidity_1
+          extraHumid1 = humidity_2
+          extraHumid2 = humidity_3
+          extraHumid3 = humidity_4
+          extraHumid4 = humidity_5
+          extraHumid5 = humidity_6
+          extraHumid6 = humidity_7
+          extraHumid7 = humidity_8
+          outTempBatteryStatus = battery_status_out
+          rain = rain
+          rainTotal = rain_total
+          rainRate = rain_rate
+          hourRain = rain_hour
+          rain24 = rain_24
+          rainBatteryStatus = battery_status_rain
+          UV = uv
+          uvBatteryStatus = battery_status_uv
+          windchill = windchill
+          heatindex = heatindex
+          forecastIcon = forecast_icon
+          outTempFault = out_fault
+          windFault = wind_fault
+          uvFault = uv_fault
+          rainFault = rain_fault
+          clockUnsynchronized = clock_unsynchronized
+
+

+ For the te923 driver: +

+ + + + + + + + + +
3.63.7
+
[TE923]
+	[[map]]
+          link_wind = windLinkStatus
+          bat_wind = windBatteryStatus
+          link_rain = rainLinkStatus
+          bat_rain = rainBatteryStatus
+          link_uv = uvLinkStatus
+          bat_uv = uvBatteryStatus
+          uv = UV
+          t_in = inTemp
+          h_in = inHumidity
+          t_1 = outTemp
+          h_1 = outHumidity
+          bat_1 = outBatteryStatus
+          link_1 = outLinkStatus
+          t_2 = extraTemp1
+          h_2 = extraHumid1
+          bat_2 = extraBatteryStatus1
+          link_2 = extraLinkStatus1
+          t_3 = extraTemp2
+          h_3 = extraHumid3
+          bat_3 = extraBatteryStatus2
+          link_3 = extraLinkStatus2
+          t_4 = extraTemp3
+          h_4 = extraHumid3
+          bat_4 = extraBatteryStatus3
+          link_4 = extraLinkStatus3
+          t_5 = extraTemp4
+          h_5 = extraHumid4
+          bat_5 = extraBatteryStatus4
+          link_5 = extraLinkStatus4
+
+
[TE923]
+	[[sensor_map]]
+          windLinkStatus = link_wind
+          windBatteryStatus = bat_wind
+          rainLinkStatus = link_rain
+          rainBatteryStatus = bat_rain
+          uvLinkStatus = link_uv
+          uvBatteryStatus = bat_uv
+          inTemp = t_in
+          inHumidity = h_in
+          outTemp = t_1
+          outHumidity = h_1
+          outTempBatteryStatus = bat_1
+          outLinkStatus = link_1
+          extraTemp1 = t_2
+          extraHumid1 = h_2
+          extraBatteryStatus1 = bat_2
+          extraLinkStatus1 = link_2
+          extraTemp2 = t_3
+          extraHumid2 = h_3
+          extraBatteryStatus2 = bat_3
+          extraLinkStatus2 = link_3
+          extraTemp3 = t_4
+          extraHumid3 = h_4
+          extraBatteryStatus3 = bat_4
+          extraLinkStatus3 = link_4
+          extraTemp4 = t_5
+          extraHumid4 = h_5
+          extraBatteryStatus4 = bat_5
+          extraLinkStatus4 = link_5
+
+ + +## Upgrading to V3.6 + +### Changes to weewx.conf +

+ In Version 3.6, the list of options that describe how derived variables are to be calculated have been moved + to a new subsection called [[Calculations]]. The upgrade process will + automatically make this change for you. +

+ + + + + + + + + + +
3.53.6
+
[StdWXCalculate]
+	pressure = prefer_hardware
+	barometer = prefer_hardware
+	altimeter = prefer_hardware
+	windchill = prefer_hardware
+	heatindex = prefer_hardware
+	dewpoint = prefer_hardware
+	inDewpoint = prefer_hardware
+	rainRate = prefer_hardware
+
+      
+
+
[StdWXCalculate]
+	[[Calculations]]
+        pressure = prefer_hardware
+        barometer = prefer_hardware
+        altimeter = prefer_hardware
+        windchill = prefer_hardware
+        heatindex = prefer_hardware
+        dewpoint = prefer_hardware
+        inDewpoint = prefer_hardware
+        rainRate = prefer_hardware
+
+ + +## Upgrading to V3.2 + +### New utilities + +

+ Version 3.2 includes new utilities to facilitate configuration. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
3.23.1 and earlier
wee_configChange driver or other station parameters
wee_extensionsetup.py or wee_setupInstall, list, and remove extensions
wee_databasewee_config_databaseManipulate the database
wee_devicewee_config_deviceGet and set parameters on the hardware
wee_reportswee_reportsRun reports
+ +### Changes to weewx.conf + +

Release 3.2 introduces a new section [DatabaseTypes]. This section defines the + default settings for each type of database. For example, the host, user, and password for MySQL databases + can be specified once in the DatabaseTypes section instead of repeating in each + Database section. The defaults in DatabaseTypes can be + overridden in each Database section as needed. +

+ + + + + + + + + + +
3.13.2
+
[DataBindings]
+    [[wx_binding]]
+        database = archive_sqlite
+        manager = weewx.wxmanager.WXDaySummaryManager
+        table_name = archive
+        schema = schemas.wview.schema
+[Databases]
+    [[archive_sqlite]]
+        root = %(WEEWX_ROOT)s
+        database = archive/weewx.sdb
+        driver = weedb.sqlite
+    [[archive_mysql]]
+        host = localhost
+        user = weewx
+        password = weewx
+        database = weewx
+        driver = weedb.mysql
+
+
+
+
+
+
+
+
+
+      
+
+
[DataBindings]
+    [[wx_binding]]
+        database = archive_sqlite
+        manager = weewx.wxmanager.WXDaySummaryManager
+        table_name = archive
+        schema = schemas.wview.schema
+[Databases]
+    [[archive_sqlite]]
+        database_name = weewx.sdb
+        database_type = SQLite
+
+    [[archive_mysql]]
+        database_name = weewx
+        database_type = MySQL
+
+
+
+	[DatabaseTypes]
+    [[SQLite]]
+          driver = weedb.sqlite
+          SQLITE_ROOT = %(WEEWX_ROOT)s/archive
+    [[MySQL]]
+          driver = weedb.mysql
+          host = localhost
+          user = weewx
+          password = weewx
+
+ +## Upgrading to V3.0 + +### Overview + +

While a lot has changed with Version 3, the upgrade process should take care of most changes for most users. + However, if you have installed an extension or, especially, if you have written a custom service, search + list extension, or uploader, you will have to make some changes. +

+ +### Database contents + +

+ With Version 3, there is no longer a separate "stats" database. Instead, "daily summaries" have been + included in the same database as the archive data — everything is under one name. For example, if you + are using sqlite, both the archive data and the daily summaries will be inside file weewx.sdb. + With MySQL, everything is inside the database weewx. This makes it easier to keep + all data together when working with multiple databases. +

+ +

This change in database structure should be transparent to you. On startup, WeeWX will automatically backfill + the new internal daily summaries from the archive data. Your old "stats" database will no longer be used and + may safely set aside or even deleted. +

+ +### Pressure calibration + +

+ Since the new StdWXCalculate service is applied after the StdCalibrate + services, the pressure_offset parameter is no longer necessary; pressure + calibration can be applied just like any other calibration. If your pressure was calibrated using the pressure_offset parameter, move the calibration to the [StdCalibrate] section. This applies only to CC3000, FineOffsetUSB, Ultimeter, WS1, + WS23xx, and WS28xx hardware. +

+ +### weewx.conf + +

+ A version 2.X weewx.conf file is not compatible with Version 3.X. However, the + upgrade process should automatically update the file. Nevertheless, the changes are documented below: +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
2.73.0
+
[StdReport]
+    data_binding = wx_binding
+
+
[StdArchive]
+    archive_database = archive_sqlite
+    stats_database = stats_sqlite
+    archive_schema = user.schemas.defaultArchiveSchema
+    stats_schema = user.schemas.defaultStatsSchema
+
+
[StdArchive]
+    data_binding = wx_binding
+
+
[DataBindings]
+    [[wx_binding]]
+        database = archive_sqlite
+        manager = weewx.wxmanager.WXDaySummaryManager
+        table_name = archive
+        schema = schemas.wview.schema
+
+
[Databases]
+	[[archive_sqlite]]
+        root = %(WEEWX_ROOT)s
+        database = archive/weewx.sdb
+        driver = weedb.sqlite
+    [[stats_sqlite]]
+        root = %(WEEWX_ROOT)s
+        database = archive/stats.sdb
+        driver = weedb.sqlite
+    [[archive_mysql]]
+        host = localhost
+        user = weewx
+        password = weewx
+        database = weewx
+        driver = weedb.mysql
+    [[stats_mysql]]
+        host = localhost
+        user = weewx
+        password = weewx
+        database = stats
+        driver = weedb.mysql
+
+
[Databases]
+    [[archive_sqlite]]
+        root = %(WEEWX_ROOT)s
+        database_name = archive/weewx.sdb
+        driver = weedb.sqlite
+
+	
+	
+	
+    [[archive_mysql]]
+        host = localhost
+        user = weewx
+        password = weewx
+        database_name = weewx
+        driver = weedb.mysql
+	
+	
+	
+	
+	
+	
+      
+
+
[Engines]
+    [[WxEngine]]
+        prep_services = \
+          weewx.wxengine.StdTimeSynch
+        process_services = \
+          weewx.wxengine.StdConvert, \
+          weewx.wxengine.StdCalibrate, \
+          weewx.wxengine.StdQC
+	
+        archive_services = \
+          weewx.wxengine.StdArchive
+        restful_services = \
+          weewx.restx.StdStationRegistry, \
+          weewx.restx.StdWunderground, \
+          weewx.restx.StdPWSweather, \
+          weewx.restx.StdCWOP, \
+          weewx.restx.StdWOW, \
+          weewx.restx.StdAWEKAS
+        report_services = \
+          weewx.wxengine.StdPrint, \
+          weewx.wxengine.StdReport
+
+
[Engine]
+    [[Services]]
+        prep_services = \
+          weewx.engine.StdTimeSynch
+        process_services = \
+          weewx.engine.StdConvert, \
+          weewx.engine.StdCalibrate, \
+          weewx.engine.StdQC, \
+          weewx.wxservices.StdWXCalculate
+        archive_services = \
+          weewx.engine.StdArchive
+        restful_services = \
+          weewx.restx.StdStationRegistry, \
+          weewx.restx.StdWunderground, \
+          weewx.restx.StdPWSweather, \
+          weewx.restx.StdCWOP, \
+          weewx.restx.StdWOW, \
+          weewx.restx.StdAWEKAS
+        report_services = \
+          weewx.engine.StdPrint, \
+          weewx.engine.StdReport
+
+ +### Custom data sources in skins + +

The mechanism for specifying non-default data sources in skins has changed. If you modified the Standard + skin, or created or used other skins that draw data from a database other than the weather database, you + must change how the other sources are specified. +

+ +

For example, in the cmon extension:

+ + + + + + + + + +
2.73.0
+
[ImageGenerator]
+	...
+	[[day_images]]
+        ...
+        [[[daycpu]]]
+            archive_database = cmon_sqlite
+        [[[cpu_user]]]
+        [[[cpu_idle]]]
+        [[[cpu_system]]]
+
+
[ImageGenerator]
+	...
+	[[day_images]]
+        ...
+        [[[daycpu]]]
+            data_binding = cmon_binding
+        [[[cpu_user]]]
+        [[[cpu_idle]]]
+        [[[cpu_system]]]
+
+ +### Extensions + +

+ Many skins will work in v3 with no modification required. However, every search list extension will have to + be upgraded, every restful extension must be upgraded, and some other services must be upgraded. +

+ +

+ There is no automated upgrade system for extensions; if an extension must be upgraded, you must do it + manually. If the extension has any python code, this will mean replacing any v2-compatible code with + v3-compatible code. In some cases parts of the configuration file weewx.conf must + be modified. +

+ +

+ For example, to update the pmon extension, replace bin/user/pmon.py + with the v3-compatible pmon.py and modify weewx.conf as + shown in this table: +

+ + + + + + + + + + + + + + + + + +
2.73.0
+
[ProcessMonitor]
+	database = pmon_sqlite
+
+
[ProcessMonitor]
+	data_binding = pmon_binding
+
+
[DataBindings]
+	  [[pmon_binding]]
+          database = pmon_sqlite
+          manager = weewx.manager.DaySummaryManager
+          table_name = archive
+          schema = user.pmon.schema
+
+
[Databases]
+	[[pmon_sqlite]]
+        root = %(WEEWX_ROOT)s
+        database = archive/pmon.sdb
+        driver = weedb.sqlite
+
+
[Databases]
+	[[pmon_sqlite]]
+        root = %(WEEWX_ROOT)s
+        database_name = archive/pmon.sdb
+        driver = weedb.sqlite
+
+

+ For other extensions, see the extension's documentation or contact the author of the extension. +

+ +### Search list extensions + +

+ The introduction of data bindings has meant a change in the calling signature of search list + extensions. By way of example, here's the example from the document Writing + search list extensions, but with the differences highlighted. +

+ + + + + + + + + + + +
2.73.0
def get_extension(self, timespan, archivedb, statsdb):
+
+	  all_stats = TimeSpanStats(
+          timespan,
+          statsdb,
+          formatter=self.generator.formatter,
+          converter=self.generator.converter)
+	  week_dt = datetime.date.fromtimestamp(timespan.stop) - \
+          datetime.timedelta(weeks=1)
+	  week_ts = time.mktime(week_dt.timetuple())
+	  seven_day_stats = TimeSpanStats(
+          TimeSpan(week_ts, timespan.stop),
+          statsdb,
+          formatter=self.generator.formatter,
+          converter=self.generator.converter)
+	  
+	  search_list_extension = {'alltime'   : all_stats,
+          'seven_day' : seven_day_stats}
+          
+	  return search_list_extension
+
def get_extension_list(self, timespan, db_lookup):
+	  
+	  all_stats = TimespanBinder(
+          timespan, 
+          db_lookup,
+          formatter=self.generator.formatter,
+          converter=self.generator.converter)
+	  week_dt = datetime.date.fromtimestamp(timespan.stop) - \
+          datetime.timedelta(weeks=1)
+	  week_ts = time.mktime(week_dt.timetuple())
+	  seven_day_stats = TimespanBinder(
+          TimeSpan(week_ts, timespan.stop),
+          db_lookup,
+          formatter=self.generator.formatter,
+          converter=self.generator.converter)
+	  
+	  search_list_extension = {'alltime'   : all_stats,
+          'seven_day' : seven_day_stats}
+          
+	  return [search_list_extension]
+
+

A few things to note:

+
    +
  • The name of the extension function has changed from get_extension to get_extension_list. +
  • +
  • The calling signature has changed. Instead of an archive database and a stats database being passed in, + a single database lookup function is passed in. +
  • +
  • The name of the top-level class in the tag chain has changed from TimeSpanStats to TimespanBinder. See the Customizing + Guide for details. +
  • +
  • Instead of returning a single search list extension, the function should now return a Python list of extensions. The list will be searched in order. +
  • +
+ +### Derived quantities + +

Some calculations that were done in drivers or in hardware are now done consistently by the new + StdWXCalculate service. Drivers should no longer calculate derived quantities such as windchill, heatindex, + dewpoint, or rain rate. +

+ +### Driver APIs + +

The base class for drivers has been renamed, and new, optional, methods have been defined to provide hooks + for configuring hardware and producing default and upgraded configuration stanzas. +

+ +

These changes affect only those who have written custom drivers.

+ + + + + + + + + +
2.73.0
+
import weewx.abstractstation
+	
+	class ACME960(weewx.abstractstation.AbstractStation):
+	...
+
+
import weewx.drivers
+	
+	class ACME960(weewx.drivers.AbstractDevice):
+	...
+
+ +### Service APIs + +

The base class for services has moved.

+ +

This affects only those who have written custom services.

+ + + + + + + + + +
2.73.0
+
import weewx.wxengine
+	
+	class BetterMousetrapService(weewx.wxengine.StdService):
+	...
+
+
import weewx.engine
+	
+	class BetterMousetrapService(weewx.engine.StdService):
+	...
+
+ +### RESTful APIs + +

Some of the methods internal to RESTful services have changed, specifically those that relate to getting + configuration options from weewx.conf and configuring databases. +

+ +

This affects only those who have written custom RESTful services.

+ +

Here is an example of obtaining the database dictionary for use in the RESTful service thread.

+ + + + + + + + + +
2.73.0
site_dict = weewx.restx.get_dict(config_dict, 'Uploader')
+	db_name = config_dict['StdArchive']['archive_database']
+	db_dict = config_dict['Databases'][db_name]
+	site_dict.setdefault('database_dict', db_dict)
+
site_dict = config_dict['StdRESTful']['Uploader']
+	site_dict = accumulateLeaves(site_dict, max_level=1)
+	manager_dict = weewx.manager.open_manager_with_config(
+	config_dict, 'wx_binding')
+
+ +### Database APIs + +

The methods for obtaining, opening, and querying databases have changed. This affects only those who have + written code that accesses databases. +

+ +

+ The class Manager and its subclasses have replaced the old Archive + class. The table name is no longer hard-coded, so developers should use the table name from the database + binding. There is no longer a separate StatsDb class, its functions having been + subsumed into the Manager class and its subclasses. +

+ +

+ A new class DBBinder is the preferred way of getting a Manager class, as it will automatically take care of instantiating the right class, as + well as caching instances of Manager. An instance of DBBinder is held by the engine as attribute db_binder. Here's + an example of making a simple query. +

+ + + + + + + + + +
2.73.0
db = config_dict['StdArchive']['archive_database']
+self.database_dict = config_dict['Databases'][db]
+with weewx.archive.Archive.open(self.database_dict) as archive:
+    val = archive.getSql("SELECT AVG(windSpeed) FROM archive"
+        " WHERE dateTime>? AND dateTime<=?",
+        (start_ts, end_ts))
+
with self.engine.db_binder.get_manager('wx_binding') as mgr:
+    val = mgr.getSql("SELECT AVG(windSpeed) FROM %s"
+        " WHERE dateTime>? AND dateTime<=?" %
+        mgr.table_name, (start_ts, end_ts))
+
+ +### Generator APIs + +

+ The base class for report generators has changed. The old class CachedReportGenerator no longer exists; its functionality has been replaced by an + instance of DBBinder, held by the generator superclass. This affects only those + who have written customer generators. +

+ +

Here's an example:

+ + + + + + + + + +
2.73.0
class GaugeGenerator(weewx.reportengine.CachedReportGenerator)
+	def run(self):
+        archive_name = self.config_dict['GaugeGenerator']['archive_name']
+        archive = self._getArchive(archive_name)
+        results = archive.getSql(...)
+
class GaugeGenerator(weewx.reportengine.ReportGenerator)
+	def run(self):
+        mgr = self.generator.db_binder.get_manager()
+        results = mgr.getSql(...)
+
+ +### Extension installer + +

The setup.py utility is now included in the installation; it is no longer necessary + to keep a copy of the WeeWX source tree just for the setup.py utility. +

+ +

For .deb and .rpm installations, the command wee_setup is a symlink to setup.py. +

+ +

The options for installing extensions changed slightly to be more consistent with the rest of the options to + setup.py. +

+ + + + + + + + + +
2.73.0
+
setup.py --extension --install extensions/basic
+setup.py --extension --install basic.tar.gz
+setup.py --extension --uninstall basic
+setup.py --extension --list
+setup.py --extension --install basic.tar.gz --dryrun
+
+
setup.py install --extension extensions/basic
+setup.py install --extension basic.tar.gz
+setup.py uninstall --extension basic
+setup.py list-extensions
+setup.py install --extension basic.tar.gz --dry-run
+
+ +## Upgrading to V2.7 + +

Version 2.7 is backwards compatible with earlier versions with one minor exception.

+ +

+It now includes the ability to localize the WeeWX and server uptimes. Previously, the labels days, hours, and minutes were hardcoded in a Python utility. There was no way of changing them. Now, like any other labels, they are taken from the skin configuration file, skin.conf, section [[Labels]]. Older configuration files had a definition for hour, but none for day, and minute. Also, the old definition for hour used an abbreviation hrs instead of hours. +

+ +

If you do nothing, your WeeWX and station uptimes will look like:

+
Weewx uptime:  1 day, 1 hrs, 41 minutes
+Server uptime: 2 days, 10 hrs, 22 minutes
+

+Note how the label for hours is abbreviated and always uses the plural. If you want the previous behavior, or if you want to localize the labels, you should update your skin configuration file. Remove the old entries for hour and second and replace them with: +

+
day               = " day",    " days"
+hour              = " hour",   " hours"
+minute            = " minute", " minutes"
+second            = " second", " seconds"
+ +

The first item is the singular spelling, the second the plural. This will result in the desired

+
Weewx uptime:  1 day, 1 hour, 41 minutes
+Server uptime: 2 days, 10 hours, 22 minutes
+ +## Upgrading to V2.6 + +

Version 2.6 is backwards compatible with earlier versions, with a couple of small exceptions.

+
    +
  • If you have written a custom WeeWX service, the install routine will try to insert its name into an + appropriate place in one of the five new lists of services to be run. You should check section [Engines][[WxEngine]] to make sure it made a reasonable guess. +
  • +
  • If you have written a custom RESTful service, the architecture for these services has completely + changed. They are now first class services, and are treated like any other WeeWX service. There are some + guides to writing RESTful services using the new architecture at the top of the file bin/weewx/restx.py. + I can also help you with the transition. +
  • +
  • Option interval in the CWOP configuration section has become option post_interval. This change should be done automatically by the install routine. +
  • +
  • The mechanism for specifying RESTful services has changed. To activate a RESTful service, put the driver + in the restful_services list in [Engines][[WxEngine]]. The driver parameter is no + longer necessary in the RESTful service's configuration stanza. These changes should be done + automatically by the install routine. +
  • +
+ +## Upgrading to V2.4 + +

The option time_length will now be the exact length of the resultant plot. Before, + a plot with time_length equal to 24 hours would result in a plot of 27 hours, now + it's 24 hours. If you want the old behavior, set it equal to 27 hours. To do this, change your section in + skin.conf from +

+ +
[[day_images]]
+  x_label_format = %H:%M
+  bottom_label_format = %m/%d/%y %H:%M
+  time_length = 86400 # == 24 hours
+ +

to

+ +
[[day_images]]
+  x_label_format = %H:%M
+  bottom_label_format = %m/%d/%y %H:%M
+  time_length = 97200 # == 27 hours
+ +

The service StdTimeSync now synchronizes the console's onboard clock on startup. + This is nice because if the clock failed, perhaps because the battery was taken out, the time is corrected + first before data is downloaded from the logger's memory. To take advantage of this, you can move + service StdTimeSync to the front of the list of services to be run. For example: +

+ +
[[WxEngine]]
+  # The list of services the main weewx engine should run:
+  service_list = weewx.wxengine.StdTimeSynch, weewx.wxengine.StdConvert,
+  weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC, weewx.wxengine.StdArchive, weewx.wxengine.StdPrint,
+  weewx.wxengine.StdRESTful, weewx.wxengine.StdReport
+ +## Upgrading to V2.3 + +

The signature of the function "loader", used to return an instance of the station + device driver, has changed slightly. It has changed from +

+ +
loader(config_dict)
+ +

to

+ +
loader(config_dict, engine)
+ +

That is, a second parameter, engine, has been added. This is a reference to the + WeeWX engine. +

+ +

This change will affect only those who have written their own device driver.

+ +## Upgrading to V2.2 + +

Version 2.2 introduces a schema, found in + bin/user/schemas.py, for the stats database. This schema is used only when initially creating the + database. If you have a specialized stats database, that is, one that saves types other than the default + that comes with WeeWX, you should edit this file to reflect your changes before attempting to rebuild the + database. +

+ +## Upgrading to V2.0 + +

Version 2.0 introduces many new features, including a revamped internal engine. There are two changes that + are not backwards compatible: +

+
    +
  • The configuration file, weewx.conf. When upgrading from V1.X, the setup + utility will install a new, fresh copy of weewx.conf, which you will then have + to edit by hand. Thereafter, V2.X upgrades should be automatic. +
  • +
  • Custom services. If you have written a custom service, it will have to be updated to use the new engine. + The overall architecture is very similar, except that functions must be bound to events, rather + than get called implicitly. See the sections Customizing + a Service and Adding a Service in the Customization Guide for details on how to do this. +
  • +
+

All skins should be completely backwards compatible, so you should not have to change your templates or skin + configuration file, skin.conf. +

+ +

If you have written a custom report generator it should also be backwards compatible.

+ +## Upgrading to V1.14 + +

Version 1.14 introduces some new webpages that have been expressly formatted for the smartphone by using jQuery. +

+ +

The skins shipped with the distribution take advantage of these features. If you do nothing, your old skins + will continue to work, but you will not be taking advantage of these new webpages. +

+ +

If you want them, then you have two choices:

+
    +
  1. Rename your old skin directory (call it "skins.old") then do the install. This will install the new skin distribution. You can then modify it to reflect any changes you have made, referring to skins.old for guidance. If you have not changed many things, this approach will be the easiest. +
  2. +
  3. Alternatively, change the contents of your existing skin directory to include the new webpages. If you take this approach, you will need to copy over the contents of the subdirectory skins/Standard/smartphone from the distribution into your skins/Standard directory. You will then need to modify your skin.conf. +

    After the section that looks like

    + +
    [[[Mobile]]]
    +      template = mobile.html.tmpl
    + +

    add the following directives:

    + +
    [[[MobileSmartphone]]]
    +      template = smartphone/index.html.tmpl
    +[[[MobileTempOutside]]]
    +      template = smartphone/temp_outside.html.tmpl
    +[[[MobileRain]]]
    +      template = smartphone/rain.html.tmpl
    +[[[MobileBarometer]]]
    +      template = smartphone/barometer.html.tmpl
    +[[[MobileWind]]]
    +      template = smartphone/wind.html.tmpl
    +[[[MobileRadar]]]
    +      template = smartphone/radar.html.tmpl
    + +

    Then modify section [CopyGenerator] to add the highlighted files: +

    + +
    [CopyGenerator]
    +      #
    +      # This section is used by the generator CopyGenerator
    +      #
    +      
    +      # List of files that are to be copied at the first invocation of the generator only
    +      copy_once = backgrounds/*, weewx.css, mobile.css, favicon.ico, smartphone/icons/*, smartphone/custom.js
    +
  4. +
+

Whichever approach you chose, the generated files will appear in public_html/smartphone. The start of the document root will be at public_html/smartphone/index.html. You may want to add a link to this in the template for your main index page skins/Standard/index.html.tmpl. +

+ +## Upgrading to V1.13 + +

Version 1.13 changed the way binding happens to the databases used in reports so that it happens much later. The upshot is that the signature of a few functions changed. Most you are unlikely to encounter. The exception is if you have written custom template search lists, as described in the Customizing weewx guide. This section has been updated to reflect the new function signatures. As a side effect, the illustrated example actually has become much simpler! +

+ +

No changes to skins.

+ +## Upgrading to V1.10 + +

Version 1.10 introduced several new features.

+ +### New almanac features, icon, and mobile template + +

Version 1.10 introduces some extra almanac features, such as the azimuth and elevation of the sun and moon, or when the next solstice will be. It also includes a template formatted for smartphones, as well as an icon ("favicon.ico") that displays in your browser toolbar. The skins shipped with the distribution take advantage of these features. If you do nothing, your old skins will continue to work, but you will not take advantage of these new features. +

+ +

If you want these new features then you have two choices:

+
    +
  1. Rename your old skin directory (call it "skin.old") then do the install. This will install the new skin distribution. You can modify it to reflect any changes you have made, referring to skin.old for guidance. +
  2. +
  3. Alternatively, change the contents of your existing skin directory to take advantage of the new features. If you take this approach, you will need to copy over files favicon.ico, mobile.css, and mobile.html.tmpl from the distribution into your skin/Standard directory. Modify skins/Standard/index.html.tmpl to take advantage of the new almanac features, using the version shipped with the distribution for guidance. You will then need to modify your skin.conf. +

    Add a new [[[Mobile]]] section:

    + +
    [FileGenerator]
    +  ...
    +  [[ToDate]]
    +      ...
    +      [[[Mobile]]]
    +          template = mobile.html.tmpl
    + +

    Then add mobile.css and favicon.ico to the list of files to be copied on report generation: +

    + +
    [CopyGenerator]
    +      copy_once = backgrounds/*, weewx.css, mobile.css, favicon.ico
    +
  4. +
+

Which approach you should take will depend on how extensively you have modified the stock skin distribution. If the modifications are slight, approach #1 will be easier, otherwise use approach #2. +

+ +### Backwards compatibility + +

With the introduction of explicit control of output units in the templates such as

+ +
$day.outTemp.max.degree_C
+ +

the calling signature of the following two Python classes was changed

+
    +
  • weewx.stats.TaggedStats
  • +
  • weewx.stats.TimeSpanStats
  • +
+

The example of writing a custom generator MyFileGenerator (which produced "all time" statistics) has been changed to reflect the new signatures. +

+ +

This will only affect you if you have written a custom generator.

+ +## Upgrading to V1.8 + +

With the introduction of a standard archiving service, StdArchive, the names of some events have changed. This will not affect you unless you have written a custom service. +

+ +## Upgrading to V1.7 + +

V1.7 introduces skins. The skins live in subdirectory skins. They are not compatible with the old template subdirectory --- you can't simply rename templates to skins. +

+ +

The part of the configuration file dealing with the presentation layer has been split off into a separate file skin.conf. Hence, once again, the installation script setup.py will NOT merge your old weewx.conf configuration file into the new one. You will have to re-edit weewx.conf to put in your customizations. You may also have to edit skin.conf for whatever skin you choose (right now, only one skin, Standard, comes with the distribution). +

+ +

However, a reinstall of V1.7 will merge your changes for weewx.conf. It will also merge any changes you have made to skin.conf as well. +

+ +

Please check the following:

+
    +
  • Option "altitude" in section [Station] now takes a unit. Hence, it should look something like: +
    altitude = 120, meter
    + +
  • +
  • In a similar manner, options heating_base and cooling_base in skin.conf also take units: +
    heating_base = 65, degree_F
    +cooling_base = 65, degree_F
    +
  • +
+

The directory 'templates' is no longer used; it has been replaced with directory 'skins'. You may delete it if you wish: +

+ +
rm -r templates
+ +## Upgrading to V1.5 + +

Because the configuration file weewx.conf changed significantly going from V1.4 to V1.5, the installation script setup.py will NOT merge your old configuration file into the new one. You will have to re-edit weewx.conf to put in your customizations. +

+ +## Upgrading to V1.4 + +

Option clock_check, previously found in the [VantagePro] section, is now found in the [Station] section. The install program will put a default value in the new place, but it will not delete nor move your old value over. If you have changed this value or if you cannot stand the thought of clock_check appearing in two different places, you should delete the old one found under [VantagePro] and make sure the new value, found under [Station] is correct. +

+ +

Two Python files are no longer used, so they may be deleted from your installation if you wish:

+ +
rm bin/weewx/processdata.py
+rm bin/weewx/mainloop.py
+ +

In addition, file readme.htm has been moved to subdirectory docs, so the old one can be deleted: +

+ +
rm readme.htm
diff --git a/dist/weewx-5.0.2/docs_src/usersguide/backup.md b/dist/weewx-5.0.2/docs_src/usersguide/backup.md new file mode 100644 index 0000000..90a8934 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/backup.md @@ -0,0 +1,70 @@ +# Backup and restore + +## Backup + +To back up a WeeWX installation, you will need to make a copy of + + * the configuration information (`weewx.conf`), + * skins and templates, + * custom code, and + * the WeeWX database. + +The location of these items depends on how you installed WeeWX. + +=== "Debian" + + | Item | Location | + |---------------|----------------------------| + | Configuration | `/etc/weewx/weewx.conf` | + | Skins | `/etc/weewx/skins` | + | Custom code | `/etc/weewx/bin/user` | + | Database | `/var/lib/weewx/weewx.sdb` | + +=== "Redhat" + + | Item | Location | + |---------------|----------------------------| + | Configuration | `/etc/weewx/weewx.conf` | + | Skins | `/etc/weewx/skins` | + | Custom code | `/etc/weewx/bin/user` | + | Database | `/var/lib/weewx/weewx.sdb` | + +=== "openSUSE" + + | Item | Location | + |---------------|----------------------------| + | Configuration | `/etc/weewx/weewx.conf` | + | Skins | `/etc/weewx/skins` | + | Custom code | `/etc/weewx/bin/user` | + | Database | `/var/lib/weewx/weewx.sdb` | + +=== "pip" + + | Item | Location | + |---------------|-----------------------------------| + | Configuration | `~/weewx-data/weewx.conf` | + | Skins | `~/weewx-data/skins` | + | Custom code | `~/weewx-data/bin/user` | + | Database | `~/weewx-data/archive/weewx.sdb` | + +It is not necessary to back up the generated images, HTML files, or NOAA +reports, because WeeWX can easily regenerate them. + +It is also not necessary to back up the WeeWX code, because it can be +installed again. However, it doesn't hurt to do so. + +!!! Note + For a SQLite configuration, do not make the copy of the database file + while in the middle of a transaction! Schedule the backup for immediately + after an archive record is written, and then make sure the backup + completes before the next archive record arrives. Alternatively, stop + WeeWX, perform the backup, then start WeeWX. + +!!! Note + For a MySQL/MariaDB configuration, save a dump of the archive database. + +## Restore + +To restore from backup, do a fresh installation of WeeWX, replace the +configuration file, skins, and database with those from a backup, then start +`weewxd`. diff --git a/dist/weewx-5.0.2/docs_src/usersguide/installing.md b/dist/weewx-5.0.2/docs_src/usersguide/installing.md new file mode 100644 index 0000000..197785e --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/installing.md @@ -0,0 +1,160 @@ +# Installing WeeWX + +## Required skills + +In the world of open-source hobbyist software, WeeWX is pretty easy to install +and configure. There are not many package dependencies, the configuration is +simple, and this guide includes extensive instructions. There are thousands of +people who have successfully done an install. However, there is no +"point-and-click" interface, so you will have to do some manual configuring. + +You should have the following skills: + +* The patience to read and follow this guide. + +* Willingness and ability to edit a configuration file. + +* Some familiarity with Linux or other Unix derivatives. + +* Ability to do simple Unix tasks such as changing file permissions and + running commands. + +No programming experience is necessary unless you wish to extend WeeWX. In +this case, you should be comfortable programming in Python. + +If you get stuck, there is a very active +[User's Group](https://groups.google.com/g/weewx-user) to help. + + +## Installation overview + +This is an outline of the process to install, configure, and run WeeWX: + +* Check the [_Hardware guide_](../hardware/drivers.md). This will let you + know of any features, limitations, or quirks of your hardware. If your weather + station is not in the guide, you will have to download the driver after you + install WeeWX. + +* Install WeeWX. Use the step-by-step instructions in one of the + [installation methods](#installation-methods). + +* If the driver for your hardware is not included with WeeWX, install the + driver as explained in the [installing a driver](#installing-a-driver) + section. + +* Configure the hardware. This involves setting things like the onboard + archive interval, rain bucket size, etc. You may have to follow directions + given by your hardware manufacturer, or you may be able to use the utility + [weectl device](../utilities/weectl-device.md). + +* Run WeeWX by launching the `weewxd` program, either + [directly](running.md#running-directly), or as a + [daemon](running.md#running-as-a-daemon). + +* Customize the installation. Typically, this is done by changing settings in + the WeeWX [configuration file `weewx.conf`](../reference/weewx-options/introduction.md). + For example, you might want to [register your + station](../reference/weewx-options/stdrestful.md#stationregistry), so it + shows up on a world-wide map of WeeWX installations. To make changes to reports, + see the [_Customization Guide_](../custom/introduction.md). + + +## Installation methods + +There are several different ways to install WeeWX. + + + + + + + + + + + + + + + + + + + + + + + + + + +
InstallerSystemsBest for...
Debianincluding Ubuntu, Mint, Raspberry Pi OS, Devuan + This is the fastest and easiest way to get up and running. The Debian, + Redhat, and SUSE package installers use apt, yum, and + zypper, respectively. You will need root access to install and + run. +
Redhatincluding Fedora, CentOS, Rocky
SUSEincluding openSUSE
pipany operating system +The pip installer will work on any operating system. Use this approach for +macOS or one of the BSDs, or if you are using an older operating system. When +used in a Python "virtual environment" (recommended), this approach is least +likely to disturb other applications on your computer. This is also a good +approach if you plan to do a lot of customization, or if you are developing a +driver, skin, or other extension. Root access is not needed to install, but +may be needed to run. +
gitany operating system +If you want to install WeeWX on a system with very little storage, or if you +want to experiment with code that is under development, then you may want to +run directly from the WeeWX sources. Root access is not needed to install, +but may be needed to run. +
+ +## Installing a driver + +If your hardware requires a driver that is not included with WeeWX, use the +WeeWX extension management utility to download and install the driver. + +First locate the driver for your hardware - start by looking in the drivers +section of the [WeeWX Wiki](https://github.com/weewx/weewx/wiki#drivers). You +will need the URL for the driver release; the URL will refer to a `zip` or +`tgz` file. + +Then install the driver, using the driver's URL: +``` +weectl extension install https://github.com/path/to/driver.zip +``` + +Finally, reconfigure WeeWX to use the driver: +``` +weectl station reconfigure +``` + +See the documentation for +[`weectl extension`](../utilities/weectl-extension.md) and +[`weectl station`](../utilities/weectl-station.md) for details. + +## Installing a skin, uploader or other extension + +There are many skins and other extensions available that add features and +functionality to the core WeeWX capabilities. Use the WeeWX extension +management utility to download, install, and manage these extensions. + +Start by looking in the extensions section of the +[WeeWX Wiki](https://github.com/weewx/weewx/wiki). When you find an +extension you like, make note of the URL to that extension. The URL will refer +to a `zip` or `tgz` file. + +Then install the extension, using the URL: +``` +weectl extension install https://github.com/path/to/extension.zip +``` + +Some extensions work with no further changes required. Others might require +changes to the WeeWX configuration file, for example, login credentials +required to upload data to an MQTT broker. If so, modify the WeeWX +configuration file using a text editor such as `nano`, and see the +extension documentation for details. + +In most cases, you will then have to restart WeeWX. + +See the documentation for [`weectl +extension`](../utilities/weectl-extension.md) for details. diff --git a/dist/weewx-5.0.2/docs_src/usersguide/introduction.md b/dist/weewx-5.0.2/docs_src/usersguide/introduction.md new file mode 100644 index 0000000..5682bd0 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/introduction.md @@ -0,0 +1,70 @@ +# User's Guide + +This is the complete guide to installing, configuring, and troubleshooting +WeeWX. + +## System requirements + +### Python + +Python 3.6 or later is required. Python 2 will not work. + + +### Station hardware + +WeeWX includes support for many types of weather stations. In addition to +hardware support, WeeWX comes with a software simulator, useful for testing +and evaluation. + +The [driver compatibility table](../hardware/drivers.md) in the _Hardware +guide_ has a detailed list of the manufacturers and models supported by the +drivers that come with WeeWX. If you do not see your hardware in this table, +check the list of [supported hardware](https://weewx.com/hardware.html); the +pictures may help you identify the manufacturer and/or model. Compatibility for +some hardware is provided by 3rd party drivers, available at the +[Wiki](https://github.com/weewx/weewx/wiki). Finally, check the [hardware +comparison](https://weewx.com/hwcmp.html) to see if your hardware is known, but +not yet supported. + +If you still cannot find your hardware, post to the +[User's Group](https://groups.google.com/g/weewx-user) for help. + + +### Computer hardware + +WeeWX is written in Python, so it has the overhead associated with that +language. Nevertheless, it is "fast enough" on just about any hardware. +It has been run on everything from an early MacBook to a router! + +I run WeeWX on a vintage 32-bit Fit-PC with a 500 MHz AMD Geode processor and +512 MB of memory. Configured this way, it consumes about 5% of the CPU, 150 MB +of virtual memory, and 50 MB of real memory. + +WeeWX also runs great on a Raspberry Pi, although report generation will take +longer. For example, here are some generation times for the 21 HTML files and 68 +images used by the _Seasons_ skin. See the Wiki article [_Benchmarks of file and +image generation_ +](https://github.com/weewx/weewx/wiki/Benchmarks-of-file-and-image-generation) +for details. + +| Hardware | Files (21) | Images (68) | +|------------------------|-----------:|------------:| +| Mac Mini, M1 2020 | 0.60s | 1.06s | +| NUC Intel i7, 11th gen | 0.89s | 1.14s | +| RPI 5 | 1.63s | 2.03s | +| RPi 4 | 6.24s | 6.39s | +| RPi 3 | 13.06s | 14.07s | +| RPi 2 (32-bit) | 24.27s | 25.95s | +| Rpi Zero W (32-bit) | 53.97s | 57.79s | + + +### Time + +You should run some sort of time synchronization daemon to ensure that your +computer has the correct time. Doing so will greatly reduce errors, especially +if you send data to services such as the Weather Underground. See the Wiki +article [*Time services*](https://github.com/weewx/weewx/wiki/Time-services). + +On stations that support it, the time is automatically synchronized with the +WeeWX server, nominally every four hours. The synchronization frequency can +be adjusted in the WeeWX configuration. diff --git a/dist/weewx-5.0.2/docs_src/usersguide/monitoring.md b/dist/weewx-5.0.2/docs_src/usersguide/monitoring.md new file mode 100644 index 0000000..337f335 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/monitoring.md @@ -0,0 +1,162 @@ +# Monitoring WeeWX + +Whether you run `weewxd` directly or in the background, `weewxd` emits +messages about its status and generates reports. The following sections +explain how to check the status of `weewxd`, locate and view the reports +that it generates, and locate and view the log messages that it emits. + +## Status + +If WeeWX was configured to run as a daemon, you can use the system's `init` +tools to check the status. + +=== "systemd" + + ```{ .shell .copy } + # For Linux systems that use systemd, e.g., Debian, Redhat, SUSE + sudo systemctl status weewx + ``` + +=== "sysV" + + ```{ .shell .copy } + # For Linux systems that use SysV init, e.g., Slackware, Devuan, Puppy + sudo /etc/init.d/weewx status + ``` + +=== "BSD" + + ```{ .shell .copy } + # For BSD systems, e.g., FreeBSD, OpenBSD + sudo service weewx status + ``` + +Another way to see whether WeeWX is running is to use a process monitoring tool +such as `ps`, `top`, or `htop`. For example, the following command will tell +you whether `weewxd` is running, and if it is, you will see the additional +information including process identifier (PID), memory used, and how long it +has been running. +```{ .shell .copy } +ps aux | grep weewxd +``` + +## Reports + +When it is running properly, WeeWX will generate reports, typically every five +minutes. The reports are not (re)generated until data have been received and +accumulated, so it could be a few minutes before you see a report or a change +to a report. + +The location of the reports depends on the operating system and how WeeWX was +installed. See `HTML_ROOT` in the [*Where to find things*](where.md) section. + +If everything is working, the report directory will contain a bunch of HTML +and PNG files. Some of these will be updated each archive interval, others +will be updated less frequently, such as each day or week. + +You can view the reports directly with a web browser on the computer that is +running WeeWX. If the computer has no GUI, consider running a web server +or pushing the reports to a computer that has a web server. These options +are explained in the section [*Web server integration*](webserver.md). + +Depending on the configuration, if WeeWX cannot get data from the sensors, +then it will probably not generate or update any reports. So if you do not +see reports, or the reports are not changing, check the log! + +## Log messages + +In the default configuration, messages from WeeWX go to the system logging +facility. + +The following sections show how to view WeeWX log messages on systems that use +`syslog` and `systemd-journald` logging facilities. See the wiki article +[*How to view the log*](https://github.com/weewx/weewx/wiki/view-logs) for more +details. + +See the wiki article [*How to configure +logging*](https://github.com/weewx/weewx/wiki/logging) for information and +examples about how to configure WeeWX logging. + + +### The `syslog` logging facility + +On traditional systems, the system logging facility puts the WeeWX messages +into a file, along with other messages from the system. The location of the +system log file varies, but it is typically `/var/log/syslog` or +`/var/log/messages`. + +You can view the messages using standard tools such as `tail`, `head`, `more`, +`less`, and `grep`, although the use of `sudo` may be necessary (the system logs +on most modern systems are readable only to administrative users). + +For example, to see only the messages from `weewxd`: +```{.shell .copy} +sudo grep weewxd /var/log/syslog +``` +To see only the latest 40 messages from `weewxd`: +```{.shell .copy} +sudo grep weewxd /var/log/syslog | tail -40 +``` +To see messages as they come into the log in real time (hit `ctrl-c` to stop): +```{.shell .copy} +sudo tail -f /var/log/syslog +``` + +### The `systemd-journald` logging facility + +Some systems with `systemd` use *only* `systemd-journald` as the system logging +facility. On these systems, you will have to use the tool `journalctl` to view +messages from WeeWX. In what follows, depending on your system, you may or may +not need `sudo`. + +For example, to see only the messages from `weewxd`: +```{.shell .copy} +sudo journalctl -u weewx +``` + +To see only the latest 40 messages from `weewxd`: +```{.shell .copy} +sudo journalctl -u weewx --lines 40 +``` + +To see messages as they come into the log in real time: +```{.shell .copy} +sudo journalctl -u weewx -f +``` + +## Logging on macOS + +Unfortunately, with the introduction of macOS Monterey (12.x), the Python +logging handler +[`SysLogHandler`](https://docs.python.org/3/library/logging.handlers.html#sysloghandler), +which is used by WeeWX, does not work[^1]. Indeed, the only handlers in the +Python [`logging`](https://docs.python.org/3/library/logging.html) facility that +work with macOS 12.x or later are standalone handlers that log to files. + +[^1]: See Python issue [#91070](https://github.com/python/cpython/issues/91070). + +Fortunately, there is a simple workaround. Put this at the bottom of your +`weewx.conf` configuration file: + +```{.ini .copy} +[Logging] + + [[root]] + handlers = timed_rotate, + + [[handlers]] + [[[timed_rotate]]] + level = DEBUG + formatter = verbose + class = logging.handlers.TimedRotatingFileHandler + filename = log/{process_name}.log + when = midnight + backupCount = 7 +``` + +This makes messages from the WeeWX application `weewxd` go to the file +`~/weewx-data/log/weewxd.log` instead of the system logger. Messages from +`weectl` will go to `~/weewx-data/log/weectl.log`. + +For an explanation of what all these lines mean, see the wiki article on +[WeeWX logging](https://github.com/weewx/weewx/wiki/WeeWX-v4-and-logging). diff --git a/dist/weewx-5.0.2/docs_src/usersguide/mysql-mariadb.md b/dist/weewx-5.0.2/docs_src/usersguide/mysql-mariadb.md new file mode 100644 index 0000000..0797325 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/mysql-mariadb.md @@ -0,0 +1,93 @@ +# Configuring MySQL / MariaDB + +This section applies only to those who wish to use the MySQL database, instead +of the default SQLite database. It assumes that you already have a working +MySQL or MariaDB server. + +### 1. Install the client libraries + +The Python client library for MySQL/MariaDB must be installed. How to do this +depends on your operating system and how you installed WeeWX. + +=== "Debian" + ``` {.shell .copy} + sudo apt install mysql-client + sudo apt install python3-mysqldb + ``` +=== "Redhat" + ``` {.shell .copy} + sudo yum install MySQL-python + ``` +=== "openSUSE" + ``` {.shell .copy} + sudo zypper install python3-mysqlclient + ``` +=== "pip" + The base MySQL libraries are included as part of a normal pip install. + However, you might want to install a standalone MySQL or MariaDB client + to help with testing. + + If you plan to use MySQL or MariaDB with `sha256_password` or + `caching_sha2_password` authentication, you will also need to install the + module `cryptography`. On some operating systems this can be a bit of a + struggle, but the following usually works. The key step is to update `pip` + before trying the install. + + ```{.shell .copy} + # Activate the WeeWX virtual environment + source ~/weewx-venv/bin/activate + # Make sure pip is up-to-date + python3 -m pip install pip --upgrade + # Install cryptography + python3 -m pip install cryptography + ``` + +### 2. Change the WeeWX configuration to use MySQL + +In the WeeWX configuration file, change the +[`[[wx_binding]]`](../reference/weewx-options/data-bindings.md#wx_binding) +section to point to the MySQL database, `archive_mysql`, instead of the SQLite +database `archive_sqlite`. + +After the change, it will look something like this (change ==Highlighted== ): +```ini hl_lines="3" + [[wx_binding]] + # The database should match one of the sections in [Databases] + database = archive_mysql + + # The name of the table within the database + table_name = archive + + # The class to manage the database + manager = weewx.manager.DaySummaryManager + + # The schema defines to structure of the database contents + schema = schemas.wview_extended.schema +``` + +### 3. Configure the MySQL host and credentials + +Assuming that you want to use the default database configuration, the +[`[[MySQL]]`](../reference/weewx-options/database-types.md#mysql) section +should look something like this: + +```ini + [[MySQL]] + driver = weedb.mysql + host = localhost + user = weewx + password = weewx +``` + +This assumes user `weewx` has the password `weewx`. Adjust as necessary. + +### 4. Configure permissions + +Configure MySQL to give the necessary permissions for the database `weewx` +to whatever MySQL user you choose. Here are the necessary minimum permissions, +again assuming user `weewx` with password `weewx`. Adjust as necessary. + +``` {.sql .copy} +CREATE USER 'weewx'@'localhost' IDENTIFIED BY 'weewx'; +GRANT select, update, create, delete, insert, alter, drop ON weewx.* TO weewx@localhost; +``` diff --git a/dist/weewx-5.0.2/docs_src/usersguide/running.md b/dist/weewx-5.0.2/docs_src/usersguide/running.md new file mode 100644 index 0000000..550a269 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/running.md @@ -0,0 +1,99 @@ +# Running WeeWX + +WeeWX can be run either directly, or as a daemon. When first trying WeeWX, it +is best to run it directly because you will be able to see sensor output and +diagnostics, as well as log messages. Once everything is working properly, run +it as a daemon. + +## Running directly + +To run WeeWX directly, invoke the main program, `weewxd`. To stop it, type +`ctrl-c` (press and hold the `control` key then hit the `c` key). + +```shell +weewxd +``` + +!!! note + Depending on device permissions, you may need root permissions to + communicate with the station hardware. If this is the case, use `sudo`: + ```shell + sudo weewxd + ``` + +!!! note + + If your configuration file is named something other than `weewx.conf`, or + if it is in a non-standard place, then you will have to specify it + explicitly on the command line. For example: + + ``` + weewxd --config=/some/path/to/weewx.conf + ``` + +If your weather station has a data logger, the program will start by +downloading any data stored in the logger into the archive database. For some +stations, such as the Davis Vantage with a couple of thousand records, this +could take a minute or two. + +WeeWX will then start monitoring live sensor data (also referred to as 'LOOP' +data), printing a short version of the received data on standard output, about +once every two seconds for a Vantage station, or considerably longer for some +other stations. + + +## Running as a daemon + +For unattended operations it is best to have WeeWX run as a daemon, so that +it is started automatically when the computer starts up. + +If you installed WeeWX from DEB or RPM package, the daemon configuration is +done automatically; the installer finishes with WeeWX running in the +background. + +For a pip install, you will have to configure the daemon yourself. See the +section [_Run as a daemon_](../quickstarts/pip.md#run-as-a-daemon) in the pip +quick start guide. + +After the daemon is configured, use the tools appropriate to your operating +system to start and stop WeeWX. + +=== "systemd" + + ```{ .shell .copy } + # For Linux systems that use systemd, e.g., Debian, Redhat, SUSE + sudo systemctl start weewx + sudo systemctl stop weewx + ``` + +=== "SysV" + + ```{ .shell .copy } + # For Linux systems that use SysV init, e.g., Slackware, Devuan, Puppy + sudo /etc/init.d/weewx start + sudo /etc/init.d/weewx stop + ``` + +=== "BSD" + + ```{ .shell .copy } + # For BSD systems, e.g., FreeBSD, OpenBSD + sudo service weewx start + sudo service weewx stop + ``` + +=== "macOS" + + ```{ .shell .copy } + # For macOS systems. + sudo launchctl load /Library/LaunchDaemons/com.weewx.weewxd.plist + sudo launchctl unload /Library/LaunchDaemons/com.weewx.weewxd.plist + ``` + +When `weewxd` runs in the background, you will not see sensor data or any other +indication that it is running. However, there are several approaches you can +take to see what's happening: + +- Use your system's [status tools](monitoring.md#status) to monitor its state; +- Look at any generated [reports](monitoring.md#reports); and +- Look at the [logs](monitoring.md#log-messages). diff --git a/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/hardware.md b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/hardware.md new file mode 100644 index 0000000..14df6c2 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/hardware.md @@ -0,0 +1,98 @@ +# Hardware problems + +## Tips on making a system reliable + +If you are having problems keeping your weather station up for long periods of +time, here are some tips, in decreasing order of importance: + +* Run on dedicated hardware. If you are using the server for other tasks, + particularly as your desktop machine, you will have reliability problems. If + you are using it as a print or network server, you will probably be OK. + +* Run headless. Modern graphical systems are extremely complex. As new features + are added, test suites do not always catch up. Your system will be much more + reliable if you run it without a windowing system. + +* Use an Uninterruptible Power Supply (UPS). The vast majority of power glitches + are very short-lived — just a second or two — so you do not need a + big one. The 425VA unit I use to protect my fit-PC cost $55 at Best Buy. + +* If you buy a Davis VantagePro and your computer has an old-fashioned serial + port, get the VantagePro with a serial connection, not a USB connection. See + the Wiki article on [Davis cp2101 converter + problems](https://github.com/weewx/weewx/wiki/Troubleshooting-the-Davis-Vantage-station#davis-cp2101-converter-problems) + for details. + +* If you do use a USB connection, put a ferrite coil on each end of the cable to + your console. If you have enough length and the ferrite coil is big enough, + make a loop, so it goes through the coil twice. See the picture below: + +
+ ![Ferrite Coils](../../images/ferrites.jpg){ width="300" } +
Cable connection looped through a ferrite coil +Ferrite coils on a Davis Envoy. There are two coils, one on the USB connection +(top wire) and one on the power supply. Both have loops.
+
+ + +## Archive interval + +Most hardware with data-logging includes a parameter to specify the archive +interval used by the logger. If the hardware and driver support it, WeeWX will +use this interval as the archive interval. If not, WeeWX will fall back to using +option `archive_interval` specified in +[[StdArchive]](../../reference/weewx-options/stdarchive.md). The default +fallback value is 300 seconds (5 minutes). + +If the hardware archive interval is large, it will take a long time before +anything shows up in the WeeWX reports. For example, WS23xx stations ship with +an archive interval of 60 minutes, and Fine Offset stations ship with an archive +interval of 30 minutes. If you run WeeWX with a WS23xx station in its factory +default configuration, it will take 60 minutes before the first data point shows +up, then another 60 minutes until the next one, and so on. + +Since reports are generated when a new archive record arrives, a large archive +interval means that reports will be generated infrequently. + +If you want data and reports closer to real-time, use the +[weectl device](../../utilities/weectl-device.md) utility to change the +interval. + + +## Raspberry Pi + +WeeWX runs very well on the Raspberry Pi, from the original Model A and Model B, +to the latest incarnations. However, the Pi does have some quirks, including +issues with USB power and lack of a clock. + +See the [Wiki](https://github.com/weewx/weewx/wiki) for up-to-date information +on [Running WeeWX on a Raspberry +Pi](https://github.com/weewx/weewx/wiki/Raspberry%20Pi). + + +## Davis stations + +For Davis-specific tips, see the Wiki article [Troubleshooting Davis +stations](https://github.com/weewx/weewx/wiki/Troubleshooting-the-Davis-Vantage-station) + + +## Fine Offset USB lockups + +The Fine Offset series weather stations and their derivatives are a fine value +and can be made to work reasonably reliably, but they have one problem that is +difficult to work around: the USB can unexpectantly lock up, making it +impossible to communicate with the console. The symptom in the log will look +something like this: + +``` +Jun 7 21:50:33 localhost weewx[2460]: fousb: get archive interval failed attempt 1 of 3: could not detach kernel driver from interface 0: No data available +``` + +The exact error may vary, but the thing to look for is the **"could not detach +kernel driver"** message. Unfortunately, we have not found a software cure for +this. Instead, you must power cycle the unit. Remove the batteries and unplug +the USB, then put it back together. No need to restart WeeWX. + +More details about [Fine Offset +lockups](https://github.com/weewx/weewx/wiki/FineOffset%20USB%20lockup) can be +found in the [Wiki](https://github.com/weewx/weewx/wiki). diff --git a/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/meteo.md b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/meteo.md new file mode 100644 index 0000000..256fdfa --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/meteo.md @@ -0,0 +1,80 @@ +# Meteorological problems + +## The pressure reported by WeeWX does not match the pressure on the console + +Be sure that you are comparing the right values. There are three different types +of pressure: + +* **Station Pressure**: The _Station Pressure_ (SP), which is the raw, absolute + pressure measured by the station. This is `pressure` in WeeWX packets and + archive records. + +* **Sea Level Pressure**: The _Sea Level Pressure_ (SLP) is obtained by + correcting the Station Pressure for altitude and local temperature. This is + `barometer` in WeeWX packets and archive records. + +* **Altimeter**: The _Altimeter Setting_ (AS) is obtained by correcting the +Station Pressure for altitude. This is `altimeter` in WeeWX packets and archive +records. Any station might require calibration. For some hardware, this can be +done at the weather station console. Alternatively, use the `StdCalibrate` +section to apply an offset. + +If your station is significantly above (or below) sea level, be sure that the +station altitude is specified properly. Also, be sure that any calibration +results in a station pressure and/or barometric pressure that matches those +reported by other stations in your area. + +## Calibrating barometer does not change the pressure displayed by WeeWX + +Be sure that the calibration is applied to the correct quantity. + +The corrections in the `StdCalibrate` section apply only to raw values from the +hardware; corrections are not applied to derived quantities. + +The station hardware matters. Some stations report gauge pressure (`pressure`) +while other stations report sea-level pressure (`barometer`). For example, if +the hardware is a Vantage station, the correction must be applied to `barometer` +since the Vantage station reports `barometer` and WeeWX calculates `pressure`. +However, if the hardware is a FineOffset station, the correction must be applied +to `pressure` since the FineOffset stations report `pressure` and WeeWX +calculates `barometer`. + +## The rainfall and/or rain rate reported by WeeWX do not match the console + +First of all, be sure that you are comparing the right quantities. The value +`rain` is the amount of rainfall observed in a period of time. The period of +time might be a LOOP interval, in which case the `rain` is the amount of rain +since the last LOOP packet. Because LOOP packets arrive quite frequently, this +value is likely to be very small. Or the period of time might be an archive +interval, in which case `rain` is the total amount of rain reported since the +last archive record. + +Some consoles report the amount of rain in the past hour, or the amount of rain +since midnight. + +The rain rate is a derived quantity. Some stations report a rain rate, but for +those that do not, WeeWX will calculate the rain rate. + +Finally, beware of calibration factors specific to the hardware. For example, +the bucket type on a Vantage station must be specified when you set up the +weather station. If you modify the rain bucket with a larger collection area, +then you will have to add a multiplier in the `StdCalibrate` section. + +To diagnose rain issues, run WeeWX directly so that you can see each LOOP packet +and REC archive record. Tip the bucket to verify that each bucket tip is +detected and reported by WeeWX. Verify that each bucket tip is converted to the +correct rainfall amount. Then check the database to verify that the values are +properly added and recorded. + +## There is no wind direction when wind speed is zero + +This is by design — if there is no wind, then the wind direction is +undefined, represented by NULL in the database or `None` in Python. This policy +is enforced by the `StdWXCalculate` service. If necessary, it can be overridden. +See option [force_null](../../reference/weewx-options/stdwxcalculate.md#force_null) +in the [[StdWXCalculate]](../../reference/weewx-options/stdwxcalculate.md) +section. + +WeeWX distinguishes between a value of zero and no value (NULL or None). +However, some services do not make this distinction and replace a NULL or None +with a clearly invalid value such as -999. \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/software.md b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/software.md new file mode 100644 index 0000000..8b20f97 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/software.md @@ -0,0 +1,326 @@ +# Software problems + +## Nothing in the log file + +As it is running, WeeWX periodically sends status information, failures, and +other things to your system's logging facility. They typically look something +like this (the first line is not actually part of the log): + +``` log + DATE TIME HOST weewx[PID] LEVL MESSAGE +Feb 8 04:25:16 hummingbird weewx[6932] INFO weewx.manager: Added record 2020-02-08 04:25:00 PST (1581164700) to database 'weewx.sdb' +Feb 8 04:25:16 hummingbird weewx[6932] INFO weewx.manager: Added record 2020-02-08 04:25:00 PST (1581164700) to daily summary in 'weewx.sdb' +Feb 8 04:25:17 hummingbird weewx[6932] INFO weewx.restx: PWSWeather: Published record 2020-02-08 04:25:00 PST (1581164700) +Feb 8 04:25:17 hummingbird weewx[6932] INFO weewx.restx: Wunderground-PWS: Published record 2020-02-08 04:25:00 PST (1581164700) +Feb 8 04:25:17 hummingbird weewx[6932] INFO weewx.restx: Windy: Published record 2020-02-08 04:25:00 PST (1581164700) +Feb 8 04:25:17 hummingbird weewx[6932] ERROR weewx.restx: WOW: Failed to publish record 2020-02-08 04:25:00 PST (1581164700): Failed upload after 3 tries +``` + +The location of this logging file varies from system to system, but it is +typically in `/var/log/syslog` or `/var/log/messages`. + +However, some systems default to saving only warning or critical information, so +**INFO** messages from WeeWX may not appear. If this happens to you, check your +system logging configuration. On Debian systems, look in `/etc/rsyslog.conf`. On +Redhat systems, look in `/etc/syslog.conf`. + + +## ConfigObj errors + +These are errors in the configuration file. Two are very common. Incidentally, +these errors are far easier to diagnose when WeeWX is run directly from the +command line than when it is run as a daemon. + +### Exception `configobj.DuplicateError` + +This error is caused by using an identifier more than once in the configuration +file. For example, you may have inadvertently listed your FTP server twice: + +```ini +[Reports] + [[FTP]] + ... (details elided) + user = fred + server = ftp.myhost.com + password = mypassword + server = ftp.myhost.com # OOPS! Listed it twice! + path = /weather +... +``` + +Generally, if you encounter this error, the log file will give you the line +number it happened in: + +``` log +Apr 24 12:09:15 raven weewx[11480]: wxengine: Error while parsing configuration file /home/weewx/weewx.conf +Apr 24 12:09:15 raven weewx[11480]: wxengine: Unable to initialize main loop: +Apr 24 12:09:15 raven weewx[11480]: **** Duplicate keyword name at line 254. +Apr 24 12:09:15 raven weewx[11480]: **** Exiting. +``` + +### Exception `configobj.NestingError` + +This is a very similar error, and is caused by a misformed section nesting. For +example: + +```ini +[Reports] + [[FTP]]] + ... +``` + +Note the extra closing bracket on the subsection `FTP`. + + +## No barometer data + +If everything appears normal except that you have no barometer data, the problem +may be a mismatch between the unit system used for service `StdConvert` and the +unit system used by service `StdQC`. For example: + +```ini +[StdConvert] + target_unit = METRIC + ... + +[StdQC] + [[MinMax]] + barometer = 28, 32.5 +``` + +The problem is that you are requiring the barometer data to be between 28 and +32.5, but with the unit system set to `METRIC`, the data will be in the range +990 to 1050 or so! + +The solution is to change the values to match the units in `StdConvert`, or, +better yet, specify the units in `MinMax`. For example: + +```ini hl_lines="7" +[StdConvert] + target_unit = US + ... + +[StdQC] + [[MinMax]] + barometer = 950, 1100, mbar +``` + +## Exception `Cheetah.NameMapper.NotFound` + +If you get errors of the sort: + +``` log +Apr 12 05:12:32 raven reportengine[3074]: filegenerator: Caught exception "" +Apr 12 05:12:32 raven reportengine[3074]: **** Message: "cannot find 'fubar' in template /home/weewx/skins/Standard/index.html.tmpl" +Apr 12 05:12:32 raven reportengine[3074]: **** Ignoring template and continuing. +``` + +you have a tag in your template that WeeWX does not recognize. In this example, +it is the tag `$fubar` in the template +`/home/weewx/skins/Standard/index.html.tmpl`. + + +## Dots in the plots + +If you see dots instead of lines in the daily plots, you might want to change +the graphing options or adjust the station's archive interval. + +In a default configuration, a time period greater than 1% of the displayed +timespan is considered to be a gap in data. So when the interval between data +points is greater than about 10 minutes, the daily plots show dots instead of +connected points. + +Change the option +[`line_gap_fraction`](../../reference/skin-options/imagegenerator.md#line_gap_fraction) +in `skin.conf` to control how much time is considered a break in data. + +As for the archive interval, check the log file for an entry like this soon +after WeeWX starts up: + +``` +Dec 30 10:54:17 saga weewx[10035]: wxengine: The archive interval in the configuration file + (300) does not match the station hardware interval (1800). +Dec 30 10:54:17 saga weewx[10035]: wxengine: Using archive interval of 1800 +``` + +In this example, the interval specified in `weewx.conf` is 5 minutes, but the +interval specified in the station hardware is 30 minutes. When the interval in +`weewx.conf` does not match the station's hardware interval, WeeWX defers to the +station's interval. + +Use the [`weectl device`](../../utilities/weectl-device.md) utility to change +the station's interval. + + +## Spikes in the graphs + +Occasionally you may see anomalous readings, typically manifested as spikes in +the graphs. The source could be a flaky serial/USB connection, radio or other +interference, a cheap USB-Serial adapter, low-quality sensors, or simply an +anomalous reading. + +Sensor quality matters. It is not unusual for some low-end hardware to report +odd sensor readings occasionally (once every few days). Some sensors, such as +solar radiation/UV, have a limited lifespan of about 5 years. The (analog) +humidity sensors on older Vantage stations are known to deteriorate after a few +years in wet environments. + +If you frequently see anomalous data, first check the hardware. + +To keep bad data from the database, add a quality control (QC) rule such as +Min/Max bounds. See the section +[`[StdQC]`](../../reference/weewx-options/stdqc.md) for details. + +To remove bad data from the database, see the Wiki article [_Cleaning up old +"bad" data_](https://github.com/weewx/weewx/wiki/Cleaning-up-old-'bad'-data). + + +## 'Database is locked' error +This seems to be a problem with the Raspberry Pi, *when using SQLite*. There is no analogous problem with MySQL databases. You will see errors in the system log that looks like this: + +``` log +Feb 12 07:11:06 rpi weewx[20930]: **** File "/usr/share/weewx/weewx/archive.py", line 118, in lastGoodStamp +Feb 12 07:11:06 rpi weewx[20930]: **** _row = self.getSql("SELECT MAX(dateTime) FROM %s" % self.table) +Feb 12 07:11:06 rpi weewx[20930]: **** File "/usr/share/weewx/weewx/archive.py", line 250, in getSql +Feb 12 07:11:06 rpi weewx[20930]: **** File "/usr/share/weewx/weedb/sqlite.py", line 120, in execute +Feb 12 07:11:06 rpi weewx[20930]: **** raise weedb.OperationalError(e) +Feb 12 07:11:06 rpi weewx[20930]: **** OperationalError: database is locked +Feb 12 07:11:06 rpi weewx[20930]: **** _cursor.execute(sql, sqlargs) +Feb 12 07:11:06 rpi weewx[20930]: **** File "/usr/share/weewx/weedb/sqlite.py", line 120, in execute +Feb 12 07:11:06 rpi weewx[20930]: **** raise weedb.OperationalError(e) +Feb 12 07:11:06 rpi weewx[20930]: **** OperationalError: database is locked +``` + +We are still trying to decipher exactly what the problem is, but it seems that +(many? most? all?) implementations of the SQLite 'C' access libraries on the RPi +sleep for a full second if they find the database locked. This gives them only +five chances within the 5-second timeout period before an exception is raised. + +Not all Raspberry Pis have this problem. It seems to be most acute when running +big templates with lots of queries, such as the forecast extension. + +There are a few possible fixes: + +* Increase the option + [`timeout`](../../reference/weewx-options/databases.md#timeout). + +* Use a high quality SD card in your RPi. There seems to be some evidence that + faster SD cards are more immune to this problem. + +* Trim the size of your templates to minimize the number of queries necessary. + +None of these 'fixes' are very satisfying, and we're trying to come up with a +more robust solution. + + +## Funky symbols in plots + +If your plots have strange looking symbols for units, such as degrees Fahrenheit +(°F), that look something like this: + +![funky degree sign](../../images/funky_degree.png) + +Then the problem may be that you are missing the fonts specified for the option `unit_label_font_path` in your `skin.conf` file and, instead, WeeWX is substituting a default font, which does not support the Unicode character necessary to make a degree sign. Look in section `[ImageGenerator]` for a line that looks like: + + unit_label_font_path = /usr/share/fonts/truetype/freefont/FreeMonoBold.ttf + +Make sure that the specified path +(`/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf` in this case) actually +exists. If it does not, on Debian operating systems (such as Ubuntu), you may be +able to install the necessary fonts: + +``` bash +sudo apt-get install fonts-freefont-ttf +sudo fc-cache -f -v +``` + +(On older systems, the package `fonts-freefont-ttf` may be called +`ttf-freefont`). The first command installs the "Truetype" fonts, the second +rebuilds the font cache. If your system does not have the `fc-cache` command, +then install it from the `fontconfig` package: + +``` bash +sudo apt-get install fontconfig +``` + +If none of this works, or if you are on a different operating system, then you +will have to change the option `unit_label_font_path` to point to something on +your system which does support the Unicode characters you plan to use. + + +## Exception `UnicodeEncodeError` + +This problem is closely related to the ["Funky +symbols"](#funky-symbols-in-plots) problem above. In this case, you may see +errors in your log that look like: + +``` log +May 14 13:35:23 web weewx[5633]: cheetahgenerator: Generated 14 files for report StandardReport in 1.27 seconds +May 14 13:35:23 web weewx[5633]: reportengine: Caught unrecoverable exception in generator weewx.imagegenerator.ImageGenerator +May 14 13:35:23 web weewx[5633]: **** 'ascii' codec can't encode character u'\xe8' in position 5: ordinal not in range(128) +May 14 13:35:23 web weewx[5633]: **** Traceback (most recent call last): +May 14 13:35:23 web weewx[5633]: **** File "/usr/share/weewx/weewx/reportengine.py", line 139, in run +May 14 13:35:23 web weewx[5633]: **** obj.start() +May 14 13:35:23 web weewx[5633]: **** File "/usr/share/weewx/weewx/reportengine.py", line 170, in start +May 14 13:35:23 web weewx[5633]: **** self.run() +May 14 13:35:23 web weewx[5633]: **** File "/usr/share/weewx/weewx/imagegenerator.py", line 36, in run +May 14 13:35:23 web weewx[5633]: **** self.gen_images(self.gen_ts) +May 14 13:35:23 web weewx[5633]: **** File "/usr/share/weewx/weewx/imagegenerator.py", line 220, in gen_images +May 14 13:35:23 web weewx[5633]: **** image = plot.render() +May 14 13:35:23 web weewx[5633]: **** File "/usr/share/weewx/weeplot/genplot.py", line 175, in render +May 14 13:35:23 web weewx[5633]: **** self._renderTopBand(draw) +May 14 13:35:23 web weewx[5633]: **** File "/usr/share/weewx/weeplot/genplot.py", line 390, in _renderTopBand +May 14 13:35:23 web weewx[5633]: **** top_label_size = draw.textsize(top_label, font=top_label_font) +May 14 13:35:23 web weewx[5633]: **** File "/usr/lib/python2.7/dist-packages/PIL/ImageDraw.py", line 278, in textsize +May 14 13:35:23 web weewx[5633]: **** return font.getsize(text) +May 14 13:35:23 web weewx[5633]: **** UnicodeEncodeError: 'ascii' codec can't encode character u'\xe8' in position 5: ordinal not in range(128) +May 14 13:35:23 web weewx[5633]: **** Generator terminated... +``` + +This is frequently caused by the necessary Truetype fonts not being installed on +your computer and, instead, a default font is being substituted, which only +knows how to plot ASCII characters. The cure is as before: install the font. + + +## Data is archived but some/all reports do not run + +If WeeWX appears to be running normally but some or all reports are not being +run, either all the time or periodically, the problem could be the inadvertant +use or incorrect setting of the `report_timing` option in `weewx.conf`. The +`report_timing` option allows the user to specify when some or all reports are +run (see [_Scheduling report generation_](../../custom/report-scheduling.md)). +By default, the option +[_`report_timing`_](../../reference/weewx-options/stdreport.md#report_timing) +is disabled and all reports are run each archive period. + +To see if the `report_timing` option is causing reports to be skipped, inspect +the log file. Any reports that are skipped due to the `report_timing` option +will be logged as follows: + +``` log +Apr 29 09:30:17 rosella weewx[3319]: reportengine: Report StandardReport skipped due to report_timing setting +``` + +If reports are being incorrectly skipped due to `report_timing`, then edit +`weewx.conf` and check for a `report_timing` option in `[StdReport]`. Either +remove all occurrences of `report_timing` to run all reports each archive +period, or confirm the correct use and setting of the `report_timing` option. + + +## The wrong reports are being skipped by report_timing + +If the option +[_`report_timing`_](../../reference/weewx-options/stdreport.md#report_timing) +is being used, and the results are not as expected, there may be an error in the +`report_timing` option. If there are errors in the `report_timing` parameter, +the report will be run on each archive interval. First check the `report_timing` +option parameters to ensure they are valid and there are no additonal spaces or +other unwanted characters. Then check that the parameters are correctly set for +the desired report generation times. For example, is the correct day of the week +number being used if limiting the day of the week parameter. Refer to the +section [_Scheduling report generation_](../../custom/report-scheduling.md). + + +Check the log file for any entries relating to the reports concerned. Errors in +the `report_timing` parameter and skipped reports are logged only when `debug=1` +in `weewx.conf`. diff --git a/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/what-to-do.md b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/what-to-do.md new file mode 100644 index 0000000..010a068 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/troubleshooting/what-to-do.md @@ -0,0 +1,31 @@ +# Troubleshooting + +If you are having problems, first look at the hardware, software, and +meteorological problems pages. You might be experiencing a problem that +someone else has already solved. + +[Hardware problems](hardware.md)
+[Software problems](software.md)
+[Meteorological problems](meteo.md) + +Read the [Frequently Asked Questions](https://github.com/weewx/weewx/wiki/WeeWX-Frequently-Asked-Questions) (FAQ). Search the +[weewx-user group](https://groups.google.com/g/weewx-user), especially +if you are using a driver or skin that is not part of the WeeWX core. + +If you still have problems, here are a few things to try on your system: + +1. Look at the [log file](../monitoring.md#log-messages). We +are always happy to take questions, but the first thing someone will ask is, +"What did you find in the log file?" + +2. Run `weewxd` directly, rather than as a daemon. Generally, WeeWX will catch +and log any unrecoverable exceptions, but if you are getting strange results, +it is worth running directly and looking for any clues. + +3. Set the option `debug = 1` in `weewx.conf`. This will put much more +information in the log file, which can be very useful for troubleshooting +and debugging! + +If you are _still_ stuck, post your problem to the +[weewx-user group](https://groups.google.com/g/weewx-user). The Wiki has some +guidelines on [how to do an effective post](https://github.com/weewx/weewx/wiki/Help!-Posting-to-weewx-user). diff --git a/dist/weewx-5.0.2/docs_src/usersguide/webserver.md b/dist/weewx-5.0.2/docs_src/usersguide/webserver.md new file mode 100644 index 0000000..52f8a7b --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/webserver.md @@ -0,0 +1,88 @@ +# Integrating with a web server + +## If the server is on the same machine + +The reports generated by WeeWX can be served by a web server running on the +same machine as WeeWX. The WeeWX reports work with most web servers, including +Apache, nginx, and lighttpd. + +There are a few strategies for making the web server see the WeeWX reports. +You can modify the web server configuration, you can modify the WeeWX +configuration, or you can make links in the file system. The strategy you +choose depends on the operating system, web server, how you installed WeeWX, +and how you prefer to manage your system. If you installed WeeWX using a +Debian/Redhat/SUSE installer, you might not have to do anything! + +See the wiki article [_Configure a web server_](https://github.com/weewx/weewx/wiki/Configure-a-web-server-(Apache,-NGINX-or-lighttpd)) for details. + +## If the server is on a different machine + +Use FTP or RSYNC to transfer the files generated by WeeWX to your remote +server. In WeeWX, FTP and RSYNC are implemented as reports. They are configured +in the [`[StdReport]`](../reference/weewx-options/stdreport.md) section of the +WeeWX configuration file. + +For example, the following configuration would use RSYNC to copy the html and +images files from the standard report to a folder `/var/www/html/weewx` on the +server `wx.example.com`: + +```ini +[StdReport] + [[RSYNC]] + skin = Rsync + server = wx.example.com + path = /var/www/html/weewx + user = wxuser +``` + +The following configuration would use FTP to copy the html and image files: + +```ini +[StdReport] + [[FTP]] + skin = Ftp + server = wx.example.com + path = /weewx + user = wxuser + password = wxpass +``` + +It is possible to rsync or FTP more than one directory to the remote server. +For example, suppose you have a home webcam that puts its images in +`/home/webcam/www`. You want to FTP not only the files generated by WeeWX, but +also these webcam images, to a remote server. The webcam images should go in a +subdirectory webcam of the weewx directory. The solution is to include more +than one FTP section under `[StdReport]`: + +``` ini +[StdReport] + + # Location of the generated reports, relative to WEEWX_ROOT + HTML_ROOT = public_html + + ... + + # As before: + [[FTP]] + skin = Ftp + server = wx.example.com + path = /weewx + user = wxuser + password = wxpass + + # Add a second FTP. You can name it anything. + [[webcam_FTP]] + skin = Ftp + # Override HTML_ROOT: + HTML_ROOT=/home/webcam/www + server = wx.example.com + path = /weewx/webcam + user = wxuser + password = wxpass +``` + +See the documentation for the [`[[FTP]]`] [ftp] and [`[[RSYNC]]`] [rsync] +sections of the configuration file `weewx.conf` for details and options. + +[ftp]: ../reference/weewx-options/stdreport.md#ftp "[[FTP]] section" +[rsync]: ../reference/weewx-options/stdreport.md/#rsync "[[RSYNC]] section" \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/usersguide/where.md b/dist/weewx-5.0.2/docs_src/usersguide/where.md new file mode 100644 index 0000000..73ef464 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/usersguide/where.md @@ -0,0 +1,91 @@ +# Where to find things + +## Location of WeeWX components + +Here is a summary of the layout for the different install methods, along with +the symbolic names used for each component. These names are used throughout the +documentation. + +=== "Debian" + + | Component | Symbolic name | Nominal value | + |-------------------------|------------------|---------------------------| + | WeeWX root directory | _`WEEWX_ROOT`_ | `/etc/weewx/` | + | Skins and templates | _`SKIN_ROOT`_ | `skins/` | + | User directory | _`USER_ROOT`_ | `bin/user/` | + | Examples | _`EXAMPLE_ROOT`_ | `examples/` | + | Executables | _`BIN_ROOT`_ | `/usr/share/weewx/` | + | SQLite databases | _`SQLITE_ROOT`_ | `/var/lib/weewx/` | + | Web pages and images | _`HTML_ROOT`_ | `/var/www/html/weewx/` | + | Documentation | | https://weewx.com/docs | + +=== "RedHat" + + | Component | Symbolic name | Nominal value | + |-------------------------|------------------|---------------------------| + | WeeWX root directory | _`WEEWX_ROOT`_ | `/etc/weewx/` | + | Skins and templates | _`SKIN_ROOT`_ | `skins/` | + | User directory | _`USER_ROOT`_ | `bin/user/` | + | Examples | _`EXAMPLE_ROOT`_ | `examples/` | + | Executables | _`BIN_ROOT`_ | `/usr/share/weewx/` | + | SQLite databases | _`SQLITE_ROOT`_ | `/var/lib/weewx/` | + | Web pages and images | _`HTML_ROOT`_ | `/var/www/html/weewx/` | + | Documentation | | https://weewx.com/docs | + +=== "openSUSE" + + | Component | Symbolic name | Nominal value | + |-------------------------|------------------|-------------------------- | + | WeeWX root directory | _`WEEWX_ROOT`_ | `/etc/weewx/` | + | Skins and templates | _`SKIN_ROOT`_ | `skins/` | + | User directory | _`USER_ROOT`_ | `bin/user/` | + | Examples | _`EXAMPLE_ROOT`_ | `examples/` | + | Executables | _`BIN_ROOT`_ | `/usr/share/weewx/` | + | SQLite databases | _`SQLITE_ROOT`_ | `/var/lib/weewx/` | + | Web pages and images | _`HTML_ROOT`_ | `/var/www/html/weewx/` | + | Documentation | | https://weewx.com/docs | + +=== "pip" + + | Component | Symbolic name | Nominal value | + |-------------------------|------------------|---------------------------| + | WeeWX root directory | _`WEEWX_ROOT`_ | `~/weewx-data/` | + | Skins and templates | _`SKIN_ROOT`_ | `skins/` | + | User directory | _`USER_ROOT`_ | `bin/user/` | + | Examples | _`EXAMPLE_ROOT`_ | `examples/` | + | Executables | _`BIN_ROOT`_ | varies (see below) | + | SQLite databases | _`SQLITE_ROOT`_ | `archive/` | + | Web pages and images | _`HTML_ROOT`_ | `public_html/` | + | Documentation | | https://weewx.com/docs | + +!!! Note + In the locations above, relative paths are *relative to _`WEEWX_ROOT`_*. + Absolute paths begin with a forward slash (`/`). The tilde character + (`~`) represents the `HOME` directory of the user. + + +## Location of log files + +In the default configuration, WeeWX sends log messages to the system logging +facility. On some systems, the log messages end up in files that you can +browse as you would any other file. On other systems you will have to use +tools provided by the operating system to see the log messages. + +See the section [_Monitoring WeeWX_](monitoring.md/#log-messages). + + +## Location of executables in a pip install + +This is something you are not likely to need, but can occasionally be useful. +It's included here for completeness. If you use a pip install, the location of +the executables will depend on how the installation was done. + +| Install method | Commands | Location of executables | +|-----------------------------------------------------|------------------------------------------------------------------------------|-------------------------| +| Virtual environment
(recommended) | `python3 -m venv ~/ve`
`source ~/ve/bin/activate`
`pip3 install weewx` | `~/ve/bin/` | +| pip
no sudo
with `--user` | `pip3 install weewx --user` | `~/.local/bin/` | +| pip
no sudo
no `--user` | `pip3 install weewx` | `~/.local/bin/` | +| pip
with sudo
(not recommended) | `sudo pip3 install weewx` | `/usr/local/bin/` (1) | +| Virtual environment
with `--user`
(not allowed) | `python3 -m venv ~/ve`
`source ~/ve/bin/activate`
`pip3 install weewx --user` | N/A | + +(1) Checked on Ubuntu 22.02 and Rocky v9.1 diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-about.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-about.md new file mode 100644 index 0000000..a9ed054 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-about.md @@ -0,0 +1,36 @@ +# weectl + +The command `weectl` is the entry point for most WeeWX utilities. To see the +various utilities available, run it with the `--help` option: + +``` +$ weectl --help +usage: weectl -v|--version + weectl -h|--help + weectl database --help + weectl debug --help + weectl device --help + weectl extension --help + weectl import --help + weectl report --help + weectl station --help + +weectl is the master utility used by WeeWX. It can invoke several different +subcommands listed below. You can explore their utility by using the --help +option. For example, to find out what the 'database' subcommand can do, use +'weectl database --help'. + +optional arguments: + -h, --help show this help message and exit + -v, --version show program's version number and exit + +Available subcommands: + {database,debug,device,extension,import,report,station} + database Manage WeeWX databases. + debug Generate debug info. + device Manage your hardware. + extension List, install, or uninstall extensions. + import Import observation data. + report List and run WeeWX reports. + station Create, modify, or upgrade a station data area. +``` diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-database.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-database.md new file mode 100644 index 0000000..98e5f99 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-database.md @@ -0,0 +1,303 @@ +# weectl database + +Use the `weectl` subcommand `database` to manage the WeeWX database. + +Specify `--help` to see the various actions and options: + + weectl database --help + +## Create a new database + + weectl database create + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action is used to create a new database by using the specifications in +the WeeWX configuration file. It is rarely needed, as `weewxd` will do this +automatically on startup. + +Use the `--help` option to see how to use this action. + + weectl database create --help + + +## Drop the daily summaries + + weectl database drop-daily + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +In addition to the regular archive data, every WeeWX database also includes +a daily summary table for each observation type. Because there can be dozens +of observation types, there can be dozens of these daily summaries. It does +not happen very often, but there can be occasions when it's necessary to drop +them all and then rebuild them. Dropping them by hand would be very tedious! +This action does them all at once. + +Use the `--help` option to see how to use this action: + + weectl database drop-daily --help + + +## Rebuild the daily summaries + + weectl database rebuild-daily + [[--date=YYYY-mm-dd] | [--from=YYYY-mm-dd] [--to=YYYY-mm-dd]] + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action is the inverse of action `weectk database drop-daily` in that it +rebuilds the daily summaries from the archive data. + +The action `rebuild-daily` accepts a number of date related options, `--date`, +`--from` and `--to` that allow selective rebuilding of the daily summaries for +one or more days rather than for the entire archive history. These options may +be useful if bogus data has been removed from the archive covering a single +day or a period of few days. The daily summaries can then be rebuilt for this +period only, resulting in a faster rebuild and detailed low/high values and +the associated times being retained for unaffected days. + +### Rebuild a specific date + + weectl database rebuild-daily --date=YYYY-mm-dd + +Use this form to rebuild the daily summaries for a specific date. + +### Rebuild for a range of dates + + weectl database rebuild-daily --from=YYYY-mm-dd --to=YYYY-mm-dd + +Use this form to rebuild for an inclusive interval of dates. The default value +for `--from` is from the first day in the database. The default value for +`--to` is the last day in the database. + +!!! Note + + The period defined by `--to` and `--from` is inclusive. + + +## Add a new observation type to the database + + weectl database add-column NAME + [--type=COLUMN-DEF] + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action adds a new database observation type (column), given by `NAME`, to +the database. The option `--type` is any valid SQL column definition. It +defaults to `REAL`. + +For example, to add a new observation `pulseCount` with a SQL type of +`INTEGER`, whose default value is zero: + + weectl database add-column pulseCount --type "INTEGER DEFAULT 0" + + +## Rename an observation type + + weectl database rename-column FROM-NAME TO-NAME + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +Use this action to rename a database observation type (column) to a new name. + +For example, to rename the column `luminosity` in your database to +`illuminance`: + + weectl database rename-column luminosity illuminance + + +## Drop (remove) observation types + + weectl database drop-columns NAME... + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action will drop one or more observation types (columns) from the +database. If more than one column name is given, they should be separated +by spaces. + +!!! Note + + When dropping columns from a SQLite database, the entire database must be + copied except for the dropped columns. Because this can be quite slow, if + you are dropping more than one column, it is better to do them all in one + pass. This is why action `drop-columns` accepts more than one name. + + +## Reconfigure a database + + weectl database reconfigure + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action is useful for changing the schema or unit system in your database. + +It creates a new database with the same name as the old, except with the suffix +`_new` attached at the end (nominally, `weewx.sdb_new` if you are using SQLite, +`weewx_new` if you are using MySQL). It then initializes the database with the +schema specified in `weewx.conf`. Finally, it copies over the data from your +old database into the new database. + +See the section [_Changing the database unit system in an existing +database_](../custom/database.md#change-unit-system) in the _Customization +Guide_ for step-by-step instructions that use this option. + + +## Transfer (copy) a database + + weectl database transfer --dest-binding=BINDING-NAME + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action is useful for moving your database from one type of database to +another, such as from SQLite to MySQL. To use it, you must have two bindings +specified in your `weewx.conf` configuration file. One will serve as the +source, the other as the destination. Specify the source binding with option +`--binding` (default `wx_binding`), the destination binding with option +`--dest-binding` (required). + +See the Wiki for examples of moving data from [SQLite to +MySQL](https://github.com/weewx/weewx/wiki/Transfer%20from%20sqlite%20to%20MySQL#using-wee_database), +and from [MySQL to SQLite](https://github.com/weewx/weewx/wiki/Transfer%20from%20MySQL%20to%20sqlite#using-wee_database) +by using `weectl database transfer`. + + +## Calculate missing derived variables + + weectl database calc-missing + [--date=YYYY-mm-dd | [--from=YYYY-mm-dd[THH:MM]] [--to=YYYY-mm-dd[THH:MM]]] + [--config=FILENAME] [--binding=BINDING-NAME] [--tranche=INT] + [--dry-run] [-y] + +This action calculates derived observations for archive records in the database +and then stores the calculated observations in the database. This can be useful +if erroneous archive data is corrected or some additional observational data +is added to the archive that may alter previously calculated or missing +derived observations. + +The period over which the derived observations are calculated can be limited +through use of the `--date`, `--from` and/or `--to` options. When used without +any of these options `--calc-missing` will calculate derived observations for +all archive records in the database. The `--date` option limits the calculation +of derived observations to the specified date only. The `--from` and `--to` +options can be used together to specify the start and end date-time +respectively of the period over which derived observations will be calculated. + +If `--from` is used by itself the period is fom the date-time specified up to +and including the last archive record in the database. + +If `--to` is used by itself the period is the first archive record in the +database through to the specified date-time. + +``` +weectl database calc-missing +weectl database calc-missing --date=YYYY-mm-dd +weectl database calc-missing --from=YYYY-mm-dd[THH:MM] +weectl database calc-missing --to=YYYY-mm-dd[THH:MM] +weectl database calc-missing --from=YYYY-mm-dd[THH:MM] --to=YYYY-mm-dd[THH:MM] +``` + +!!! Note + Action `calc-missing` uses the `StdWXCalculate` service to calculate + missing derived observations. The data binding used by the + `StdWXCalculate` service should normally match the data binding of the + database being operated on by `calc-missing`. Those who use custom or + additional data bindings should take care to ensure the correct data + bindings are used by both `calc-missing` and the `StdWXCalculate` service. + + +## Check a database + + weectl database check + [--config=FILENAME] [--binding=BINDING-NAME] + +Databases created earlier than 3.7.0 (released 11-March-2017) have a flaw that +prevents them from being used with archive intervals that change with time. +This utility check whether your database is affected. See +[Issue #61](https://github.com/weewx/weewx/issues/61). + + +## Update a database + + weectl database update + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +This action updates the daily summary tables to use interval weighted +calculations (see [Issue #61](https://github.com/weewx/weewx/issues/61)) as +well as recalculating the `windSpeed` maximum daily values and times (see +[Issue #195](https://github.com/weewx/weewx/issues/195)). Interval weighted +calculations are only applied to the daily summaries if not previously applied. +The update process is irreversible and users are advised to back up their +database before performing this action. + +For further information on interval weighting and recalculation of daily +`windSpeed` maximum values, see the sections +[_Changes to daily summaries_](../upgrade.md#changes-to-daily-summaries) and +[_Recalculation of wind speed maximum values_](../upgrade.md#recalculation-of-windspeed-maximum-values) +in the [_Upgrade Guide_](../upgrade.md). + + +## Recalculate daily summary weights + + weectl database reweight + [[--date=YYYY-mm-dd] | [--from=YYYY-mm-dd] [--to=YYYY-mm-dd]] + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y] + +As an alternative to dropping and rebuilding the daily summaries, this action +simply rebuilds the weighted daily sums (used to calculate averages) from the +archive data. It does not touch the highs and lows. It is much faster than +`weectl database rebuild-daily`, and has the advantage that the highs and lows +remain unchanged. + +Other options are as in `weectl database rebuild-daily`. + + +## Optional arguments + +These are options used by most of the actions. + +### --binding + +The database binding to use. Default is `wx_binding`. + +### --config + +Path to the configuration file. Default is `~/weewx-data/weewx.conf`. + +### --date + +Nominate a single date to be acted on. The format should be `YYYY-mm-dd`. +For example, `2012-02-08` would specify 8 February 2012. If used, neither +option `--from` nor option `--to` can be used. + +### --dry-run + +Show what would happen if the action was run, but do not actually make any +writable changes. + +### --from + +Nominate a starting date for an action (inclusive). The format should be +`YYYY-mm-dd`. For example, `2012-02-08` would specify 8 February 2012. If not +specified, the first day in the database will be used. If specified, option +`--date` cannot be used. + +### --to + +Nominate an ending date for an action (inclusive). The format should be +`YYYY-mm-dd`. For example, `2012-02-08` would specify 8 February 2012. If not +specified, the last day in the database will be used. If specified, option +`--date` cannot be used. + +### --tranche + +Some of the actions can be quite memory intensive, so they done in "tranches", +specified in days. If you are working on a small machine, a smaller tranche size +might be necessary. Default is 10. + +### -y | --yes + +Do not ask for confirmation. Just do it. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-debug.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-debug.md new file mode 100644 index 0000000..707cd62 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-debug.md @@ -0,0 +1,57 @@ +# weectl debug + +Use the `weectl` subcommand `debug` to produce information about your +environment. + +Specify `--help` to see how it is used: + + weectl debug --help + +## Create debug information + + weectl debug + [--config=FILENAME] [--output=FILENAME] + +Troubleshooting problems when running WeeWX often involves analysis of a number +of pieces of seemingly disparate system and WeeWX related information. The +`weectl debug` command gathers all this information together into a single output +to make troubleshooting easier. The command is particularly useful +for new users as the output may be redirected to a file then emailed or posted +to a forum to assist in remote troubleshooting. + +The utility produces two types of information: + +1. General information about your environment. This includes: + - System information, + - Load information, + - Driver type, + - Any installed extensions, and + - Information about your databse + +2. An obfuscated copy of your configuration file (nominally, `weewx.conf`). + +!!! Warning + The `weectl debug` output includes a copy of the WeeWX config file + (typically `weewx.conf`) and whilst the utility attempts to obfuscate any + personal or sensitive information, the user should check the output + carefully for any remaining personal or sensitive information before + emailing or posting the output publicly. + +## Options + +### --config=FILENAME + +The utility is pretty good about guessing where the configuration file is, +but if you have an unusual installation or multiple stations, you may have to +tell it explicitly. You can do this using the `--config` option. For example, + + weectl debug --config=/etc/weewx/alt_config.conf + +### --output=FILENAME + +By default, `weectl debug` writes to standard output (the console). However, +the output can be sent somewhere else using option `--output`. For example, +to send it to `/var/tmp/weewx.info`: + + weectl debug --output=/var/tmp/weewx.info + diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-device.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-device.md new file mode 100644 index 0000000..9006db8 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-device.md @@ -0,0 +1,47 @@ +# weectl device + +The `weectl` subcommand `device` is used to configure hardware settings, such as +rain bucket size, station archive interval, altitude, EEPROM constants, *etc.*, +on your station. In order to do its job, it depends on optional code being +present in the hardware driver. Because not all drivers have this code, it may +not work for your specific device. If it does not, you will have to consult your +manufacturer's instructions for how to set these things through your console or +other means. + +`weectl device` uses the option `station_type` in `weewx.conf` to determine what +device you are using and what options to display. Make sure it is set correctly +before attempting to use this utility. + +Because `weectl device` uses hardware-specific code, its options are different +for every station type. You should run it with `--help` to see how to use it for +your specific station: + + weectl device --help + +The utility requires a WeeWX configuration file. If no file is specified, it +will look for a file called `weewx.conf` in the standard location. If your +configuration file is in a non-standard location, specify the path to the +configuration file either as the first argument, or by using the `--config` +option. For example, + + weectl device /path/to/weewx.conf --help + +or + + weectl device --config=/path/to/weewx.conf --help + +For details about the options available for each type of hardware, see the +appropriate hardware section: + +* [AcuRite](../hardware/acurite.md) +* [CC3000](../hardware/cc3000.md) +* [FineOffset](../hardware/fousb.md) +* [TE923](../hardware/te923.md) +* [Ultimeter](../hardware/ultimeter.md) +* [Vantage](../hardware/vantage.md) +* [WMR100](../hardware/wmr100.md) +* [WMR300](../hardware/wmr300.md) +* [WMR9x8](../hardware/wmr9x8.md) +* [WS1](../hardware/ws1.md) +* [WS23xx](../hardware/ws23xx.md) +* [WS28xx](../hardware/ws28xx.md) diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-extension.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-extension.md new file mode 100644 index 0000000..c15e223 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-extension.md @@ -0,0 +1,141 @@ +# weectl extension + +Use the `weectl` subcommand `extension` to manage WeeWX extensions. + +Specify `--help` to see the actions and options: +``` +weectl extension --help +``` + +## List installed extensions + + weectl extension list [--config=FILENAME] + +This action will list all the extensions that you have installed. + + +## Install an extension + + weectl extension install (FILE|DIR|URL) + [--config=FILENAME] + [--dry-run] [--yes] [--verbosity=N] + +This action will install an extension from a zip file, tar file, directory, or +URL. + +For example, this would install the `windy` extension from the latest zip file +located at `github.com`: +```shell +weectl extension install https://github.com/matthewwall/weewx-windy/archive/master.zip +``` + +This would install the `windy` extension from a compressed tar archive in the +current directory: +```shell +weectl extension install windy-0.1.tgz +``` + +This would install the `windy` extension from a zip file in the `Downloads` +directory: +```shell +weectl extension install ~/Downloads/windy-0.1.zip +``` + + +## Uninstall an extension + + weectl extension uninstall NAME + [--config=FILENAME] + [--dry-run] [--yes] [--verbosity=N] [-y] + +This action uninstalls an extension. Use the [action +`list`](#list-installed-extensions) to see what to use for `NAME`. + +For example, this would uninstall the extension called `windy`: +```shell +weectl extension uninstall windy +``` + + +## Examples + +These examples illustrate how to use the extension installer to install, list, +and uninstall the `windy` extension. + +Do a dry run of installing an uploader for the Windy website, maximum +verbosity: + +``` shell +% weectl extension install https://github.com/matthewwall/weewx-windy/archive/master.zip --dry-run --verbosity=3 +weectl extension install https://github.com/matthewwall/weewx-windy/archive/master.zip --dry-run --verbosity=3 +Using configuration file /Users/joe_user/weewx-data/weewx.conf +This is a dry run. Nothing will actually be done. +Install extension 'https://github.com/matthewwall/weewx-windy/archive/master.zip'? y +Extracting from zip archive /var/folders/xm/72q6zf8j71x8df2cqh0j9f6c0000gn/T/tmpjusc3qrv + Request to install extension found in directory /var/folders/xm/72q6zf8j71x8df2cqh0j9f6c0000gn/T/tmpo0oq1u34/weewx-windy-master/. + Found extension with name 'windy'. + Copying new files... + Fake copying from '/var/folders/xm/72q6zf8j71x8df2cqh0j9f6c0000gn/T/tmpo0oq1u34/weewx-windy-master/bin/user/windy.py' to '/Users/joe_user/weewx-data/bin/user/windy.py' + Fake copied 1 files. + Adding services to service lists. + Added new service user.windy.Windy to restful_services. + Adding sections to configuration file + Merged extension settings into configuration file +Saving installer file to /Users/joe_user/weewx-data/bin/user/installer/windy. +Finished installing extension windy from https://github.com/matthewwall/weewx-windy/archive/master.zip. +This was a dry run. Nothing was actually done. +``` + +Do it for real, default verbosity: + +``` +% weectl extension install https://github.com/matthewwall/weewx-windy/archive/master.zip +Using configuration file /Users/joe_user/weewx-data/weewx.conf +Install extension 'https://github.com/matthewwall/weewx-windy/archive/master.zip'? y +Extracting from zip archive /var/folders/xm/72q6zf8j71x8df2cqh0j9f6c0000gn/T/tmpcc92m0oq +Saving installer file to /Users/joe_user/weewx-data/bin/user/installer/windy. +Saved configuration dictionary. Backup copy at /Users/joe_user/weewx-data/weewx.conf.20231222135954. +Finished installing extension windy from https://github.com/matthewwall/weewx-windy/archive/master.zip. +``` + +List the results: + +``` +% weectl extension list +Extension Name Version Description +windy 0.7 Upload weather data to Windy. +``` + +Uninstall the extension without asking for confirmation: + +``` +% weectl extension uninstall windy -y +Using configuration file /Users/joe_user/weewx-data/weewx.conf +Finished removing extension 'windy' +``` + + +## Options + +These are options used by most of the actions. + +### --config + +Path to the configuration file. Default is `~/weewx-data/weewx.conf`. + +### --dry-run + +Show what would happen if the action was run, but do not actually make any +writable changes. + +### --help + +Show the help message, then exit. + +### --verbosity=(0|1|2|3) + +How much information to display (0-3). + +### -y | --yes + +Do not ask for confirmation. Just do it. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-about.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-about.md new file mode 100644 index 0000000..6cde9e1 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-about.md @@ -0,0 +1,16 @@ +# weectl import + +Some WeeWX users will have historical data from another source (e.g., other +weather station software or a manually compiled file) which they wish to +import into WeeWX. Such data can, depending upon the source, be imported +using the `weectl import` utility. + +The `weectl import` utility supports importing observational data from the +following sources: + +* a single [Comma Separated Values (CSV)](weectl-import-csv.md) format file +* the historical observations of a [Weather Underground](weectl-import-wu.md) + personal weather station +* one or more [Cumulus](weectl-import-cumulus.md) monthly log files +* one or more [Weather Display](weectl-import-wd.md) monthly log files +* one or more [WeatherCat](weectl-import-weathercat.md) monthly .cat files diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-common-opt.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-common-opt.md new file mode 100644 index 0000000..b3e2d3b --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-common-opt.md @@ -0,0 +1,243 @@ +Before starting, it's worth running the utility with the `--help` flag to see +how `weectl import` is used: + +``` +weectl import --help +``` +``` +usage: weectl import --help + weectl import --import-config=IMPORT_CONFIG_FILE + [--config=CONFIG_FILE] + [[--date=YYYY-mm-dd] | [[--from=YYYY-mm-dd[THH:MM]] [--to=YYYY-mm-dd[THH:MM]]]] + [--dry-run][--verbose] + [--no-prompt][--suppress-warnings] + +Import observation data into a WeeWX archive. + +optional arguments: + -h, --help show this help message and exit + --config FILENAME Path to configuration file. + --import-config IMPORT_CONFIG_FILE + Path to import configuration file. + --dry-run Print what would happen but do not do it. + --date YYYY-mm-dd Import data for this date. Format is YYYY-mm-dd. + --from YYYY-mm-dd[THH:MM] + Import data starting at this date or date-time. Format is YYYY- + mm-dd[THH:MM]. + --to YYYY-mm-dd[THH:MM] + Import data up until this date or date-time. Format is YYYY-mm- + dd[THH:MM]. + --verbose Print and log useful extra output. + --no-prompt Do not prompt. Accept relevant defaults and all y/n prompts. + --suppress-warnings Suppress warnings to stdout. Warnings are still logged. + +Import data from an external source into a WeeWX archive. Daily summaries are updated as +each archive record is imported so there should be no need to separately rebuild the +daily summaries. +``` + +## Options + +### `--config=FILENAME` + + +The utility can usually guess where the configuration file is, +but if you have an unusual installation or multiple stations, you may have to +tell it explicitly. + +``` +weectl import --config=/this/directory/weewx.conf --import-config=/directory/import.conf +``` + +### `--import-config=FILENAME` + +`weectl import` uses a secondary configuration file, known as the import +configuration file, to store various import parameters. The `--import-config` +option is mandatory for all imports. Example import configuration files for +each type of import supported by `weectl import` are provided in the +`util/import` directory. These example files are best used by making a copy of +into a working directory and then modifying the copy to suit your needs. The +`--import-config` option is used as follows: + +``` +weectl import --import-config=/directory/import.conf +``` + +### `--dry-run` + +The `--dry-run` option will cause the import to proceed but no actual data +will be saved to the database. This is a useful option to use when first +importing data. + +``` +weectl import --import-config=/directory/import.conf --dry-run +``` + +### `--date=YYYY-mm-dd` + +Records from a single date can be imported by use of the `--date` option. +The `--date` option accepts strings of the format `YYYY-mm-dd`. Whilst the +use of the `--date` option will limit the imported data to that of a single +date, the default action if the `--date` option (and the `--from` and `--to` +options) is omitted may vary depending on the source. The operation of the +`--date` option is summarised in the following table: + + + + + + + + + + + + + + + + + + + + +
Option --date
optionRecords imported for a CSV, Cumulus, Weather Display or WeatherCat +importRecords imported for a Weather Underground import
omitted
(i.e., the default)
All available recordsToday's records only
--date=2015-12-22All records from 2015-12-22 00:00 (exclusive) to 2015-12-23 00:00 +(inclusive)All records from 2015-12-22 00:00 (exclusive) to 2015-12-23 00:00 +(inclusive)
+ +!!! Note + If the `--date`, `--from` and `--to` options are omitted the default is to + import today's records only when importing from Weather Underground or to + import all available records when importing from any other source. + +!!! Note + WeeWX considers an archive record to represent an aggregation of data over + the archive interval preceding the archive record's timestamp. For this + reason imports which are to be limited to a given date with the `--date` + option will only import records timestamped after midnight at the start + of the day concerned and up to and including midnight at the end of the + day concerned. + +### `--from` and `--to` + +Whilst the `--date` option allows imported data to be limited to a single +date, the `--from` and `--to` options allow finer control by importing +only the records that fall within the date or date-time range specified by +the `--from` and `--to` options. The `--from` option determines the +earliest (inclusive), and the `--to` option determines the latest +(exclusive), date or date-time of the records being imported. The `--from` +and `--to` options accept a string of the format `YYYY-mm-dd[THH:MM]`. The +T literal is mandatory if specifying a date-time. + +!!! Note + The `--from` and `--to` options must be used as a pair, they cannot be + used individually or in conjunction with the `--date`option. + +The operation of the `--from` and `--to` options is summarised in the +following table: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Options --from and --to
optionsRecords imported for a CSV, Cumulus, Weather Display or WeatherCat +importRecords imported for a Weather Underground import
omitted
(i.e., the default)
omitted
(i.e., the default)
All available recordsToday's records only
--from=2015-12-22--to=2015-12-29All records from 2015-12-22 00:00 (exclusive) to 2015-12-30 00:00 +(inclusive)All records from 2015-12-22 00:00 (exclusive) to 2015-12-30 00:00 +(inclusive)
--from=2016-7-18T15:29--to=2016-7-25All records from 2016-7-18 15:29 (exclusive) to 2016-7-26 00:00 +(inclusive)All records from 2016-7-18 15:29 (exclusive) to 2016-7-26 00:00 +(inclusive)
--from=2016-5-12--to=2016-7-22T22:15All records from 2016-5-12 00:00 (exclusive) to 2016-7-22 22:15 +(inclusive)All records from 2016-5-12 00:00 (exclusive) to 2016-7-22 22:15 +(inclusive)
--from=2016-3-18T15:29--to=2016-6-20T22:00All records from 2016-3-18 15:29 (exclusive) to 2016-6-20 22:00 +(inclusive)All records from 2016-3-18 15:29 (exclusive) to 2016-6-20 22:00 +(inclusive)
+ +!!! Note + If the `--date`, `--from` and `--to` options are omitted the default is + to import today's records only when importing from Weather Underground or + to import all available records when importing from any other source. + +!!! Note + WeeWX considers an archive record to represent an aggregation of data over + the archive interval preceding the archive record's timestamp. For this + reason imports which are to be limited to a given timespan with the + `--from` and `--to` options will only import records timestamped after + the timestamp represented by the `--from` option and up to and including + the timestamp represented by the `--to` option. + +### `--verbose` + +Inclusion of the `--verbose` option will cause additional information to be +printed during `weectl import` execution. + +``` +weectl import --import-config=/directory/import.conf --verbose +``` + +### `--no-prompt` + +Inclusion of the `--no-prompt` option will run `weectl import` without prompts. +Relevant defaults will be used and all y/n prompts are automatically accepted +as 'y'. This may be useful for unattended use of `weectl import`. + +``` +weectl import --import-config=/directory/import.conf --no-prompt +``` + +!!! Warning + Care must be taken when using the `--no-prompt` option as ignoring + warnings during the import process can lead to unexpected results. Whilst + existing data will be protected, the use or acceptance of an + incorrect or unexpected parameter or default may lead to significant + amounts of unwanted data being imported. + +### `--suppress-warnings` + +The `--suppress-warnings` option suppresses `weectl import` warning messages from +being displayed on the console during the import. `weectl import` may issue a +number of warnings during import. These warnings may be due to the source +containing more than one entry for a given timestamp or there being no data +found for a mapped import field. These warnings do not necessarily require +action, but they can consist of extensive output and thus make it difficult +to follow the import progress. Irrespective of whether `--suppress-warnings` +is used all warnings are sent to log. + +``` +weectl import --import-config=/directory/import.conf --suppress-warnings +``` diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-config-opt.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-config-opt.md new file mode 100755 index 0000000..a5687fd --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-config-opt.md @@ -0,0 +1,1760 @@ +`weectl import` requires a second configuration file, the import configuration +file, in addition to the standard WeeWX configuration file. The import +configuration file specifies the import type and various options associated +with the import type. The import configuration file is specified using the +mandatory `--import-config` option. How you construct the import +configuration file is up to you; however, the recommended method is to copy +one of the example import configuration files located in the `util/import` +directory, modify the configuration options in the newly copied file to suit +the import to be performed and then use this file as the import configuration +file. + +Following is the definitive guide to the options available in the import +configuration file. Where a default value is shown this is the value that will +be used if the option is omitted from the import configuration file. + +### `source`{#import_config_source} + +The `source` option determines the type of import to be performed by +`weectl import`. The option is mandatory and must be set to one of the following: + +* `CSV` to import from a single CSV format file. +* `WU` to import from a Weather Underground PWS history +* `Cumulus` to import from one or more Cumulus monthly log files. +* `WD` to import from one or more Weather Display monthly log files. +* `WeatherCat` to import from one or more WeatherCat monthly .cat files. + +Mandatory, there is no default. + +## [CSV] + +The `[CSV]` section contains the options controlling the import of +observational data from a CSV format file. + +### `file`{#csv_file} + +The file containing the CSV format data to be used as the source during the +import. Include full path and filename. + +Mandatory, there is no default. + +### `source_encoding`{#csv_encoding} + +The source file encoding. This parameter should only need be +used if the source file uses an encoding other than UTF-8 or an ASCII +compatible encoding. If used, the setting used should be a +Python Standard Encoding. + +Optional, the default is `utf-8-sig`. + +### `delimiter`{#csv_delimiter} + +The character used to separate fields. This parameter must be included in +quotation marks. + +Optional, the default is `','` (comma). + +### `decimal`{#csv_decimal} + +The character used as the decimal point in the source files. A full stop is +frequently used, but it may be another character. This parameter must be +included in quotation marks. + +Optional, the default is `'.'`. + +### `interval`{#csv_interval} + +Determines how the time interval (WeeWX archive table field `interval`) +between successive observations is derived. The interval can be derived by +one of three methods: + +* The interval can be calculated as the time, rounded to the nearest minute, + between the date-time of successive records. This method is suitable when + the data was recorded at fixed intervals and there are **NO** missing records + in the source data. Use of this method when there are missing records in + the source data can compromise the integrity of the WeeWX statistical data. + Select this method by setting `interval = derive`. + +* The interval can be set to the same value as the `archive_interval` + setting under `[StdArchive]` in `weewx.conf`. This setting is useful if + the data was recorded at fixed intervals, but there are some missing + records and the fixed interval is the same as the `archive_interval` + setting under `[StdArchive]` in `weewx.conf`. Select this method by + setting `interval = conf`. + +* The interval can be set to a fixed number of minutes. This setting is + useful if the source data was recorded at fixed intervals, but there are + some missing records and the fixed interval is different to the + `archive_interval` setting under `[StdArchive]` in `weewx.conf`. Select + this method by setting `interval = x` where `x` is an integer number of + minutes. + +If the CSV source data records are equally spaced in time, but some +records are missing, then a better result may be achieved using `conf` or +a fixed interval setting. + +Optional, the default is `derive`. + +### `qc`{#csv_qc} + +Determines whether simple quality control checks are applied to imported +data. Setting `qc = True` will result in `weectl import` applying the WeeWX +`StdQC` minimum and maximum checks to any imported observations. Setting `qc += False` will result in `weectl import` not applying quality control checks +to imported data. `weectl import` quality control checks use the same +configuration settings, and operate in the same manner, as the +[_StdQC_](../reference/weewx-options/stdqc.md) service. For example, for +minimum/maximum quality checks, if an observation falls outside of the +quality control range for that observation, the observation will be set to +`None`. In such cases you will be alerted through a log entry similar to: + +``` +2023-11-04 16:59:01 weectl-import[3795]: WARNING weewx.qc: 2023-10-05 18:30:00 +AEST (1696494600) Archive value 'outTemp' 194.34 outside limits (0.0, 120.0) +``` + +!!! Note + As derived observations are calculated after the quality control check is + applied, derived observations are not subject to quality control checks. + +Optional, the default is `True`. + +### `calc_missing`{#csv_calc_missing} + +Determines whether any missing derived observations will be calculated +from the imported data. Setting `calc_missing = True` will result in +`weectl import` using the WeeWX `StdWXCalculate` service to calculate any +missing derived observations from the imported data. Setting `calc_missing += False` will result in WeeWX leaving any missing derived observations as +`None`. See [_[StdWXCalculate]_](../reference/weewx-options/stdwxcalculate.md) +for details of the observations the `StdWXCalculate` service can calculate. + +Optional, the default is `True`. + +### `ignore_invalid_data`{#csv_ignore_invalid_data} + +Determines whether invalid data in a source field is ignored or the import +aborted. If invalid data is found in a source field and +`ignore_invalid_data` is `True` the corresponding WeeWX destination field is +set to `None` and the import continues. The import is aborted if +`ignore_invalid_data` is `False` and invalid data is found in a source field. + +Optional, the default is `True`. + +### `tranche`{#csv_tranche} + +To speed up database operations imported records are committed to database +in groups of records rather than individually. The size of the group is +set by the `tranche` parameter. Increasing the `tranche` parameter may +result in a slight speed increase, but at the expense of increased memory +usage. Decreasing the `tranche` parameter will result in less memory usage, +but at the expense of more frequent database access and likely increased +time to import. + +Optional, the default is `250` which should suit most users. + +### `UV_sensor`{#csv_UV} + +WeeWX records a `None/null` for `UV` when no UV sensor is installed, whereas +some weather station software records a value of 0 for UV index when there +is no UV sensor installed. The `UV_sensor` parameter enables `weectl import` +to distinguish between the case where a UV sensor is present and the UV index +is 0 and the case where no UV sensor is present and UV index is 0. +`UV_sensor = False` should be used when no UV sensor was used in producing +the source data. `UV_sensor = False` will result in `None/null` being recorded +in the WeeWX archive field `UV` irrespective of any UV observations in the +source data. `UV_sensor = True` should be used when a UV sensor was used +in producing the source data. `UV_sensor = True` will result in UV +observations in the source data being stored in the WeeWX archive field `UV`. + +Optional, the default is `True`. + +### `solar_sensor`{#csv_solar} + +WeeWX records a `None/null` when no solar radiation sensor is installed, +whereas some weather station software records a value of 0 for solar +radiation when there is no solar radiation sensor installed. The +`solar_sensor` parameter enables `weectl import` to distinguish between +the case where a solar radiation sensor is present and solar radiation is +0 and the case where no solar radiation sensor is present and solar +radiation is 0. `solar_sensor = False` should be used when no solar +radiation sensor was used in producing the source data. `solar_sensor = +False` will result in `None/null` being recorded in the WeeWX archive +field `radiation` irrespective of any solar radiation observations in the +source data. `solar_sensor = True` should be used when a solar radiation +sensor was used in producing the source data. `solar_sensor = True` will +result in solar radiation observations in the source data being stored in +the WeeWX archive field `radiation`. + +Optional, the default is `True`. + +### `raw_datetime_format`{#csv_raw_datetime_format} + +WeeWX stores each record with a unique unix epoch timestamp, whereas many +weather station applications or web sources export observational data with +a human-readable date-time. This human-readable date-time is interpreted +according to the format set by the `raw_datetime_format` option. This +option consists of [Python strptime() format codes](https://docs.python. org/2/library/datetime.html#strftime-and-strptime-behavior) and literal +characters to represent the date-time data being imported. + +For example, if the source data uses the format '23 January 2015 15:34' the +appropriate setting for `raw_datetime_format` would be `%d %B %Y %H:%M`, +'9:25:00 12/28/16' would use `%H:%M:%S %m/%d/%y`. If the source data +provides a unix epoch timestamp as the date-time field the unix epoch +timestamp is used directly and the `raw_datetime_format` option is ignored. + +Optional, the default is `%Y-%m-%d %H:%M:%S`. + +!!! Note + `weectl import` does not support the construction of the unique record + date-time stamp from separate date and time fields, rather the date-time + information for each imported record must be contained in a single field. + CSV data containing separate date and time fields may require further + manual processing before it can be imported. + +### `wind_direction`{#csv_wind_direction} + +WeeWX records wind direction in degrees as a number from 0 to 360 +inclusive (no wind direction is recorded as `None/null`), whereas some +data sources may provide wind direction as a number over a different range +(e.g., -180 to +180), or may use a particular value when there is no wind +direction (e.g., 0 may represent no wind direction and 360 may represent a +northerly wind, or -9999 (or some similar clearly invalid number of degrees) +to represent there being no wind direction). `weectl import` handles such +variations in data by defining a range over which imported wind direction +values are accepted. Any value outside of this range is treated as there +being no wind direction and is recorded as `None/null`. Any value inside +the range is normalised to the range 0 to 360 inclusive (e.g., -180 would +be normalised to 180). The `wind_direction` option consists of two comma +separated numbers of the format lower, upper where lower and upper are +inclusive. The operation of the `wind_direction` option is best +illustrated through the following table: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Option wind_direction
wind_direction option settingSource data wind direction valueImported wind direction value
0, 36000
160160
360360
500None/null
-45None/null
-9999None/null
No dataNone/null
-360, 36000
160160
360360
500None/null
-45315
-9999None/null
No dataNone/null
-180, 18000
160160
360None/null
500None/null
-45315
-9999None/null
No dataNone/null
+ +The default is `0, 360`. + +### `[[FieldMap]]`{#csv_fieldmap} + +The `[[FieldMap]]` stanza defines the mapping from the CSV source data fields +to WeeWX archive fields. This allows `weectl import` to take a source data +field, perform the appropriate unit conversion and store the resulting value in +the appropriate WeeWX archive field. The map consists of one sub-stanza per +WeeWX archive field being populated using the following format: + +``` + [[[weewx_archive_field_name]]] + source_field = csv_field_name + unit = weewx_unit_name + is_cumulative = True | False + is_text = True | False +``` + +Where + +* `weewx_archive_field_name` is a field in the in-use WeeWX archive table + schema +* `csv_field_name` is the name of a field in the CSV source data +* `weewx_unit_name` is a WeeWX unit name; e.g., `degree_C` + +Each WeeWX archive field stanza supports the following options: + +* `source_field`. The name of the CSV field to be mapped to the WeeWX archive + field. Mandatory. +* `unit`. The WeeWX unit name of the units used by `source_field`. Mandatory + for non-text source fields. Ignored for source text fields. +* `is_cumulative`. Whether the WeeWX archive field is to be derived from a + cumulative source field (e.g., daily rainfall) or not. Optional boolean + value. Default is `False`. +* `is_text`. Whether the source field is to be imported as text or not. + Optional boolean. Default is `False`. + +A mapping is not required for every WeeWX archive field and neither does +every CSV field need to be included in a mapping. + +!!! Note + Importing of text data into text fields in the WeeWX archive is only + supported for WeeWX archive fields that have been configured as text + fields. Refer to the Wiki page + [Storing text in the database](https://github.com/weewx/weewx/wiki/Storing-text-in-the-database) for details. + +If the source data includes a field that contains a WeeWX unit system code +(i.e. the equivalent of the WeeWX `usUnits` field such as may be obtained from +WeeWX or wview data) then this field may be mapped to the WeeWX `usUnits` +field and used to set the units used for all fields being imported. In such +cases, except for the `[[[dateTime]]]` field map entry, the `weewx_unit_name` +portion of the imported fields in the field map is not used and may be omitted. + +For example, source CSV data with the following structure: + +``` +date_and_time,temp,humid,wind,dir,dayrain,rad,river,decsription +23 May 2018 13:00,17.4,56,3.0,45,10.0,956,340,'cloudy' +23 May 2018 13:05,17.6,56,1.0,22.5,10.4,746,341, +``` + +where `temp` is temperature in Celsius, `humid` is humidity in percent, `wind` +is wind speed in km/h, `dir` is wind direction in degrees, `rainfall` is rain +in mm, `rad` is radiation in watts per square meter, `river` is river height in +mm and `description` is a text field might use a field map as follows: + +``` +[[FieldMap]] + [[[dateTime]]] + source_field = date_and_time + unit = unix_epoch + [[[outTemp]]] + source_field = temp + unit = degree_C + [[[outHumidity]]] + source_field = humid + unit = percent + [[[windSpeed]]] + source = wind + unit = km_per_hour + [[[windDir]]] + source_field = dir + unit = degree_compass + [[[rain]]] + source_field = dayrain + unit = mm + is_cumulative = True + [[[radiation]]] + source_field = rad + unit = watt_per_meter_squared + [[[outlook]]] + source_field = description + is_text = True +``` + +If the same source CSV data included a field `unit_info` that contains WeeWX +unit system data as follows: + +``` +date_and_time,temp,humid,wind,dir,dayrain,rad,river,decsription,unit_info +23 May 2018 13:00,17.4,56,3.0,45,0.0,956,340,'cloudy',1 +23 May 2018 13:05,17.6,56,1.0,22.5,0.4,746,341,'showers developing',16 +``` + +then a field map such as the following might be used: + +``` +[[FieldMap]] + [[[dateTime]]] + source_field = date_and_time + unit = unix_epoch + [[[usUnits]]] + source_field = unit_info + [[[outTemp]]] + source_field = temp + [[[outHumidity]]] + source_field = humid + [[[windSpeed]]] + source = wind + [[[windDir]]] + source_field = dir + [[[rain]]] + source_field = dayrain + is_cumulative = True + [[[radiation]]] + source_field = rad + [[[outlook]]] + source_field = description + is_text = True +``` + +!!! Note + Any WeeWX archive fields that are derived (e.g., `dewpoint`), and for + which there is no field mapping, may be calculated during import by use of + the [`calc_missing`](#csv_calc_missing) option in the `[CSV]` section of + the import configuration file. + +!!! Note + The `dateTime` field map entry is a special case. Whereas other field map + entries may use any supported WeeWX unit name, or no unit name if the + `usUnits` field is populated, the `dateTime` field map entry must include + the WeeWX unit name `unix_epoch`. This is because `weectl import` uses the + [raw_datetime_format](#csv_raw_datetime_format) config option to convert + the supplied date-time field data to a Unix epoch timestamp before the + field map is applied. + + +## [WU] + +The `[WU]` section contains the options relating to the import of observational +data from a Weather Underground PWS history. + +### `station_id`{#wu_station_id} + +The Weather Underground weather station ID of the PWS from which the +historical data will be imported. + +Mandatory, there is no default. + +### `api_key`{#wu_api_key} + +The Weather Underground API key to be used to obtain the PWS history data. + +Mandatory, there is no default. + +!!! Note + The API key is a seemingly random string of 32 characters used to access + the new (2019) Weather Underground API. PWS contributors can obtain an API + key by logging onto the Weather Underground internet site and accessing + 'Member Settings'. 16 character API keys used with the previous Weather + Underground API are not supported. + +### `interval`{#wu_interval} + +Determines how the time interval (WeeWX database field `interval`) between +successive observations is determined. This option is identical in operation +to the CSV [interval](#csv_interval) option, but applies to Weather +Underground imports only. As a Weather Underground PWS history sometimes has +missing records, the use of `interval = derive` may give incorrect or +inconsistent interval values. Better results may be obtained by using +`interval = conf` if the current WeeWX installation has the same +`archive_interval` as the Weather Underground data, or by using `interval += x` where `x` is the time interval in minutes used to upload the Weather +Underground data. The most appropriate setting will depend on the +completeness and (time) accuracy of the Weather Underground data being +imported. + +Optional, the default is `derive`. + +### `qc`{#wu_qc} + +Determines whether simple quality control checks are applied to imported +data. This option is identical in operation to the CSV [qc](#csv_qc) option +but applies to Weather Underground imports only. As Weather Underground +imports at times contain nonsense values, particularly for fields for which +no data was uploaded to Weather Underground by the PWS, the use of quality +control checks on imported data can prevent these nonsense values from being +imported and contaminating the WeeWX database. + +Optional, the default is `True`. + +### `calc_missing`{#wu_calc_missing} + +Determines whether any missing derived observations will be calculated from +the imported data. This option is identical in operation to the CSV +[calc_missing](#csv_calc_missing) option but applies to Weather Underground +imports only. + +Optional, the default is `True`. + +### `ignore_invalid_data`{#wu_ignore_invalid_data} + +Determines whether invalid data in a source field is ignored or the import +aborted. This option is identical in operation to the CSV +[ignore_invalid_data](#csv_ignore_invalid_data) option but applies to Weather +Underground imports only. + +Optional, the default is `True`. + +### `tranche`{#wu_tranche} + +The number of records written to the WeeWX database in each transaction. +This option is identical in operation to the CSV [tranche](#csv_tranche) +option but applies to Weather Underground imports only. + +Optional, the default is `250` which should suit most users. + +### `wind_direction`{#wu_wind_direction} + +Determines the range of acceptable wind direction values in degrees. This +option is identical in operation to the CSV +[wind_direction](#csv_wind_direction) option but applies to Weather +Underground imports only. + +Optional, the default is `0, 360`. + +### `[[FieldMap]]`{#wu_fieldmap} + +The `[[FieldMap]]` stanza defines the mapping from the Weather Underground +source data fields to WeeWX archive fields. This allows `weectl import` to +take a source data field, perform the appropriate unit conversion and store +the resulting value in the appropriate WeeWX archive field. Weather +Undergroundimports use a simplified map that consists of one sub-stanza per +WeeWX archive field being populated using the following format: + +``` + [[[weewx_archive_field_name]]] + source_field = wu_field_name +``` + +Where + +* `weewx_archive_field_name` is a field in the in-use WeeWX archive table + schema +* `wu_field_name` is the name of a Weather Underground source field as + detailed in the _Available Weather Underground import field names_ table + below. + +Each WeeWX archive field stanza supports the following option: + +* `source_field`. The name of the Cumulus field to be mapped to the WeeWX + archive field. Mandatory. + +A mapping is not required for every WeeWX archive field and neither does +every Weather Underground field need to be included in a mapping. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Current o + + + + + + + + + + + + + + + + + + + + + + +
Available Weather Underground import field names
Field nameDescription
epochDate and time
dewptAvgCurrent dewpoint
heatindexAvgCurrent heat index
humidityAvgCurrent outside Humidity
precipRateCurrent rain rate
precipTotalRainfall since midnight
pressureAvgCurrent barometric pressure
solarRadiationHighCurrent solar radiation
tempAvgOutside temperature
uvHighCurrent UV index
windchillAvgCurrent windchill
winddirAvgCurrent wind direction
windgustHighCurrent wind gust
windspeedAvgCurrent average wind speed
+ +!!! Note + The above field names are internally generated by `weectl import` and do + not represent any field names used within Weather Underground. They have + only been provided for use in the field map. + +For example, the following field map might be used to import outside +temperature to WeeWX field `outTemp`, outside humidity to WeeWX field +`outHumidity` and rainfall to WeeWX field `rain`: + +``` +[[FieldMap]] + [[[dateTime]]] + source_field = epoch + [[[outTemp]]] + source_field = tempAvg + [[[outHumidity]]] + source_field = humidityAvg + [[[rain]]] + source = precipTotal +``` + +!!! Note + Any WeeWX archive fields that are derived (e.g., `dewpoint`) and for + which there is no field mapping may be calculated during import by use + of the [`calc_missing`](#wu_calc_missing) option in the `[WU]` section + of the import configuration file. + +The example Weather Underground import configuration file located in the +`util/import` directory contains an example field map. + + +## [Cumulus] + +The `[Cumulus]` section contains the options relating to the import of +observational data from Cumulus monthly log files. + +### `directory`{#cumulus_directory} + +The full path to the directory containing the Cumulus monthly log files to be +imported. Do not include a trailing `/`. + +Mandatory, there is no default. + +### `source_encoding`{#cumulus_encoding} + +The Cumulus monthly log file encoding. This option is identical in operation +to the CSV [source_encoding](#csv_encoding) option but applies to Cumulus +imports only. + +Optional, the default is `utf-8-sig`. + +### `interval`{#cumulus_interval} + +Determines how the time interval (WeeWX database field `interval`) between +successive observations is determined. This option is identical in operation +to the CSV [interval](#csv_interval) option but applies to Cumulus monthly +log file imports only. As Cumulus monthly log files can, at times, have +missing entries, the use of `interval = derive` may give incorrect or +inconsistent interval values. Better results may be obtained by using +`interval = conf` if the `archive_interval` for the current WeeWX +installation is the same as the Cumulus 'data log interval' setting used to +generate the Cumulus monthly log files, or by using `interval = x` where `x` +is the time interval in minutes used as the Cumulus 'data log interval' +setting. The most appropriate setting will depend on the completeness and +(time) accuracy of the Cumulus data being imported. + +Optional, the default is `derive`. + +### `qc`{#cumulus_qc} + +Determines whether simple quality control checks are applied to imported +data. This option is identical in operation to the CSV [qc](#csv_qc) option +but applies to Cumulus imports only. + +Optional, the default is `True`. + +### `calc_missing`{#cumulus_calc_missing} + +Determines whether any missing derived observations will be calculated from +the imported data. This option is identical in operation to the CSV +[calc_missing](#csv_calc_missing) option but applies to Cumulus imports only. + +Optional, the default is `True`. + +### `separator`{#cumulus_separator} + +The character used as the date field separator in the Cumulus monthly log +file. A solidus (/) is frequently used, but it may be another character +depending on the settings on the machine that produced the Cumulus monthly +log files. This parameter must be included in quotation marks. + +Optional, the default is `'/'`. + +### `delimiter`{#cumulus_delimiter} + +The character used as the field delimiter in the Cumulus monthly log file. +A comma is frequently used, but it may be another character depending on the +settings on the machine that produced the Cumulus monthly log files. This +parameter must be included in quotation marks. + +Optional, the default is `','`. + +### `decimal`{#cumulus_decimal} + +The character used as the decimal point in the Cumulus monthly log files. A +period is frequently used, but it may be another character depending on the +settings on the machine that produced the Cumulus monthly log files. This +parameter must be included in quotation marks. + +Optional, the default is `'.'`. + +### `ignore_invalid_data`{#cumulus_ignore_invalid_data} + +Determines whether invalid data in a source field is ignored or the import +aborted. This option is identical in operation to the CSV +[ignore_invalid_data](#csv_ignore_invalid_data) option but applies to Cumulus +monthly log file imports only. + +Optional, the default is `True`. + +### `tranche`{#cumulus_tranche} + +The number of records written to the WeeWX database in each transaction. This +option is identical in operation to the CSV [tranche](#csv_tranche) option +but applies to Cumulus monthly log file imports only. + +Optional, the default is `250` which should suit most users. + +### `UV_sensor`{#cumulus_UV} + +Enables `weectl import` to distinguish between the case where a UV sensor is +present and the UV index is 0 and where no UV sensor is present and the UV +index is 0. This option is identical in operation to the CSV +[UV_sensor](#csv_UV) option but applies to Cumulus monthly log file imports +only. + +Optional, the default is `True`. + +### `solar_sensor`{#cumulus_solar} + +Enables `weectl import` to distinguish between the case where a solar +radiation sensor is present and solar radiation is 0 and where no solar +radiation sensor is present and solar radiation is 0. This option is +identical in operation to the CSV [solar_sensor](#csv_solar) option but +applies to Cumulus monthly log file imports only. + +Optional, the default is `True`. + +### `[[FieldMap]]`{#cumulus_fieldmap} + +The `[[FieldMap]]` stanza defines the mapping from the Cumulus source data +fields to WeeWX archive fields. This allows `weectl import` to take a source +data field, perform the appropriate unit conversion and store the resulting +value in the appropriate WeeWX archive field. The map consists of one +sub-stanza per WeeWX archive field being populated using the following +format: + +``` + [[[weewx_archive_field_name]]] + source_field = cumulus_field_name + unit = weewx_unit_name + is_cumulative = True | False + is_text = True | False +``` + +Where + +* `weewx_archive_field_name` is a field in the in-use WeeWX archive table + schema +* `cumulus_field_name` is the name of a Cumulus source field as detailed in + the _Available Cumulus import field names_ table below. +* `weewx_unit_name` is a WeeWX unit name; e.g., `degree_C` +* `is_text`. Whether the source field is to be imported as text or not. + Optional boolean. Default is `False`. + +Each WeeWX archive field stanza supports the following options: + +* `source_field`. The name of the Cumulus field to be mapped to the WeeWX + archive field. Mandatory. +* `unit`. The WeeWX unit name of the units used by `source_field`. Mandatory. +* `is_cumulative`. Whether the WeeWX archive field is to be derived from a + cumulative source field (e.g., daily rainfall) or not. Optional boolean + value. Default is `False`. + +A mapping is not required for every WeeWX archive field and neither does +every Cumulus field need to be included in a mapping. + +!!! Note + The `unit` option setting for each field map entry will depend on the + Cumulus settings used to generate the Cumulus monthly log files. + Depending on the Cumulus field type, the supported WeeWX units names + for that field may only be a subset of the corresponding WeeWX unit + names; e.g., WeeWX supports temperatures in Celsius, Fahrenheit and + Kelvin, but Cumulus logs may only include temperatures in Celsius or + Fahrenheit. Refer to [_Units_](../reference/units.md) for details of + available WeeWX unit names. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Available Cumulus import field names
Field nameDescription
datetimeDate and time
annual_etAnnual evapotranspiration
avg_wind_speedAverage wind speed
cur_app_tempCurrent apparent temperature
avg_wind_bearingCurrent wind direction
cur_dewpointCurrent dew point
cur_etEvapotranspiration
cur_heatindexCurrent heat index
cur_in_humCurrent inside humidity
curr_in_tempCurrent inside temperature
cur_out_humCurrent outside humidity
cur_out_tempCurrent outside temperature
cur_rain_rateCurrent rain rate
cur_slpCurrent barometric pressure
cur_solarCurrent solar radiation
cur_tmax_solarCurrent theoretical maximum solar radiation
cur_uvCurrent UV index
cur_wind_bearingCurrent wind direction
cur_windchillCurrent windchill
day_rainTotal rainfall since the daily rollover
day_rain_rg11Today's RG-11 rainfall
day_sunshine_hoursToday's sunshine hours
gust_wind_speedWind gust speed
latest_wind_gustLatest measured wind speed
midnight_rainTotal rainfall since midnight
rain_counterTotal rainfall counter
+ +!!! Note + The above field names are internally generated by `weectl import` and do + not represent any field names used within Cumulus. They have only been + provided for use in the field map. + +For example, the following field map might be used to import outside +temperature to WeeWX field `outTemp`, outside humidity to WeeWX field +`outHumidity` and extra temperature 1 to WeeWX field `poolTemp`: + +``` +[[FieldMap]] + [[[dateTime]]] + source_field = datetime + unit = unix_epoch + [[[outTemp]]] + source_field = temp + unit = degree_C + [[[outHumidity]]] + source_field = humid + unit = percent + [[[poolTemp]]] + source = temp1 + unit = degree_C +``` + +!!! Note + Any WeeWX archive fields that are derived (e.g., `dewpoint`) and for + which there is no field mapping may be calculated during import by use of + the [`calc_missing`](#cumulus_calc_missing) option in the `[Cumulus]` + section of the import configuration file. + +!!! Note + The `dateTime` field map entry is a special case. Whereas other field map + entries may use any WeeWX unit name for a unit supported by the import + source, the `dateTime` field map entry must use the WeeWX unit name + `unix_epoch`. + +The example Cumulus import configuration file located in the `util/import` +directory contains an example field map. + + +## [WD] + +The `[WD]` section contains the options relating to the import of +observational data from Weather Display monthly log files. + +### `directory`{#wd_directory} + +The full path to the directory containing the Weather Display monthly log +files to be imported. Do not include a trailing `/`. + +Mandatory, there is no default. + +### `logs_to_process`{#wd_logs_to_process} + +The Weather Display monthly log files to be processed. Weather Display uses +multiple files to record each month of data. Which monthly log files are +produced depends on the Weather Display configuration and the capabilities of +the weather station. `weectl import` supports the following Weather Display +monthly log files: + +* `MMYYYYlg.txt` +* `MMYYYYlgcsv.csv` (csv format version of `MMYYYYlg.txt`) +* `MMYYYYvantagelog.txt` +* `MMYYYYvantagelogcsv.csv` (csv format version of `MMYYYYvantagelog.txt`) +* `MMYYYYvantageextrasensorslog.csv` + +where MM is a one or two-digit month and YYYY is a four digit year + +The format for the `logs_to_process` setting is: + +``` +logs_to_process = [lg.txt, | logcsv.csv, | vantagelog.txt, | vantagelogcsv. +csv, | vantageextrasensorslog.csv] +``` + +!!! Note + The leading MMYYYY is omitted when listing the monthly log files to be + processed using the `logs_to_process` setting. Inclusion of the leading + MMYYYY will cause the import to fail. + +!!! Note + The `MMYYYYlgcsv.csv` and `MMYYYYvantagelogcsv.csv` log files are CSV + versions of `MMYYYYlg.txt` and `MMYYYYvantagelog.txt` respectively. + Either the `.txt` or `.csv` version of these files should be used but + not both. + +The monthly log files selected for processing should be chosen carefully as +the selected log files will determine the Weather Display data fields +available for import. `weectl import` is able to import the following data +from the indicated monthly log files: + +* `MMYYYYlg.txt`/`MMYYlgcsv.csv`: + * `average wind speed` + * `barometer` + * `date and time` + * `dew point` + * `heat index` + * `outside humidity` + * `outside temperature` + * `rain fall` + * `wind direction` + * `wind gust speed` + +* `MMYYYYvantagelog.txt`/`MMYYYYvantagelogcsv.csv`: + * `date and time` + * `soil moisture` + * `soil temperature` + * `solar radiation` + * `UV index` + +* `MMYYYYvantageextrasensorslog.csv`: + * `date and time` + * `extra humidity 1` + * `extra humidity 2` + * `extra humidity 3` + * `extra humidity 4` + * `extra humidity 5` + * `extra humidity 6` + * `extra temperature 1` + * `extra temperature 2` + * `extra temperature 3` + * `extra temperature 4` + * `extra temperature 5` + * `extra temperature 6` + +!!! Note + Whilst the above log files may contain the indicated data the data may + only be imported subject to a suitable field map and in-use WeeWX archive + table schema (refer to the [[[FieldMap]]](#wd_fieldmap) option). + +Optional, the default is `lg.txt, vantagelog.txt, vantageextrasensorslog.csv`. + +### `source_encoding`{#wd_encoding} + +The Weather Display monthly log file encoding. This option is identical in +operation to the CSV [source_encoding](#csv_encoding) option but applies to +Weather Display imports only. + +Optional, the default is `utf-8-sig`. + +### `interval`{#wd_interval} + +Determines how the time interval (WeeWX database field `interval`) between +successive observations is determined. This option is identical in operation +to the CSV [interval](#csv_interval) option but applies to Weather Display +monthly log file imports only. As Weather Display log files nominally have +entries at one minute intervals the recommended approach is to set +`interval = 1`. As Weather Display monthly log files can, at times, have +missing entries, the use of `interval = derive` may give incorrect or +inconsistent interval values. If then `archive_interval` for the current +WeeWX installation is 1 minute `interval = conf` may be used. In most cases +the most appropriate setting will be `interval = 1`. + +Optional, the default is `1`. + +### `qc`{#wd_qc} + +Determines whether simple quality control checks are applied to imported +data. This option is identical in operation to the CSV [qc](#csv_qc) option +but applies to Weather Display imports only. + +Optional, the default is `True`. + +### `calc_missing`{#wd_calc_missing} + +Determines whether any missing derived observations will be calculated from +the imported data. This option is identical in operation to the CSV +[calc_missing](#csv_calc_missing) option but applies to Weather Display +imports only. + +Optional, the default is `True`. + +### `txt_delimiter`{#wd_txt_delimiter} + +The character used as the field delimiter in Weather Display text format +monthly log files (.txt files). A space is normally used but another +character may be used if necessary. This parameter must be included in +quotation marks. + +Optional, the default is `' '`. + +### `csv_delimiter`{#wd_csv_delimiter} + +The character used as the field delimiter in Weather Display csv format +monthly log files (.csv files). A comma is normally used but another +character may be used if necessary. This parameter must be included in +quotation marks. + +Optional, the default is `','`. + +### `decimal`{#wd_decimal} + +The character used as the decimal point in the Weather Display monthly log +files. A period is frequently used but another character may be used if +necessary. This parameter must be included in quotation marks. + +Optional, the default is `'.'`. + +### `ignore_missing_log`{#wd_ignore_missing_log} + +Determines whether missing log files are to be ignored or the import aborted. +Weather Display log files are complete in themselves and a missing log file +will have no effect other than there will be no imported data for the period +covered by the missing log file. + +Optional, the default is `True`. + +### `ignore_invalid_data`{#wd_ignore_invalid_data} + +Determines whether invalid data in a source field is ignored or the import +aborted. This option is identical in operation to the CSV +[ignore_invalid_data](#csv_ignore_invalid_data) option but applies to Weather +Display monthly log file imports only. + +Optional, the default is `True`. + +### `tranche`{#wd_tranche} + +The number of records written to the WeeWX database in each transaction. +This option is identical in operation to the CSV [tranche](#csv_tranche) +option but applies to Weather Display monthly log file imports only. + +Optional, the default is `250` which should suit most users. + +### `UV_sensor`{#wd_UV} + +Enables `weectl import` to distinguish between the case where a UV sensor is +present and the UV index is 0 and where no UV sensor is present and the UV +index is 0. This option is identical in operation to the CSV +[UV_sensor](#csv_UV) option but applies to Weather Display monthly log file +imports only. + +Optional, the default is `True`. + +### `solar_sensor`{#wd_solar} + +Enables `weectl import` to distinguish between the case where a solar +radiation sensor is present and solar radiation is 0 and where no solar +radiation sensor is present and solar radiation is 0. This option is identical +in operation to the CSV [solar_sensor](#csv_solar) option but applies to +Weather Display monthly log file imports only. + +Optional, the default is `True`. + +### `ignore_extreme_temp_hum`{#wd_ignore_extreme_temp_hum} + +Determines whether extreme temperature and humidity values are ignored. +Weather Display log files record the value 255 for temperature and humidity +fields if no corresponding sensor is present. Setting +`ignore_extreme_temp_hum = True` will cause temperature and humidity +values of 255 to be ignored. Setting `ignore_extreme_temp_hum = False` will +cause temperature and humidity values of 255 to be treated as valid data to +be imported. + +Optional, the default is `True`. + +!!! Note + Setting `ignore_extreme_temp_hum = False` will cause temperature and + humidity values of 255 to be imported; however, these values may be + rejected by the simple quality control checks implemented if `qc = True` + is used. + +### `[[FieldMap]]`{#wd_fieldmap} + +The `[[FieldMap]]` stanza defines the mapping from the Weather Display +source data fields to WeeWX archive fields. This allows `weectl import` to +take a source data field, perform the appropriate unit conversion and store +the resulting value in the appropriate WeeWX archive field. The map consists +of one sub-stanza per WeeWX archive field being populated using the following +format: + +``` + [[[weewx_archive_field_name]]] + source_field = wd_field_name + unit = weewx_unit_name + is_cumulative = True | False + is_text = True | False +``` + +Where + +* `weewx_archive_field_name` is a field in the in-use WeeWX archive table + schema +* `wd_field_name` is the name of a Weather Display source field as detailed + in the _Available Weather Display import field names_ table below. +* `weewx_unit_name` is a WeeWX unit name; e.g., `degree_C` + +Each WeeWX archive field stanza supports the following options: + +* `source_field`. The name of the Weather Display field to be mapped to the + WeeWX archive field. Mandatory. +* `unit`. The WeeWX unit name of the units used by `source_field`. Mandatory. +* `is_cumulative`. Whether the WeeWX archive field is to be derived from a + cumulative source field (e.g., daily rainfall) or not. Optional boolean + value. Default is `False`. +* `is_text`. Whether the source field is to be imported as text or not. + Optional boolean. Default is `False`. + +A mapping is not required for every WeeWX archive field and neither does +every Weather Display field need to be included in a mapping. + +!!! Note + The `unit` option setting for each field map entry will depend on the + Weather Display settings used to generate the Weather Display log files. + Depending on the Weather Display field type, the supported WeeWX units + names for that field may only be a subset of the corresponding WeeWX unit + names; e.g., WeeWX supports temperatures in Celsius, Fahrenheit and + Kelvin, but Weather Display log files may only include temperatures in + Celsius or Fahrenheit. Refer to [_Units_](../reference/units.md) for + details of available WeeWX unit names. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Available Weather Display import field names
Field nameDescription
datetimeDate and time
barometerBarometric pressure
dailyetDaily evapotranspiration
dewpointDew point
directionWind direction
gustspeedWind gust speed
heatindexHeat index
humidityOutside humidity
hum1Extra humidity 1
hum2Extra humidity 2
hum3Extra humidity 3
hum4Extra humidity 4
hum5Extra humidity 5
hum6Extra humidity 6
hum7Extra humidity 7
radiationSolar radiation
rainlastminRainfall in the last 1 minute
soilmoistSoil moisture
soiltempSoil temperature
temperatureOutside temperature
temp1Extra temperature 1
temp2Extra temperature 2
temp3Extra temperature 3
temp4Extra temperature 4
temp5Extra temperature 5
temp6Extra temperature 6
temp7Extra temperature 7
uvUV index
windspeedAverage wind speed
+ +!!! Note + The above field names are internally generated by `weectl import` and do + not represent any field names used within Weather Display. They have only + been provided for use in the field map. + +For example, the following field map might be used to import outside +temperature to WeeWX field `outTemp`, outside humidity to WeeWX field +`outHumidity` and extra temperature 1 to WeeWX field `poolTemp`: + +``` +[[FieldMap]] + [[[dateTime]]] + source_field = datetime + unit = unix_epoch + [[[outTemp]]] + source_field = temperature + unit = degree_C + [[[outHumidity]]] + source_field = humidity + unit = percent + [[[poolTemp]]] + source = temp1 + unit = degree_C +``` + +!!! Note + Any WeeWX archive fields that are derived (e.g., `dewpoint`) and for + which there is no field mapping may be calculated during import by use of + the [`calc_missing`](#wd_calc_missing) option in the `[WD]` section of + the import configuration file. + +!!! Note + The `dateTime` field map entry is a special case. Whereas other field + map entries may use any WeeWX unit name for a unit supported by the + import source, the `dateTime` field map entry must use the WeeWX unit + name `unix_epoch`. + +The example Weather Display import configuration file located in the +`util/import` directory contains an example field map. + + +## [WeatherCat] + +The `[WeatherCat]` section contains the options relating to the import of +observational data from WeatherCat monthly .cat files. + +### `directory`{#wcat_directory} + +The full path to the directory containing the year directories that contain +the WeatherCat monthly .cat files to be imported. Do not include a trailing +`/`. + +Mandatory, there is no default. + +### `source_encoding`{#wcat_encoding} + +The WeatherCat monthly .cat file encoding. This option is identical in +operation to the CSV [source_encoding](#csv_encoding) option but applies to +WeatherCat imports only. + +Optional, the default is `utf-8-sig`. + +### `interval`{#wcat_interval} + +Determines how the time interval (WeeWX database field `interval`) between +successive observations is determined. This option is identical in operation +to the CSV [interval](#csv_interval) option but applies to WeatherCat imports +only. As WeatherCat monthly .cat files can, at times, have missing entries, +the use of `interval = derive` may give incorrect or inconsistent interval +values. Better results may be obtained by using `interval = conf` if the +`archive_interval` for the current WeeWX installation is the same as the +WeatherCat .cat file log interval, or by using `interval = x` where `x` is +the time interval in minutes used in the WeatherCat monthly .cat file(s). The +most appropriate setting will depend on the completeness and (time) accuracy +of the WeatherCat data being imported. + +Optional, the default is `derive`. + +### `qc`{#wcat_qc} + +Determines whether simple quality control checks are applied to imported +data. This option is identical in operation to the CSV [qc](#csv_qc) option +but applies to WeatherCat imports only. + +Optional, the default is `True`. + +### `calc_missing`{#wcat_calc_missing} + +Determines whether any missing derived observations will be calculated from +the imported data. This option is identical in operation to the CSV +[calc_missing](#csv_calc_missing) option but applies to WeatherCat imports only. + +Optional, the default is `True`. + +### `decimal`{#wcat_decimal} + +The character used as the decimal point in the WeatherCat monthly .cat files. +This parameter must be included in quotation marks. + +Optional, the default is `'.'`. + +### `tranche`{#wcat_tranche} + +The number of records written to the WeeWX database in each transaction. This +option is identical in operation to the CSV [tranche](#csv_tranche) option +but applies to WeatherCat imports only. + +Optional, the default is `250` which should suit most users. + +### `UV_sensor`{#wcat_UV} + +Enables `weectl import` to distinguish between the case where a UV sensor is +present and the UV index is 0 and where no UV sensor is present and the UV +index is 0. This option is identical in operation to the CSV +[UV_sensor](#csv_UV) option but applies to WeatherCat imports only. + +Optional, the default is `True`. + +### `solar_sensor`{#wcat_solar} + +Enables `weectl import` to distinguish between the case where a solar +radiation sensor is present and solar radiation is 0 and where no solar +radiation sensor is present and solar radiation is 0. This option is +identical in operation to the CSV [solar_sensor](#csv_solar) option but +applies to WeatherCat imports only. + +Optional, the default is `True`. + +### `[[FieldMap]]`{#wcat_fieldmap} + +The `[[FieldMap]]` stanza defines the mapping from the WeatherCat source data +fields to WeeWX archive fields. This allows `weectl import` to take a source +data field, perform the appropriate unit conversion and store the resulting +value in the appropriate WeeWX archive field. The map consists of one +sub-stanza per WeeWX archive field being populated using the following format: + +``` + [[[weewx_archive_field_name]]] + source_field = wc_field_name + unit = weewx_unit_name + is_cumulative = True | False + is_text = True | False +``` + +Where + +* `weewx_archive_field_name` is a field in the in-use WeeWX archive table + schema +* `wc_field_name` is the nameof a WeatherCat source field as detailed in the + _Available WeatherCat import field names_ table below. +* `weewx_unit_name` is a WeeWX unit name; e.g., `degree_C` + +Each WeeWX archive field stanza supports the following options: + +* `source_field`. The name of the WeatherCat field to be mapped to the WeeWX + archive field. Mandatory. +* `unit`. The WeeWX unit name of the units used by `source_field`. Mandatory. +* `is_cumulative`. Whether the WeeWX archive field is to be derived from a + cumulative source field (e.g., daily rainfall) or not. Optional boolean + value. Default is `False`. +* `is_text`. Whether the source field is to be imported as text or not. + Optional boolean. Default is `False`. + +!!! Note + The `unit` option setting for each field map entry will depend on the + WeatherCat settings used to generate the WeatherCat .cat files. + Depending on the WeatherCat field type, the supported WeeWX unit names + for that field may only be a subset of the corresponding WeeWX unit names; + e.g., WeeWX supports temperatures in Celsius, Fahrenheit and Kelvin, but + WeatherCat .cat files may only include temperatures in Celsius or + Fahrenheit. Refer to [_Units_](../reference/units.md) for details of + available WeeWX unit names. + +A mapping is not required for every WeeWX archive field and neither does +every WeatherCat field need to be included in a mapping. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Available WeatherCat import field names
Field nameDescription
datetimedate and time
PrBarometric pressure
DDew point
HiInside humidity
TiInside temperature
H1Extra humidity 1
H2Extra humidity 2
T1Extra temperature 1
T2Extra temperature 2
T3Extra temperature 3
Lt1Leaf temperature 1
Lt2Leaf temperature 2
Lw1Leaf wetness 1
Lw2Leaf wetness 2
HOutside humidity
TOutside temperature
PPrecipitation
Sm1Soil moisture 1
Sm2Soil moisture 2
Sm3Soil moisture 3
Sm4Soil moisture 4
St1Soil temperature 1
St2Soil temperature 2
St3Soil temperature 3
St4Soil temperature 4
SSolar radiation
UUV index
WcWindchill
WdWind direction
WgWind gust speed
WWind speed
+ +!!! Note + The above field names are internally generated by `weectl import` and do + not represent any field names used within WeatherCat. They have only been + provided for use in the field map. + +For example, the following field map might be used to import outside +temperature to WeeWX field `outTemp`, outside humidity to WeeWX field +`outHumidity` and extra temperature 1 to WeeWX field `poolTemp`: + +``` +[[FieldMap]] + [[[dateTime]]] + source_field = datetime + unit = unix_epoch + [[[outTemp]]] + source_field = T + unit = degree_C + [[[outHumidity]]] + source_field = H + unit = percent + [[[poolTemp]]] + source = T1 + unit = degree_C +``` + +!!! Note + Any WeeWX archive fields that are derived (e.g., `dewpoint`) and for + which there is no field mapping may be calculated during import by use of + the [`calc_missing`](#wc_calc_missing) option in the `[WeatherCat]` + section of the import configuration file. + +!!! Note + The `dateTime` field map entry is a special case. Whereas other field map + entries may use any WeeWX unit name for a unit supported by the import + source, the `dateTime` field map entry must use the WeeWX unit name + `unix_epoch`. + +The example WeatherCat import configuration file located in the `util/import` +directory contains an example field map. \ No newline at end of file diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-csv.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-csv.md new file mode 100644 index 0000000..7032276 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-csv.md @@ -0,0 +1,270 @@ +!!! Warning + Running WeeWX during a `weectl import` session can lead to abnormal + termination of the import. If WeeWX must remain running (e.g., so that + live data is not lost) run the `weectl import` session on another machine + or to a second database and merge the in-use and second database once the + import is complete. + +`weectl import` can import data from a single CSV file. The CSV source file +must be structured as follows: + +* The file must have a header row consisting of a comma separated list of + field names. The field names can be any valid string as long as each field + name is unique within the list. There is no requirement for the field names + to be in any particular order as long as the same order is used for the + observations on each row in the file. These field names will be mapped to + WeeWX field names in the `[CSV]` section of the import configuration file. + +* Observation data for a given date-time must be listed on a single line with + individual fields separated by a comma. The fields must be in the same + order as the field names in the header row. + +* Blank fields are represented by the use of white space or no space only + between commas. + +* Direction data being imported may be represented as numeric degrees or + as a string representing the [cardinal, intercardinal and/or secondary + intercardinal directions](https://en.wikipedia.org/wiki/Cardinal_direction). + +* There must a field that represents the date-time of the observations on + each line. This date-time field must be either a Unix epoch timestamp or + any date-time format that can be represented using [Python strptime() + format codes](https://docs.python.org/2/library/datetime. + html#strftime-and-strptime-behavior). + +A CSV file suitable for import by `weectl import` may look like this: + +``` +Time,Barometer,Temp,Humidity,Windspeed,Dir,Gust,Dayrain,Radiation,Uv,Comment +28/11/2017 08:00:00,1016.9,24.6,84,1.8,113,8,0,359,3.8,"start of observations" +28/11/2017 08:05:00,1016.9,25.1,82,4.8,135,11.3,0,775,4.7, +28/11/2017 08:10:00,1016.9,25.4,80,4.4,127,11.3,0,787,5.1,"note temperature" +28/11/2017 08:15:00,1017,25.7,79,3.5,74,11.3,0,800,5.4, +28/11/2017 08:20:00,1016.9,25.9,79,1.6,95,9.7,0,774,5.5, +28/11/2017 08:25:00,1017,25.5,78,2.9,48,9.7,0,303,3.4,"forecast received" +28/11/2017 08:30:00,1017.1,25.1,80,3.1,54,9.7,0,190,3.6, +``` + +or this: + +``` +Time,Barometer,Temp,Humidity,Windspeed,Dir,Gust,Dayrain,Radiation,Uv +2/1/2017 06:20:00,1006.4,4.8,48,2.8,NE,4,0,349,2.8 +2/1/2017 06:25:00,1006.9,5.0,48,3.8,NNE,21.3,0,885,4.3 +2/1/2017 06:30:00,1006.8,5.4,47,3.4,North,12.3,0,887,5.3 +2/1/2017 06:35:00,1007,5.2,49,5.5,NNE,13.3,0,600,5.4 +2/1/2017 06:40:00,1006.9,5.7,49,2.6,ESE,9.7,0,732,5.5 +2/1/2017 06:45:00,1007,5.5,48,1.9,Southsoutheast,9.8,0,393,6.4 +2/1/2017 06:50:00,1007.1,5.2,50,2.1,southeast,9.9,0,180,6.6 +``` + +!!! Note + [Cardinal, intercardinal and/or secondary intercardinal directions](https://en.wikipedia.org/wiki/Cardinal_direction) + may be represented by one, two or three letter abbreviations e.g., N, SE + or SSW; by a single word e.g., North, Southwest or Southsouthwest or + by hyphenated or spaced words e.g., North West or South-south-west. + Capitalisation is ignored as are any spaces, hyphens or other white + space. At present only English abbreviations and directions are supported. + +## Mapping data to archive fields + +The WeeWX archive fields populated during a CSV import depend on the +CSV-to-WeeWX field mappings specified in `[[FieldMap]]` stanza in the import +configuration file. If a valid field mapping exists, the WeeWX field exists +in the WeeWX archive table schema and provided the mapped CSV field contains +valid data, the corresponding WeeWX field will be populated. + +!!! Note + The use of the [calc_missing](weectl-import-config-opt.md#csv_calc_missing) + option in the import configuration file may result in a number of derived + fields being calculated from the imported data. If these derived fields + exist in the in-use database schema they will be saved to the database as + well. + +## Step-by-step instructions + +To import observations from a CSV file: + +1. Ensure the source data file is in a directory accessible by the machine + that will run `weectl import`. For the purposes of the following examples + the source data file `data.csv` located in the `/var/tmp` directory + will be used. + +2. Make a backup of the WeeWX database in case the import should go awry. + +3. Create an import configuration file. In this case we will make a copy of + the example CSV import configuration file and save it as `csv.conf` in the + `/var/tmp` directory: + + ``` + cp /home/weewx/util/import/csv-example.conf /var/tmp/csv.conf + ``` + +4. Confirm that the [`source`](weectl-import-config-opt.md#import_config_source) + option is set to CSV: + + ``` + source = CSV + ``` + +5. Confirm the following options in the `[CSV]` section are set: + + * [file](weectl-import-config-opt.md#csv_file). The full path and + file +name of the file containing the CSV formatted data to be imported. + + * [delimiter](weectl-import-config-opt.md#csv_delimiter). The single + character used to separate fields. + + * [interval](weectl-import-config-opt.md#csv_interval). Determines how + the + WeeWX interval field is derived. + + * [qc](weectl-import-config-opt.md#csv_qc). Determines whether quality + control checks are performed on the imported data. + + * [calc_missing](weectl-import-config-opt.md#csv_calc_missing). + Determines + whether missing derived observations will be calculated from the imported + data. + + * [ignore_invalid_data](weectl-import-config-opt.md#csv_ignore_invalid_data). + Determines whether invalid data in a source field is ignored or the import + aborted. + + * [tranche](weectl-import-config-opt.md#csv_tranche). The number of + records + written to the WeeWX database in each transaction. + + * [UV_sensor](weectl-import-config-opt.md#csv_UV). Whether a UV sensor + was + installed when the source data was produced. + + * [solar_sensor](weectl-import-config-opt.md#csv_solar). Whether a solar + radiation sensor was installed when the source data was produced. + + * [raw_datetime_format](weectl-import-config-opt.md#csv_raw_datetime_format). + The format of the imported date time field. + + * [rain](weectl-import-config-opt.md#csv_rain). Determines how the + WeeWX rain + field is derived. + + * [wind_direction](weectl-import-config-opt.md#csv_wind_direction). + Determines how imported wind direction fields are interpreted. + + * [[[FieldMap]]](weectl-import-config-opt.md#csv_fieldmap). Defines the + mapping between imported data fields and WeeWX archive fields. Also defines + the units of measure for each imported field. + +6. When first importing data it is prudent to do a dry run import before any + data are actually imported. A dry run import will perform all steps of the + import without actually writing imported data to the WeeWX database. In + addition, consideration should be given to any additional options such + as `--date`. + + To perform a dry run enter the following command: + + ``` + weectl import --import-config=/var/tmp/csv.conf --dry-run + ``` + + The output should be something like: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + A CSV import from source file '/var/tmp/data.csv' has been requested. + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + 27337 records identified for import. + Unique records processed: 27337; Last timestamp: 2018-03-03 06:00:00 AEST (1520020800) + Finished dry run import + 27337 records were processed and 27337 unique records would have been imported. + ``` + + The output includes details about the data source, the destination of the + imported data and some other details on how the data will be processed. + The import will then be performed but no data will be written to the + WeeWX database. Upon completion a brief summary of the records processed + is provided. + +7. Once the dry run results are satisfactory the data can be imported using + the following command: + + ``` + weectl import --import-config=/var/tmp/csv.conf + ``` + + This will result in a short preamble similar to that from the dry run. At + the end of the preamble there will be a prompt: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + A CSV import from source file '/var/tmp/data.csv' has been requested. + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Starting import ... + 27337 records identified for import. + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + +8. If the import parameters are acceptable enter `y` to proceed with the + import or `n` to abort the import. If the import is confirmed the source + data will be imported, processed and saved in the WeeWX database. + Information on the progress of the import will be displayed similar to the + following: + + ``` + Unique records processed: 3250; Last timestamp: 2017-12-09 14:45:00 AEST (1512794700) + ``` + + The line commencing with `Unique records processed` should update as + records are imported with progress information on number of records + processed, number of unique records imported and the date time of the + latest record processed. Once the initial import is complete + `weectl import` will, if requested, calculate any missing derived + observations and rebuild the daily summaries. A brief summary should be + displayed similar to the following: + + ``` + Calculating missing derived observations... + Processing record: 27337; Last record: 2018-03-03 06:00:00 AEST (1520020800) + Recalculating daily summaries... + Records processed: 27337; Last date: 2018-03-03 06:00:00 AEST (1520020800) + Finished recalculating daily summaries + Finished calculating missing derived observations + ``` + + When the import is complete a brief summary is displayed similar to the + following: + + ``` + Finished import + 27337 records were processed and 27337 unique records imported in 113.91 seconds. + Those records with a timestamp already in the archive will not have been + imported. Confirm successful import in the WeeWX log file. + ``` + +9. Whilst `weectl import` will advise of the number of records processed and + the number of unique records found, `weectl import` does know how many, if + any, of the imported records were successfully saved to the database. You + should look carefully through the WeeWX log file covering the `weectl + import` session and take note of any records that were not imported. The + most common reason for imported records not being saved to the database is + because a record with that timestamp already exists in the database, in + such cases something similar to the following will be found in the log: + + ``` + 2023-11-04 15:33:01 weectl-import[3795]: ERROR weewx.manager: Unable to add record 2018-09-04 04:20:00 AEST (1535998800) to database 'weewx.sdb': UNIQUE constraint failed: archive.dateTime + ``` + + In such cases you should take note of the timestamp of the record(s) + concerned and make a decision about whether to delete the pre-existing + record and re-import the record or retain the pre-existing record. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-cumulus.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-cumulus.md new file mode 100644 index 0000000..73d94be --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-cumulus.md @@ -0,0 +1,323 @@ +!!! Warning + Running WeeWX during a `weectl import` session can lead to abnormal + termination of the import. If WeeWX must remain running (e.g., so that + live data is not lost) run the `weectl import` session on another machine or + to a second database and merge the in-use and second database once the + import is complete. + +`weectl import` can import observational data from the one or more Cumulus +monthly log files. A Cumulus monthly log file records weather station +observations for a single month. These files are accumulated over time and +can be considered analogous to the WeeWX archive table. When `weectl import` +imports data from the Cumulus monthly log files each log file is +considered a 'period'. `weectl import` processes one period at a time in +chronological order (oldest to newest) and provides import summary data on +a per period basis. + +## Mapping data to archive fields + +A Cumulus monthly log file import will populate the WeeWX archive fields as +follows: + +* Provided data exists for each field in the Cumulus monthly logs, the + following WeeWX archive fields will be directly populated by imported data: + + * `dateTime` + * `barometer` + * `dewpoint` + * `heatindex` + * `inHumidity` + * `inTemp` + * `outHumidity` + * `outTemp` + * `radiation` + * `rain` + * `rainRate` + * `UV` + * `windDir` + * `windGust` + * `windSpeed` + * `windchill` + + !!! Note + If a field in the Cumulus monthly log file has no data the + corresponding WeeWX archive field will be set to `None/null`. + +* The following WeeWX archive fields will be populated from other settings + or configuration options: + + * `interval` + * `usUnits` + +* The following WeeWX archive fields will be populated with values derived + from the imported data provided `calc_missing = True` is included in the + `[Cumulus]` section of the import configuration file being used and the + field exists in the in-use WeeWX archive table schema. + + * `altimeter` + * `ET` + * `pressure` + + !!! Note + If `calc_missing = False` is included in the `[Cumulus]` section + of the import configuration file being used then all of the above + fields will be set to `None/null`. The `calc_missing` option + default is `True`. + + +## Step-by-step instructions + +To import observations from one or more Cumulus monthly log files: + +1. Ensure the Cumulus monthly log file(s) to be used for the import are + located in a directory accessible by the machine that will run + `weectl import`. For the purposes of the following examples, there are + nine monthly logs files covering the period October 2016 to June 2017, + inclusive, located in the `/var/tmp/cumulus` directory. + +2. Make a backup of the WeeWX database in case the import should go awry. + +3. Create an import configuration file. In this case we will make a copy of + the example Cumulus import configuration file and save it as + `cumulus.conf` in the `/var/tmp` directory: + + ``` + cp /home/weewx/util/import/cumulus-example.conf /var/tmp/cumulus.conf + ``` + +4. Open `cumulus.conf` and: + + * confirm the [`source`](weectl-import-config-opt.md#import_config_source) + option is set to Cumulus: + + ``` + source = Cumulus + ``` + + * confirm the following options in the `[Cumulus]` section are correctly + set: + + * [directory](weectl-import-config-opt.md#cumulus_directory). The + full path to the directory containing the Cumulus monthly log files + to be used as the source of the imported data. + + * [interval](weectl-import-config-opt.md#cumulus_interval). + Determines how the WeeWX interval field is derived. + + * [qc](weectl-import-config-opt.md#cumulus_qc). Determines whether + quality control checks are performed on the imported data. + + * [calc_missing](weectl-import-config-opt.md#cumulus_calc_missing). + Determines whether missing derived observations will be calculated + from the imported data. + + * [separator](weectl-import-config-opt.md#cumulus_separator). The + date field separator used in the Cumulus monthly log files. + + * [delimiter](weectl-import-config-opt.md#cumulus_delimiter). The + field delimiter used in the Cumulus monthly log files. + + * [decimal](weectl-import-config-opt.md#cumulus_decimal). The decimal + point character used in the Cumulus monthly log files. + + * [ignore_invalid_data](weectl-import-config-opt.md#cumulus_ignore_invalid_data). + Determines whether invalid data in a source field is ignored or the + import aborted. + + * [tranche](weectl-import-config-opt.md#cumulus_tranche). The number + of records written to the WeeWX database in each transaction. + + * [UV_sensor](weectl-import-config-opt.md#cumulus_UV). Whether a UV + sensor was installed when the source data was produced. + + * [solar_sensor](weectl-import-config-opt.md#cumulus_solar). Whether + a solar radiation sensor was installed when the source data was + produced. + + * [[[FieldMap]]](weectl-import-config-opt.md#cumulus_fieldmap). + Defines the mapping between imported data fields and WeeWX archive + fields. Also defines the units of measure for each imported field. + +5. When first importing data it is prudent to do a dry run import before any + data is actually imported. A dry run import will perform all steps of the + import without actually writing imported data to the WeeWX database. In + addition, consideration should be given to any additional options to be + used such as `--date`. + + To perform a dry run enter the following command: + + ``` + weectl import --import-config=/var/tmp/cumulus.conf --dry-run + ``` + + This will result in a short preamble with details on the data source, the + destination of the imported data and some other details on how the data + will be processed. The import will then be performed but no data will be + written to the WeeWX database. + + The output should be similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Cumulus monthly log files in the '/var/tmp/cumulus' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Unique records processed: 8858; Last timestamp: 2016-10-31 23:55:00 AEST (1477922100) + Period 2 ... + Unique records processed: 8636; Last timestamp: 2016-11-30 23:55:00 AEST (1480514100) + Period 3 ... + Unique records processed: 8925; Last timestamp: 2016-12-31 23:55:00 AEST (1483192500) + Period 4 ... + Unique records processed: 8908; Last timestamp: 2017-01-31 23:55:00 AEST (1485870900) + Period 5 ... + Unique records processed: 8029; Last timestamp: 2017-02-28 23:55:00 AEST (1488290100) + Period 6 ... + Unique records processed: 8744; Last timestamp: 2017-03-31 23:55:00 AEST (1490968500) + Period 7 ... + Unique records processed: 8489; Last timestamp: 2017-04-30 23:02:00 AEST (1493557320) + Period 8 ... + Unique records processed: 8754; Last timestamp: 2017-05-31 23:55:00 AEST (1496238900) + Period 9 ... + Unique records processed: 8470; Last timestamp: 2017-06-30 23:55:00 AEST (1498830900) + Finished dry run import + 77813 records were processed and 77813 unique records would have been imported. + ``` + + !!! Note + The nine periods correspond to the nine monthly log files used for + this import. + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to a missing Cumulus monthly log file. A + short explanatory note to this effect will be displayed against the + period concerned and an entry included in the log. + +6. Once the dry run results are satisfactory the data can be imported + using the following command: + + ``` + weectl import --import-config=/var/tmp/cumulus.conf + ``` + + This will result in a preamble similar to that of a dry run. At the + end of the preamble there will be a prompt: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Cumulus monthly log files in the '/var/tmp/cumulus' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + + If there is more than one Cumulus monthly log file then `weectl import` + will provide summary information on a per period basis during the + import. In addition, if the `--date` option is used then source data + that falls outside the date or date range specified with the `--date` + option is ignored. In such cases the preamble may look similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Cumulus monthly log files in the '/var/tmp/cumulus' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Period 1 - no records identified for import. + Period 2 ... + Period 2 - no records identified for import. + Period 3 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + +7. If the import parameters are acceptable enter `y` to proceed with the + import or `n` to abort the import. If the import is confirmed, the source + data will be imported, processed and saved in the WeeWX database. + Information on the progress of the import will be displayed similar to the + following: + + ``` + Unique records processed: 2305; Last timestamp: 2016-12-30 00:00:00 AEST (1483020000) + ``` + + Again if there is more than one Cumulus monthly log file and if the + `--date` option is used the progress information may instead look + similar to: + + ``` + Period 4 ... + Unique records processed: 8908; Last timestamp: 2017-01-31 23:55:00 AEST;(1485870900) + Period 5 ... + Unique;records processed: 8029; Last timestamp: 2017-02-28 23:55:00 AEST (1488290100) + Period 6 ... + Unique;records processed: 8744; Last timestamp: 2017-03-31 23:55:00 AEST (1490968500) + ``` + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to a missing Cumulus monthly log file. A + short explanatory note to this effect will be displayed against the + period concerned and an entry included in the log. + + The line commencing with `Unique records processed` should update as + records are imported with progress information on number of records + processed, number of unique records imported and the date time of the + latest record processed. If the import spans multiple months (ie multiple + monthly log files) then a new `Period` line is created for each month. + + Once the initial import is complete `weectl import` will, if requested, + calculate any missing derived observations and rebuild the daily + summaries. A brief summary should be displayed similar to the following: + + ``` + Calculating missing derived observations ... + Processing record: 77782; Last record: 2017-06-30 00:00:00 AEST (1519826400) + Recalculating daily summaries... + Records processed: 77000; Last date: 2017-06-28 11:45:00 AEST (1519811100) + Finished recalculating daily summaries + Finished calculating missing derived observations + ``` + + When the import is complete a brief summary is displayed similar to the + following: + + ``` + Finished import + 77813 records were processed and 77813 unique records imported in 106.96 seconds. + Those records with a timestamp already in the archive will not have been + imported. Confirm successful import in the WeeWX log file. + ``` + +8. Whilst `weectl import` will advise of the number of records processed and + the number of unique records found, `weectl import` does know how many, if + any, of the imported records were successfully saved to the database. You + should look carefully through the WeeWX log file covering the `weectl + import` session and take note of any records that were not imported. The + most common reason for imported records not being saved to the database is + because a record with that timestamp already exists in the database, in + such cases something similar to the following will be found in the log: + + ``` + 2023-11-04 15:33:01 weectl-import[3795]: ERROR weewx.manager: Unable to add record 2018-09-04 04:20:00 AEST (1535998800) to database 'weewx.sdb': UNIQUE constraint failed: archive.dateTime + ``` + + In such cases take note of the timestamp of the record(s) concerned + and make a decision about whether to delete the pre-existing record and + re-import the record or retain the pre-existing record. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-troubleshoot.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-troubleshoot.md new file mode 100644 index 0000000..c260b77 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-troubleshoot.md @@ -0,0 +1,24 @@ +## Dealing with import failures {#import_failures} + +Sometimes bad things happen during an import. + +If errors were encountered, or if you suspect that the WeeWX database has +been contaminated with incorrect data, here are some things you can try to +fix things up. + +* Manually delete the contaminated data. Use SQL commands to manipulate + the data in the WeeWX archive database. The simplicity of this process + will depend on your ability to use SQL, the amount of data imported, and + whether the imported data was dispersed amongst existing. Once + contaminated data have been removed the daily summary tables will need + to be rebuilt using the `weectl database rebuild-daily` utility. + +* Delete the database and start over. For SQLite, simply delete the + database file. For MySQL, drop the database. Then try the import again. + + !!! Warning + Deleting the database file or dropping the database will result in + all data in the database being lost. + +* If the above steps are not appropriate the database should be restored + from backup. You did make a backup before starting the import? diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-wd.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-wd.md new file mode 100644 index 0000000..0ece954 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-wd.md @@ -0,0 +1,445 @@ +!!! Warning + Running WeeWX during an `import` session can lead to abnormal termination + of the import. If WeeWX must remain running (e.g., so that live data is + not lost) run the `import` session on another machine or to a second + database and merge the in-use and second database once the import is + complete. + +Weather Display records observational data on a monthly basis in a number of +either space delimited (.txt) and/or comma separated (.csv) text files. The +`import` utility can import observational data from the following Weather +Display log files: + +* `MMYYYYlg.txt` +* `MMYYYYlgcsv.csv` (csv format version of `MMYYYYlg.txt`) +* `MMYYYYvantagelog.txt` +* `MMYYYYvantagelogcsv.csv` (csv format version of `MMYYYYvantagelog.txt`) +* `MMYYYYvantageextrasensorslog.csv` + +where MM is a one or two-digit month and YYYY is a four digit year. + +The Weather Display monthly log files record observational data using a +nominal one-minute interval, with each file recording various observations +for the month and year designated by the MM and YYYY components of the file +name. These files are accumulated over time and can be considered analogous +to the WeeWX archive table. When data is imported from the Weather Display +monthly log files each set of log files for a given month and year is +considered a 'period'. The `import` utility processes one period at a time in +chronological order (oldest to newest) and provides import summary data on a +per period basis. + +## Mapping data to archive fields + +The WeeWX archive fields populated during the import of Weather Display data +depends on the field mapping specified in the +[`[[FieldMap]]`](weectl-import-config-opt.md#wd_fieldmap) stanza in the +import configuration file. A given WeeWX field will be populated if: + +* a valid mapping exists for the field, + +* the field exists in the WeeWX archive table schema, and + +* the mapped Weather Display field contains valid data. + +The following WeeWX archive fields will be populated from other settings or +configuration options and need not be included in the field map: + +* `interval` + +* `usUnits` + +The following WeeWX archive fields will be populated with values derived from +the imported data provided +[`calc_missing = True`](weectl-import-config-opt.md#wd_calc_missing) is +included in the `[WD]` section of the import configuration file being used +and the field exists in the in-use WeeWX archive table schema: + +* `altimeter` + +* `pressure` + +* `rainRate` + +* `windchill` + +!!! Note + If `calc_missing = False` is included in the `[WD]` section of the import + configuration file being used then all of the above fields will be set to + `None/null`. The `calc_missing` option default is `True`. + +## Step-by-step instructions + +To import observations from one or more Weather Display monthly log files: + +1. Ensure the Weather Display monthly log file(s) to be used for the import + are located in a directory accessible by the machine that will run + the `import` utility. For the purposes of the following examples, there + are five months of logs files covering the period September 2018 to + January 2019 inclusive located in the `/var/tmp/wd` directory. + +2. Make a backup of the WeeWX database in case the import should go awry. + +3. Create an import configuration file, the recommended approach is to make a + copy of the example Weather Display import configuration file located + in the `util/import` directory. In this case we will make a copy of the + example Weather Display import configuration file and save it as `wd.conf` + in the `/var/tmp` directory: + + ``` + cp /home/weewx/weewx-data/util/import/wd-example.conf /var/tmp/wd.conf + ``` + +4. Open `wd.conf` and: + + * confirm the [`source`](weectl-import-config-opt.md#import_config_source) + option is set to WD: + + ``` + source = WD + ``` + + * confirm the following options in the `[WD]` section are correctly set: + + * [directory](weectl-import-config-opt.md#wd_directory). The full + path to the directory containing the Weather Display monthly log + files to be used as the source of the imported data. + + * [logs_to_process](weectl-import-config-opt.md#wd_logs_to_process). + Specifies the Weather Display monthly log files to be used to + import data. + + * [interval](weectl-import-config-opt.md#wd_interval). How the WeeWX + `interval` field is derived. + + * [qc](weectl-import-config-opt.md#wd_qc). Whether quality control + checks are performed on the imported data. + + * [calc_missing](weectl-import-config-opt.md#wd_calc_missing). + Whether missing derived observations will be calculated from the + imported data. + + * [txt_delimiter](weectl-import-config-opt.md#wd_txt_delimiter). The + field delimiter used in the Weather Display space delimited (*.txt) + monthly log files. + + * [csv_delimiter](weectl-import-config-opt.md#wd_csv_delimiter). The + field delimiter used in the Weather Display monthly comma separated + values (*.csv) monthly log files. + + * [decimal](weectl-import-config-opt.md#wd_decimal). The decimal + point character used in the Weather Display monthly log files. + + * [ignore_missing_log](weectl-import-config-opt.md#wd_ignore_missing_log). + Whether missing log files are to be ignored or the import aborted. + + * [ignore_invalid_data](weectl-import-config-opt.md#wd_ignore_invalid_data). + Whether invalid data in a source field is ignored or the import + aborted. + + * [tranche](weectl-import-config-opt.md#wd_tranche). The number of + records written to the WeeWX database in each transaction. + + * [UV_sensor](weectl-import-config-opt.md#wd_UV). Whether a UV sensor + was installed when the source data was produced. + + * [solar_sensor](weectl-import-config-opt.md#wd_solar). Whether a + solar radiation sensor was installed when the source data was + produced. + + * [ignore_extreme_temp_hum](weectl-import-config-opt.md#wd_ignore_extreme_temp_hum). + Whether temperature and humidity values of 255 will be ignored. + + * [[[FieldMap]]](weectl-import-config-opt.md#wd_fieldmap). Defines + the mapping between imported data fields and WeeWX archive fields. + +5. When first importing data it is prudent to do a dry run import before any + data is actually imported. A dry run import will perform all steps of the + import without actually writing imported data to the WeeWX database. In + addition, consideration should be given to any additional options to be + used such as `--date`. + + !!! Note + Due to some peculiarities of the Weather Display log structure it may + be prudent to use the `--suppress-warnings` option during the initial + dry run so the overall progress of the import can be more easily + observed. + + To perform a dry run enter the following command: + + ``` + weectl import --import-config=/var/tmp/wd.conf --dry-run --suppress-warnings + ``` + + This will result in a short preamble with details of the data source, the + destination of the imported data and some other details on how the data + will be processed. The import will then be performed but no data will be + written to the WeeWX database. + + The output should be similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Weather Display monthly log files in the '/var/tmp/WD' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Unique records processed: 43183; Last timestamp: 2018-09-30 23:59:00 AEST (1538315940) + Period 2 ... + Unique records processed: 44620; Last timestamp: 2018-10-31 23:59:00 AEST (1540994340) + Period 3 ... + Unique records processed: 43136; Last timestamp: 2018-11-30 23:59:00 AEST (1543586340) + Period 4 ... + Unique records processed: 44633; Last timestamp: 2018-12-31 23:59:00 AEST (1546264740) + Period 5 ... + Unique records processed: 8977; Last timestamp: 2019-01-07 05:43:00 AEST (1546803780) + Finished dry run import + 184765 records were processed and 184549 unique records would have been imported. + 216 duplicate records were ignored. + ``` + + !!! Note + The five periods correspond to the five months of log files used for + this import. + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to a missing Weather Display log file. A + short explanatory note to this effect will be displayed against the + period concerned and an entry included in the log. + +6. If the `--suppress-warnings` option was used it may be prudent to do a + second dry run this time without the `--suppress-warnings` option. This + will allow any warnings generated by the dry run import to be observed: + + ``` + weectl import --import-config=/var/tmp/wd.conf --dry-run + ``` + + This will result in a short preamble with details on the data source, the + destination of the imported data and some other details on how the data + will be processed. The import will then be performed but no data will be + written to the WeeWX database. + + The output should be similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Weather Display monthly log files in the '/var/tmp/WD' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Warning: Import field 'radiation' is mapped to WeeWX field 'radiation' but the + import field 'radiation' could not be found in one or more records. + WeeWX field 'radiation' will be set to 'None' in these records. + Warning: Import field 'soiltemp' is mapped to WeeWX field 'soilTemp1' but the + import field 'soiltemp' could not be found in one or more records. + WeeWX field 'soilTemp1' will be set to 'None' in these records. + Warning: Import field 'soilmoist' is mapped to WeeWX field 'soilMoist1' but the + import field 'soilmoist' could not be found in one or more records. + WeeWX field 'soilMoist1' will be set to 'None' in these records. + Warning: Import field 'humidity' is mapped to WeeWX field 'outHumidity' but the + import field 'humidity' could not be found in one or more records. + WeeWX field 'outHumidity' will be set to 'None' in these records. + Warning: Import field 'heatindex' is mapped to WeeWX field 'heatindex' but the + import field 'heatindex' could not be found in one or more records. + WeeWX field 'heatindex' will be set to 'None' in these records. + Warning: Import field 'windspeed' is mapped to WeeWX field 'windSpeed' but the + import field 'windspeed' could not be found in one or more records. + WeeWX field 'windSpeed' will be set to 'None' in these records. + Warning: Import field 'barometer' is mapped to WeeWX field 'barometer' but the + import field 'barometer' could not be found in one or more records. + WeeWX field 'barometer' will be set to 'None' in these records. + Warning: Import field 'dewpoint' is mapped to WeeWX field 'dewpoint' but the + import field 'dewpoint' could not be found in one or more records. + WeeWX field 'dewpoint' will be set to 'None' in these records. + Warning: Import field 'rainlastmin' is mapped to WeeWX field 'rain' but the + import field 'rainlastmin' could not be found in one or more records. + WeeWX field 'rain' will be set to 'None' in these records. + Warning: Import field 'direction' is mapped to WeeWX field 'windDir' but the + import field 'direction' could not be found in one or more records. + WeeWX field 'windDir' will be set to 'None' in these records. + Warning: Import field 'temperature' is mapped to WeeWX field 'outTemp' but the + import field 'temperature' could not be found in one or more records. + WeeWX field 'outTemp' will be set to 'None' in these records. + Warning: Import field 'gustspeed' is mapped to WeeWX field 'windGust' but the + import field 'gustspeed' could not be found in one or more records. + WeeWX field 'windGust' will be set to 'None' in these records. + Unique records processed: 43183; Last timestamp: 2018-09-30 23:59:00 AEST (1538315940) + Period 2 ... + Unique records processed: 44620; Last timestamp: 2018-10-31 23:59:00 AEST (1540994340) + Period 3 ... + Unique records processed: 43136; Last timestamp: 2018-11-30 23:59:00 AEST (1543586340) + Period 4 ... + Unique records processed: 44633; Last timestamp: 2018-12-31 23:59:00 AEST (1546264740) + Period 5 ... + Unique records processed: 8977; Last timestamp: 2019-01-07 05:43:00 AEST (1546803780) + 6 duplicate records were identified in period 5: + 2019-01-04 10:31:00 AEST (1546561860) + 2019-01-04 10:32:00 AEST (1546561920) + 2019-01-04 10:33:00 AEST (1546561980) + 2019-01-04 10:34:00 AEST (1546562040) + 2019-01-04 10:35:00 AEST (1546562100) + 2019-01-04 10:36:00 AEST (1546562160) + Finished dry run import + 184555 records were processed and 184549 unique records would have been imported. + 6 duplicate records were ignored. + ``` + + In this case the following warnings are evident: + + * Period one had 12 warnings for import fields that were mapped to WeeWX + data fields but for which no data was found. This could be a sign that + a complete month of data or a significant portion of the month could be + missing, or it could be a case of just the first record of the month is + missing (a significant number of Weather Display monthly log files have + been found to be missing the first record of the month). In most cases + this warning can be ignored. + + * Period five shows warnings for six entries in the period that have + duplicate timestamps. This could be a sign that there is a problem in + one or more of the Weather Display monthly log files for that month. + However, anecdotally it has been found that duplicate entries often + exist in one or more Weather Display monthly log files. If the + duplicates are to be ignored then such warnings can be ignored + otherwise the incorrect data should be removed from the affected log + files before import. + +7. Once the dry run results are satisfactory the data can be imported using + the following command: + + ``` + weectl import --import-config=/var/tmp/wd.conf --suppress-warnings + ``` + + !!! Note + The `--suppress-warnings` option has been used to suppress the + previously encountered warnings. + + This will result in a preamble similar to that of a dry run. At the end + of the preamble there will be a prompt: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Weather Display monthly log files in the '/var/tmp/WD' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + + If there is more than one month of Weather Display monthly log files the + `import` utility will provide summary information on a per period basis + during the import. In addition, if the `--date` option is used then + source data that falls outside the date or date range specified with the + `--date` option is ignored. In such cases the preamble may look similar + to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Weather Display monthly log files in the '/var/tmp/WD' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Observations timestamped after 2018-10-12 00:00:00 AEST (1539266400) and up to and + including 2018-10-13 00:00:00 AEST (1539352800) will be imported. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Period 1 - no records identified for import. + Period 2 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + +8. If the import parameters are acceptable enter `y` to proceed with the + import or `n` to abort the import. If the import is confirmed, the source + data will be imported, processed and saved in the WeeWX database. + Information on the progress of the import will be displayed similar to the + following: + + ``` + Unique records processed: 1250; Last timestamp: 2018-12-01 20:49:00 AEST (1543661340) + ``` + + Again if there is more than one month of Weather Display monthly log + files and if the `--date` option is used the progress information may + instead look similar to: + + ``` + Period 2 ... + Unique records processed: 44620; Last timestamp: 2018-10-31 23:59:00 AEST (1540994340) + Period 3 ... + Unique records processed: 43136; Last timestamp: 2018-11-30 23:59:00 AEST (1543586340) + Period 4 ... + Unique records processed: 12000; Last timestamp: 2018-12-09 07:59:00 AEST (1544306340) + ``` + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to a missing Weather Display log file. A + short explanatory note to this effect will be displayed against the + period concerned and an entry included in the log. + + The line commencing with `Unique records processed` should update as + records are imported with progress information on the number of unique + records processed and the date-time of the latest record processed. If + the import spans multiple months then a new `Period` line is created for + each month. + + Once the initial import is complete the `import` utility will, if + requested, calculate any missing derived observations and rebuild the + daily summaries. A brief summary should be displayed similar to the + following: + + ``` + Calculating missing derived observations ... + Processing record: 184549; Last record: 2019-01-08 00:00:00 AEST (1546869600) + Recalculating daily summaries... + Records processed: 184000; Last date: 2019-01-06 20:34:00 AEST (1546770840) + Finished recalculating daily summaries + Finished calculating missing derived observations + ``` + + When the import is complete a brief summary is displayed similar to the + following: + + ``` + Finished import + 184765 records were processed and 184549 unique records imported in 699.27 seconds. + 216 duplicate records were ignored. + Those records with a timestamp already in the archive will not have been + imported. Confirm successful import in the WeeWX log file. + ``` + +9. Whilst the `import` utility will advise of the number of unique records + imported, it does not know how many, if any, of the imported records were + successfully saved to the database. You should look carefully through the + WeeWX log file covering the import session and take note of any records + that were not imported. The most common reason for imported records not + being saved to the database is because a record with that timestamp + already exists in the database, in such cases something similar to the + following will be found in the log: + + ``` + 2023-11-04 15:33:01 weectl-import[3795]: ERROR weewx.manager: Unable to add record 2018-09-04 04:20:00 AEST (1535998800) to database 'weewx.sdb': UNIQUE constraint failed: archive.dateTime + ``` + + In such cases take note of the timestamp of the record(s) concerned and + make a decision about whether to delete the pre-existing record and + re-import the record or retain the pre-existing record. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-weathercat.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-weathercat.md new file mode 100644 index 0000000..d711507 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-weathercat.md @@ -0,0 +1,507 @@ +!!! Warning + Running WeeWX during an `import` session can lead to abnormal termination + of the import. If WeeWX must remain running (e.g., so that live data is + not lost) run the `import` session on another machine or to a second + database and merge the in-use and second database once the import is + complete. + +WeatherCat records observational data in formatted text files with a .cat +extension with each file containing weather station observations for a single +month. These files are accumulated over time with month coded files names +organised into year based directories. These files can be considered +analogous to the WeeWX archive table. When data is imported from the +WeatherCat .cat files each .cat file is considered a 'period'. The `import` +utility processes one period at a time in chronological order (oldest to +newest) and provides import summary data on a per period basis. + +## Mapping data to archive fields + +The WeeWX archive fields populated during the import of WeatherCat data +depends on the field mapping specified in the +[`[[FieldMap]]`](weectl-import-config-opt.md#wcat_fieldmap) stanza in the +import configuration file. A given WeeWX field will be populated if: + +* a valid mapping exists for the field, +* the field exists in the WeeWX archive table schema, and +* the mapped WeatherCat field contains valid data. + +The following WeeWX archive fields will be populated from other settings or +configuration options and need not be included in the field map: + +* `interval` +* `usUnits` + +The following WeeWX archive fields will be populated with values derived from +the imported data provided +[`calc_missing = True`](weectl-import-config-opt.md#wcat_calc_missing) is +included in the `[WeatherCat]` section of the import configuration file being +used and the field exists in the in-use WeeWX archive table schema: + +* `altimeter` +* `ET` +* `pressure` + +!!! Note + If `calc_missing = False` is included in the `[WeatherCat]` section of + the import configuration file being used then all of the above fields + will be set to `None/null`. The `calc_missing` option default is `True`. + + +## Step-by-step instructions + +To import observations from one or more WeatherCat monthly .cat files: + +1. Ensure the WeatherCat monthly .cat file(s) to be used for the import are + located in year directories with the year directories in turn located in a + directory accessible by the machine that will run the `import` utility. + For the purposes of the following examples there are nine monthly .cat + files covering the period October 2016 to June 2017 inclusive located in + the `/var/tmp/wcat/2016` and `/var/tmp/wcat/2017` directories respectively. + +2. Make a backup of the WeeWX database in case the import should go awry. + +3. Create an import configuration file, the recommended approach is to make a + copy of the example WeatherCat import configuration file located in the + `util/import` directory. In this case we will make a copy of the example + WeatherCat import configuration file and save it as `wcat.conf` in the + `/var/tmp` directory: + + ``` + cp /home/weewx/www-data/util/import/weathercat-example.conf /var/tmp/wcat.conf + ``` + +4. Open `wcat.conf` and: + + * confirm the [`source`](weectl-import-config-opt.md#import_config_source) + option is set to `WeatherCat`: + + ``` + source = WeatherCat + ``` + + * confirm the following options in the `[WeatherCat]` section are + correctly set: + + * [directory](weectl-import-config-opt.md#wcat_directory). The full + path to the directory containing the directories containing the + WeatherCat monthly .cat files to be used as the source of the + imported data. + + * [interval](weectl-import-config-opt.md#wcat_interval). How the + WeeWX `interval` field is derived. + + * [qc](weectl-import-config-opt.md#wcat_qc). Whether quality control + checks are performed on the imported data. + + * [calc_missing](weectl-import-config-opt.md#wcat_calc_missing). + Whether missing derived observations will be calculated from the + imported data. + + * [decimal](weectl-import-config-opt.md#wcat_decimal). The decimal + point character used in the WeatherCat monthly .cat files. + + * [tranche](weectl-import-config-opt.md#wcat_tranche). The number of + records written to the WeeWX database in each transaction. + + * [UV_sensor](weectl-import-config-opt.md#wcat_UV). Whether a UV + sensor was installed when the source data was produced. + + * [solar_sensor](weectl-import-config-opt.md#wcat_solar). Whether a + solar radiation sensor was installed when the source data was + produced. + + * [[[FieldMap]]](weectl-import-config-opt.md#wcat_fieldmap). Defines + the mapping between imported data fields and WeeWX archive fields. + +5. When first importing data it is prudent to do a dry run import before any + data is actually imported. A dry run import will perform all steps of the + import without actually writing imported data to the WeeWX database. In + addition, consideration should be given to any additional options to be + used such as `--date`. + + !!! Note + Whilst WeatherCat monthly .cat files use a fixed set of fields the + inclusion of fields other than `t` (timestamp) and `V` (validation) + is optional. For this reason the field map used for WeatherCat + imports includes fields that may not exist in some WeatherCat monthly + .cat files resulting in warnings by the `import` utility there may be + missing data in the import source. These warnings can be extensive + and may detract from the ability of the user to monitor the progress + of the import. It may be prudent to use the `--suppress-warnings` + option during the initial dry run so the overall progress of the + import can be more easily observed. + + To perform a dry run enter the following command: + + ``` + weectl import --import-config=/var/tmp/wcat.conf --dry-run --suppress-warnings + ``` + + This will result in a short preamble with details on the data source, the + destination of the imported data and some other details on how the data + will be processed. The import will then be performed but no data will be + written to the WeeWX database. + + The output should be similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + WeatherCat monthly .cat files in the '/var/tmp/wcat' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Unique records processed: 39555; Last timestamp: 2016-10-31 23:59:00 AEST (1477922340) + Period 2 ... + Unique records processed: 38284; Last timestamp: 2016-11-30 23:59:00 AEST (1480514340) + Period 3 ... + Unique records processed: 39555; Last timestamp: 2016-12-31 23:59:00 AEST (1483192740) + Period 4 ... + Unique records processed: 39555; Last timestamp: 2017-01-31 23:59:00 AEST (1485871140) + Period 5 ... + Unique records processed: 35598; Last timestamp: 2017-02-28 23:59:00 AEST (1488290340) + Period 6 ... + Unique records processed: 39555; Last timestamp: 2017-03-31 23:59:00 AEST (1490968740) + Period 7 ... + Unique records processed: 38284; Last timestamp: 2017-04-30 23:59:00 AEST (1493560740) + Period 8 ... + Unique records processed: 38284; Last timestamp: 2017-06-30 23:59:00 AEST (1498831140) + Finished dry run import + 308670 records were processed and 308670 unique records would have been imported. + ``` + + !!! Note + The eight periods correspond to the eight monthly .cat files used for + this import. + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to a missing WeatherCat monthly .cat file. A + short explanatory note to this effect will be displayed against the + period concerned and an entry included in the log. + +6. If the `--suppress-warnings` option was used it may be prudent to do a + second dry run this time without the `--suppress-warnings` option. This + will allow any warnings generated by the dry run import to be observed: + + ``` + weectl import --import-config=/var/tmp/wcat.conf --dry-run + ``` + + This will result in a short preamble with details on the data source, the + destination of the imported data and some other details on how the data + will be processed. The import will then be performed but no data will be + written to the WeeWX database. + + The output should be similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + WeatherCat monthly .cat files in the '/var/tmp/wcat' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Warning: Import field 'T1' is mapped to WeeWX field 'extraTemp1' but the + import field 'T1' could not be found in one or more records. + WeeWX field 'extraTemp1' will be set to 'None' in these records. + Warning: Import field 'T2' is mapped to WeeWX field 'extraTemp2' but the + import field 'T2' could not be found in one or more records. + WeeWX field 'extraTemp2' will be set to 'None' in these records. + Warning: Import field 'T3' is mapped to WeeWX field 'extraTemp3' but the + import field 'T3' could not be found in one or more records. + WeeWX field 'extraTemp3' will be set to 'None' in these records. + Warning: Import field 'H1' is mapped to WeeWX field 'extraHumid1' but the + import field 'H1' could not be found in one or more records. + WeeWX field 'extraHumid1' will be set to 'None' in these records. + Warning: Import field 'H2' is mapped to WeeWX field 'extraHumid2' but the + import field 'H2' could not be found in one or more records. + WeeWX field 'extraHumid2' will be set to 'None' in these records. + Warning: Import field 'Sm1' is mapped to WeeWX field 'soilMoist1' but the + import field 'Sm1' could not be found in one or more records. + WeeWX field 'soilMoist1' will be set to 'None' in these records. + Warning: Import field 'Sm2' is mapped to WeeWX field 'soilMoist2' but the + import field 'Sm2' could not be found in one or more records. + WeeWX field 'soilMoist2' will be set to 'None' in these records. + Warning: Import field 'Sm3' is mapped to WeeWX field 'soilMoist3' but the + import field 'Sm3' could not be found in one or more records. + WeeWX field 'soilMoist3' will be set to 'None' in these records. + Warning: Import field 'Sm4' is mapped to WeeWX field 'soilMoist4' but the + import field 'Sm4' could not be found in one or more records. + WeeWX field 'soilMoist4' will be set to 'None' in these records. + Warning: Import field 'Lw1' is mapped to WeeWX field 'leafWet1' but the + import field 'Lw1' could not be found in one or more records. + WeeWX field 'leafWet1' will be set to 'None' in these records. + Warning: Import field 'Lw2' is mapped to WeeWX field 'leafWet2' but the + import field 'Lw2' could not be found in one or more records. + WeeWX field 'leafWet2' will be set to 'None' in these records. + Warning: Import field 'St1' is mapped to WeeWX field 'soilTemp1' but the + import field 'St1' could not be found in one or more records. + WeeWX field 'soilTemp1' will be set to 'None' in these records. + Warning: Import field 'St2' is mapped to WeeWX field 'soilTemp2' but the + import field 'St2' could not be found in one or more records. + WeeWX field 'soilTemp2' will be set to 'None' in these records. + Warning: Import field 'St3' is mapped to WeeWX field 'soilTemp3' but the + import field 'St3' could not be found in one or more records. + WeeWX field 'soilTemp3' will be set to 'None' in these records. + Warning: Import field 'St4' is mapped to WeeWX field 'soilTemp4' but the + import field 'St4' could not be found in one or more records. + WeeWX field 'soilTemp4' will be set to 'None' in these records. + Warning: Import field 'Lt1' is mapped to WeeWX field 'leafTemp1' but the + import field 'Lt1' could not be found in one or more records. + WeeWX field 'leafTemp1' will be set to 'None' in these records. + Warning: Import field 'Lt2' is mapped to WeeWX field 'leafTemp2' but the + import field 'Lt2' could not be found in one or more records. + WeeWX field 'leafTemp2' will be set to 'None' in these records. + Unique records processed: 39555; Last timestamp: 2016-10-31 23:59:00 AEST (1477922340) + Period 2 ... + Warning: Import field 'T1' is mapped to WeeWX field 'extraTemp1' but the + import field 'T1' could not be found in one or more records. + WeeWX field 'extraTemp1' will be set to 'None' in these records. + Warning: Import field 'T2' is mapped to WeeWX field 'extraTemp2' but the + import field 'T2' could not be found in one or more records. + WeeWX field 'extraTemp2' will be set to 'None' in these records. + Warning: Import field 'T3' is mapped to WeeWX field 'extraTemp3' but the + import field 'T3' could not be found in one or more records. + WeeWX field 'extraTemp3' will be set to 'None' in these records. + Warning: Import field 'H1' is mapped to WeeWX field 'extraHumid1' but the + import field 'H1' could not be found in one or more records. + WeeWX field 'extraHumid1' will be set to 'None' in these records. + Warning: Import field 'H2' is mapped to WeeWX field 'extraHumid2' but the + import field 'H2' could not be found in one or more records. + WeeWX field 'extraHumid2' will be set to 'None' in these records. + Warning: Import field 'Sm1' is mapped to WeeWX field 'soilMoist1' but the + import field 'Sm1' could not be found in one or more records. + WeeWX field 'soilMoist1' will be set to 'None' in these records. + Warning: Import field 'Sm2' is mapped to WeeWX field 'soilMoist2' but the + import field 'Sm2' could not be found in one or more records. + WeeWX field 'soilMoist2' will be set to 'None' in these records. + Warning: Import field 'Sm3' is mapped to WeeWX field 'soilMoist3' but the + import field 'Sm3' could not be found in one or more records. + WeeWX field 'soilMoist3' will be set to 'None' in these records. + Warning: Import field 'Sm4' is mapped to WeeWX field 'soilMoist4' but the + import field 'Sm4' could not be found in one or more records. + WeeWX field 'soilMoist4' will be set to 'None' in these records. + Warning: Import field 'Lw1' is mapped to WeeWX field 'leafWet1' but the + import field 'Lw1' could not be found in one or more records. + WeeWX field 'leafWet1' will be set to 'None' in these records. + Warning: Import field 'Lw2' is mapped to WeeWX field 'leafWet2' but the + import field 'Lw2' could not be found in one or more records. + WeeWX field 'leafWet2' will be set to 'None' in these records. + Warning: Import field 'St1' is mapped to WeeWX field 'soilTemp1' but the + import field 'St1' could not be found in one or more records. + WeeWX field 'soilTemp1' will be set to 'None' in these records. + Warning: Import field 'St2' is mapped to WeeWX field 'soilTemp2' but the + import field 'St2' could not be found in one or more records. + WeeWX field 'soilTemp2' will be set to 'None' in these records. + Warning: Import field 'St3' is mapped to WeeWX field 'soilTemp3' but the + import field 'St3' could not be found in one or more records. + WeeWX field 'soilTemp3' will be set to 'None' in these records. + Warning: Import field 'St4' is mapped to WeeWX field 'soilTemp4' but the + import field 'St4' could not be found in one or more records. + WeeWX field 'soilTemp4' will be set to 'None' in these records. + Warning: Import field 'Lt1' is mapped to WeeWX field 'leafTemp1' but the + import field 'Lt1' could not be found in one or more records. + WeeWX field 'leafTemp1' will be set to 'None' in these records. + Warning: Import field 'Lt2' is mapped to WeeWX field 'leafTemp2' but the + import field 'Lt2' could not be found in one or more records. + WeeWX field 'leafTemp2' will be set to 'None' in these records. + Unique records processed: 38284; Last timestamp: 2016-11-30 23:59:00 AEST (1480514340) + + ... (identical entries for periods 3 to 7 omitted for conciseness) + + Period 8 ... + Warning: Import field 'T1' is mapped to WeeWX field 'extraTemp1' but the + import field 'T1' could not be found in one or more records. + WeeWX field 'extraTemp1' will be set to 'None' in these records. + Warning: Import field 'T2' is mapped to WeeWX field 'extraTemp2' but the + import field 'T2' could not be found in one or more records. + WeeWX field 'extraTemp2' will be set to 'None' in these records. + Warning: Import field 'T3' is mapped to WeeWX field 'extraTemp3' but the + import field 'T3' could not be found in one or more records. + WeeWX field 'extraTemp3' will be set to 'None' in these records. + Warning: Import field 'H1' is mapped to WeeWX field 'extraHumid1' but the + import field 'H1' could not be found in one or more records. + WeeWX field 'extraHumid1' will be set to 'None' in these records. + Warning: Import field 'H2' is mapped to WeeWX field 'extraHumid2' but the + import field 'H2' could not be found in one or more records. + WeeWX field 'extraHumid2' will be set to 'None' in these records. + Warning: Import field 'Sm1' is mapped to WeeWX field 'soilMoist1' but the + import field 'Sm1' could not be found in one or more records. + WeeWX field 'soilMoist1' will be set to 'None' in these records. + Warning: Import field 'Sm2' is mapped to WeeWX field 'soilMoist2' but the + import field 'Sm2' could not be found in one or more records. + WeeWX field 'soilMoist2' will be set to 'None' in these records. + Warning: Import field 'Sm3' is mapped to WeeWX field 'soilMoist3' but the + import field 'Sm3' could not be found in one or more records. + WeeWX field 'soilMoist3' will be set to 'None' in these records. + Warning: Import field 'Sm4' is mapped to WeeWX field 'soilMoist4' but the + import field 'Sm4' could not be found in one or more records. + WeeWX field 'soilMoist4' will be set to 'None' in these records. + Warning: Import field 'Lw1' is mapped to WeeWX field 'leafWet1' but the + import field 'Lw1' could not be found in one or more records. + WeeWX field 'leafWet1' will be set to 'None' in these records. + Warning: Import field 'Lw2' is mapped to WeeWX field 'leafWet2' but the + import field 'Lw2' could not be found in one or more records. + WeeWX field 'leafWet2' will be set to 'None' in these records. + Warning: Import field 'St1' is mapped to WeeWX field 'soilTemp1' but the + import field 'St1' could not be found in one or more records. + WeeWX field 'soilTemp1' will be set to 'None' in these records. + Warning: Import field 'St2' is mapped to WeeWX field 'soilTemp2' but the + import field 'St2' could not be found in one or more records. + WeeWX field 'soilTemp2' will be set to 'None' in these records. + Warning: Import field 'St3' is mapped to WeeWX field 'soilTemp3' but the + import field 'St3' could not be found in one or more records. + WeeWX field 'soilTemp3' will be set to 'None' in these records. + Warning: Import field 'St4' is mapped to WeeWX field 'soilTemp4' but the + import field 'St4' could not be found in one or more records. + WeeWX field 'soilTemp4' will be set to 'None' in these records. + Warning: Import field 'Lt1' is mapped to WeeWX field 'leafTemp1' but the + import field 'Lt1' could not be found in one or more records. + WeeWX field 'leafTemp1' will be set to 'None' in these records. + Warning: Import field 'Lt2' is mapped to WeeWX field 'leafTemp2' but the + import field 'Lt2' could not be found in one or more records. + WeeWX field 'leafTemp2' will be set to 'None' in these records. + Unique records processed: 38284; Last timestamp: 2017-06-30 23:59:00 AEST (1498831140) + Finished dry run import + 308670 records were processed and 308670 unique records would have been imported. + ``` + + In this case warnings are evident for numerous import/WeeWX field pairs + that are mapped but for which no data could be found. If the warnings + relate to fields that are not included in the import source data the + warning may be safely ignored. If the warning relate to fields the user + expects to be in the import source data the issue should be investigated + further before the import is completed. + +7. Once the dry run results are satisfactory the data can be imported using + the following command: + + ``` + weectl import --import-config=/var/tmp/wcat.conf --suppress-warnings + ``` + + This will result in a preamble similar to that of a dry run. At the end + of the preamble there will be a prompt: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + WeatherCat monthly .cat files in the '/var/tmp/wcat' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + + If there is more than one WeatherCat monthly .cat file the `import` + utility will provide summary information on a per period basis during the + import. In addition, if the `--date` option is used the source data that + falls outside the date or date range specified with the `--date` option + is ignored. In such cases the preamble may look similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + WeatherCat monthly .cat files in the '/var/tmp/wcat' directory will be imported + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Period 1 - no records identified for import. + Period 2 ... + Period 2 - no records identified for import. + Period 3 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + +8. If the import parameters are acceptable enter `y` to proceed with the + import or `n` to abort the import. If the import is confirmed, the source + data will be imported, processed and saved in the WeeWX database. + Information on the progress of the import will be displayed similar to the + following: + + ``` + Unique records processed: 2305; Last timestamp: 2016-12-30 00:00:00 AEST (1483020000) + ``` + + Again if there is more than one WeatherCat monthly .cat file and if the + `--date` option is used the progress information may instead look similar + to: + + ``` + Period 4 ... + Unique records processed: 8908; Last timestamp: 2017-01-31 23:59:00 AEST (1485870900) + Period 5 ... + Unique records processed: 8029; Last timestamp: 2017-02-28 23:59:00 AEST (1488290100) + Period 6 ... + Unique records processed: 8744; Last timestamp: 2017-03-31 23:59:00 AEST (1490968500) + ``` + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to a missing WeatherCat monthly .cat file. A + short explanatory note to this effect will be displayed against the + period concerned and an entry included in the log. + + The line commencing with `Unique records processed` should update as + records are imported with progress information on number of records + processed, the number of unique records imported and the date-time of the + latest record processed. If the import spans multiple months (ie multiple + monthly .cat files) a new `Period` line is created for each month. + + Once the initial import is complete the `import` utility will, if + requested, calculate any missing derived observations and rebuild the + daily summaries. A brief summary should be displayed similar to the + following: + + ``` + Calculating missing derived observations ... + Processing record: 77782; Last record: 2017-06-30 00:00:00 AEST (1519826400) + Recalculating daily summaries... + Records processed: 77000; Last date: 2017-06-28 11:45:00 AEST (1519811100) + Finished recalculating daily summaries + Finished calculating missing derived observations + ``` + + When the import is complete a brief summary is displayed similar to the + following: + + ``` + Finished import + 308670 records were processed and 08670 unique records imported in 1907.61 seconds. + Those records with a timestamp already in the archive will not have been + imported. Confirm successful import in the WeeWX log file. + ``` + +9. Whilst the `import` utility will advise of the number of unique records + imported, it does not know how many, if any, of the imported records were + successfully saved to the database. You should look carefully through the + WeeWX log file covering the import session and take note of any records + that were not imported. The most common reason for imported records not + being saved to the database is because a record with that timestamp + already exists in the database, in such cases something similar to the + following will be found in the log: + + ``` + 2023-11-04 15:33:01 weectl-import[3795]: ERROR weewx.manager: Unable to add record 2018-09-04 04:20:00 AEST (1535998800) to database 'weewx.sdb': UNIQUE constraint failed: archive.dateTime + ``` + + In such cases take note of the timestamp of the record(s) concerned and + make a decision about whether to delete the pre-existing record and + re-import the record or retain the pre-existing record. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-import-wu.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-wu.md new file mode 100644 index 0000000..a816999 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-import-wu.md @@ -0,0 +1,332 @@ +!!! Warning + Running WeeWX during a `weectl import` session can lead to abnormal + termination of the import. If WeeWX must remain running (e.g., so that + live data is not lost) run the `weectl import` session on another + machine or to a second database and merge the in-use and second + database once the import is complete. + +`weectl import` can import historical observation data for a Weather +Underground PWS via the Weather Underground API. The Weather Underground API +provides historical weather station observations received by Weather +Underground for the PWS concerned on a day by day basis. As such, the data is +analogous to the WeeWX archive table. When `weectl import` imports data from +the Weather Underground API each day is considered a 'period'. `weectl +import` processes one period at a time in chronological order (oldest to +newest) and provides import summary data on a per period basis. + +## Mapping data to archive fields + +A Weather Underground import will populate WeeWX archive fields as follows: + +* Provided data exists for each field returned by the Weather Underground + API, the following WeeWX archive fields will be directly populated by + imported data: + + * `dateTime` + * `barometer` + * `dewpoint` + * `heatindex` + * `outHumidity` + * `outTemp` + * `radiation` + * `rain` + * `rainRate` + * `UV` + * `windchill` + * `windDir` + * `windGust` + * `windSpeed` + + !!! Note + If an appropriate field is not returned by the Weather Underground + API the corresponding WeeWX archive field will contain no data. If + the API returns an appropriate field but with no data, the + corresponding WeeWX archive field will be set to `None/null`. For + example, if the API response has no solar radiation field the WeeWX + `radiation` archive field will have no data stored. However, if the + API response has a solar radiation field but contains no data, the + WeeWX `radiation` archive field will be `None/null`. + +* The following WeeWX archive fields will be populated from other settings or + configuration options: + + * `interval` + * `usUnits` + +* The following WeeWX archive fields will be populated with values derived + from the imported data provided `calc_missing = True` is included in the + `[WU]` section of the import configuration file and the field exists in the + in-use WeeWX archive table schema. + + * `altimeter` + * `ET` + * `pressure` + +!!! Note + If `calc_missing = False` is included in the `[WU]` section of the + import configuration file being used then all of the above fields will be + set to `None/null`. The `calc_missing` option default is `True`. + + +## Step-by-step instructions + +To import observations from a Weather Underground PWS history: + +1. Obtain the weather station ID of the Weather Underground PWS from which + data is to be imported. The station ID will be a sequence of numbers and + upper case letters that is usually 11 or 12 characters in length. For the + purposes of the following examples a weather station ID of `ISTATION123` + will be used. + +2. Obtain the API key to be used to access the Weather Underground API. This + will be a seemingly random alphanumeric sequence of 32 characters. API + keys are available to Weather Underground PWS contributors by logging on + to their Weather Underground account and accessing Member Settings. + +3. Make a backup of the WeeWX database in case the import should go awry. + +4. Create an import configuration file. In this case we will make a copy of + the example Weather Underground import configuration file and save it as + `wu.conf` in the `/var/tmp` directory: + + ``` + cp /home/weewx/util/import/wu-example.conf /var/tmp/wu.conf + ``` + +5. Open `wu.conf` and: + + * confirm the [`source`](weectl-import-config-opt.md#import_config_source) + option is set to `WU`: + + ``` + source = WU + ``` + + * confirm the following options in the `[WU]` section are correctly set: + + * [station_id](weectl-import-config-opt.md#wu_station_id). The 11 or + 12 character weather station ID of the Weather Underground PWS that + will be the source of the imported data. + + * [api_key](weectl-import-config-opt.md#wu_api_key). The 32 character + API key to be used to access the Weather Underground API. + + * [interval](weectl-import-config-opt.md#wu_interval). Determines how + the WeeWX interval field is derived. + + * [qc](weectl-import-config-opt.md#wu_qc). Determines whether quality + control checks are performed on the imported data. + + !!! Note + As Weather Underground imports at times contain nonsense + values, particularly for fields for which no data were + uploaded to Weather Underground by the PWS, the use of + quality control checks on imported data can prevent these + nonsense values from being imported and contaminating the + WeeWX database. + + * [calc_missing](weectl-import-config-opt.md#wu_calc_missing). + Determines whether missing derived observations will be calculated + from the imported data. + + * [ignore_invalid_data](weectl-import-config-opt.md#wu_ignore_invalid_data). + Determines whether invalid data in a source field is ignored or the + import aborted + + * [tranche](weectl-import-config-opt.md#wu_tranche). The number of + records written to the WeeWX database in each transaction. + + * [wind_direction](weectl-import-config-opt.md#wu_wind_direction). + Determines how imported wind direction fields are interpreted. + + * [[[FieldMap]]](weectl-import-config-opt.md#wu_fieldmap). Defines + the mapping between imported data fields and WeeWX archive fields. + +6. When first importing data it is prudent to do a dry run import before any + data is actually imported. A dry run import will perform all steps of the + import without actually writing imported data to the WeeWX database. In + addition, consideration should be given to any additional options to be + used such as `--date`, `--from` or `--to`. + + To perform a dry run enter the following command: + + ``` + weectl import --import-config=/var/tmp/wu.conf --from=2016-01-20T22:30 --to=2016-01-23T06:00 --dry-run + ``` + + In this case the `--from` and `--to` options have been used to import + Weather Underground records from 10:30pm on 20 January 2016 to 6:00am + on 23 January 2016 inclusive. + + !!! Note + If the `--date` option is omitted, or a date (not date-time) range + is specified using the `--from` and `--to` options during a Weather + Underground import, then one or more full days of history data will + be imported. This includes records timestamped from `00:00` + (inclusive) at the start of the day up to but NOT including the + `00:00` record at the end of the last day. As the timestamped record + refers to observations of the previous interval, such an import + actually includes one record with observations from the previous day + (the `00:00` record at the start of the day). Whilst this will not + present a problem for `weectl import` as any records being imported + with a timestamp that already exists in the WeeWX database are + ignored, you may wish to use the `--from` and `--to` options with a + suitable date-time range to precisely control which records are + imported. + + !!! Note + `weectl import` obtains Weather Underground daily history data one + day at a time via a HTTP request and as such the import of large time + spans of data may take some time. Such imports may be best handled as + a series of imports of smaller time spans. + + This will result in a short preamble with details on the data source, the + destination of the imported data and some other details on how the data + will be processed. The import will then be performed but no data will be + written to the WeeWX database. + + The output should be similar to: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Observation history for Weather Underground station 'ISTATION123' will be imported. + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Observations timestamped after 2016-01-20 22:30:00 AEST (1453293000) and up to and including 2016-01-23 06:00:00 AEST (1453492800) will be imported. + This is a dry run, imported data will not be saved to archive. + Starting dry run import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Unique records processed: 18; Last timestamp: 2016-01-20 23:55:00 AEST (1453298100) + Period 2 ... + Unique records processed: 284; Last timestamp: 2016-01-21 23:55:00 AEST (1453384500) + Period 3 ... + Unique records processed: 284; Last timestamp: 2016-01-22 23:55:00 AEST (1453470900) + Period 4 ... + Unique records processed: 71; Last timestamp: 2016-01-23 06:00:00 AEST (1453492800) + Finished dry run import + 657 records were processed and 657 unique records would have been imported. + ``` + + !!! Note + Any periods for which no data could be obtained will be skipped. + The lack of data may be due to an incorrect station ID, an incorrect + date or Weather Underground API problems. A short explanatory note to + this effect will be displayed against the period concerned and an + entry included in the log. + +7. Once the dry run results are satisfactory the source data can be imported + using the following command: + + ``` + weectl import --import-config=/var/tmp/wu.conf --from=2016-01-20T22:30 --to=2016-01-23T06:00 + ``` + + This will result in a short preamble similar to that of a dry run. At the + end of the preamble there will be a prompt: + + ``` + Using WeeWX configuration file /home/weewx/www-data/weewx.conf + Starting weectl import... + Observation history for Weather Underground station 'ISTATION123' will be imported. + Using database binding 'wx_binding', which is bound to database 'weewx.sdb' + Destination table 'archive' unit system is '0x01' (US). + Missing derived observations will be calculated. + Observations timestamped after 2016-01-20 22:30:00 AEST (1453293000) and up to and including 2016-01-23 06:00:00 AEST (1453492800) will be imported. + Starting import ... + Records covering multiple periods have been identified for import. + Period 1 ... + Proceeding will save all imported records in the WeeWX archive. + Are you sure you want to proceed (y/n)? + ``` + + !!! Note + `weectl import` obtains Weather Underground data one day at a time + via a HTTP request and as such the import of large time spans of data + may take some time. Such imports may be best handled as a series of + imports of smaller time spans. + +8. If the import parameters are acceptable enter `y` to proceed with the + import or `n` to abort the import. If the import is confirmed, the + source data will be imported, processed and saved in the WeeWX database. + Information on the progress of the import will be displayed similar to + the following: + + ``` + Unique records processed: 18; Last timestamp: 2016-01-20 23:55:00 AEST (1453298100) + Period 2 ... + Unique records processed: 284; Last timestamp: 2016-01-21 23:55:00 AEST (1453384500) + Period 3 ... + Unique records processed: 284; Last timestamp: 2016-01-22 23:55:00 AEST (1453470900) + ``` + + !!! Note + Any periods for which no data could be obtained will be skipped. The + lack of data may be due to an incorrect station ID, an incorrect date + or Weather Underground API problems. A short explanatory note to this + effect will be displayed against the period concerned and an entry + included in the log. + + The line commencing with `Unique records processed` should update as + records are imported with progress information on number of records + processed, number of unique records imported and the date time of the + latest record processed. If the import spans multiple days then a new + `Period` line is created for each day. + + Once the initial import is complete `weectl import` will, if requested, + calculate any missing derived observations and rebuild the daily + summaries. A brief summary should be displayed similar to the following: + + ``` + Calculating missing derived observations ... + Processing record: 204; Last record: 2016-01-22 23:55:00 AEST (1453470900) + Recalculating daily summaries... + Finished recalculating daily summaries + Finished calculating missing derived observations + ``` + + When the import is complete a brief summary is displayed similar to + the following: + + ``` + Finished import + 657 records were processed and 657 unique records imported in 78.97 seconds. + Those records with a timestamp already in the archive will not have been + imported. Confirm successful import in the WeeWX log file. + ``` + + !!! Note + The new (2019) Weather Underground API appears to have an issue + when obtaining historical data for the current day. The first time + the API is queried the API returns all historical data up to and + including the most recent record. However, subsequent later API + queries during the same day return the same set of records rather + than all records up to and including the time of the latest API + query. Users importing Weather Underground data that includes data + from the current day are advised to carefully check the WeeWX log + to ensure that all expected records were imported. If some records + are missing from the current day try running an import for the + current day again using the `--date` option setting. If this fails + then wait until the following day and perform another import for + the day concerned again using the `--date` option setting. In all + cases confirm what data has been imported by referring to the + WeeWX log. + +9. Whilst `weectl import` will advise of the number of records processed and + the number of unique records found, `weectl import` does know how many, if + any, of the imported records were successfully saved to the database. You + should look carefully through the WeeWX log file covering the `weectl + import` session and take note of any records that were not imported. The + most common reason for imported records not being saved to the database + is because a record with that timestamp already exists in the database, + in such cases something similar to the following will be found in the log: + + ``` + 2023-11-04 15:33:01 weectl-import[3795]: ERROR weewx.manager: Unable to add record 2018-09-04 04:20:00 AEST (1535998800) to database 'weewx.sdb': UNIQUE constraint failed: archive.dateTime + ``` + + In such cases you should take note of the timestamp of the record(s) + concerned and make a decision about whether to delete the pre-existing + record and re-import the record or retain the pre-existing record. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-report.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-report.md new file mode 100644 index 0000000..684fffb --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-report.md @@ -0,0 +1,95 @@ +# weectl report + +Use the `weectl` subcommand `report` to run and list reports. + +Specify `--help` to see the actions and options. + +## List reports + + weectl report list + [--config=FILENAME] + +The `list` action will list all the reports in the configuration file, along +with which skin they use, and other information. For example: + +``` +$ weectl report list +Using configuration file /Users/ted_user/weewx-data/weewx.conf + + Report Skin Enabled Units Language + SeasonsReport Seasons Y US EN + SmartphoneReport Smartphone N US EN + MobileReport Mobile N US EN + StandardReport Standard N US EN + FTP Ftp N US EN + RSYNC Rsync N US EN +``` + +## Run reports on demand + + weectl report run [NAME ...] + [--config=FILENAME] + [--epoch=EPOCH_TIME | --date=YYYY-mm-dd --time=HH:MM] + +In normal operation, WeeWX generates reports at each archive interval after new +data has arrived. The action `weectl report run` is used to generate reports on +demand. It uses the same configuration file that `weewxd` uses. + +The names of the reports to be run can be given on the command line, separated +by spaces. It does not matter whether the report has been enabled or not: it +will be run. Note: names are _case sensitive!_ Use `weectl report list` to +determine the names. + +For example, to run the reports `MobileReport` and `SmartphoneReport`: + + weectl report run MobileReport SmartphoneReport + +If no report names are given, then all enabled reports will be run: + + # Run all enabled reports: + weectl report run + +By default, the reports are generated as of the last timestamp in the database, +however, an explicit time can be given by using either option `--epoch`, or by +using options `--date` and `--time` together. + +For example, to specify an explicit unix epoch time, use option `--epoch`: + +``` +weectl report run --epoch=1652367600 +``` + +This would generate a report for unix epoch time 1652367600 (12-May-2022 at +8AM PDT). + +Alternatively, you can specify a date and time, by using options `--date` and +`--time`: + +``` +weectl report run --date=2022-05-12 --time=08:00 +``` + +This would generate a report for 12-May-2022 at 8AM (unix epoch time +1652367600). + +## Options + +These are options used by most of the actions. + +### --config + +Path to the configuration file. Default is `~/weewx-data/weewx.conf`. + +### --date=YYYY-mm-dd and --time=HH:MM + +Generate the reports so that they are current as of the given date +and time. The date should be given in the form `YYYY-mm-dd` and the time should +be given as `HH:MM`. + +### --epoch=EPOCH_TIME + +Generate the reports so that they are current as of the given unix epoch time. + +### --help + +Show the help message, then exit. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weectl-station.md b/dist/weewx-5.0.2/docs_src/utilities/weectl-station.md new file mode 100644 index 0000000..cab08bc --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weectl-station.md @@ -0,0 +1,297 @@ +# weectl station + +Use the `weectl` subcommand `station` to create and manage the data for a +station, including its configuration file. + +Specify `--help` to see the actions and options. + +See the section [_Options_](#options) below for details of the various options. + +## Create a new station data directory + + weectl station create [WEEWX-ROOT] + [--driver=DRIVER] + [--location=LOCATION] + [--altitude=ALTITUDE,(foot|meter)] + [--latitude=LATITUDE] [--longitude=LONGITUDE] + [--register=(y,n) [--station-url=URL]] + [--units=(us|metricwx|metric)] + [--skin-root=DIRECTORY] + [--sqlite-root=DIRECTORY] + [--html-root=DIRECTORY] + [--user-root=DIRECTORY] + [--examples-root=DIRECTORY] + [--no-prompt] + [--config=FILENAME] + [--dist-config=FILENAME] + [--dry-run] + +The `create` action will create a new directory in location `WEEWX-ROOT` and +populate it with station data. The default location for `WEEWX-ROOT` is +`~/weewx-data`, that is, the directory `weewx-data` in your home directory. + +After the command completes, the directory will contain: + +- a configuration file called `weewx.conf`; +- documentation; +- examples; +- utility files; and +- skins. + +This action is typically used to create the initial station configuration when +installing WeeWX for the first time. It can also be used to create +configurations for multiple stations. + +For example, to create a station data area in the default area `~/weewx-data`, +you would specify + + weectl station create + +The resultant directory would contain a configuration file `weewx.conf`. + +To add another station using the same station data area, but a separate +configuration file named `barn.conf`, you would specify + + weectl station create --config=barn.conf + +You would end up with a single station data area, `~/weewx-data`, with two +different configuration files, `weewx.conf` and `barn.conf`. + +If invoked without any options, the `create` action will prompt you for +various settings, such as the type of hardware you are using, the station +altitude and location, etc. + +Use the option `--no-prompt` to create a configuration without prompts. This +will use settings specified as options, and default values for any setting +not specified. This is useful when creating a station with an automated script. +For example, + + weectl station create --no-prompt --driver=weewx.drivers.vantage \ + --altitude="400,foot" --latitude=45.1 --longitude=-105.9 \ + --location="My Special Station" + +will create a station with the indicated values. Default values will be used +for any setting that is not specified. + + +## Reconfigure an existing station + + weectl station reconfigure + [--driver=DRIVER] + [--location=LOCATION] + [--altitude=ALTITUDE,(foot|meter)] + [--latitude=LATITUDE] [--longitude=LONGITUDE] + [--register=(y,n) [--station-url=URL]] + [--units=(us|metricwx|metric)] + [--skin-root=DIRECTORY] + [--sqlite-root=DIRECTORY] + [--html-root=DIRECTORY] + [--user-root=DIRECTORY] + [--weewx-root=DIRECTORY] + [--no-backup] + [--no-prompt] + [--config=FILENAME] + [--dry-run] + +The `reconfigure` action will modify the contents of an existing configuration +file. It is often used to change drivers. + +If invoked without any options, `reconfigure` will prompt you for settings. + +Use the option `--no-prompt` to reconfigure without prompts. This will use +the settings specified as options, and existing values for anything that is +not specified. + +For example, to keep all settings, but change to the Vantage driver you would +use + + weectl station reconfigure --no-prompt --driver=weewx.drivers.vantage + + +## Upgrade an existing station + + weectl station upgrade + [--examples-root=DIRECTORY] + [--skin-root=DIRECTORY] + [--what ITEM [ITEM ...] + [--no-backup] + [--yes] + [--config=FILENAME] + [--dist-config=FILENAME]] + [--dry-run] + + +When you upgrade WeeWX, only the code is upgraded; upgrades to WeeWX +do not modify the station data, including the configuration file, database, +skins or utility files. + +Use the `upgrade` action to upgrade one or more of these items. + +``` +weectl station upgrade +``` + +When invoked with no options, the `upgrade` action upgrades only the examples, +and utility files. By default, the configuration file and skins are not +upgraded. This is to avoid overwriting any changes you might have made. + +However, you can use the `--what` option to explicitly choose what to upgrade. + +For example, if you wish to upgrade the skins, you must specify `--what skins`. +This will save timestamped copies of your old skins, then copy in the new +versions. + +``` {.shell .copy} +weectl station upgrade --what skins +``` + +See the details below for [option `--what`](#what-option). + + +## Positional argument + +### WEEWX-ROOT + +Use this option with `weectl station create` to specify a directory that is to +hold the station data area. Default is `~/weewx-data`. + +## Optional arguments + +### --altitude=ALTITUDE + +The altitude of your station, along with the unit it is measured in. For +example, `--altitude=50,meter`. Note that the unit is measured in the singular +(`foot`, not `feet`). Default is `"0,foot"`. + +### --config=FILENAME + +Path to the configuration file, *relative to `WEEWX_ROOT`*. +If the filename starts with a slash (`/`), it is an absolute path. +Default is `weewx.conf`. + +### --driver=DRIVER + +Which driver to use. Default is `weewx.drivers.simulator`. + +### --dry-run + +With option `--dry-run` you can test what `weect station` would do +without actually doing it. It will print out the steps, but not +actually write anything. + +### --examples-root=DIRECTORY + +Where the WeeWX examples can be found, *relative to `WEEWX_ROOT`*. +If the directory starts with a slash (`/`), it is an absolute path. +Default is `examples`. This option is rarely needed by the average user. + +### --html-root=DIRECTORY + +Where generated HTML files should be placed, *relative to `WEEWX_ROOT`*. +If the directory starts with a slash (`/`), it is an absolute path. +Default is `public_html`. This option is rarely needed by the average user. + +### --latitude=LATITUDE + +The station latitude in decimal degrees. Negative for the southern hemisphere. +Default is `0`. + +### --location=LOCATION + +A description of your station, such as `--location="A small town in Rongovia"` +Default is `WeeWX`. + +### --longitude=LONGITUDE + +The station longitude in decimal degrees. Negative for the western hemisphere. +Default is `0`. + +### --no-backup + +If `weectl station` changes your configuration file or skins, it will save a +timestamped copy of the original. If you specify `--no-backup`, then it will +not save a copy. + +### --no-prompt + +Generally, the utility will prompt for values unless `--no-prompt` has been +set. When `--no-prompt` is specified, the values to be used are the default +values, replaced with whatever options have been set on the command line. +For example, + +``` +weectl station create --driver='weewx.drivers.vantage' --no-prompt +``` + +will cause the defaults to be used for all values except `--driver`. + +### --register=(y|n) + +Whether to include the station in the WeeWX registry and [map](https://weewx.com/stations.html). +If you choose to register your station, you must also specify a unique URL for +your station with option `--station-url`. Default is `n` (do not register). + +### --skin-root=DIRECTORY + +The location of the directory holding the skins *relative to `WEEWX_ROOT`*. +If the directory starts with a slash (`/`), it is an absolute path. +Default is `skins`. This option is rarely needed by the average user. + +### --sqlite-root=DIRECTORY + +The location of the directory holding the SQLite database *relative to +`WEEWX_ROOT`*. If the directory starts with a slash (`/`), it is an absolute +path. Default is `skins`. This option is rarely needed by the average user. + +### --station-url=URL + +A unique URL for the station. The station URL identifies each station in +the WeeWX registry and map. + +Example: `--station-url=https://www.wunderground.com/dashboard/pws/KNDPETE15`. +No default. + +### --units=UNIT_SYSTEM + +What units to use for your reports. Options are `us`, `metricwx`, or `metric`. +See the section [_Units_](../reference/units.md) for details. Default +is `us`. + +### --user-root=DIRECTORY + +Where user extensions can be found, *relative to `WEEWX_ROOT`*. +If the directory starts with a slash (`/`), it is an absolute path. +Default is `bin/user`. This option is rarely needed by the average user. + +### --weewx-root=DIRECTORY + +Use this option with `weectl station reconfigure` to change the station data +area. This option is rarely needed by the average user. + +### --what {#what-option} + +By default, the `upgrade` action will upgrade the documentation, examples, +and utility files. However, you can specify exactly what gets upgraded by +using the `--what` option. + +The `--what` option understands the following: + +* config - the configuration file +* examples - the example extensions +* util - the system utility files +* skins - the report templates + +For example, to upgrade the configuration file and skins only, you would +specify + +``` +weectl station upgrade --what config skins +``` + +!!! Note + The `--what` option does not take an equal sign (`=`). Just list the + desired things to be upgraded, without commas between them. + +### -y | --yes + +Do not ask for confirmation. Just do it. diff --git a/dist/weewx-5.0.2/docs_src/utilities/weewxd.md b/dist/weewx-5.0.2/docs_src/utilities/weewxd.md new file mode 100644 index 0000000..273f618 --- /dev/null +++ b/dist/weewx-5.0.2/docs_src/utilities/weewxd.md @@ -0,0 +1,46 @@ +# weewxd + +The `weewxd` application is the heart of WeeWX. It collects data from +hardware, processes the data, archives the data, then generates reports +from the data. + +It can be run directly, or in the background as a daemon. When it is run +directly, `weewxd` emits LOOP and ARCHIVE data to stdout. When it is run +as a daemon, it will fork, output will go to log, and the process ID will be +written to the `pidfile`. + +Specify `--help` to see how it is used: +``` +weewxd --help +``` +``` +usage: weewxd --help + weewxd --version + weewxd [FILENAME|--config=FILENAME] + [--daemon] + [--pidfile=PIDFILE] + [--exit] + [--loop-on-init] + [--log-label=LABEL] + +The main entry point for WeeWX. This program will gather data from your +station, archive its data, then generate reports. + +positional arguments: + FILENAME + +optional arguments: + -h, --help show this help message and exit + --config FILENAME Use configuration file FILENAME + -d, --daemon Run as a daemon + -p PIDFILE, --pidfile PIDFILE + Store the process ID in PIDFILE + -v, --version Display version number then exit + -x, --exit Exit on I/O and database errors instead of restarting + -r, --loop-on-init Retry forever if device is not ready on startup + -n LABEL, --log-label LABEL + Label to use in syslog entries + +Specify either the positional argument FILENAME, or the optional argument +using --config, but not both. +``` diff --git a/dist/weewx-5.0.2/makefile b/dist/weewx-5.0.2/makefile new file mode 100644 index 0000000..d10841f --- /dev/null +++ b/dist/weewx-5.0.2/makefile @@ -0,0 +1,532 @@ +# -*- makefile -*- +# this makefile controls the build and packaging of weewx +# Copyright 2013-2024 Matthew Wall + +# if you do not want to sign the packages, set SIGN to 0 +SIGN=1 + +# the WeeWX WWW server +WEEWX_COM:=weewx.com +# location of the html documentation on the WWW server +WEEWX_HTMLDIR=/var/www/html +# location of weewx downloads +WEEWX_DOWNLOADS=$(WEEWX_HTMLDIR)/downloads +# location for staging weewx package uploads +WEEWX_STAGING=$(WEEWX_HTMLDIR)/downloads/development_versions + +# Directory for artifacts created during the build process +BLDDIR=build +# Directory for completed files that will be tested and/or distributed +DSTDIR=dist +# Location of doc sources +DOC_SRC=docs_src +# Location of built docs +DOC_BUILT=$(BLDDIR)/docs +# Location of the skins +SKINLOC=src/weewx_data/skins + +CWD=$(shell pwd) +# the current version is extracted from the pyproject.toml file +VERSION:=$(shell sed -ne 's/^version = "\(.*\)"/\1/p;' pyproject.toml) +# just the major.minor part of the version +MMVERSION:=$(shell echo "$(VERSION)" | sed -e 's%.[0-9a-z]*$$%%') + +SRCPKG=weewx-$(VERSION).tgz +WHEELSRC=weewx-$(VERSION).tar.gz +WHEEL=weewx-$(VERSION)-py3-none-any.whl + +PYTHON?=python3 + +TMPDIR?=/var/tmp + +all: help + +help: info + @echo "options include:" + @echo " info display values of variables we care about" + @echo " version get version from pyproject.toml and insert elsewhere" + @echo "" + @echo " debian-changelog prepend stub changelog entry for debian" + @echo " redhat-changelog prepend stub changelog entry for redhat" + @echo " suse-changelog prepend stub changelog entry for suse" + @echo "" + @echo " pypi-package create wheel and source tarball for pypi" + @echo "debian-package create the debian package(s)" + @echo "redhat-package create the redhat package(s)" + @echo " suse-package create the suse package(s)" + @echo "" + @echo " check-debian check the debian package" + @echo " check-redhat check the redhat package" + @echo " check-suse check the suse package" + @echo " check-docs run weblint on the docs" + @echo "" + @echo " upload-src upload the src package to $(WEEWX_COM)" + @echo " upload-pypi upload wheel and src package to pypi.org" + @echo " upload-debian upload the debian deb package" + @echo " upload-redhat upload the redhat rpm packages" + @echo " upload-suse upload the suse rpm packages" + @echo "" + @echo " build-docs build the docs using mkdocs" + @echo " upload-docs upload docs to $(WEEWX_COM)" + @echo "" + @echo " release promote staged files on the download server" + @echo "" + @echo " test run all unit tests" + @echo " SUITE=path/to/foo.py to run only foo tests" + @echo " test-clean remove test databases" + @echo "" + @echo " apt repository management" + @echo " pull-apt-repo" + @echo " update-apt-repo" + @echo " push-apt-repo" + @echo "" + @echo " yum repository management" + @echo " pull-yum-repo" + @echo " update-yum-repo" + @echo " push-yum-repo" + @echo "" + @echo " suse repository management" + @echo " pull-suse-repo" + @echo " update-suse-repo" + @echo " push-suse-repo" + @echo "" + +info: + @echo " VERSION: $(VERSION)" + @echo " MMVERSION: $(MMVERSION)" + @echo " PYTHON: $(PYTHON)" + @echo " CWD: $(CWD)" + @echo " USER: $(USER)" + @echo " WEEWX_COM: $(WEEWX_COM)" + @echo " WEEWX_STAGING: $(WEEWX_STAGING)" + @echo " DOC_SRC: $(DOC_SRC)" + @echo " DOC_BUILT: $(DOC_BUILT)" + @echo " SKINLOC: $(SKINLOC)" + @echo "" + +clean: + find src -name "*.pyc" -exec rm {} \; + find src -name "__pycache__" -prune -exec rm -rf {} \; + rm -rf $(BLDDIR) $(DSTDIR) + + +############################################################################### +# update the version in all relevant places +VCONFIGS=src/weewx_data/weewx.conf src/weecfg/tests/expected/weewx43_user_expected.conf +VSKINS=Ftp/skin.conf Mobile/skin.conf Rsync/skin.conf Seasons/skin.conf Smartphone/skin.conf Standard/skin.conf +version: + sed -e "s/^site_name.*/site_name: \'WeeWX $(MMVERSION)\'/" mkdocs.yml > mkdocs.yml.tmp; mv mkdocs.yml.tmp mkdocs.yml + for f in $(VCONFIGS); do \ + sed -e 's/version = [0-9].*/version = $(VERSION)/' $$f > $$f.tmp; \ + mv $$f.tmp $$f; \ +done + for f in $(VSKINS); do \ + sed -e 's/^SKIN_VERSION = [0-9].*/SKIN_VERSION = $(VERSION)/' $(SKINLOC)/$$f > $(SKINLOC)/$$f.tmp; \ + mv $(SKINLOC)/$$f.tmp $(SKINLOC)/$$f; \ +done + sed -e 's/__version__ *=.*/__version__ = "$(VERSION)"/' src/weewx/__init__.py > weeinit.py.tmp + mv weeinit.py.tmp src/weewx/__init__.py + + +############################################################################### +## testing targets + +# if no suite is specified, find all test suites in the source tree +SUITE?=`find src -name "test_*.py"` +test: src/weewx_data/ + @rm -f $(BLDDIR)/test-results + @mkdir -p $(BLDDIR) + @echo "Python interpreter in use:" >> $(BLDDIR)/test-results 2>&1; + @$(PYTHON) -c "import sys;print(sys.executable+'\n')" >> $(BLDDIR)/test-results 2>&1; + @for f in $(SUITE); do \ + echo running $$f; \ + echo $$f >> $(BLDDIR)/test-results; \ + PYTHONPATH="src:src/weewx_data/examples:src/weewx/tests" $(PYTHON) $$f >> $(BLDDIR)/test-results 2>&1; \ + echo >> $(BLDDIR)/test-results; \ +done + @grep "ERROR:\|FAIL:" $(BLDDIR)/test-results || echo "no failures" + @grep "skipped=" $(BLDDIR)/test-results || echo "no tests were skipped" + @echo "see $(BLDDIR)/test-results for output from the tests" + @grep -q "ERROR:\|FAIL:" $(BLDDIR)/test-results && exit 1 || true + +test-setup: + src/weedb/tests/setup_mysql.sh + +test-setup-ci: + MYSQL_NO_OPTS=1 src/weedb/tests/setup_mysql.sh + +TESTDIR=/var/tmp/weewx_test +MYSQLCLEAN="drop database test_weewx;\n\ +drop database test_alt_weewx;\n\ +drop database test_sim;\n\ +drop database test_weewx1;\n\ +drop database test_weewx2;\n\ +drop database test_scratch;\n" + +test-clean: + rm -rf $(TESTDIR) + echo $(MYSQLCLEAN) | mysql --user=weewx --password=weewx --force >/dev/null 2>&1 + + +############################################################################### +## documentation targets + +# Build the documentation: +build-docs: $(DOC_BUILT) + +$(DOC_BUILT): $(shell find $(DOC_SRC) -type f) + @rm -rf $(DOC_BUILT) + @mkdir -p $(DOC_BUILT) + @echo "Building documents" + $(PYTHON) -m mkdocs --quiet build --site-dir=$(DOC_BUILT) + +check-docs: + weblint $(DOC_BUILT)/*.htm + +# upload docs to the web site +upload-docs: $(DOC_BUILT) + rsync -Orv --delete --exclude *~ --exclude "#*" $(DOC_BUILT)/ $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/docs/$(MMVERSION) + + +############################################################################### +## source targets + +src-tarball: $(DSTDIR)/$(SRCPKG) + +$(DSTDIR)/$(SRCPKG): + mkdir -p $(BLDDIR)/weewx-$(VERSION) + rsync -ar ./ $(BLDDIR)/weewx-$(VERSION) --exclude-from .gitignore --exclude .git --exclude .editorconfig --exclude .github --exclude .gitignore --delete + mkdir -p $(DSTDIR) + tar cfz $(DSTDIR)/$(SRCPKG) -C $(BLDDIR) weewx-$(VERSION) + +# upload the source tarball to the web site +upload-src: + scp $(DSTDIR)/$(SRCPKG) $(USER)@$(WEEWX_COM):$(WEEWX_STAGING) + + +############################################################################### +## pypi targets + +pypi-package $(DSTDIR)/$(WHEELSRC) $(DSTDIR)/$(WHEEL): pyproject.toml + poetry build + +# Upload wheel and src package to pypi.org +upload-pypi: $(DSTDIR)/$(WHEELSRC) $(DSTDIR)/$(WHEEL) + poetry publish + + +############################################################################### +## debian targets + +DEBREVISION=1 +DEBVER=$(VERSION)-$(DEBREVISION) +# add a skeleton entry to deb changelog +debian-changelog: + if [ "`grep '($(DEBVER))' pkg/debian/changelog`" = "" ]; then \ + pkg/mkchangelog.pl --action stub --format debian --release-version $(DEBVER) > pkg/debian/changelog.new; \ + cat pkg/debian/changelog >> pkg/debian/changelog.new; \ + mv pkg/debian/changelog.new pkg/debian/changelog; \ +fi + +# use dpkg-buildpackage to create the debian package +# -us -uc - skip gpg signature on .dsc, .buildinfo, and .changes +# the latest version in the debian changelog must match the packaging version +DEBARCH=all +DEBBLDDIR=$(BLDDIR)/weewx-$(VERSION) +DEBPKG=weewx_$(DEBVER)_$(DEBARCH).deb +ifneq ("$(SIGN)","1") +DPKG_OPT=-us -uc +endif +debian-package: deb-package-prep + cp pkg/debian/control $(DEBBLDDIR)/debian/control + rm -f $(DEBBLDDIR)/debian/files + (cd $(DEBBLDDIR); dpkg-buildpackage $(DPKG_OPT)) + mkdir -p $(DSTDIR) + mv $(BLDDIR)/$(DEBPKG) $(DSTDIR)/python3-$(DEBPKG) + +deb-package-prep: $(DSTDIR)/$(SRCPKG) + mkdir -p $(DEBBLDDIR) + cp -p $(DSTDIR)/$(SRCPKG) $(BLDDIR)/weewx_$(VERSION).orig.tar.gz + rm -rf $(DEBBLDDIR)/debian + mkdir -m 0755 $(DEBBLDDIR)/debian + mkdir -m 0755 $(DEBBLDDIR)/debian/source + cp pkg/debian/changelog $(DEBBLDDIR)/debian + cp pkg/debian/compat $(DEBBLDDIR)/debian + cp pkg/debian/conffiles $(DEBBLDDIR)/debian + cp pkg/debian/config $(DEBBLDDIR)/debian + cp pkg/debian/copyright $(DEBBLDDIR)/debian + cp pkg/debian/postinst $(DEBBLDDIR)/debian + cp pkg/debian/postrm $(DEBBLDDIR)/debian + cp pkg/debian/preinst $(DEBBLDDIR)/debian + cp pkg/debian/prerm $(DEBBLDDIR)/debian + cp pkg/debian/source/format $(DEBBLDDIR)/debian/source + cp pkg/debian/templates $(DEBBLDDIR)/debian + cp pkg/debian/weewx.lintian-overrides $(DEBBLDDIR)/debian + sed -e 's%WEEWX_VERSION%$(VERSION)%' \ + pkg/debian/rules > $(DEBBLDDIR)/debian/rules + +# run lintian on the deb package +check-debian: + lintian -Ivi $(DSTDIR)/python3-$(DEBPKG) + +upload-debian: + scp $(DSTDIR)/python3-$(DEBPKG) $(USER)@$(WEEWX_COM):$(WEEWX_STAGING) + + +############################################################################### +## rpm targets + +# specify the operating system release target (e.g., 7 for centos7) +OSREL= +# specify the operating system label (e.g., el, suse) +RPMOS=$(shell if [ -f /etc/SuSE-release -o -f /etc/SUSE-brand ]; then echo suse; elif [ -f /etc/redhat-release ]; then echo el; else echo os; fi) + +RPMREVISION=1 +RPMVER=$(VERSION)-$(RPMREVISION) +# add a skeleton entry to rpm changelog +rpm-changelog: + if [ "`grep '\- $(RPMVER)' pkg/changelog.$(RPMOS)`" = "" ]; then \ + pkg/mkchangelog.pl --action stub --format redhat --release-version $(RPMVER) > pkg/changelog.$(RPMOS).new; \ + cat pkg/changelog.$(RPMOS) >> pkg/changelog.$(RPMOS).new; \ + mv pkg/changelog.$(RPMOS).new pkg/changelog.$(RPMOS); \ +fi + +# use rpmbuild to create the rpm package +# specify the architecture (always noarch) +RPMARCH=noarch +RPMBLDDIR=$(BLDDIR)/weewx-$(RPMVER).$(RPMOS)$(OSREL).$(RPMARCH) +RPMPKG=weewx-$(RPMVER).$(RPMOS)$(OSREL).$(RPMARCH).rpm +rpm-package: $(DSTDIR)/$(SRCPKG) + rm -rf $(RPMBLDDIR) + mkdir -p -m 0755 $(RPMBLDDIR) + mkdir -p -m 0755 $(RPMBLDDIR)/BUILD + mkdir -p -m 0755 $(RPMBLDDIR)/BUILDROOT + mkdir -p -m 0755 $(RPMBLDDIR)/RPMS + mkdir -p -m 0755 $(RPMBLDDIR)/SOURCES + mkdir -p -m 0755 $(RPMBLDDIR)/SPECS + mkdir -p -m 0755 $(RPMBLDDIR)/SRPMS + sed -e 's%WEEWX_VERSION%$(VERSION)%' \ + -e 's%RPMREVISION%$(RPMREVISION)%' \ + -e 's%OSREL%$(OSREL)%' \ + pkg/weewx.spec.in > $(RPMBLDDIR)/SPECS/weewx.spec + cat pkg/changelog.$(RPMOS) >> $(RPMBLDDIR)/SPECS/weewx.spec + cp $(DSTDIR)/$(SRCPKG) $(RPMBLDDIR)/SOURCES/weewx-$(VERSION).tar.gz + rpmbuild -ba --clean --define '_topdir $(CWD)/$(RPMBLDDIR)' --target noarch $(CWD)/$(RPMBLDDIR)/SPECS/weewx.spec + mkdir -p $(DSTDIR) + mv $(RPMBLDDIR)/RPMS/$(RPMARCH)/$(RPMPKG) $(DSTDIR) +# mv $(RPMBLDDIR)/SRPMS/weewx-$(RPMVER).$(RPMOS)$(OSREL).src.rpm $(DSTDIR) +ifeq ("$(SIGN)","1") + rpm --addsign $(DSTDIR)/$(RPMPKG) +# rpm --addsign $(DSTDIR)/weewx-$(RPMVER).$(RPMOS)$(OSREL).src.rpm +endif + +redhat-changelog: + make rpm-changelog RPMOS=el + +redhat-package: rpm-package-rh8 rpm-package-rh9 + +rpm-package-rh8: + make rpm-package RPMOS=el OSREL=8 + +rpm-package-rh9: + make rpm-package RPMOS=el OSREL=9 + +suse-changelog: + make rpm-changelog RPMOS=suse + +suse-package: rpm-package-suse15 + +rpm-package-suse15: + make rpm-package RPMOS=suse OSREL=15 + +# run rpmlint on the rpm package +check-rpm: + rpmlint -f pkg/rpmlint.$(RPMOS) $(DSTDIR)/$(RPMPKG) + +check-redhat: check-rh8 check-rh9 + +check-rh8: + make check-rpm RPMOS=el OSREL=8 + +check-rh9: + make check-rpm RPMOS=el OSREL=9 + +check-suse: + make check-rpm RPMOS=suse OSREL=15 + +upload-rpm: + scp $(DSTDIR)/$(RPMPKG) $(USER)@$(WEEWX_COM):$(WEEWX_STAGING) + +upload-redhat: upload-rh8 upload-rh9 + +upload-rh8: + make upload-rpm RPMOS=el OSREL=8 + +upload-rh9: + make upload-rpm RPMOS=el OSREL=9 + +upload-suse: + make upload-rpm RPMOS=suse OSREL=15 + +# shortcut to upload all packages from a single machine +DEB3_PKG=python3-weewx_$(DEBVER)_$(DEBARCH).deb +RHEL8_PKG=weewx-$(RPMVER).el8.$(RPMARCH).rpm +RHEL9_PKG=weewx-$(RPMVER).el9.$(RPMARCH).rpm +SUSE15_PKG=weewx-$(RPMVER).suse15.$(RPMARCH).rpm +upload-pkgs: + scp $(DSTDIR)/$(SRCPKG) $(DSTDIR)/$(DEB3_PKG) $(DSTDIR)/$(RHEL8_PKG) $(DSTDIR)/$(RHEL9_PKG) $(DSTDIR)/$(SUSE15_PKG) $(USER)@$(WEEWX_COM):$(WEEWX_STAGING) + +# move files from the upload directory to the release directory and set up the +# symlinks to them from the download root directory +DEVDIR=$(WEEWX_DOWNLOADS)/development_versions +RELDIR=$(WEEWX_DOWNLOADS)/released_versions +ARTIFACTS=$(DEB3_PKG) $(RHEL8_PKG) $(RHEL9_PKG) $(SUSE15_PKG) $(SRCPKG) +release: + ssh $(USER)@$(WEEWX_COM) "for f in $(ARTIFACTS); do if [ -f $(DEVDIR)/\$$f ]; then mv $(DEVDIR)/\$$f $(RELDIR); fi; done" + ssh $(USER)@$(WEEWX_COM) "rm -f $(WEEWX_DOWNLOADS)/weewx*" + ssh $(USER)@$(WEEWX_COM) "if [ -f $(RELDIR)/$(SRCPKG) ]; then ln -s released_versions/$(SRCPKG) $(WEEWX_DOWNLOADS); fi" + ssh $(USER)@$(WEEWX_COM) "chmod 664 $(WEEWX_DOWNLOADS)/released_versions/weewx?$(VERSION)*" + + +############################################################################### +## repository management targets + +# update the repository html index files, without touching the contents of the +# repositories. this is rarely necessary, since the index files are included +# in the pull/push cycle of repository maintenance. it is needed when the +# operating systems make changes that are not backward compatible, for example +# when debian deprecated the use of apt-key. +upload-repo-index: + scp pkg/index-apt.html $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/aptly/public/index.html + scp pkg/index-yum.html $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/yum/index.html + scp pkg/index-suse.html $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/suse/index.html + +# 'apt-repo' is only used when creating a new apt repository from scratch +# the .html and .list files are not part of an official apt repository. they +# are included to make the repository self-documenting. +apt-repo: + aptly repo create -distribution=squeeze -component=main -architectures=all python2-weewx + aptly repo create -distribution=buster -component=main -architectures=all python3-weewx + mkdir -p ~/.aptly/public + cp -p pkg/index-apt.html ~/.aptly/public/index.html + cp -p pkg/weewx-python2.list ~/.aptly/public + cp -p pkg/weewx-python3.list ~/.aptly/public +# this is for backward-compatibility when there was not python2/3 distinction + cp -p pkg/weewx-python2.list ~/.aptly/public/weewx.list +# these are for backward-compatibility for users that do not have python2 or +# python3 in the paths in their .list file - default to python2 + ln -s python2/dists ~/.aptly/public + ln -s python2/pool ~/.aptly/public + +# make local copy of the published apt repository +pull-apt-repo: + mkdir -p ~/.aptly + rsync -Oarvz $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/aptly/ ~/.aptly + +# add the latest version to the local apt repo using aptly +update-apt-repo: + aptly repo add python3-weewx $(DSTDIR)/python3-$(DEBPKG) + aptly snapshot create python3-weewx-$(DEBVER) from repo python3-weewx + aptly publish drop buster python3 + aptly publish -architectures=all snapshot python3-weewx-$(DEBVER) python3 +# aptly publish switch buster python3 python3-weewx-$(DEBVER) + +# publish apt repo changes to the public weewx apt repo +push-apt-repo: + find ~/.aptly -type f -exec chmod 664 {} \; + find ~/.aptly -type d -exec chmod 2775 {} \; + rsync -Ortlvz ~/.aptly/ $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/aptly-test + +# copy the testing repository onto the production repository +release-apt-repo: + ssh $(USER)@$(WEEWX_COM) "rsync -Ologrvz /var/www/html/aptly-test/ /var/www/html/aptly" + +# 'yum-repo' is only used when creating a new yum repository from scratch +# the index.html is not part of an official rpm repository. it is included +# to make the repository self-documenting. +YUM_REPO=~/.yum/weewx +yum-repo: + mkdir -p $(YUM_REPO)/{el7,el8,el9}/RPMS + cp -p pkg/index-yum.html ~/.yum/index.html + cp -p pkg/weewx-el.repo ~/.yum/weewx.repo + cp -p pkg/weewx-el7.repo ~/.yum + cp -p pkg/weewx-el8.repo ~/.yum + cp -p pkg/weewx-el9.repo ~/.yum + +pull-yum-repo: + mkdir -p $(YUM_REPO) + rsync -Oarvz $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/yum/ ~/.yum + +update-yum-repo: + mkdir -p $(YUM_REPO)/el8/RPMS + cp -p $(DSTDIR)/weewx-$(RPMVER).el8.$(RPMARCH).rpm $(YUM_REPO)/el8/RPMS + createrepo $(YUM_REPO)/el8 + mkdir -p $(YUM_REPO)/el9/RPMS + cp -p $(DSTDIR)/weewx-$(RPMVER).el9.$(RPMARCH).rpm $(YUM_REPO)/el9/RPMS + createrepo $(YUM_REPO)/el9 +ifeq ("$(SIGN)","1") + gpg -abs -o $(YUM_REPO)/el8/repodata/repomd.xml.asc $(YUM_REPO)/el8/repodata/repomd.xml + gpg --export --armor > $(YUM_REPO)/el8/repodata/repomd.xml.key + gpg -abs -o $(YUM_REPO)/el9/repodata/repomd.xml.asc $(YUM_REPO)/el9/repodata/repomd.xml + gpg --export --armor > $(YUM_REPO)/el9/repodata/repomd.xml.key +endif + +push-yum-repo: + find ~/.yum -type f -exec chmod 664 {} \; + find ~/.yum -type d -exec chmod 2775 {} \; + rsync -Ortlvz ~/.yum/ $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/yum-test + +# copy the testing repository onto the production repository +release-yum-repo: + ssh $(USER)@$(WEEWX_COM) "rsync -Ologrvz /var/www/html/yum-test/ /var/www/html/yum" + +# 'suse-repo' is only used when creating a new suse repository from scratch +# the index.html is not part of an official rpm repository. it is included +# to make the repository self-documenting. +SUSE_REPO=~/.suse/weewx +suse-repo: + mkdir -p $(SUSE_REPO)/{suse12,suse15}/RPMS + cp -p pkg/index-suse.html ~/.suse/index.html + cp -p pkg/weewx-suse.repo ~/.suse/weewx.repo + cp -p pkg/weewx-suse12.repo ~/.suse + cp -p pkg/weewx-suse15.repo ~/.suse + +pull-suse-repo: + mkdir -p $(SUSE_REPO) + rsync -Oarvz $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/suse/ ~/.suse + +update-suse-repo: + mkdir -p $(SUSE_REPO)/suse15/RPMS + cp -p $(DSTDIR)/weewx-$(RPMVER).suse15.$(RPMARCH).rpm $(SUSE_REPO)/suse15/RPMS + createrepo $(SUSE_REPO)/suse15 +ifeq ("$(SIGN)","1") + gpg -abs -o $(SUSE_REPO)/suse15/repodata/repomd.xml.asc $(SUSE_REPO)/suse15/repodata/repomd.xml + gpg --export --armor > $(SUSE_REPO)/suse15/repodata/repomd.xml.key +endif + +push-suse-repo: + find ~/.suse -type f -exec chmod 664 {} \; + find ~/.suse -type d -exec chmod 2775 {} \; + rsync -Ortlvz ~/.suse/ $(USER)@$(WEEWX_COM):$(WEEWX_HTMLDIR)/suse-test + +# copy the testing repository onto the production repository +release-suse-repo: + ssh $(USER)@$(WEEWX_COM) "rsync -Ologrvz /var/www/html/suse-test/ /var/www/html/suse" + +# shortcuts to upload everything. assumes that the assets have been staged +# to the local 'dist' directory. +upload-all: upload-docs upload-pkgs + +# shortcut to release everything. assumes that all of the assets have been +# staged to the development area on the distribution server. +release-all: release release-apt-repo release-yum-repo release-suse-repo + +# run perlcritic to ensure clean perl code. put these in ~/.perlcriticrc: +# [-CodeLayout::RequireTidyCode] +# [-Modules::ProhibitExcessMainComplexity] +# [-Modules::RequireVersionVar] +critic: + perlcritic -1 --verbose 8 pkg/mkchangelog.pl + +code-summary: + cloc --force-lang="HTML",tmpl --force-lang="INI",conf --force-lang="INI",inc src docs_src diff --git a/dist/weewx-5.0.2/mkdocs.yml b/dist/weewx-5.0.2/mkdocs.yml new file mode 100644 index 0000000..863dd27 --- /dev/null +++ b/dist/weewx-5.0.2/mkdocs.yml @@ -0,0 +1,228 @@ +# yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json +site_name: 'WeeWX 5.0' +site_url: 'https://www.weewx.com' +site_author: "Tom Keffer " +# do not display GitHub info since it is always obscured anyway +#repo_url: https://github.com/weewx/weewx +#repo_name: WeeWX GitHub Repository +# Shut off the "edit on GitHub" feature: +edit_uri: '' +copyright: Copyright © 2009-2024 Thomas Keffer, Matthew Wall, and Gary Roderick, all rights reserved +theme: + name: 'material' + logo: 'images/logo-weewx.png' + favicon: 'images/favicon.png' + features: + - navigation.instant + - navigation.tracking + - navigation.indexes + - navigation.top + - toc.follow + - search.highlight + - search.share + - search.suggest + - content.tabs.link + + font: + text: 'Noto Sans' + code: 'Inconsolata Mono' + + palette: + - scheme: default + primary: teal + accent: white + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + - scheme: slate + primary: white + accent: teal + toggle: + icon: material/toggle-switch + name: Switch to light mode + +extra_css: + - css/weewx_docs.css + +extra: + generator: false + +docs_dir: 'docs_src' +site_dir: 'build/docs' + +nav: + - "Overview" : index.md + + - "Quick start": + - Debian: quickstarts/debian.md + - RedHat: quickstarts/redhat.md + - SuSE: quickstarts/suse.md + - pip: quickstarts/pip.md + - git: quickstarts/git.md + + - "User's guide": + - "Introduction": usersguide/introduction.md + - "Installing WeeWX": usersguide/installing.md + - "Where to find things" : usersguide/where.md + - "Running WeeWX": usersguide/running.md + - "Monitoring WeeWX": usersguide/monitoring.md + - "Web server integration": usersguide/webserver.md + - "Backup & restore": usersguide/backup.md + - "MySQL/MariaDB": usersguide/mysql-mariadb.md + - "Troubleshooting": + - "What to do": usersguide/troubleshooting/what-to-do.md + - "Hardware problems": usersguide/troubleshooting/hardware.md + - "Software problems": usersguide/troubleshooting/software.md + - "Meteorological problems": usersguide/troubleshooting/meteo.md + + - Customization guide: + - "Introduction": custom/introduction.md + - "Customizing reports": custom/custom-reports.md + - "Scheduling reports": custom/report-scheduling.md + - "The Cheetah generator": custom/cheetah-generator.md + - "The Image generator": custom/image-generator.md + - "Localization": custom/localization.md + - "Customizing the database": custom/database.md + - "Customizing units": custom/units.md + - "Multiple data bindings": custom/multiple-bindings.md + - "Search lists": custom/sle.md + - "Services": custom/service-engine.md + - "Derived types": custom/derived.md + - "Drivers": custom/drivers.md + - "Extensions": custom/extensions.md + + - Utilities guide: + - weewxd: utilities/weewxd.md + - weectl: utilities/weectl-about.md + - weectl database: utilities/weectl-database.md + - weectl debug: utilities/weectl-debug.md + - weectl device: utilities/weectl-device.md + - weectl extension: utilities/weectl-extension.md + - weectl import: + - Introduction: utilities/weectl-import-about.md + - Common options: utilities/weectl-import-common-opt.md + - Configuration options: utilities/weectl-import-config-opt.md + - CSV: utilities/weectl-import-csv.md + - Weather Underground: utilities/weectl-import-wu.md + - Cumulus: utilities/weectl-import-cumulus.md + - Weather Display: utilities/weectl-import-wd.md + - WeatherCat: utilities/weectl-import-weathercat.md + - Troubleshooting: utilities/weectl-import-troubleshoot.md + - weectl report: utilities/weectl-report.md + - weectl station: utilities/weectl-station.md + + - "Hardware guide": + - Drivers: hardware/drivers.md + - AcuRite: hardware/acurite.md + - CC3000: hardware/cc3000.md + - FineOffset: hardware/fousb.md + - TE923: hardware/te923.md + - Ultimeter: hardware/ultimeter.md + - Vantage: hardware/vantage.md + - WMR100: hardware/wmr100.md + - WMR300: hardware/wmr300.md + - WMR9x8: hardware/wmr9x8.md + - WS1: hardware/ws1.md + - WS23xx: hardware/ws23xx.md + - WS28xx: hardware/ws28xx.md + + - "Upgrade guide": upgrade.md + + - Reference: + - "Application options": + - "Introduction": reference/weewx-options/introduction.md + - "General options": reference/weewx-options/general.md + - "[Station]": reference/weewx-options/stations.md + - "[StdRESTful]": reference/weewx-options/stdrestful.md + - "[StdReport]": reference/weewx-options/stdreport.md + - "[StdConvert]": reference/weewx-options/stdconvert.md + - "[StdCalibrate]": reference/weewx-options/stdcalibrate.md + - "[StdQC]": reference/weewx-options/stdqc.md + - "[StdWXCalculate]": reference/weewx-options/stdwxcalculate.md + - "[StdArchive]": reference/weewx-options/stdarchive.md + - "[StdTimeSynch]": reference/weewx-options/stdtimesynch.md + - "[DataBindings]": reference/weewx-options/data-bindings.md + - "[Databases]": reference/weewx-options/databases.md + - "[DatabaseTypes]": reference/weewx-options/database-types.md + - "[Engine]": reference/weewx-options/engine.md + - "Skin options": + - "Introduction": reference/skin-options/introduction.md + - "[Extras]": reference/skin-options/extras.md + - "[Labels]": reference/skin-options/labels.md + - "[Almanac]": reference/skin-options/almanac.md + - "[Units]": reference/skin-options/units.md + - "[Texts]": reference/skin-options/texts.md + - "[CheetahGenerator]": reference/skin-options/cheetahgenerator.md + - "[ImageGenerator]": reference/skin-options/imagegenerator.md + - "[CopyGenerator]": reference/skin-options/copygenerator.md + - "[Generators]": reference/skin-options/generators.md + - "Aggregation types": reference/aggtypes.md + - "Durations": reference/durations.md + - "Units": reference/units.md + - "ValueTuple": reference/valuetuple.md + - "ValueHelper": reference/valuehelper.md + + - "Notes for developers": devnotes.md + - "Change log": changes.md + +plugins: + - search + +markdown_extensions: + + # Code highlighting in ``` ``` blocks + # https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown-extensions/#highlight + - pymdownx.highlight + - pymdownx.inlinehilite + + - pymdownx.superfences + + # https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown-extensions/#details + - pymdownx.details + + # linkifies URL and email links without having to wrap them in Markdown syntax. Also, allows shortens repository issue, pull request, and commit links. + - pymdownx.magiclink + + # Highlight words with ==mark me== + - pymdownx.mark + + # Adds support for strike through ~~strike me~~ and subscript text~a\ subscript~ + - pymdownx.tilde + + # Tabbed provides a syntax to easily add tabbed Markdown content. + # https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/ + - pymdownx.tabbed: + alternate_style: true + + - pymdownx.snippets: + # auto_append abbreviations.md to every file + # https://squidfunk.github.io/mkdocs-material/reference/tooltips/#adding-a-glossary + auto_append: + - docs/abbreviations.md + + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + + # Adds the ability to define abbreviations (https://squidfunk.github.io/mkdocs-material/reference/tooltips/) + - abbr + + # block-styled side content + # https://squidfunk.github.io/mkdocs-material/reference/admonitions/ + - admonition + + - attr_list + + # Adds syntax for defining footnotes in Markdown documents (https://squidfunk.github.io/mkdocs-material/reference/footnotes/) + - footnotes + + - md_in_html + + - tables + + # Table of Contents` + # https://python-markdown.github.io/extensions/toc/ + - toc: + permalink: true diff --git a/dist/weewx-5.0.2/pkg/changelog.el b/dist/weewx-5.0.2/pkg/changelog.el new file mode 100644 index 0000000..3381097 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/changelog.el @@ -0,0 +1,281 @@ +* Sat Feb 10 2024 Matthew Wall - 5.0.2-1 +- new upstream release +* Sun Feb 04 2024 Matthew Wall - 5.0.1-3 +- fix permissions for real this time +* Sun Feb 04 2024 Matthew Wall - 5.0.1-2 +- set permissions on all files and directories in /etc/weewx +* Mon Jan 22 2024 Matthew Wall - 5.0.1-1 +- new upstream release +* Sun Jan 14 2024 Matthew Wall (weewx) - 5.0.0-1 +- new upstream release +* Thu Jan 11 2024 Matthew Wall (weewx) - 5.0.0rc3-3 +- there is no pre-built pyephem for redhat9 (as of 9.3) +* Mon Jan 08 2024 Matthew Wall (weewx) - 5.0.0rc3-2 +- change ownership of database and report directories unconditionally +* Sun Jan 07 2024 Matthew Wall (weewx) - 5.0.0rc3-1 +- new upstream release +* Fri Dec 29 2023 Matthew Wall (weewx) - 5.0.0rc2-1 +- new upstream release +* Thu Dec 28 2023 Matthew Wall (weewx) - 5.0.0rc1-3 +- do skins in post +- do systemd units in post +- do udev rules in post +* Tue Dec 26 2023 Matthew Wall (weewx) - 5.0.0rc1-2 +- include ephem as a dependency +* Thu Dec 21 2023 Matthew Wall (weewx) - 5.0.0rc1-1 +- new upstream release +* Mon Dec 18 2023 Matthew Wall (weewx) - 5.0.0b18-1 +- new upstream release +* Tue Dec 13 2023 Matthew Wall (weewx) - 5.0.0b17-4 +- remove bytecompiled code before removing package +- do not enable template unit +* Tue Dec 13 2023 Matthew Wall (weewx) - 5.0.0b17-3 +- fix typo in config file macro name +* Tue Dec 13 2023 Matthew Wall (weewx) - 5.0.0b17-2 +- do not fail if there is no systemd +- set the x bit on weewxd and weectl +* Tue Dec 12 2023 Matthew Wall (weewx) - 5.0.0b17-1 +- new upstream release +* Tue Nov 28 2023 Matthew Wall (weewx) - 5.0.0b16-1 +- new upstream release +* Sat Oct 29 2023 Matthew Wall (weewx) - 5.0.0b15-1 +- new upstream release +* Sat May 06 2023 Matthew Wall (weewx) - 5.0.0b1-1 +- new upstream release +* Sat May 06 2023 Matthew Wall (weewx) - 5.0.0a30-1 +- new upstream release +* Fri May 05 2023 Matthew Wall (weewx) - 5.0.0a29-1 +- new upstream release +* Wed Feb 22 2023 Thomas Keffer (Author of weewx) - 4.10.2-1 +- new upstream release +* Mon Jan 30 2023 Matthew Wall (weewx) - 4.10.1-1 +- new upstream release +* Sun Jan 29 2023 Matthew Wall (weewx) - 4.10.0-1 +- new upstream release +* Tue Oct 25 2022 Thomas Keffer (Author of weewx) - 4.9.1-1 +- new upstream release +* Mon Oct 24 2022 Thomas Keffer (Author of weewx) - 4.9.0-1 +- new upstream release +* Fri Sep 30 2022 Thomas Keffer (Author of weewx) - 4.9.0b1-1 +- new upstream release +* Sat Apr 23 2022 Matthew Wall (weewx) - 4.8.0-2 +- new upstream release +* Thu Apr 21 2022 Thomas Keffer (Author of weewx) - 4.8.0-1 +- new upstream release +* Tue Mar 01 2022 Thomas Keffer (Author of weewx) - 4.7.0-1 +- new upstream release +* Thu Feb 10 2022 Matthew Wall (weewx) - 4.6.2-1 +- new upstream release +* Thu Feb 10 2022 Matthew Wall (weewx) - 4.6.1-1 +- new upstream release +* Fri Feb 04 2022 Matthew Wall (weewx) - 4.6.0-1 +- new upstream release +* Sat Nov 06 2021 Thomas Keffer (Author of weewx) - 4.6.0b7-1 +- new upstream release +* Tue Nov 02 2021 Thomas Keffer (Author of weewx) - 4.6.0b6-1 +- new upstream release +* Tue Oct 05 2021 Thomas Keffer (Author of weewx) - 4.6.0b3-1 +- new upstream release +* Tue Sep 28 2021 Thomas Keffer (Author of weewx) - 4.6.0b2-1 +- new upstream release +* Fri Aug 13 2021 Thomas Keffer (Author of weewx) - 4.6.0b1-1 +- new upstream release +* Sun May 30 2021 Thomas Keffer (Author of weewx) - 4.6.0a4-1 +- new upstream release +* Mon May 24 2021 Thomas Keffer (Author of weewx) - 4.6.0a3-1 +- new upstream release +* Fri Apr 02 2021 Thomas Keffer (Author of weewx) - 4.5.1-1 +- new upstream release +* Fri Apr 02 2021 Thomas Keffer (Author of weewx) - 4.5.0-1 +- new upstream release +* Wed Mar 24 2021 Thomas Keffer (Author of weewx) - 4.5.0b2-1 +- new upstream release +* Sun Mar 21 2021 Thomas Keffer (Author of weewx) - 4.5.0a3-1 +- new upstream release +* Sat Mar 20 2021 Thomas Keffer (Author of weewx) - 4.5.0a2-1 +- new upstream release +* Mon Mar 15 2021 Thomas Keffer (Author of weewx) - 4.5.0a1-1 +- new upstream release +* Sat Jan 30 2021 Thomas Keffer (Author of weewx) - 4.4.0-1 +- new upstream release +* Mon Jan 04 2021 Thomas Keffer (Author of weewx) - 4.3.0-1 +- new upstream release +* Sat Dec 26 2020 Thomas Keffer (Author of weewx) - 4.3.0b3-1 +- new upstream release +* Fri Dec 18 2020 Thomas Keffer (Author of weewx) - 4.3.0b2-1 +- new upstream release +* Mon Dec 14 2020 Thomas Keffer (Author of weewx) - 4.3.0b1-1 +- new upstream release +* Mon Oct 26 2020 Matthew Wall - 4.2.0-1 +- new upstream release +* Mon Oct 26 2020 Thomas Keffer (Author of weewx) - 4.2.0b2-1 +- new upstream release +* Fri Oct 23 2020 Thomas Keffer (Author of weewx) - 4.2.0b1-1 +- new upstream release +* Mon Oct 19 2020 Thomas Keffer (Author of weewx) - 4.2.0a1-1 +- new upstream release +* Sat May 30 2020 Matthew Wall (weewx) - 4.1.1-1 +- new upstream release +- remove the implicitly-applied dependencies in the redhat rpms +* Mon May 25 2020 Matthew Wall (weewx) - 4.1.0-1 +- new upstream release +* Thu Apr 30 2020 Matthew Wall (weewx) - 4.0.0-1 +- new upstream release +* Thu Apr 09 2020 Matthew Wall (weewx) - 4.0.0b18-2 +- fix python/python3 invocations +* Thu Apr 09 2020 Matthew Wall (weewx) - 4.0.0b18-1 +- new upstream release +* Tue Mar 31 2020 Matthew Wall - 4.0.0b17-1 +- new upstream release +* Wed Feb 26 2020 Matthew Wall - 4.0.0b13-1 +- new upstream release +* Sun Feb 02 2020 Matthew Wall - 4.0.0b11-1 +- new upstream release +* Sat Jan 04 2020 Matthew Wall - 4.0.0b6-1 +- new upstream release +* Sun Jul 14 2019 [ultimate] Matthew Wall (weewx) - 3.9.2-1 +- new upstream release +* Wed Feb 06 2019 Matthew Wall (weewx) - 3.9.1-2 +- fix html_root location for suse +* Wed Feb 06 2019 Matthew Wall (weewx) - 3.9.1-1 +- new upstream release +* Tue Feb 05 2019 Matthew Wall (weewx) - 3.9.0-1 +- new upstream release +* Mon Jan 28 2019 Matthew Wall (weewx) - 3.9.0b3-1 +- new upstream release +* Sat Jan 26 2019 Matthew Wall (weewx) - 3.9.0b2-1 +- new upstream release +* Tue Jan 22 2019 Matthew Wall (weewx) - 3.9.0b1-1 +- new upstream release +* Thu Aug 16 2018 Matthew Wall (weewx) - 3.8.2-1 +- new upstream release +* Fri Jun 22 2018 Matthew Wall (weewx) - 3.8.1-1 +- new upstream release +* Tue Nov 21 2017 mwall - 3.8.0-1 +- new upstream release +* Tue Nov 21 2017 mwall - 3.8.0a2-1 +- new upstream release +* Wed Mar 22 2017 Matthew Wall (weewx) - 3.7.1-1 +- new upstream release +* Fri Mar 10 2017 Matthew Wall (weewx) - 3.7.0-1 +- new upstream release +* Sat Mar 04 2017 Matthew Wall (weewx) - 3.7.0b3-1 +- new upstream release +* Sat Feb 18 2017 Matthew Wall (weewx) - 3.7.0b2-1 +- new upstream release +* Thu Feb 09 2017 Matthew Wall (weewx) - 3.7.0a3-1 +- new upstream release +* Thu Oct 13 2016 Matthew Wall (weewx) - 3.6.1-1 +- new upstream release +* Fri Oct 07 2016 Matthew Wall (weewx) - 3.6.0-1 +- new upstream release +* Tue Oct 04 2016 Thomas Keffer (Author of weewx) - 3.6.0b3-1 +- new upstream release +* Mon Sep 26 2016 Matthew Wall (weewx) - 3.6.0b2-1 +- new upstream release +* Sun Sep 25 2016 Matthew Wall (weewx) - 3.6.0b1-1 +- new upstream release +* Thu Sep 22 2016 Matthew Wall (weewx) - 3.6.0a1-1 +- new upstream release +* Sun Mar 13 2016 Matthew Wall (weewx) - 3.5.0-1 +- new upstream release +* Sat Jan 16 2016 Matthew Wall (weewx) - 3.4.0-1 +- new upstream release +* Sun Dec 06 2015 Matthew Wall (weewx) - 3.3.1-1 +- new upstream release +* Sat Dec 05 2015 Matthew Wall (weewx) - 3.3.0-1 +- new upstream release +* Sat Oct 31 2015 mwall - 3.3.0b1-1 +- new upstream release +* Sat Jul 18 2015 Matthew Wall (weewx) - 3.2.1-1 +- new upstream release +* Wed Jul 15 2015 Matthew Wall (weewx) - 3.2.0-1 +- new upstream release +* Wed Jul 15 2015 Thomas Keffer (Author of weewx) - 3.2.0-1 +- new upstream release +* Wed Jul 15 2015 Matthew Wall (weewx) - 3.2.0b3-1 +- new upstream release +* Tue Jul 07 2015 Matthew Wall (weewx) - 3.2.0b1-1 +- new upstream release +- fixes to rpm pre/post scripts in weewx.spec +* Sun Jul 05 2015 Matthew Wall (weewx) - 3.2.0a4-1 +- new upstream release +* Sat Apr 25 2015 Matthew Wall (weewx) - 3.2.0a1-1 +- new upstream release +- use unified wee_X utilities +* Thu Feb 05 2015 Matthew Wall (weewx) - 3.1.0-1 +- new upstream release +* Sat Dec 06 2014 Matthew Wall (weewx) - 3.0.1-1 +- new upstream release +* Fri Dec 05 2014 Thomas Keffer (Author of weewx) - 3.0.0-1 +- new upstream release +* Mon Dec 01 2014 Matthew Wall (weewx) - 3.0.0b2-1 +- new upstream release +* Sat Nov 29 2014 Matthew Wall (weewx) - 3.0.0a5-1 +- new upstream release +* Sat Nov 29 2014 Matthew Wall (weewx) - 3.0.0a4-1 +- new upstream release +* Fri Nov 28 2014 Matthew Wall (weewx) - 3.0.0a3-1 +- new upstream release +* Thu Nov 27 2014 Matthew Wall (weewx) - 3.0.0a2-1 +- new upstream release +* Sat Oct 11 2014 Matthew Wall (weewx) - 2.7.0-1 +- new upstream release +* Mon Jun 16 2014 Matthew Wall (weewx) - 2.6.4-1 +- new upstream release +- added cc3000, ultimeter, ws1 drivers +* Thu Apr 10 2014 Matthew Wall (weewx) - 2.6.3-1 +- new upstream release +* Sun Feb 16 2014 Matthew Wall (weewx) - 2.6.2-1 +- new upstream release +* Sat Feb 08 2014 Matthew Wall (weewx) - 2.6.1-1 +- new upstream release +* Fri Feb 07 2014 Matthew Wall (weewx) - 2.6.0-1 +- new upstream release +* Wed Feb 05 2014 Matthew Wall (weewx) - 2.6.0b2-1 +- new upstream release +* Tue Feb 04 2014 Matthew Wall (weewx) - 2.6.0b1-1 +- new upstream release +* Tue Jan 28 2014 Matthew Wall (weewx) - 2.6.0a6-1 +- new upstream release +* Mon Dec 30 2013 Matthew Wall - 2.5.1-1 +- added ws23xx and te923 drivers +* Tue Oct 29 2013 Matthew Wall - 2.5.0-1 +- new upstream release +* Sat Oct 19 2013 Matthew Wall - 2.5.0b3-1 +- new upstream release +* Fri Oct 18 2013 Matthew Wall - 2.5.0b2-1 +- new upstream release +* Sat Oct 12 2013 Matthew Wall - 2.5.0b1-1 +- new upstream release +* Sun Aug 04 2013 Matthew Wall - 2.4.0-1 +- new upstream release +* Sat Jun 22 2013 Matthew Wall - 2.3.3-1 +- new upstream release +* Sun Jun 16 2013 Matthew Wall - 2.3.2-1 +- new upstream release +* Mon Apr 15 2013 Thomas Keffer - 2.3.1-1 +- new upstream release +* Tue Apr 09 2013 Matthew Wall - 2.3.0-1 +- new upstream release +* Fri Feb 15 2013 Matthew Wall - 2.2.1-1 +- fixed ordinals +* Thu Feb 14 2013 Matthew Wall - 2.2.0-1 +- no packaging changes for the 2.2.0 release +* Wed Feb 13 2013 Matthew Wall - 2.2.0b2-1 +- second beta for 2.2.0 +* Sun Feb 10 2013 Matthew Wall - 2.2.0b1-1 +- first beta for 2.2.0 +* Sat Feb 09 2013 Matthew Wall - 2.2.0a5-1 +- fixed postrm to work with ubuntu systems +* Fri Feb 08 2013 Matthew Wall - 2.2.0a4-1 +- include logrotate and syslog snippets +- use wee_config_* naming +* Sun Feb 03 2013 Matthew Wall - 2.2.0a3-1 +- removed apache dependencies +- put generated html in /var/www/html/weewx +* Mon Jan 28 2013 Matthew Wall - 2.2.0a2-1 +- merged packaging branch to trunk +- put chkconfig in preun rather than postun as per rpmlint suggestion +* Sat Jan 26 2013 Matthew Wall - 2.1.1-1 +- initial redhat package diff --git a/dist/weewx-5.0.2/pkg/changelog.suse b/dist/weewx-5.0.2/pkg/changelog.suse new file mode 100644 index 0000000..d140898 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/changelog.suse @@ -0,0 +1,266 @@ +* Sat Feb 10 2024 Matthew Wall - 5.0.2-1 +- new upstream release +* Sun Feb 04 2024 Matthew Wall - 5.0.1-3 +- fix permissions for real this time +* Sun Feb 04 2024 Matthew Wall - 5.0.1-2 +- set permissions on all files and directories in /etc/weewx +* Sun Feb 04 2024 Matthew Wall - 5.0.1-1 +- new upstream release +* Sun Jan 14 2024 Matthew Wall (weewx) - 5.0.0-1 +- new upstream release +* Mon Jan 08 2024 Matthew Wall (weewx) - 5.0.0rc3-2 +- change ownership of database and report directories unconditionally +* Sun Jan 07 2024 Matthew Wall (weewx) - 5.0.0rc3-1 +- new upstream release +* Fri Dec 29 2023 Matthew Wall (weewx) - 5.0.0rc2-1 +- new upstream release +* Thu Dec 28 2023 Matthew Wall (weewx) - 5.0.0rc1-3 +- do skins in post +- do systemd units in post +- do udev rules in post +* Tue Dec 26 2023 Matthew Wall (weewx) - 5.0.0rc1-2 +- include ephem as a dependency +- systemd units go in /usr/lib not /lib +* Thu Dec 21 2023 Matthew Wall (weewx) - 5.0.0rc1-1 +- new upstream release +* Mon Dec 18 2023 Matthew Wall (weewx) - 5.0.0b18-1 +- new upstream release +* Tue Nov 28 2023 Matthew Wall (weewx) - 5.0.0b16-1 +- new upstream release +* Sat May 06 2023 Matthew Wall (weewx) - 5.0.0b1-1 +- new upstream release +* Sat May 06 2023 Matthew Wall (weewx) - 5.0.0a30-1 +- new upstream release +* Fri May 05 2023 Matthew Wall (weewx) - 5.0.0a29-1 +- new upstream release +* Wed Feb 22 2023 Thomas Keffer (Author of weewx) - 4.10.2-1 +- new upstream release +* Mon Jan 30 2023 Matthew Wall (weewx) - 4.10.1-1 +- new upstream release +* Sun Jan 29 2023 Matthew Wall (weewx) - 4.10.0-1 +- new upstream release +* Tue Oct 25 2022 Thomas Keffer (Author of weewx) - 4.9.1-1 +- new upstream release +* Mon Oct 24 2022 Thomas Keffer (Author of weewx) - 4.9.0-1 +- new upstream release +* Fri Sep 30 2022 Thomas Keffer (Author of weewx) - 4.9.0b1-1 +- new upstream release +* Sat Apr 23 2022 Matthew Wall (weewx) - 4.8.0-2 +- new upstream release +* Thu Apr 21 2022 Thomas Keffer (Author of weewx) - 4.8.0-1 +- new upstream release +* Tue Mar 01 2022 Thomas Keffer (Author of weewx) - 4.7.0-1 +- new upstream release +* Thu Feb 10 2022 Matthew Wall (weewx) - 4.6.2-1 +- new upstream release +* Thu Feb 10 2022 Matthew Wall (weewx) - 4.6.1-1 +- new upstream release +* Fri Feb 04 2022 Matthew Wall (weewx) - 4.6.0-1 +- new upstream release +* Sat Nov 06 2021 Thomas Keffer (Author of weewx) - 4.6.0b7-1 +- new upstream release +* Tue Nov 02 2021 Thomas Keffer (Author of weewx) - 4.6.0b6-1 +- new upstream release +* Tue Oct 05 2021 Thomas Keffer (Author of weewx) - 4.6.0b3-1 +- new upstream release +* Tue Sep 28 2021 Thomas Keffer (Author of weewx) - 4.6.0b2-1 +- new upstream release +* Fri Aug 13 2021 Thomas Keffer (Author of weewx) - 4.6.0b1-1 +- new upstream release +* Sun May 30 2021 Thomas Keffer (Author of weewx) - 4.6.0a4-1 +- new upstream release +* Mon May 24 2021 Thomas Keffer (Author of weewx) - 4.6.0a3-1 +- new upstream release +* Fri Apr 02 2021 Thomas Keffer (Author of weewx) - 4.5.1-1 +- new upstream release +* Fri Apr 02 2021 Thomas Keffer (Author of weewx) - 4.5.0-1 +- new upstream release +* Wed Mar 24 2021 Thomas Keffer (Author of weewx) - 4.5.0b2-1 +- new upstream release +* Sun Mar 21 2021 Thomas Keffer (Author of weewx) - 4.5.0a3-1 +- new upstream release +* Sat Mar 20 2021 Thomas Keffer (Author of weewx) - 4.5.0a2-1 +- new upstream release +* Mon Mar 15 2021 Thomas Keffer (Author of weewx) - 4.5.0a1-1 +- new upstream release +* Sat Jan 30 2021 Thomas Keffer (Author of weewx) - 4.4.0-1 +- new upstream release +* Mon Jan 04 2021 Thomas Keffer (Author of weewx) - 4.3.0-1 +- new upstream release +* Sat Dec 26 2020 Thomas Keffer (Author of weewx) - 4.3.0b3-1 +- new upstream release +* Fri Dec 18 2020 Thomas Keffer (Author of weewx) - 4.3.0b2-1 +- new upstream release +* Mon Dec 14 2020 Thomas Keffer (Author of weewx) - 4.3.0b1-1 +- new upstream release +* Mon Oct 26 2020 Matthew Wall - 4.2.0-1 +- new upstream release +* Mon Oct 26 2020 Thomas Keffer (Author of weewx) - 4.2.0b2-1 +- new upstream release +* Fri Oct 23 2020 Thomas Keffer (Author of weewx) - 4.2.0b1-1 +- new upstream release +* Sat May 30 2020 Matthew Wall (weewx) - 4.1.1-1 +- new upstream release +- remove the implicitly-applied dependencies in the redhat rpms +* Mon May 25 2020 Matthew Wall (weewx) - 4.1.0-1 +- new upstream release +* Thu Apr 30 2020 Matthew Wall (weewx) - 4.0.0-1 +- new upstream release +* Thu Apr 09 2020 Matthew Wall (weewx) - 4.0.0b18-2 +- fix python/python3 invocations +* Thu Apr 09 2020 Matthew Wall (weewx) - 4.0.0b18-1 +- new upstream release +* Tue Mar 31 2020 Matthew Wall - 4.0.0b17-1 +- new upstream release +* Wed Feb 26 2020 Matthew Wall - 4.0.0b13-1 +- new upstream release +* Sun Feb 02 2020 Matthew Wall - 4.0.0b11-1 +- new upstream release +* Sat Jan 04 2020 Matthew Wall - 4.0.0b6-1 +- new upstream release +* Sun Jul 14 2019 [ultimate] Matthew Wall (weewx) - 3.9.2-1 +- new upstream release +* Wed Feb 06 2019 Matthew Wall (weewx) - 3.9.1-2 +- fix html_root location for suse +* Wed Feb 06 2019 Matthew Wall (weewx) - 3.9.1-1 +- new upstream release +* Tue Feb 05 2019 Matthew Wall (weewx) - 3.9.0-1 +- new upstream release +* Mon Jan 28 2019 Matthew Wall (weewx) - 3.9.0b3-1 +- new upstream release +* Sat Jan 26 2019 Matthew Wall (weewx) - 3.9.0b2-1 +- new upstream release +* Tue Jan 22 2019 Matthew Wall (weewx) - 3.9.0b1-1 +- new upstream release +* Thu Aug 16 2018 Matthew Wall (weewx) - 3.8.2-1 +- new upstream release +* Fri Jun 22 2018 Matthew Wall (weewx) - 3.8.1-1 +- new upstream release +* Tue Nov 21 2017 mwall - 3.8.0-1 +- new upstream release +* Tue Nov 21 2017 mwall - 3.8.0a2-1 +- new upstream release +* Wed Mar 22 2017 Matthew Wall (weewx) - 3.7.1-1 +- new upstream release +* Fri Mar 10 2017 Matthew Wall (weewx) - 3.7.0-1 +- new upstream release +* Sat Mar 04 2017 Matthew Wall (weewx) - 3.7.0b3-1 +- new upstream release +* Sat Feb 18 2017 Matthew Wall (weewx) - 3.7.0b2-1 +- new upstream release +* Thu Feb 09 2017 Matthew Wall (weewx) - 3.7.0a3-1 +- new upstream release +* Thu Oct 13 2016 Matthew Wall (weewx) - 3.6.1-1 +- new upstream release +* Fri Oct 07 2016 Matthew Wall (weewx) - 3.6.0-1 +- new upstream release +* Tue Oct 04 2016 Thomas Keffer (Author of weewx) - 3.6.0b3-1 +- new upstream release +* Mon Sep 26 2016 Matthew Wall (weewx) - 3.6.0b2-1 +- new upstream release +* Sun Sep 25 2016 Matthew Wall (weewx) - 3.6.0b1-1 +- new upstream release +* Thu Sep 22 2016 Matthew Wall (weewx) - 3.6.0a1-1 +- new upstream release +* Sun Mar 13 2016 Matthew Wall (weewx) - 3.5.0-1 +- new upstream release +* Sat Jan 16 2016 Matthew Wall (weewx) - 3.4.0-1 +- new upstream release +* Sun Dec 06 2015 Matthew Wall (weewx) - 3.3.1-1 +- new upstream release +* Sat Dec 05 2015 Matthew Wall (weewx) - 3.3.0-1 +- new upstream release +* Sat Oct 31 2015 mwall - 3.3.0b1-1 +- new upstream release +* Sat Jul 18 2015 Matthew Wall (weewx) - 3.2.1-1 +- new upstream release +* Wed Jul 15 2015 Matthew Wall (weewx) - 3.2.0-1 +- new upstream release +* Wed Jul 15 2015 Thomas Keffer (Author of weewx) - 3.2.0-1 +- new upstream release +* Wed Jul 15 2015 Matthew Wall (weewx) - 3.2.0b3-1 +- new upstream release +* Tue Jul 07 2015 Matthew Wall (weewx) - 3.2.0b1-1 +- new upstream release +- fixes to rpm pre/post scripts in weewx.spec +* Sun Jul 05 2015 Matthew Wall (weewx) - 3.2.0a4-1 +- new upstream release +* Sat Apr 25 2015 Matthew Wall (weewx) - 3.2.0a1-1 +- new upstream release +- use unified wee_X utilities +* Thu Feb 05 2015 Matthew Wall (weewx) - 3.1.0-1 +- new upstream release +* Sat Dec 06 2014 Matthew Wall (weewx) - 3.0.1-1 +- new upstream release +* Fri Dec 05 2014 Thomas Keffer (Author of weewx) - 3.0.0-1 +- new upstream release +* Mon Dec 01 2014 Matthew Wall (weewx) - 3.0.0b2-1 +- new upstream release +* Sat Nov 29 2014 Matthew Wall (weewx) - 3.0.0a5-1 +- new upstream release +* Sat Nov 29 2014 Matthew Wall (weewx) - 3.0.0a4-1 +- new upstream release +* Fri Nov 28 2014 Matthew Wall (weewx) - 3.0.0a3-1 +- new upstream release +* Thu Nov 27 2014 Matthew Wall (weewx) - 3.0.0a2-1 +- new upstream release +* Sat Oct 11 2014 Matthew Wall (weewx) - 2.7.0-1 +- new upstream release +* Mon Jun 16 2014 Matthew Wall (weewx) - 2.6.4-1 +- new upstream release +- added cc3000, ultimeter, ws1 drivers +* Thu Apr 10 2014 Matthew Wall (weewx) - 2.6.3-1 +- new upstream release +* Sun Feb 16 2014 Matthew Wall (weewx) - 2.6.2-1 +- new upstream release +* Sat Feb 08 2014 Matthew Wall (weewx) - 2.6.1-1 +- new upstream release +* Fri Feb 07 2014 Matthew Wall (weewx) - 2.6.0-1 +- new upstream release +* Wed Feb 05 2014 Matthew Wall (weewx) - 2.6.0b2-1 +- new upstream release +* Tue Feb 04 2014 Matthew Wall (weewx) - 2.6.0b1-1 +- new upstream release +* Tue Jan 28 2014 Matthew Wall (weewx) - 2.6.0a6-1 +- new upstream release +* Mon Dec 30 2013 Matthew Wall - 2.5.1-1 +- added ws23xx and te923 drivers +* Tue Oct 29 2013 Matthew Wall - 2.5.0-1 +- new upstream release +* Sat Oct 19 2013 Matthew Wall - 2.5.0b3-1 +- new upstream release +* Fri Oct 18 2013 Matthew Wall - 2.5.0b2-1 +- new upstream release +* Sat Oct 12 2013 Matthew Wall - 2.5.0b1-1 +- new upstream release +* Sun Aug 04 2013 Matthew Wall - 2.4.0-1 +- new upstream release +* Sat Jun 22 2013 Matthew Wall - 2.3.3-1 +- new upstream release +* Sun Jun 16 2013 Matthew Wall - 2.3.2-1 +- new upstream release +* Mon Apr 15 2013 Thomas Keffer - 2.3.1-1 +- new upstream release +* Tue Apr 09 2013 Matthew Wall - 2.3.0-1 +- new upstream release +* Fri Feb 15 2013 Matthew Wall - 2.2.1-1 +- fixed ordinals +* Thu Feb 14 2013 Matthew Wall - 2.2.0-1 +- no packaging changes for the 2.2.0 release +* Wed Feb 13 2013 Matthew Wall - 2.2.0b2-1 +- second beta for 2.2.0 +* Sun Feb 10 2013 Matthew Wall - 2.2.0b1-1 +- first beta for 2.2.0 +* Sat Feb 09 2013 Matthew Wall - 2.2.0a5-1 +- fixed postrm to work with ubuntu systems +* Fri Feb 08 2013 Matthew Wall - 2.2.0a4-1 +- include logrotate and syslog snippets +- use wee_config_* naming +* Sun Feb 03 2013 Matthew Wall - 2.2.0a3-1 +- removed apache dependencies +- put generated html in /var/www/html/weewx +* Mon Jan 28 2013 Matthew Wall - 2.2.0a2-1 +- merged packaging branch to trunk +- put chkconfig in preun rather than postun as per rpmlint suggestion +* Sat Jan 26 2013 Matthew Wall - 2.1.1-1 +- initial redhat package diff --git a/dist/weewx-5.0.2/pkg/debian/README b/dist/weewx-5.0.2/pkg/debian/README new file mode 100644 index 0000000..00d54b7 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/README @@ -0,0 +1,75 @@ +These are notes regarding packaging of weewx for debian systems. +mwall 09apr2020 + +For the transition from python2 to python3, we introduce a new package +python3-weewx. This contains the python3-compatible weewx4 implementation. +Those who need python2 can continue with the weewx packages for as long as +weewx supports python2. + +There is one control file for python2 and a different control file for python3. +The primary reason for this is that the dependencies for python2 have different +names from those for python3 + +It is not possible to have a single weewx installation that will work for both +python2 and python3. Python ensures that the compiled code will not conflict +(.pyc files for python2 versus __pycache__ directories for python3), however +the weewx entry points have a shebang specifies the python version. + +The weewx packaging uses relatively low-level debian packaging tools. It uses +some of the debhelper python rules. It does not use the python tools for +creating deb packages. + +One reason for this approach is that WeeWX is not just another python package +that is plopped into the python library tree. WeeWX prefers instead to live +on its own as a standalone application, with everything in a single directory +tree (or in system-specific trees such as /var/lib, /etc). + +The packaging process does the following: + +1) use setup.py sdist to generate the 'official' source tree (setup.py) +2) expand that source tree into the build space (makefile) +3) copy the parts of the build tree into the debian staging tree (debian/rules) +4) invoke debian packaging tool to create the package (dpkg-buildpackage) + +WeeWX was traditionally packaged in a single deb file with this naming: + +weewx_x.y.z-n_all.deb + +As of 2020, the packaging files now generate two different deb files: + +weewx_x.y.z-n_all.deb +python3-weewx_x.y.z-n_all.deb + +These packages are identical other than the shebang in the weewx entry points +and the dependencies. + + +References: + +debian python policy: +https://www.debian.org/doc/packaging-manuals/python-policy/ + +debian library style guide: +https://wiki.debian.org/Python/LibraryStyleGuide + +debian pybuild: +https://wiki.debian.org/Python/Pybuild + +basic debian lore: +https://www.debian.org/doc/debian-policy/ch-source.html + +what does dh_auto do for python? +dh_auto_configure - +dh_auto_build - ./setup.py build +dh_auto_test - +dh_auto_install - ./setup.py install --blah +dh_auto_clean - ./setup.py clean + +override_dh_auto_install: + ./setup.py install --no-prompt --prefix=build + +targets: clean, binary, binary-arch, binary-indep, build, build-arch, build-indep + +useful examples: +https://www.wefearchange.org/2012/01/ebian-package-for-python-2-and-3.html +https://www.v13.gr/blog/?p=412 diff --git a/dist/weewx-5.0.2/pkg/debian/changelog b/dist/weewx-5.0.2/pkg/debian/changelog new file mode 100644 index 0000000..112a93c --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/changelog @@ -0,0 +1,503 @@ +weewx (5.0.2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Sat, 10 Feb 2024 02:03:25 -0500 +weewx (5.0.1-4) unstable; urgency=low + * added network dependency to systemd unit + * attempt to fix the repository signing + -- Matthew Wall Wed, 07 Feb 2024 21:33:52 -0500 +weewx (5.0.1-3) unstable; urgency=low + * fix permissions for real this time + -- Matthew Wall Sun, 04 Feb 2024 21:48:54 -0500 +weewx (5.0.1-2) unstable; urgency=low + * set permissions on all files and directories in /etc/weewx + -- Matthew Wall Sun, 04 Feb 2024 21:29:29 -0500 +weewx (5.0.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Mon, 22 Jan 2024 20:21:32 -0500 +weewx (5.0.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 14 Jan 2024 11:38:34 -0500 +weewx (5.0.0rc3-5) unstable; urgency=low + * provide feedback about existing systemd/sysv configurations + * prefer /usr/lib but fall back to /lib + -- Matthew Wall (weewx) Sat, 13 Jan 2024 11:02:21 -0500 +weewx (5.0.0rc3-4) unstable; urgency=low + * fix for pre-compile fails + -- Matthew Wall (weewx) Wed, 10 Jan 2024 23:57:51 -0500 +weewx (5.0.0rc3-3) unstable; urgency=low + * added output to postinst to aid failure diagnostics + -- Matthew Wall (weewx) Wed, 10 Jan 2024 20:24:04 -0500 +weewx (5.0.0rc3-2) unstable; urgency=low + * change ownership of database and report directories unconditionally + -- Matthew Wall (weewx) Mon, 08 Jan 2024 14:41:13 -0500 +weewx (5.0.0rc3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 07 Jan 2024 13:32:38 -0500 +weewx (5.0.0rc2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 29 Dec 2023 08:40:25 -0500 +weewx (5.0.0rc1-4) unstable; urgency=low + * create user dir if it does not exist + -- Matthew Wall (weewx) Thu, 28 Dec 2023 22:54:31 -0500 +weewx (5.0.0rc1-3) unstable; urgency=low + * do skins in post + * do systemd units in post + * do udev rules in post + -- Matthew Wall (weewx) Thu, 28 Dec 2023 21:08:30 -0500 +weewx (5.0.0rc1-2) unstable; urgency=low + * include ephem as a dependency + -- Matthew Wall (weewx) Tue, 26 Dec 2023 17:05:12 -0500 +weewx (5.0.0rc1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 21 Dec 2023 17:43:27 -0500 +weewx (5.0.0b18-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 18 Dec 2023 22:12:38 -0500 +weewx (5.0.0b17-4) unstable; urgency=low + * fix permissions on /var/log/weewx, especially for ubuntu + -- Matthew Wall (weewx) Tue, 15 Dec 2023 21:00:00 -0500 +weewx (5.0.0b17-3) unstable; urgency=low + * do not enable unit template + -- Matthew Wall (weewx) Tue, 13 Dec 2023 21:00:00 -0500 +weewx (5.0.0b17-2) unstable; urgency=low + * do not fail if there is no systemd + -- Matthew Wall (weewx) Tue, 13 Dec 2023 20:34:00 -0500 +weewx (5.0.0b17-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 12 Dec 2023 19:15:36 -0500 +weewx (5.0.0b16-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 28 Nov 2023 14:44:05 -0500 +weewx (5.0.0b15-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 29 Oct 2023 22:41:56 -0400 +weewx (5.0.0b14-1) unstable; urgency=low + * Refactor, using 'src' instead of 'bin'. + * Put weewx_data in version control. Put built docs in it. + -- Tom Keffer (Author of WeeWX) Tue, 17 Oct 2023 04:30:09 -0700 +weewx (5.0.0b10-1) unstable; urgency=low + * weectl device replaces wee_device + -- Thomas Keffer (Author of weewx) Thu, 03 Aug 2023 18:46:49 -0700 +weewx (5.0.0b9-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 14 Jul 2023 06:25:50 -0700 +weewx (5.0.0b3-1) unstable; urgency=low + * Update version numbers + -- Thomas Keffer (Author of weewx) Sat, 27 May 2023 08:03:33 -0700 +weewx (5.0.0b2-1) unstable; urgency=low + * Remove utility wunderfixer. + * Most almanac attributes now return a ValueHelper. + -- Thomas Keffer (Author of weewx) Sat, 27 May 2023 05:07:43 -0700 +weewx (5.0.0b1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 06 May 2023 07:35:56 -0400 +weewx (5.0.0a30-1) unstable; urgency=low + * Back up skins individually, instead of the whole directory. + -- Thomas Keffer (Author of weewx) Fri, 05 May 2023 11:28:04 -0700 +weewx (5.0.0a29-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 05 May 2023 13:35:31 -0400 +weewx (5.0.0a28-1) unstable; urgency=low + * Use flag --config-only to upgrade config file + -- Thomas Keffer (Author of weewx) Fri, 07 Apr 2023 05:36:56 -0700 +weewx (5.0.0a27-1) unstable; urgency=low + * Fix bug in postinst + -- Thomas Keffer (Author of weewx) Wed, 29 Mar 2023 14:47:50 -0700 +weewx (5.0.0a26-1) unstable; urgency=low + * Use /etc/default/weewx with systemd + -- Thomas Keffer (Author of weewx) Tue, 28 Mar 2023 16:26:30 -0700 +weewx (5.0.0a25-1) unstable; urgency=low + * Switch to systemd install + -- Thomas Keffer (Author of weewx) Sun, 26 Mar 2023 05:18:19 -0700 +weewx (5.0.0a24-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 24 Mar 2023 06:25:32 -0700 +weewx (5.0.0a21-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 03 Mar 2023 23:21:55 -0500 +weewx (5.0.0a20-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 25 Feb 2023 19:01:01 -0500 +weewx (4.10.2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Wed, 22 Feb 2023 17:05:04 -0800 +weewx (4.10.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 30 Jan 2023 10:00:21 -0500 +weewx (4.10.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 29 Jan 2023 20:56:42 -0500 +weewx (4.9.1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Tue, 25 Oct 2022 04:29:14 -0700 +weewx (4.9.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 24 Oct 2022 11:11:25 -0700 +weewx (4.9.0b1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 30 Sep 2022 06:03:30 -0700 +weewx (4.8.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Thu, 21 Apr 2022 17:32:55 -0700 +weewx (4.7.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Tue, 01 Mar 2022 15:19:53 -0800 +weewx (4.6.2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 10 Feb 2022 22:15:26 -0500 +weewx (4.6.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 10 Feb 2022 19:28:13 -0500 +weewx (4.6.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 04 Feb 2022 08:32:35 -0500 +weewx (4.6.0b7-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Sat, 06 Nov 2021 15:13:04 -0700 +weewx (4.6.0b6-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Tue, 02 Nov 2021 15:34:16 -0700 +weewx (4.6.0b3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Tue, 05 Oct 2021 15:03:48 -0700 +weewx (4.6.0b2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Tue, 28 Sep 2021 15:56:05 -0700 +weewx (4.6.0b1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 13 Aug 2021 09:48:47 -0700 +weewx (4.6.0a4-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Sun, 30 May 2021 16:47:43 -0700 +weewx (4.6.0a3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 24 May 2021 10:01:01 -0700 +weewx (4.5.1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 02 Apr 2021 13:15:41 -0700 +weewx (4.5.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 02 Apr 2021 07:48:33 -0700 +weewx (4.5.0b2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Wed, 24 Mar 2021 13:46:45 -0700 +weewx (4.5.0a3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Sun, 21 Mar 2021 12:02:57 -0700 +weewx (4.5.0a2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Sat, 20 Mar 2021 07:34:39 -0700 +weewx (4.5.0a1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 15 Mar 2021 14:50:42 -0700 +weewx (4.4.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Sat, 30 Jan 2021 10:52:17 -0800 +weewx (4.3.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 04 Jan 2021 11:43:12 -0800 +weewx (4.3.0b3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Sat, 26 Dec 2020 14:02:49 -0800 +weewx (4.3.0b2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 18 Dec 2020 08:24:50 -0800 +weewx (4.3.0b1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 14 Dec 2020 06:14:23 -0800 +weewx (4.2.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 26 Oct 2020 21:12:08 -0400 +weewx (4.2.0b2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 26 Oct 2020 08:20:30 -0700 +weewx (4.2.0b1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 23 Oct 2020 14:25:32 -0700 +weewx (4.2.0a1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 19 Oct 2020 15:46:40 -0700 +weewx (4.1.1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Mon, 01 Jun 2020 15:16:57 -0700 +weewx (4.1.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 25 May 2020 15:10:24 -0400 +weewx (4.0.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 30 Apr 2020 11:21:09 -0400 +weewx (4.0.0b18-4) unstable; urgency=low + * delete the python3 __pycache__ files as well as python2 .pyc files + * build as weewx_x.deb but save as python-weewx_x.deb and python3-weewx_x.deb + -- Matthew Wall (weewx) Thu, 09 Apr 2020 20:44:56 -0400 +weewx (4.0.0b18-3) unstable; urgency=low + * use weewx instead of python-weewx and python3-weewx + -- Matthew Wall (weewx) Thu, 09 Apr 2020 20:44:56 -0400 +weewx (4.0.0b18-2) unstable; urgency=low + * separate squeeze and buster repositories with aptly + -- Matthew Wall (weewx) Thu, 09 Apr 2020 20:44:56 -0400 +weewx (4.0.0b18-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 09 Apr 2020 20:44:56 -0400 +weewx (4.0.0b17-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 31 Mar 2020 10:27:10 -0400 +weewx (4.0.0b13-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 25 Feb 2020 10:12:11 -0500 +weewx (4.0.0b12-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 22 Feb 2020 16:05:04 -0500 +weewx (4.0.0b11-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 02 Feb 2020 09:48:39 -0500 +weewx (4.0.0b6-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 04 Jan 2020 09:42:26 -0500 +weewx (3.9.2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 14 Jul 2019 08:00:08 -0400 +weewx (3.9.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Wed, 06 Feb 2019 08:21:47 -0500 +weewx (3.9.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 05 Feb 2019 13:32:45 -0500 +weewx (3.9.0b3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 28 Jan 2019 18:40:32 -0500 +weewx (3.9.0b2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 26 Jan 2019 10:02:14 -0500 +weewx (3.9.0b1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 22 Jan 2019 12:40:02 -0500 +weewx (3.9.0a5-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 11 Dec 2018 09:04:04 -0500 +weewx (3.8.2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 16 Aug 2018 11:40:52 -0400 +weewx (3.8.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 22 Jun 2018 17:48:03 -0400 +weewx (3.8.0-1) unstable; urgency=low + * new upstream release + -- mwall Tue, 21 Nov 2017 19:49:56 -0500 +weewx (3.8.0a2-1) unstable; urgency=low + * new upstream release + -- mwall Tue, 21 Nov 2017 18:45:33 -0500 +weewx (3.7.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Wed, 22 Mar 2017 20:21:09 -0400 +weewx (3.7.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 10 Mar 2017 15:48:50 -0500 +weewx (3.7.0b3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 04 Mar 2017 23:06:41 -0500 +weewx (3.7.0b2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 18 Feb 2017 10:39:16 -0500 +weewx (3.7.0a3-1) unstable; urgency=low + * new upstream release + -- mwall Thu, 09 Feb 2017 22:54:21 -0500 +weewx (3.6.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 13 Oct 2016 18:19:31 -0400 +weewx (3.6.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 07 Oct 2016 18:22:14 -0400 +weewx (3.6.0b3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Tue, 04 Oct 2016 09:08:30 -0700 +weewx (3.6.0b2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 26 Sep 2016 08:27:19 -0400 +weewx (3.6.0b1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 25 Sep 2016 09:24:38 -0400 +weewx (3.6.0a1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 22 Sep 2016 20:29:30 -0400 +weewx (3.5.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 13 Mar 2016 21:27:26 -0400 +weewx (3.4.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 16 Jan 2016 18:02:00 -0500 +weewx (3.3.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 06 Dec 2015 18:25:17 -0500 +weewx (3.3.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 05 Dec 2015 16:21:48 -0500 +weewx (3.3.0b1-1) unstable; urgency=low + * new upstream release + -- mwall Mon, 26 Oct 2015 19:15:51 -0400 +weewx (3.2.2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Thu, 30 Jul 2015 19:06:09 -0700 +weewx (3.2.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 18 Jul 2015 21:08:12 -0400 +weewx (3.2.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Wed, 15 Jul 2015 16:53:29 -0400 +weewx (3.2.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Wed, 15 Jul 2015 13:43:37 -0700 +weewx (3.2.0b3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Wed, 15 Jul 2015 14:29:55 -0400 +weewx (3.2.0b1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 07 Jul 2015 11:49:42 -0400 +weewx (3.2.0a4-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 05 Jul 2015 21:51:41 -0400 +weewx (3.2.0a1-1) unstable; urgency=low + * new upstream release + * use unified wee_X utilities + -- Matthew Wall (weewx) Sat, 25 Apr 2015 06:40:05 -0400 +weewx (3.1.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 05 Feb 2015 16:36:21 -0500 +weewx (3.0.1-1) unstable; urgency=low + * new upstream release + * fixed postinst so that it only processes weewx.conf one time instead of two + -- Matthew Wall (weewx) Sat, 06 Dec 2014 12:30:42 -0500 +weewx (3.0.0-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 05 Dec 2014 10:36:34 -0800 +weewx (3.0.0b3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 05 Dec 2014 04:49:15 -0800 +weewx (3.0.0b2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Mon, 01 Dec 2014 21:37:29 -0500 +weewx (3.0.0a5-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 29 Nov 2014 11:32:52 -0500 +weewx (3.0.0a4-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 29 Nov 2014 07:30:45 -0500 +weewx (3.0.0a3-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 28 Nov 2014 10:24:52 -0800 +weewx (3.0.0a3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 28 Nov 2014 13:09:45 -0500 +weewx (3.0.0a2-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer (Author of weewx) Fri, 28 Nov 2014 08:42:34 -0800 +weewx (3.0.0a2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 27 Nov 2014 15:04:29 -0500 +weewx (3.0.0a1-1) unstable; urgency=low + * adapt to restructured setup.py options + * alphabetize list of station types for easier browsing/finding + -- Matthew Wall (weewx) Fri, 31 Oct 2014 22:38:18 -0400 +weewx (2.7.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 11 Oct 2014 21:13:16 -0400 +weewx (2.6.4-1) unstable; urgency=low + * new upstream release + * added cc3000, ultimeter, ws1 drivers + * added option to specify display units during installation + -- Matthew Wall (weewx) Mon, 16 Jun 2014 07:22:26 -0400 +weewx (2.6.3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Thu, 10 Apr 2014 20:25:10 -0400 +weewx (2.6.2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sun, 16 Feb 2014 06:18:40 -0500 +weewx (2.6.1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Sat, 08 Feb 2014 13:02:03 -0500 +weewx (2.6.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Fri, 07 Feb 2014 20:19:14 -0500 +weewx (2.6.0b2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Wed, 05 Feb 2014 18:23:12 -0500 +weewx (2.6.0b1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 04 Feb 2014 17:45:23 -0500 +weewx (2.6.0a6-1) unstable; urgency=low + * new upstream release + -- Matthew Wall (weewx) Tue, 28 Jan 2014 12:49:17 -0500 +weewx (2.5.1-1) unstable; urgency=low + * added ws23xx and te923 drivers + -- Matthew Wall Mon, 30 Dec 2013 18:43:18 -0500 +weewx (2.5.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Tue, 29 Oct 2013 11:06:50 -0400 +weewx (2.5.0b3-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Sat, 19 Oct 2013 12:19:43 -0400 +weewx (2.5.0b2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Fri, 18 Oct 2013 21:35:21 -0400 +weewx (2.5.0b1-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Sat, 12 Oct 2013 15:33:16 -0400 +weewx (2.5.0a3-1) unstable; urgency=low + * new upstream release + * added config support for ws28xx driver + * make weewx.conf replacements more robust + -- Matthew Wall Wed, 09 Oct 2013 11:21:13 -0400 +weewx (2.4.0-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Sun, 04 Aug 2013 02:00:51 -0400 +weewx (2.3.3-1) unstable; urgency=low + * new upstream release + * respect files in user directory + -- Matthew Wall Sat, 22 Jun 2013 08:06:43 -0400 +weewx (2.3.2-1) unstable; urgency=low + * new upstream release + -- Matthew Wall Sun, 16 Jun 2013 22:17:57 -0400 +weewx (2.3.1-3) unstable; urgency=low + * include weewx.conf.dist in conffiles + -- Matthew Wall Tue, 16 Apr 2013 10:42:35 -0400 +weewx (2.3.1-2) unstable; urgency=low + * in postinst, invoke db_stop at end of script, not end of function + -- Matthew Wall Mon, 15 Apr 2013 23:50:06 -0400 +weewx (2.3.1-1) unstable; urgency=low + * new upstream release + -- Thomas Keffer Mon, 15 Apr 2013 09:06:33 -0700 +weewx (2.3.0-1) unstable; urgency=low + * ensure that prerm stops any running process on upgrade, not just remove + * new upstream release + -- Matthew Wall Tue, 09 Apr 2013 22:07:41 -0400 +weewx (2.2.1-1) unstable; urgency=low + * fixed ordinals + -- Matthew Wall Fri, 15 Feb 2013 12:02:32 -0500 +weewx (2.2.0-1) unstable; urgency=low + * no packaging changes for the 2.2.0 release + -- Matthew Wall Thu, 14 Feb 2013 19:02:00 -0500 +weewx (2.2.0b2-1) unstable; urgency=low + * second beta for 2.2.0 + -- Matthew Wall Wed, 13 Feb 2013 15:38:36 -0500 +weewx (2.2.0b1-1) unstable; urgency=low + * first beta for 2.2.0 + -- Matthew Wall Sun, 10 Feb 2013 11:01:43 -0500 +weewx (2.2.0a5-1) unstable; urgency=low + * fix postrm to work with ubuntu systems + -- Matthew Wall Sat, 09 Feb 2013 21:10:20 -0500 +weewx (2.2.0a4-1) unstable; urgency=low + * include logrotate and syslog snippets + * use wee_config_* naming + -- Matthew Wall Fri, 08 Feb 2013 20:41:25 -0500 +weewx (2.2.0a3-1) unstable; urgency=low + * fixed debian start script + * fixed debian-specific paths and commands in documentation + * removed apache dependencies + * put generated html in /var/www/weewx + -- Matthew Wall Sun, 03 Feb 2013 01:17:02 -0500 +weewx (2.2.0a2-1) unstable; urgency=low + * merged packaging branch to trunk + -- Matthew Wall Mon, 28 Jan 2013 14:17:45 -0500 +weewx (2.1.1-1) unstable; urgency=low + * initial debian package + -- Matthew Wall Thu, 24 Jan 2013 00:00:00 -0700 diff --git a/dist/weewx-5.0.2/pkg/debian/compat b/dist/weewx-5.0.2/pkg/debian/compat new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/compat @@ -0,0 +1 @@ +10 diff --git a/dist/weewx-5.0.2/pkg/debian/conffiles b/dist/weewx-5.0.2/pkg/debian/conffiles new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/pkg/debian/config b/dist/weewx-5.0.2/pkg/debian/config new file mode 100755 index 0000000..be2130b --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/config @@ -0,0 +1,127 @@ +#!/bin/sh + +# abort if any command returns error +set -e + +# prompt for configuration settings that are required and have no default + +# load the debconf functions +. /usr/share/debconf/confmodule +db_version 2.0 + +# this conf script is capable of backing up +db_capb backup + +STATE=1 +while [ "$STATE" != 0 -a "$STATE" != 9 ]; do + + case "$STATE" in + 1) + db_input high weewx/location || true + ;; + + 2) + db_input high weewx/latlon || true + ;; + + 3) + db_input high weewx/altitude || true + ;; + + 4) + db_input high weewx/units || true + ;; + + 5) + db_input high weewx/station_type || true + ;; + + 6) # prompt for station-specific parameters + db_get weewx/station_type + + if [ "$RET" = "AcuRite" ]; then + db_input high weewx/acurite_model || true + fi + + if [ "$RET" = "CC3000" ]; then + db_input high weewx/cc3000_model || true + db_input high weewx/cc3000_port || true + fi + + if [ "$RET" = "FineOffsetUSB" ]; then + db_input high weewx/fousb_model || true + fi + + if [ "$RET" = "TE923" ]; then + db_input high weewx/te923_model || true + fi + + if [ "$RET" = "Ultimeter" ]; then + db_input high weewx/ultimeter_model || true + db_input high weewx/ultimeter_port || true + fi + + if [ "$RET" = "Vantage" ]; then + db_input high weewx/vantage_type || true + db_go || true + db_get weewx/vantage_type + if [ "$RET" = "serial" ]; then + db_input high weewx/vantage_port || true + else + db_input high weewx/vantage_host || true + fi + fi + + if [ "$RET" = "WMR100" ]; then + db_input high weewx/wmr100_model || true + fi + + if [ "$RET" = "WMR300" ]; then + db_input high weewx/wmr300_model || true + fi + + if [ "$RET" = "WMR9x8" ]; then + db_input high weewx/wmr9x8_model || true + db_input high weewx/wmr9x8_port || true + fi + + if [ "$RET" = "WS1" ]; then + db_input high weewx/ws1_port || true + fi + + if [ "$RET" = "WS23xx" ]; then + db_input high weewx/ws23xx_model || true + db_input high weewx/ws23xx_port || true + fi + + if [ "$RET" = "WS28xx" ]; then + db_input high weewx/ws28xx_model || true + db_input high weewx/ws28xx_frequency || true + fi + ;; + + 7) + db_input high weewx/register || true + ;; + + 8) # if the user requested station registration, get an url + db_get weewx/register + + if [ "$RET" = "true" ]; then + db_input high weewx/station_url || true + fi + ;; + esac + + if db_go; then + STATE=$(($STATE + 1)) + else + STATE=$(($STATE - 1)) + fi +done + +if [ "$STATE" = 0 ]; then + # user has cancelled the first prompt. according to debconf docs we + # should return 10, leaving the package installed but unconfigured. + exit 10 +fi diff --git a/dist/weewx-5.0.2/pkg/debian/control b/dist/weewx-5.0.2/pkg/debian/control new file mode 100644 index 0000000..65add8c --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/control @@ -0,0 +1,34 @@ +Source: weewx +Section: science +Priority: optional +Maintainer: Tom Keffer +Standards-Version: 3.9.2 +Homepage: http://www.weewx.com +Build-Depends: debhelper (>=8) + +Package: weewx +Priority: optional +Architecture: all +Pre-Depends: debconf +Depends: + adduser (>= 3.11), + python3 (>=3.7) | python (>=3.7), + python3-configobj, + python3-cheetah, + python3-pil, + python3-serial, + python3-usb, + python3-ephem +Suggests: + sqlite, + rsync, + ftp, + httpd, + python3-dev, + python3-pip +Description: weather software + WeeWX interacts with a weather station to produce graphs, reports, and HTML + pages. WeeWX can upload data to many weather services including + WeatherUnderground, PWSweather.com, CWOP, and others. + . + This version requires Python 3.7 or greater. diff --git a/dist/weewx-5.0.2/pkg/debian/copyright b/dist/weewx-5.0.2/pkg/debian/copyright new file mode 100644 index 0000000..d52fabc --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/copyright @@ -0,0 +1,26 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: weewx +Source: https://www.weewx.com + +Files: * +Copyright: Copyright (C) 2009-2024 Tom Keffer +License: GPL-3.0+ + +Files: debian/* +Copyright: Copyright (C) 2009-2024 Tom Keffer +License: GPL-3.0+ + +License: GPL-3.0+ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + On Debian systems, the GPL is located at /usr/share/common-licenses/GPL-3. + . + See . diff --git a/dist/weewx-5.0.2/pkg/debian/postinst b/dist/weewx-5.0.2/pkg/debian/postinst new file mode 100755 index 0000000..29256ad --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/postinst @@ -0,0 +1,532 @@ +#!/bin/sh +# postinst script for weewx debian package +# Copyright 2013-2024 Matthew Wall +# +# ways this script might be invoked: +# +# postinst configure most-recently-configured-version +# old-postinst abort-upgrade new-version +# conflictor's-postinst abort-remove in-favour package new-version +# postinst abort-remove +# deconfigured's-postinst abort-deconfigure in-favour failed-install-package +# version [removing conflicting-package version] + +# abort if any command returns error +set -e + +# get debconf stuff so we can set configuration defaults +. /usr/share/debconf/confmodule + +cfgfile=/etc/weewx/weewx.conf +cfgapp=/usr/bin/weectl +ts=`date +"%Y%m%d%H%M%S"` + +WEEWX_USER="${WEEWX_USER:-weewx}" +WEEWX_GROUP="${WEEWX_GROUP:-weewx}" +WEEWX_HOME="${WEEWX_HOME:-/var/lib/weewx}" +WEEWX_HTMLDIR="${WEEWX_HTMLDIR:-/var/www/html/weewx}" +WEEWX_USERDIR=/etc/weewx/bin/user + +# insert the driver and stanza into the configuration file +insert_driver() { + if [ ! -f $cfgfile ]; then + return + fi + + # FIXME: generalize this so it does not have to be modified every time a + # new station type is added or new station options are added. + db_get weewx/station_type + if [ "$RET" != "" ]; then + sed -i "s%station_type[ ]*=.*%station_type = $RET%" $cfgfile + if [ "$RET" = "AcuRite" ]; then + db_get weewx/acurite_model + sed -i "/\[AcuRite\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + fi + if [ "$RET" = "CC3000" ]; then + db_get weewx/cc3000_model + sed -i "/\[CC3000\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + db_get weewx/cc3000_port + sed -i "/\[CC3000\]/,/\[.*\]/ s% port[ ]*=.*% port = $RET%" $cfgfile + fi + if [ "$RET" = "FineOffsetUSB" ]; then + db_get weewx/fousb_model + sed -i "/\[FineOffsetUSB\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + fi + if [ "$RET" = "TE923" ]; then + db_get weewx/te923_model + sed -i "/\[TE923\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + fi + if [ "$RET" = "Ultimeter" ]; then + db_get weewx/ultimeter_model + sed -i "/\[Ultimeter\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + db_get weewx/ultimeter_port + sed -i "/\[Ultimeter\]/,/\[.*\]/ s% port[ ]*=.*% port = $RET%" $cfgfile + fi + if [ "$RET" = "Vantage" ]; then + db_get weewx/vantage_type + sed -i "/\[Vantage\]/,/\[.*\]/ s% type[ ]*=.*% type = $RET%" $cfgfile + if [ "$RET" = "serial" ]; then + db_get weewx/vantage_port + sed -i "/\[Vantage\]/,/\[.*\]/ s% port[ ]*=.*% port = $RET%" $cfgfile + else + db_get weewx/vantage_host + sed -i "/\[Vantage\]/,/\[.*\]/ s% host[ ]*=.*% host = $RET%" $cfgfile + fi + fi + if [ "$RET" = "WMR100" ]; then + db_get weewx/wmr100_model + sed -i "/\[WMR100\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + fi + if [ "$RET" = "WMR300" ]; then + db_get weewx/wmr300_model + sed -i "/\[WMR300\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + fi + if [ "$RET" = "WMR9x8" ]; then + db_get weewx/wmr9x8_model + sed -i "/\[WMR9x8\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + db_get weewx/wmr9x8_port + sed -i "/\[WMR9x8\]/,/\[.*\]/ s% port[ ]*=.*% port = $RET%" $cfgfile + fi + if [ "$RET" = "WS1" ]; then + db_get weewx/ws1_port + sed -i "/\[WS1\]/,/\[.*\]/ s% port[ ]*=.*% port = $RET%" $cfgfile + fi + if [ "$RET" = "WS23xx" ]; then + db_get weewx/ws23xx_model + sed -i "/\[WS23xx\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + db_get weewx/ws23xx_port + sed -i "/\[WS23xx\]/,/\[.*\]/ s%[# ]*port[ ]*=.*% port = $RET%" $cfgfile + fi + if [ "$RET" = "WS28xx" ]; then + db_get weewx/ws28xx_model + sed -i "/\[WS28xx\]/,/\[.*\]/ s%[# ]*model[ ]*=.*% model = $RET%" $cfgfile + db_get weewx/ws28xx_frequency + sed -i "/\[WS28xx\]/,/\[.*\]/ s%[# ]*transceiver_frequency[ ]*=.*% transceiver_frequency = $RET%" $cfgfile + fi + fi +} + +install_weewxconf() { + if [ ! -f $cfgfile ]; then + return + fi + + driver=weewx.drivers.simulator + db_get weewx/station_type + if [ "$RET" = "AcuRite" ]; then + driver=weewx.drivers.acurite + elif [ "$RET" = "CC3000" ]; then + driver=weewx.drivers.cc3000 + elif [ "$RET" = "FineOffsetUSB" ]; then + driver=weewx.drivers.fousb + elif [ "$RET" = "TE923" ]; then + driver=weewx.drivers.te923 + elif [ "$RET" = "Ultimeter" ]; then + driver=weewx.drivers.ultimeter + elif [ "$RET" = "Vantage" ]; then + driver=weewx.drivers.vantage + elif [ "$RET" = "WMR100" ]; then + driver=weewx.drivers.wmr100 + elif [ "$RET" = "WMR300" ]; then + driver=weewx.drivers.wmr300 + elif [ "$RET" = "WMR9x8" ]; then + driver=weewx.drivers.wmr9x8 + elif [ "$RET" = "WS1" ]; then + driver=weewx.drivers.ws1 + elif [ "$RET" = "WS23xx" ]; then + driver=weewx.drivers.ws23xx + elif [ "$RET" = "WS28xx" ]; then + driver=weewx.drivers.ws28xx + fi + + # default to US unit system + units=us + # get the system's unit system from debconf + db_get weewx/units + # sanitize the input. for backward compatibility, we recognize the older + # keywords 'US' and 'Metric', which might still be in the system's debconf + # otherwise, ensure that debconf contains one of 'us', 'metric', or + # 'metricwx' + if [ "$RET" = "US" ]; then + units=us + elif [ "$RET" = "Metric" ]; then + units=metric + elif [ "$RET" = "metric" ]; then + units=metric + elif [ "$RET" = "metricwx" ]; then + units=metricwx + fi + + db_get weewx/location + location=$RET + + db_get weewx/latlon + lat=$(echo $RET | cut -d, -f1 | sed 's/^ //g' | sed 's/ $//g') + lon=$(echo $RET | cut -d, -f2 | sed 's/^ //g' | sed 's/ $//g') + + db_get weewx/altitude + altitude=$(echo $RET | cut -d, -f1 | sed 's/^ //g' | sed 's/ $//g') + altitude_unit=$(echo $RET | cut -d, -f2 | sed 's/^ //g' | sed 's/ $//g') + if [ "$altitude_unit" = "feet" ]; then + altitude_unit=foot + elif [ "$altitude_unit" = "meters" ]; then + altitude_unit=meter + elif [ "$altitude_unit" != "foot" -a "$altitude_unit" != "meter" ]; then + altitude_unit=foot + fi + + db_get weewx/register + if [ "$RET" = 'true' ]; then + register=y + db_get weewx/station_url + station_url_param="--station-url=$RET" + else + register=n + station_url_param=" " + fi + + $cfgapp station reconfigure --config=$cfgfile \ + --driver=$driver --units=$units --location="$location" \ + --latitude=$lat --longitude=$lon --altitude=$altitude,$altitude_unit \ + --register=$register $station_url_param \ + --no-prompt --no-backup + + insert_driver +} + +# use weewx setup utilities to merge new features into existing weewx.conf. +# user will be prompted about whether to accept the new version or the previous +# configuration. +# +# if they choose existing, then they end up with: +# weewx.conf - previous config +# weewx.conf.x.y.z - new config +# +# if they choose new, then they end up with: +# weewx.conf.dpkg-old - previous config +# weewx.conf - new config +# +# new install: +# weewx.conf - new config filled with values from debconf +# weewx.conf-new - new config (created by dpkg rules) +# +# upgrade: +# weewx.conf - previous config +# weewx.conf-new - new config (created by dpkg rules) +# weewx.conf-old-new - old config upgraded to this version +# +merge_weewxconf() { + if [ ! -f $cfgfile ]; then + return + fi + + if [ -f $cfgfile ]; then + NEWVER=$($cfgapp --version | cut -d' ' -f2) + OLDVER=$(get_conf_version $cfgfile) + if dpkg --compare-versions $OLDVER lt $NEWVER; then + # this is an old config, so create a maintainer's version + if [ -f $cfgfile-$NEWVER ]; then + MNT=$OLDVER-$NEWVER + echo "Creating maintainer config file as $cfgfile-$MNT" + cp -p $cfgfile $cfgfile-$MNT + $cfgapp station upgrade --config=$cfgfile-$MNT --dist-config=$cfgfile-$NEWVER --what=config --no-backup --yes + fi + else + # this is a new config, so just insert debconf values into it + echo "Using debconf configuration values from previous install" + install_weewxconf + fi + fi +} + +# precompile the bytecode +precompile() { + rc=$(python3 -m compileall -q -x 'user' /usr/share/weewx) + if [ "$rc" != "" ]; then + echo "Pre-compile failed!" + echo "$rc" + fi +} + +# get the version number from the specified file, without the debian revisions +get_conf_version() { + v=$(grep '^version.*=' $1 | sed -e 's/\s*version\s*=\s*//' | sed -e 's/-.*//') + if [ "$v" = "" ]; then + # someone might have messed with the version string + v="xxx" + fi + echo $v +} + +# if this is an upgrade and the owner of the previous data is other than weewx +# or root, then use that user as the weewx user. net effect is that we convert +# ownership to weewx:weewx for anything other than a previous install with +# customized ownership. +get_user() { + if [ -d $WEEWX_HOME ]; then + TMP_USER=$(stat -c "%U" $WEEWX_HOME) + if [ "$TMP_USER" != "root" -a "$TMP_USER" != "weewx" -a "$TMP_USER" != "UNKNOWN" ]; then + WEEWX_USER=$TMP_USER + WEEWX_GROUP=$(stat -c "%G" $WEEWX_HOME) + fi + fi + echo "Using $WEEWX_USER:$WEEWX_GROUP as user:group" +} + +# create/modify the defaults file to match this installation. if the file does +# not exist, then create it with our values. if there are already values, then +# move it aside. +setup_defaults() { + dflts=/etc/default/weewx + if [ -f $dflts ]; then + echo "Saving old defaults to ${dflts}-$ts" + mv $dflts ${dflts}-$ts + fi + echo "Creating /etc/default/weewx" + echo "WEEWX_PYTHON=python3" > $dflts + echo "WEEWX_BINDIR=/usr/share/weewx" >> $dflts + + # the SysV rc script uses additional variables, so set values for them + if [ "$1" = "init" ]; then + grep -q "^WEEWX_CFGDIR=" $dflts || \ + echo "WEEWX_CFGDIR=/etc/weewx" >> $dflts + grep -q "^WEEWX_RUNDIR=" $dflts || \ + echo "WEEWX_RUNDIR=/var/lib/weewx" >> $dflts + grep -q "^WEEWX_USER=" $dflts || \ + echo "WEEWX_USER=$WEEWX_USER" >> $dflts + grep -q "^WEEWX_GROUP=" $dflts || \ + echo "WEEWX_GROUP=$WEEWX_GROUP" >> $dflts + grep -q "^WEEWX_INSTANCES=" $dflts || \ + echo "WEEWX_INSTANCES=\"weewx\"" >> $dflts + fi +} + +# create the user that the daemon will run as, but only if not already exist +create_user() { + if ! getent group | grep -q "^$WEEWX_GROUP:"; then + echo -n "Adding system group $WEEWX_GROUP..." + addgroup --quiet --system $WEEWX_GROUP || true + echo "done" + fi + if ! getent passwd | grep -q "^$WEEWX_USER:"; then + echo -n "Adding system user $WEEWX_USER..." + adduser --quiet --system --ingroup $WEEWX_GROUP \ + --no-create-home --home $WEEWX_HOME \ + --disabled-password weewx || true + echo "done" + fi +} + +# add the user who is installing into the weewx group, if appropriate +add_to_group() { + # add user only if the group is not a privileged group + if [ "$WEEWX_GROUP" != "root" ]; then + # see who is running the installation + inst_user=$USER + if [ "$SUDO_USER" != "" ]; then + inst_user=$SUDO_USER + fi + # put the user who is doing the installation into the weewx group, + # but only if it is not root or the weewx user. + if [ "$inst_user" != "root" -a "$inst_user" != "$WEEWX_USER" ]; then + # if user is already in the group, then skip it + if ! getent group $WEEWX_GROUP | grep -q $inst_user; then + echo "Adding user $inst_user to group $WEEWX_GROUP" + usermod -aG $WEEWX_GROUP $inst_user + else + echo "User $inst_user is already in group $WEEWX_GROUP" + fi + fi + fi +} + +# put the init files in place +setup_init() { + if [ "$1" = "systemd" ]; then + # prefer /usr/lib but fall back to /lib + dst="/usr/lib/systemd/system" + if [ ! -d $dst ]; then + dst="/lib/systemd/system" + fi + if [ -d $dst ]; then + echo "Installing systemd units" + for f in weewx.service weewx@.service; do + sed \ + -e "s/User=.*/User=${WEEWX_USER}/" \ + -e "s/Group=.*/Group=${WEEWX_GROUP}/" \ + /etc/weewx/systemd/$f > $dst/$f + done + systemctl daemon-reload + fi + elif [ "$1" = "init" ]; then + echo "Installing SysV rc script" + cp /etc/weewx/init.d/weewx-multi /etc/init.d/weewx + chmod 755 /etc/init.d/weewx + fi +} + +enable_init() { + echo "Enabling startup using $1" + if [ "$1" = "systemd" ]; then + systemctl enable weewx > /dev/null + elif [ "$1" = "init" ]; then + update-rc.d weewx defaults > /dev/null + fi +} + +start_weewx() { + echo "Starting weewxd using $1" + if [ "$1" = "systemd" ]; then + systemctl start weewx + elif [ "$1" = "init" ]; then + invoke-rc.d weewx start + fi +} + +# put the udev rules in place +setup_udev() { + # prefer /usr/lib but fall back to /lib + dst=/usr/lib/udev/rules.d + if [ ! -d $dst ]; then + dst=/lib/udev/rules.d + fi + if [ -d $dst ]; then + echo "Installing udev rules" + sed \ + -e "s/GROUP=\"weewx\"/GROUP=\"${WEEWX_GROUP}\"/" \ + /etc/weewx/udev/weewx.rules > $dst/60-weewx.rules + fi +} + +# create the skins if skins do not already exist +setup_skins() { + if [ ! -d /etc/weewx/skins ]; then + echo "Creating skins directory /etc/weewx/skins" + cp -rp /usr/share/weewx/weewx_data/skins /etc/weewx + fi +} + +# create the user extensions directory if one does not already exist +setup_user_dir() { + if [ ! -d $WEEWX_USERDIR ]; then + echo "Creating user extension directory $WEEWX_USERDIR" + mkdir -p $WEEWX_USERDIR + cp /usr/share/weewx/weewx_data/bin/user/__init__.py $WEEWX_USERDIR + cp /usr/share/weewx/weewx_data/bin/user/extensions.py $WEEWX_USERDIR + fi +} + +set_permissions() { + usr=$1 + grp=$2 + dir=$3 + find $3 -type f -exec chmod 664 {} \; + find $3 -type d -exec chmod 2775 {} \; + chmod 2775 $dir + chown -R $usr:$grp $dir +} + +# create the database directory +setup_database_dir() { + echo "Configuring database directory $WEEWX_HOME" + mkdir -p $WEEWX_HOME + set_permissions $WEEWX_USER $WEEWX_GROUP $WEEWX_HOME +} + +# create the reporting directory +setup_reporting_dir() { + echo "Configuring reporting directory $WEEWX_HTMLDIR" + mkdir -p $WEEWX_HTMLDIR + set_permissions $WEEWX_USER $WEEWX_GROUP $WEEWX_HTMLDIR +} + +# set the permissions on the configuration, skins, and extensions +set_config_permissions() { + echo "Setting permissions $WEEWX_USER:$WEEWX_GROUP on /etc/weewx" + set_permissions $WEEWX_USER $WEEWX_GROUP /etc/weewx +} + +# if there are any existing extensions in the V4 location, copy them to the +# V5 location, then move aside the old location +migrate_extensions() { + if [ -d /usr/share/weewx/user ]; then + echo "Migrating old extensions to $WEEWX_USERDIR" + mkdir -p $WEEWX_USERDIR + cp -rp /usr/share/weewx/user/* $WEEWX_USERDIR + echo "Saving old extensions to /usr/share/weewx/user-$ts" + mv /usr/share/weewx/user /usr/share/weewx/user-$ts + fi +} + +# check for systemd and/or sysV init files that might affect the init setup +# that we install. +check_init() { + files_to_check="" + if [ "$1" = "systemd" ]; then + files_to_check="/etc/init.d/weewx-multi /etc/init.d/weewx /etc/systemd/system/weewx.service" + elif [ "$1" = "init" ]; then + files_to_check="/etc/init.d/weewx-multi" + fi + files="" + for f in $files_to_check; do + if [ -f $f ]; then + files="$files $f" + fi + done + if [ "$files" != "" ]; then + echo "The following files might interfere with the init configuration:" + for f in $files; do + echo " $f" + done + fi +} + + +# see which init system is running +pid1=none +if [ -d /run/systemd/system ]; then + pid1=systemd +else + pid1=init +fi + +case "$1" in + configure) + get_user + setup_defaults $pid1 + + if [ "$2" != "" ]; then + # this is an upgrade so create a maintainers version by merging + merge_weewxconf + # migrate any extensions from V4 location to V5 + migrate_extensions + else + # virgin install so insert debconf values into the config file + install_weewxconf + fi + + create_user + add_to_group + setup_init $pid1 + setup_udev + setup_skins + setup_user_dir + setup_database_dir + setup_reporting_dir + precompile + set_config_permissions + enable_init $pid1 + start_weewx $pid1 + check_init $pid1 + ;; + + abort-remove) + ;; +esac + +# let debconf know that we are finished +db_stop + +#DEBHELPER# + +exit 0 diff --git a/dist/weewx-5.0.2/pkg/debian/postrm b/dist/weewx-5.0.2/pkg/debian/postrm new file mode 100755 index 0000000..6ece0cf --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/postrm @@ -0,0 +1,87 @@ +#!/bin/sh +# postrm script for weewx debian package +# Copyright 2013-2024 Matthew Wall +# +# ways this script might be invoked: +# +# postrm remove +# postrm purge +# old-postrm upgrade new-version +# disappearer's-postrm disappear overwriter overwriter-version +# new-postrm failed-upgrade old-version +# new-postrm abort-install +# new-postrm abort-install old-version +# new-postrm abort-upgrade old-version + +# abort if any command returns error +set -e + +# see which init system is running +pid1=none +if [ -d /run/systemd/system ]; then + pid1=systemd +else + pid1=init +fi + +case "$1" in +remove) + # remove the startup configuration + if [ "$pid1" = "systemd" ]; then + echo "Removing systemd units" + systemctl disable weewx > /dev/null + dst="/usr/lib/systemd/system" + if [ ! -d $dst ]; then + dst="/lib/systemd/system" + fi + for f in weewx.service weewx@.service; do + if [ -f $dst/$f ]; then + rm -f $dst/$f + fi + done + elif [ "$pid1" = "init" ]; then + echo "Removing SysV rc script" + update-rc.d weewx remove > /dev/null + if [ -f /etc/init.d/weewx ]; then + rm /etc/init.d/weewx + fi + fi + # remove udev rules + dst=/usr/lib/udev/rules.d + if [ ! -d $dst ]; then + dst=/lib/udev/rules.d + fi + if [ -f $dst/60-weewx.rules ]; then + echo "Removing udev rules" + rm -f $dst/60-weewx.rules + fi + ;; + +purge) + # remove any debconf entries + if [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_purge + fi + ;; + +upgrade) + ;; + +abort-install) + ;; + +failed-upgrade) + ;; + +abort-install) + ;; + +abort-upgrade) + ;; + +esac + +#DEBHELPER# + +exit 0 diff --git a/dist/weewx-5.0.2/pkg/debian/preinst b/dist/weewx-5.0.2/pkg/debian/preinst new file mode 100755 index 0000000..8430299 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/preinst @@ -0,0 +1,18 @@ +#!/bin/sh +# preinst script for weewx debian package +# Copyright 2014-2024 Matthew Wall + +# abort if any command returns error +set -e + +case "$1" in + install|upgrade) + ;; + + abort-upgrade) + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/dist/weewx-5.0.2/pkg/debian/prerm b/dist/weewx-5.0.2/pkg/debian/prerm new file mode 100755 index 0000000..7293165 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/prerm @@ -0,0 +1,42 @@ +#!/bin/sh +# prerm script for weewx debian package +# Copyright 2013-2023 Matthew Wall +# +# ways this script might be invoked: +# +# prerm remove +# old-prerm upgrade new-version +# conflictor's-prerm remove in-favor package new-version +# deconfigured's-prerm deconfigure in-favour package-being-installed version +# [removing conflicting-package version] +# new-prerm failed-upgrade old-version + +# abort if any command returns error +set -e + +# see which init system is running +pid1=none +if [ -d /run/systemd/system ]; then + pid1=systemd +else + pid1=init +fi + +case "$1" in +remove|upgrade) + # stop the weewx daemon + echo "Stopping weewxd using $pid1" + if [ "$pid1" = "systemd" ]; then + systemctl stop weewx + elif [ "$pid1" = "init" ]; then + invoke-rc.d weewx stop + fi + # remove any bytecompiled code + find /usr/share/weewx -name '*.pyc' -delete + find /usr/share/weewx -name __pycache__ -delete + find /etc/weewx/bin -name '*.pyc' -delete + find /etc/weewx/bin -name __pycache__ -delete + ;; +esac + +exit 0 diff --git a/dist/weewx-5.0.2/pkg/debian/rules b/dist/weewx-5.0.2/pkg/debian/rules new file mode 100755 index 0000000..718758b --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/rules @@ -0,0 +1,103 @@ +#!/usr/bin/make -f +# -*- makefile -*- +# debian makefile for weewx +# Copyright 2013-2024 Matthew Wall + +# Uncomment this to turn on verbose mode. +#export DH_VERBOSE=1 + +PKG=weewx +PKG_VERSION=WEEWX_VERSION +PYTHON=python3 +SRC=$(CURDIR) +DST=$(CURDIR)/debian/$(PKG) +DST_BINDIR=$(DST)/usr/share/weewx +DST_CFGDIR=$(DST)/etc/weewx +DST_DOCDIR=$(DST)/usr/share/doc/weewx + +# these are the entry points +ENTRIES=weewxd weectl + +%: + dh $@ --with python3 + +override_dh_auto_clean: + dh_auto_clean + rm -rf build dist + rm -f *.egg-info + +# this rule grabs all of the bits from the source tree and puts them into +# a staging area that has the directory structure of a debian system. it +# explicitly does *not* do things the 'python way' using pip. +install: + dh_testdir + dh_testroot + dh_prep + dh_installdirs + dh_installchangelogs + +# create the directory structure + mkdir -p $(DST_BINDIR) + mkdir -p $(DST_CFGDIR) + mkdir -p $(DST_DOCDIR) + mkdir -p $(DST)/usr/bin + +# copyright, license, and upstream changelog + cp docs_src/copyright.md $(DST_DOCDIR)/copyright + cp LICENSE.txt $(DST_DOCDIR)/license + cp docs_src/changes.md $(DST_DOCDIR)/changelog + dh_compress usr/share/doc/weewx/changelog + +# copy the weewx code + cp -r $(SRC)/src/* $(DST_BINDIR) + +# copy selected ancillary files to the config dir + cp -r $(SRC)/src/weewx_data/examples $(DST_CFGDIR) + cp -r $(SRC)/src/weewx_data/util/import $(DST_CFGDIR) + cp -r $(SRC)/src/weewx_data/util/logwatch $(DST_CFGDIR) + cp -r $(SRC)/src/weewx_data/util/rsyslog.d $(DST_CFGDIR) + cp -r $(SRC)/src/weewx_data/util/logrotate.d $(DST_CFGDIR) + mkdir $(DST_CFGDIR)/init.d + cp $(SRC)/src/weewx_data/util/init.d/weewx $(DST_CFGDIR)/init.d + cp $(SRC)/src/weewx_data/util/init.d/weewx-multi $(DST_CFGDIR)/init.d + mkdir $(DST_CFGDIR)/systemd + cp $(SRC)/pkg/etc/systemd/system/weewx.service $(DST_CFGDIR)/systemd + cp $(SRC)/pkg/etc/systemd/system/weewx@.service $(DST_CFGDIR)/systemd + mkdir $(DST_CFGDIR)/udev + cp $(SRC)/pkg/etc/udev/rules.d/weewx.rules $(DST_CFGDIR)/udev + +# create the configuration file + sed \ + -e 's%HTML_ROOT = public_html%HTML_ROOT = /var/www/html/weewx%' \ + -e 's%SQLITE_ROOT = .*%SQLITE_ROOT = /var/lib/weewx%' \ + $(SRC)/src/weewx_data/weewx.conf > $(DST_CFGDIR)/weewx.conf + +# make a virgin copy of the configuration file + cp $(DST_CFGDIR)/weewx.conf $(DST_CFGDIR)/weewx.conf-$(PKG_VERSION) + +# create the entry points + for f in $(ENTRIES); do \ + sed \ + -e 's%WEEWX_BINDIR=.*%WEEWX_BINDIR=/usr/share/weewx%' \ + -e 's%WEEWX_PYTHON=.*%WEEWX_PYTHON=$(PYTHON)%' \ + $(SRC)/bin/$$f > $(DST)/usr/bin/$$f; \ +done + +# additional debian control files that dpkg-buildpackage seems to ignore + mkdir -p $(DST)/DEBIAN + cp $(SRC)/debian/config $(DST)/DEBIAN + cp $(SRC)/debian/templates $(DST)/DEBIAN + +binary-indep: install + dh_fixperms + dh_installdeb + dh_gencontrol + dh_lintian + dh_md5sums + dh_builddeb -- -Zgzip + +binary-arch: + +binary: binary-indep binary-arch + +.PHONY: build clean binary-indep binary-arch binary install configure diff --git a/dist/weewx-5.0.2/pkg/debian/source/format b/dist/weewx-5.0.2/pkg/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/dist/weewx-5.0.2/pkg/debian/templates b/dist/weewx-5.0.2/pkg/debian/templates new file mode 100644 index 0000000..81cd753 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/templates @@ -0,0 +1,158 @@ +Template: weewx/units +Type: select +Default: us +Choices: us, metric, metricwx +Description: display units: + Choose a unit system: us (F, inHg, in, mph), metric (C, mbar, cm, km/h), or metricwx (C, mbar, mm, m/s). The unit system can be modified later to any combination of units. + +Template: weewx/location +Type: string +Default: Santa's Workshop, North Pole +Description: location of the weather station: + This is a string that describes the location of the weather station, for example 'Hood River, Oregon' or 'Boston, MA'. + +Template: weewx/latlon +Type: string +Default: 90.000, 0.000 +Description: latitude, longitude of the weather station: + The latitude and longitude should be specified in decimal degrees, with negative values for southern and western hemispheres. For example, '45.686,-121.566' or '42.385, -71.060' + +Template: weewx/altitude +Type: string +Default: 0, meter +Description: altitude of the weather station: + Normally the altitude is downloaded from the station hardware, but not all stations support this. Specify the altitude of the station and the singular form of the unit used for the altitude, for example '700, foot' or '120, meter' + +Template: weewx/register +Type: boolean +Default: false +Description: register this station? + The station can be registered at weewx.com, where it will be included in a map. Registration requires a unique URL to identify the station, such as a website, or a WeatherUnderground link. + +Template: weewx/station_url +Type: string +Description: unique URL for the station: + Provide a unique URL for the station. It must be a valid URL, starting with 'http://' or 'https://' + +Template: weewx/station_type +Type: select +Choices: Simulator, AcuRite, CC3000, FineOffsetUSB, TE923, Ultimeter, Vantage, WMR100, WMR300, WMR9x8, WS1, WS23xx, WS28xx +Default: Simulator +Description: weather station type: + The weather station hardware type. Specify simulator to run weewx without a weather station. + +Template: weewx/acurite_model +Type: string +Default: AcuRite 01035 +Description: weather station model: + Specify the station model, for example 01025, 01035, 01036, 02032C + +Template: weewx/cc3000_port +Type: string +Default: /dev/ttyUSB0 +Description: serial port: + Specify the serial port on which the station is connected, for example /dev/ttyUSB0 or /dev/ttyS0. + +Template: weewx/cc3000_model +Type: string +Default: RainWise +Description: weather station model: + Specify the station model, for example RainWise CC3000. + +Template: weewx/fousb_model +Type: string +Default: WS2080 +Description: weather station model: + Specify the station model, for example WH1080, WS2080, WH3080. + +Template: weewx/te923_model +Type: string +Default: TE923 +Description: weather station model: + Specify the station model, for example TFA Nexus, IROX Pro X, or Meade TE923W. + +Template: weewx/ultimeter_model +Type: string +Default: Ultimeter +Description: weather station model: + Specify the station model, for example Ultimeter 2000, Ultimeter 100 + +Template: weewx/ultimeter_port +Type: string +Default: /dev/ttyUSB0 +Description: serial port: + Specify the serial port on which the station is connected, for example /dev/ttyUSB0 or /dev/ttyS0. + +Template: weewx/vantage_type +Type: select +Choices: serial, ethernet +Default: serial +Description: hardware interface: + If the station is connected by serial, USB, or serial-to-USB adapter, specify serial. Specify ethernet for stations with the WeatherLinkIP interface. + +Template: weewx/vantage_port +Type: string +Default: /dev/ttyUSB0 +Description: serial port: + Specify a port for stations with a serial interface, for example /dev/ttyUSB0 or /dev/ttyS0. + +Template: weewx/vantage_host +Type: string +Default: 192.168.0.10 +Description: weather station hostname or IP address: + Specify the IP address (e.g. 192.168.0.10) or hostname (e.g. console or console.example.com) for stations with an ethernet interface. + +Template: weewx/wmr100_model +Type: string +Default: WMR100 +Description: weather station model: + Specify the station model, for example WMR100, WMR100A, WMRS200. + +Template: weewx/wmr300_model +Type: string +Default: WMR300 +Description: weather station model: + Specify the station model, for example WMR300 or WMR300A. + +Template: weewx/wmr9x8_model +Type: string +Default: WMR968 +Description: weather station model: + Specify the station model, for example WMR918, WMR968, Radio Shack 63-1016. + +Template: weewx/wmr9x8_port +Type: string +Default: /dev/ttyUSB0 +Description: serial port: + Specify the serial port on which the station is connected, for example /dev/ttyUSB0 or /dev/ttyS0. + +Template: weewx/ws1_port +Type: string +Default: /dev/ttyUSB0 +Description: serial port: + Specify the serial port on which the station is connected, for example /dev/ttyUSB0 or /dev/ttyS0. + +Template: weewx/ws23xx_model +Type: string +Default: LaCrosse WS-2300 +Description: weather station model: + Specify the station model, for example La Crosse WS2317. + +Template: weewx/ws23xx_port +Type: string +Default: /dev/ttyUSB0 +Description: serial port: + Specify the serial port on which the station is connected, for example /dev/ttyUSB0 or /dev/ttyS0. + +Template: weewx/ws28xx_model +Type: string +Default: LaCrosse WS-2810 +Description: weather station model: + Specify the station model, for example TFA Primus, La Crosse C86234. + +Template: weewx/ws28xx_frequency +Type: select +Choices: US, EU +Default: US +Description: weather station frequency: + Specify the frequency used between the station and the transceiver, either US (915 MHz) or EU (868.3 MHz). diff --git a/dist/weewx-5.0.2/pkg/debian/weewx.lintian-overrides b/dist/weewx-5.0.2/pkg/debian/weewx.lintian-overrides new file mode 100644 index 0000000..7398cda --- /dev/null +++ b/dist/weewx-5.0.2/pkg/debian/weewx.lintian-overrides @@ -0,0 +1,15 @@ +weewx: debian-changelog-file-contains-invalid-email-address mwall@picodeb8 +weewx: package-contains-documentation-outside-usr-share-doc * +weewx: copyright-should-refer-to-common-license-file-for-gpl +weewx: spelling-error-in-copyright +weewx: using-first-person-in-templates +weewx: extra-license-file +weewx: font-outside-font-dir +weewx: font-in-non-font-package +weewx: duplicate-font-file +weewx: truetype-font-prohibits-installable-embedding +weewx: python-script-but-no-python-dep usr/share/weewx/weewx_data/util/i18n/i18n-report #!python +weewx: binary-without-manpage +weewx: maintainer-script-should-not-use-recursive-chown-or-chmod +weewx: maintainer-script-calls-systemctl +weewx: init.d-script-not-included-in-package diff --git a/dist/weewx-5.0.2/pkg/etc/systemd/system/weewx.service b/dist/weewx-5.0.2/pkg/etc/systemd/system/weewx.service new file mode 100644 index 0000000..927a5f5 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/etc/systemd/system/weewx.service @@ -0,0 +1,21 @@ +# systemd service configuration file for WeeWX + +[Unit] +Description=WeeWX +Documentation=https://weewx.com/docs +Requires=time-sync.target +After=time-sync.target +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart=weewxd /etc/weewx/weewx.conf +StandardOutput=null +StandardError=journal+console +RuntimeDirectory=weewx +RuntimeDirectoryMode=775 +User=weewx +Group=weewx + +[Install] +WantedBy=multi-user.target diff --git a/dist/weewx-5.0.2/pkg/etc/systemd/system/weewx@.service b/dist/weewx-5.0.2/pkg/etc/systemd/system/weewx@.service new file mode 100644 index 0000000..a5c801d --- /dev/null +++ b/dist/weewx-5.0.2/pkg/etc/systemd/system/weewx@.service @@ -0,0 +1,30 @@ +# systemd service template file for running multiple instances of weewxd +# +# Each instance XXX must have its own config, database, and HTML_ROOT: +# +# item name where to specify +# -------- ----------------------------- ---------------------------- +# config /etc/weewx/XXX.conf configuration directory +# database_name /var/lib/weewx/XXX.sdb specified in XXX.conf +# HTML_ROOT /var/www/html/XXX specified in XXX.conf + +[Unit] +Description=WeeWX %i +Documentation=https://weewx.com/docs +Requires=time-sync.target +After=time-sync.target +Wants=network-online.target +After=network-online.target +PartOf=weewx.service + +[Service] +ExecStart=weewxd --log-label weewxd-%i /etc/weewx/%i.conf +StandardOutput=null +StandardError=journal+console +RuntimeDirectory=weewx +RuntimeDirectoryMode=775 +User=weewx +Group=weewx + +[Install] +WantedBy=multi-user.target diff --git a/dist/weewx-5.0.2/pkg/etc/udev/rules.d/weewx.rules b/dist/weewx-5.0.2/pkg/etc/udev/rules.d/weewx.rules new file mode 100644 index 0000000..bc6346b --- /dev/null +++ b/dist/weewx-5.0.2/pkg/etc/udev/rules.d/weewx.rules @@ -0,0 +1,30 @@ +# udev rules for hardware recognized by weewx + +# acurite +SUBSYSTEM=="usb",ATTRS{idVendor}=="24c0",ATTRS{idProduct}=="0003",MODE="0664",GROUP="weewx" + +# fine offset usb +SUBSYSTEM=="usb",ATTRS{idVendor}=="1941",ATTRS{idProduct}=="8021",MODE="0664",GROUP="weewx" + +# te923 +SUBSYSTEM=="usb",ATTRS{idVendor}=="1130",ATTRS{idProduct}=="6801",MODE="0664",GROUP="weewx" + +# oregon scientific wmr100 +SUBSYSTEM=="usb",ATTRS{idVendor}=="0FDE",ATTRS{idProduct}=="CA01",MODE="0664",GROUP="weewx" + +# oregon scientific wmr200 +SUBSYSTEM=="usb",ATTRS{idVendor}=="0FDE",ATTRS{idProduct}=="CA01",MODE="0664",GROUP="weewx" + +# oregon scientific wmr300 +SUBSYSTEM=="usb",ATTRS{idVendor}=="0FDE",ATTRS{idProduct}=="CA08",MODE="0664",GROUP="weewx" + +# ws28xx transceiver +SUBSYSTEM=="usb",ATTRS{idVendor}=="6666",ATTRS{idProduct}=="5555",MODE="0664",GROUP="weewx" + +# rainwise cc3000 via usb-serial +SUBSYSTEM=="tty",ATTRS{idVendor}=="0403",ATTRS{idProduct}=="6001",MODE="0664",GROUP="weewx",SYMLINK+="cc3000" + +# davis vantage via usb-serial +SUBSYSTEM=="tty",ATTRS{idVendor}=="10c4",ATTRS{idProduct}=="ea60",MODE="0664",GROUP="weewx",SYMLINK+="vantage" +SUBSYSTEM=="tty",ATTRS{idVendor}=="10c4",ATTR{idProduct}=="ea61",MODE="0664",GROUP="weewx",SYMLINK+="vantage" + diff --git a/dist/weewx-5.0.2/pkg/index-apt.html b/dist/weewx-5.0.2/pkg/index-apt.html new file mode 100644 index 0000000..d0304f6 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/index-apt.html @@ -0,0 +1,67 @@ + + + + + + + WeeWX: Installation using apt-get + + + + + +

apt repository for WeeWX

+ +

Tell the system to trust weewx.com

+ +
sudo apt-get -y install wget gnupg
+wget -qO - https://weewx.com/keys.html | sudo gpg --dearmor --output /etc/apt/trusted.gpg.d/weewx.gpg
+ +

Tell apt where to find the WeeWX repository

+ +For Debian10 and later (WeeWX5 and Python3): +
echo "deb [arch=all] https://weewx.com/apt/python3 buster main" | sudo tee /etc/apt/sources.list.d/weewx.list
+ +For Debian9 and earlier (WeeWX4 and Python2): +
echo "deb [arch=all] https://weewx.com/apt/python2 squeeze main" | sudo tee /etc/apt/sources.list.d/weewx.list
+ +

Install and/or Upgrade WeeWX

+
sudo apt-get update
+sudo apt-get install weewx
+ + + diff --git a/dist/weewx-5.0.2/pkg/index-suse.html b/dist/weewx-5.0.2/pkg/index-suse.html new file mode 100644 index 0000000..1d4cd0e --- /dev/null +++ b/dist/weewx-5.0.2/pkg/index-suse.html @@ -0,0 +1,64 @@ + + + + + + + WeeWX: Installation using zypper + + + + + +

SUSE repository for WeeWX

+ +

Tell the system to trust weewx.com

+ +
sudo rpm --import http://weewx.com/keys.html
+ +

Tell zypper where to find the WeeWX repository

+ +
curl -s http://weewx.com/suse/weewx.repo | sudo tee /etc/zypp/repos.d/weewx.repo
+ +

Install WeeWX

+
sudo zypper install weewx
+ +

Update WeeWX

+
sudo zypper update weewx
+ + + diff --git a/dist/weewx-5.0.2/pkg/index-yum.html b/dist/weewx-5.0.2/pkg/index-yum.html new file mode 100644 index 0000000..05142a1 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/index-yum.html @@ -0,0 +1,64 @@ + + + + + + + WeeWX: Installation using yum + + + + + +

yum repository for WeeWX

+ +

Tell the system to trust weewx.com

+ +
sudo rpm --import http://weewx.com/keys.html
+ +

Tell yum where to find the WeeWX repository

+ +
curl -s http://weewx.com/yum/weewx.repo | sudo tee /etc/yum.repos.d/weewx.repo
+ +

Install WeeWX

+
sudo yum install weewx
+ +

Update WeeWX

+
sudo yum update weewx
+ + + diff --git a/dist/weewx-5.0.2/pkg/mkchangelog.pl b/dist/weewx-5.0.2/pkg/mkchangelog.pl new file mode 100755 index 0000000..74a4cbc --- /dev/null +++ b/dist/weewx-5.0.2/pkg/mkchangelog.pl @@ -0,0 +1,308 @@ +#!/usr/bin/perl +# Copyright Matthew Wall +# +# Convert the changelog to various formats, or create a changelog stub suitable +# for inclusion in debian or redhat packaging. Username and email are +# required. This script uses gpg to guess username and email, since packages +# must be signed by gpg credentials with the username and email in the package +# changelog. +# +# examples of usage: +# mkchangelog.pl --ifile docs/changes.txt > dist/README.txt +# mkchangelog.pl --action stub --format redhat --release-version 3.4-1 +# mkchangelog.pl --action stub --format debian --release-version 3.4-2 +# +# +# input format: +# +# x.y[.z] [mm/dd/(yy|YYYY)] +# +# Added README file (#42) +# +# +# debian format: (intended for /usr/share/doc/weewx/changelog.Debian) +# +# package (x.y.z) unstable; urgency=low +# * Added README file (#42) +# -- Joe Packager Sat, 06 Oct 2012 06:50:47 -0500 +# +# +# rpm format (three permissible variants) (intended for %changelog in .spec): +# +# * Wed Jun 14 2003 Joe Packager - 1.0-2 +# - Added README file (#42). +# +# * Wed Jun 14 2003 Joe Packager 1.0-2 +# - Added README file (#42). +# +# * Wed Jun 14 2003 Joe Packager +# - 1.0-2 +# - Added README file (#42). + +## no critic (RegularExpressions) +## no critic (ProhibitPostfixControls) +## no critic (InputOutput::RequireCheckedSyscalls) +## no critic (ProhibitCascadingIfElse) +## no critic (ProhibitManyArgs) +## no critic (RequireBriefOpen) +## no critic (ProhibitReusedNames) +## no critic (ProhibitBacktickOperators) +## no critic (ValuesAndExpressions::ProhibitMagicNumbers) +## no critic (ProhibitPunctuationVars) + +use POSIX; +use Time::Local; +use Text::Wrap; +use strict; +use warnings; + +my $user = 'Mister Package'; +my $email = 'user@example.com'; +my $pkgname = 'weewx'; +my $ifn = q(); # input filename +my $release = q(); # release version +my $action = 'app'; # what to do, can be app or stub +my $fmt = '80col'; # format can be 80col, debian, or redhat +my $rc = 0; +my $MAXCOL = 75; +my %MONTHS = ('jan',1,'feb',2,'mar',3,'apr',4,'may',5,'jun',6, + 'jul',7,'aug',8,'sep',9,'oct',10,'nov',11,'dec',12,); + +($user,$email) = guessuser($user,$email); + +while ($ARGV[0]) { + my $arg = shift; + if ($arg eq '--ifile') { + $ifn = shift; + } elsif ($arg eq '--release-version') { + $release = shift; + } elsif ($arg eq '--user') { + $user = shift; + } elsif ($arg eq '--email') { + $email = shift; + } elsif ($arg eq '--action') { + $action = shift; + if ($action ne 'app' && $action ne 'stub') { + print {*STDERR} "mkchangelog: unrecognized action $action\n"; + $rc = 1; + } + } elsif ($arg eq '--format') { + $fmt = shift; + if ($fmt ne 'debian' && $fmt ne 'redhat') { + print {*STDERR} "mkchangelog: unrecognized format $fmt\n"; + $rc = 1; + } + } +} + +if ($action eq 'stub' && $release eq q()) { + print {*STDERR} "mkchangelog: warning! no version specified\n"; +} +if ($action eq 'app' && $ifn eq q()) { + print {*STDERR} "mkchangelog: no input file specified\n"; + $rc = 1; +} +if ($user eq q()) { + print {*STDERR} "mkchangelog: no user specified\n"; + $rc = 1; +} +if ($email eq q()) { + print {*STDERR} "mkchangelog: no email specified\n"; + $rc = 1; +} + +exit $rc if $rc != 0; + +if ($action eq 'stub') { + $rc = dostub($fmt, $release, $pkgname, $user, $email); +} elsif ($action eq 'app') { + $rc = doapp($ifn, $fmt, $release, $pkgname, $user, $email); +} + +exit $rc; + + + + +# create a skeletal changelog entry in the specified format. +# output goes to stdout. +sub dostub { + my ($fmt, $version, $pkgname, $user, $email) = @_; + my $rc = 0; + my $msg = 'new upstream release'; + + if ($fmt eq 'debian') { + my $tstr = strftime '%a, %d %b %Y %H:%M:%S %z', localtime time; + print {*STDOUT} "$pkgname ($version) unstable; urgency=low\n"; + print {*STDOUT} " * $msg\n"; + print {*STDOUT} " -- $user <$email> $tstr\n"; + } elsif ($fmt eq 'redhat') { + my $tstr = strftime '%a %b %d %Y', localtime time; + print {*STDOUT} "* $tstr $user <$email> - $version\n"; + print {*STDOUT} "- $msg\n"; + } else { + print {*STDERR} "mkchangelog: unrecognized format $fmt\n"; + $rc = 1; + } + + return $rc; +} + + +# convert the application log to the specified format. +# output goes to stdout. +sub doapp { + my ($ifn, $fmt, $release, $pkgname, $user, $email) = @_; + my $rc = 0; + + if (open my $IFH, '<', $ifn) { + my @paragraphs; + my $cp = q(); + my $version = q(); + my $ts = 0; + while(<$IFH>) { + my $line = $_; + if ($line =~ /^([0-9]+\.[0-9.X]+)/) { + my $v = $1; + if ($version ne q()) { + dumpsection($fmt, $pkgname, $version, $user, $email, $ts, + \@paragraphs); + } else { + # if a release version was specified, check the first + # version number in the changelog and ensure that it + # matches the release version. + if ($release ne q() && $version ne $release) { + print {*STDERR} "mkchangelog: latest changelog entry ($1) does not match release ($release)\n"; +# exit 1; + $v = $release; + } + } + @paragraphs = (); # ignore anything before first valid version + $version = $v; + $ts = time; + if ($line =~ /(\d+)\/(\d+)\/(\d+)/) { + my($month,$day,$year) = ($1,$2,$3); + $ts = timelocal(0,0,0,$day,$month-1,$year); + } elsif ($line =~ /(\d+) (\S+) (\d+)/) { + my($day,$mstr,$year) = ($1,$2,$3); + $mstr = lc $mstr; + my $month = $MONTHS{$mstr}; + $ts = timelocal(0,0,0,$day,$month-1,$year); + } + } elsif ($line =~ /\S/) { + $cp .= $line; + } else { + push @paragraphs, $cp; + $cp = q(); + } + } + push @paragraphs, $cp if $cp ne q(); + dumpsection($fmt,$pkgname, $version, $user, $email, $ts, \@paragraphs); + } else { + print {*STDERR} "mkchangelog: cannot read $ifn: $!\n"; + $rc = 1; + } + + return $rc; +} + +# print out a block of paragraphs in the appropriate format +sub dumpsection { + my ($fmt, $pkgname, $version, $user, $email, $ts, $pref) = @_; + my @paragraphs = @{$pref}; + return if ($#paragraphs < 0); + + my $prefix = q(); + my $firstlinepfx = q(); + my $laterlinepfx = q(); + my $postfix = q(); + + if ($fmt eq '80col') { +# my $tstr = strftime '%m/%d/%y', localtime $ts; + my $tstr = strftime '%d %b %Y', localtime $ts; + $prefix = "$version ($tstr)\n"; + $firstlinepfx = "\n"; + $postfix = "\n"; + } elsif ($fmt eq 'debian') { + my $tstr = strftime '%a, %d %b %Y %H:%M:%S %z', localtime $ts; + $prefix = "$pkgname ($version) unstable; urgency=low\n\n"; + $firstlinepfx = q( * ); + $laterlinepfx = q( ); + $postfix = "\n -- $user <$email> $tstr\n\n\n"; + } elsif ($fmt eq 'redhat') { + # use redhat format number 3 + my $tstr = strftime '%a %b %d %Y', localtime $ts; + $prefix = " * $tstr $user <$email>\n - $version\n"; + $firstlinepfx = q( - ); + $laterlinepfx = q( ); + $postfix = "\n"; + } + + # lines that beging with two or more spaces will be considered fixed space + # and will not be subjected to word wrap. we do this by replacing spaces + # in those lines with the ~ character then padding them with the ! + # character, then replacing both after word wrap has been applied. + print $prefix; + foreach my $p (@paragraphs) { + # escape lines that begin with spaces to prevent them from wrapping + my @lines; + foreach my $line (split /\n/,$p) { + if ($line =~ /^\s+/) { + $line =~ s/\s/~/g; + while(length($line) < $MAXCOL) { $line .= q(!); } + } + push @lines, $line; + } + # do the word wrap + $p = Text::Wrap::fill(q(),q(),join q( ), @lines); + # unescape the fixed spacing lines + @lines = (); + foreach my $line (split /\n/,$p) { + if ($line =~ /~/) { + $line =~ s/~/ /g; + $line =~ s/!//g; + } + push @lines, $line; + } + # print out the result + my $pfx = $firstlinepfx; + foreach my $ln (@lines) { + print {*STDOUT} "$pfx$ln\n"; + $pfx = $laterlinepfx; + } + } + print $postfix; + return; +} + +# use gpg to guess the user,email pair of the person running this script. +# if there are multiple gpg identities, then use the last one. +# if gpg gives us nothing, fallback to USER and USER@hostname. +sub guessuser { + my($fb_user,$fb_email) = @_; + my($user) = q(); + my($email) = q(); + my $env_user = $ENV{USER}; + if ($env_user ne q()) { + my @lines = `gpg --list-keys $env_user`; + foreach my $line (@lines) { + if ($line =~ /$env_user/) { + if ($line =~ /uid\s+(.*) <([^>]+)/) { + $user = $1; + $email = $2; + # strip off any [xxx] prefix (introduced in gpg 2017-ish) + $user =~ s/\s*\[[^\]]+\]\s*//; + } + } + } + if ($user eq q()) { + $user = $env_user; + my $hn = `hostname`; + chop $hn; + $email = $user . q(@) . $hn; + } + } + $user = $fb_user if $user eq q(); + $email = $fb_email if $email eq q(); + return ($user,$email); +} diff --git a/dist/weewx-5.0.2/pkg/rpmlint.el b/dist/weewx-5.0.2/pkg/rpmlint.el new file mode 100644 index 0000000..0986e44 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/rpmlint.el @@ -0,0 +1,12 @@ +addFilter("summary-not-capitalized") +addFilter("no-manual-page-for-binary weectl") +addFilter("no-manual-page-for-binary weewxd") +addFilter("dangerous-command-in-%preun rm") +addFilter("dangerous-command-in-%pre cp") +addFilter("dangerous-command-in-%post cp") +addFilter("dangerous-command-in-%post mv") +# these are helper scripts that use /usr/bin/env +addFilter("wrong-script-interpreter .*/setup_mysql.sh") +addFilter("wrong-script-interpreter .*/i18n-report") +# logwatch stuff belongs in /etc in case logwatch not installed +addFilter("executable-marked-as-config-file /etc/weewx/logwatch/scripts/services/weewx") diff --git a/dist/weewx-5.0.2/pkg/rpmlint.suse b/dist/weewx-5.0.2/pkg/rpmlint.suse new file mode 100644 index 0000000..74e01b3 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/rpmlint.suse @@ -0,0 +1,9 @@ +addFilter("summary-not-capitalized") +addFilter("invalid-license GPLv3") +addFilter("env-script-interpreter .*setup_mysql.sh") +addFilter("env-script-interpreter .*i18n-report") +addFilter("files-duplicate /etc/weewx/weewx.conf") +addFilter("files-duplicate .*/favicon.ico") +addFilter("files-duplicate .*/custom.js") +addFilter("files-duplicate .*/icon_ipad_x..png") +addFilter("files-duplicate .*/icon_iphone_x..png") diff --git a/dist/weewx-5.0.2/pkg/weewx-el.repo b/dist/weewx-5.0.2/pkg/weewx-el.repo new file mode 100644 index 0000000..c07e14b --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-el.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/yum/weewx/el$releasever +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx-el7.repo b/dist/weewx-5.0.2/pkg/weewx-el7.repo new file mode 100644 index 0000000..98b6161 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-el7.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/yum/weewx/el7 +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx-el8.repo b/dist/weewx-5.0.2/pkg/weewx-el8.repo new file mode 100644 index 0000000..8145b9a --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-el8.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/yum/weewx/el8 +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx-el9.repo b/dist/weewx-5.0.2/pkg/weewx-el9.repo new file mode 100644 index 0000000..57f9dd7 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-el9.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/yum/weewx/el9 +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx-python2.list b/dist/weewx-5.0.2/pkg/weewx-python2.list new file mode 100644 index 0000000..1413e6e --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-python2.list @@ -0,0 +1 @@ +deb [arch=all] http://weewx.com/apt/python2 squeeze main diff --git a/dist/weewx-5.0.2/pkg/weewx-python3.list b/dist/weewx-5.0.2/pkg/weewx-python3.list new file mode 100644 index 0000000..60e6cbb --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-python3.list @@ -0,0 +1 @@ +deb [arch=all] http://weewx.com/apt/python3 buster main diff --git a/dist/weewx-5.0.2/pkg/weewx-suse.repo b/dist/weewx-5.0.2/pkg/weewx-suse.repo new file mode 100644 index 0000000..d68b8ec --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-suse.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/suse/weewx/suse$releasever_major +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx-suse12.repo b/dist/weewx-5.0.2/pkg/weewx-suse12.repo new file mode 100644 index 0000000..28e1a25 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-suse12.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/suse/weewx/suse12 +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx-suse15.repo b/dist/weewx-5.0.2/pkg/weewx-suse15.repo new file mode 100644 index 0000000..876d383 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx-suse15.repo @@ -0,0 +1,5 @@ +[weewx] +name=weewx +baseurl=http://weewx.com/suse/weewx/suse15 +enabled=1 +gpgcheck=1 diff --git a/dist/weewx-5.0.2/pkg/weewx.spec.in b/dist/weewx-5.0.2/pkg/weewx.spec.in new file mode 100644 index 0000000..b9d1df2 --- /dev/null +++ b/dist/weewx-5.0.2/pkg/weewx.spec.in @@ -0,0 +1,377 @@ +# spec for building a weewx rpm for redhat or suse systems +# License: GPLv3 +# Author: (c) 2013-2024 Matthew Wall + +# the operating system release number is specified externaly, so that we can +# do cross-release (but not cross-platform) packaging. +%global os_target OSREL +%global relnum RPMREVISION +%global weewx_version WEEWX_VERSION + +# suse 15: python3 +%if 0%{?suse_version} && "%{os_target}" == "15" +%define app_group Productivity/Scientific/Other +%define relos .suse15 +%define platform suse +%define deps python3, python3-importlib_resources, python3-configobj, python3-Cheetah3, python3-Pillow, python3-pyserial, python3-usb, python3-ephem +%define python python3 +%endif + +# rh: python3 on redhat, fedora, centos, rocky +%if "%{_vendor}" == "redhat" +%define app_group Applications/Science +%define platform redhat +# disable shebang mangling. see https://github.com/atom/atom/issues/21937 +%undefine __brp_mangle_shebangs +%if "%{os_target}" == "8" +%define relos .el8 +# rh8 ships with python 3.6, which has pre-built modules required by weewx. +# weewx also requires the importlib.resource module from python 3.7, which is +# backported to python 3.6. python 3.8, python 3.9, and python 3.11 are also +# available on rh8, but none of the modules required by weewx are available for +# those python (as of nov2023). +%define deps epel-release, python3, python3-importlib-resources, python3-configobj, python3-cheetah, python3-pillow, python3-pyserial, python3-pyusb, python3-ephem +%define python python3 +%endif +%if "%{os_target}" == "9" +%define relos .el9 +# rh9 ships with python 3.9, which has pre-built modules required by weewx. +# python3-cheetah, python3-pillow are in epel +# ephem is not available for redhat9 +%define deps epel-release, python3, python3-configobj, python3-cheetah, python3-pillow, python3-pyserial, python3-pyusb +%define python python3 +%endif +%endif + +%global release %{relnum}%{?relos:%{relos}} + +%global dst_code_dir %{_datadir}/weewx +%global dst_cfg_dir %{_sysconfdir}/weewx +%global dst_user_dir %{dst_cfg_dir}/bin/user +%global dst_doc_dir %{_datadir}/weewx-doc +%global cfg_file %{dst_cfg_dir}/weewx.conf +%global systemd_dir %{_unitdir} +%global udev_dir %{_udevrulesdir} +%global sqlite_root /var/lib/weewx +%global html_root /var/www/html/weewx + +%define entry_points weewxd weectl + +Summary: weather software +Name: weewx +Version: %{weewx_version} +Release: %{release} +Group: %{app_group} +Source: %{name}-%{version}.tar.gz +URL: https://www.weewx.com +License: GPLv3 +AutoReqProv: no +Requires: %{deps} +Requires(pre): /usr/bin/getent, /usr/sbin/groupadd, /usr/sbin/useradd +BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-%(%{__id_u} -n) +BuildArch: noarch + +%description +weewx interacts with a weather station to produce graphs, reports, and HTML +pages. weewx can upload data to weather services such as WeatherUnderground, +PWSweather.com, or CWOP. + +%prep +%setup -q + +%build + +%install +rm -rf %{buildroot} +mkdir -p %{buildroot}%{dst_code_dir} +mkdir -p %{buildroot}%{dst_cfg_dir} +mkdir -p %{buildroot}%{dst_doc_dir} +mkdir -p %{buildroot}%{_bindir} + +# rpm wants copyright and license even if no docs +cp docs_src/copyright.md %{buildroot}%{dst_doc_dir}/copyright +cp LICENSE.txt %{buildroot}%{dst_doc_dir}/license + +# copy the weewx code +cp -r src/* %{buildroot}%{dst_code_dir} + +# copy the ancillary files to the correct location +cp -r src/weewx_data/examples %{buildroot}%{dst_cfg_dir} +cp -r src/weewx_data/util/import %{buildroot}%{dst_cfg_dir} +cp -r src/weewx_data/util/logwatch %{buildroot}%{dst_cfg_dir} +cp -r src/weewx_data/util/rsyslog.d %{buildroot}%{dst_cfg_dir} +cp -r src/weewx_data/util/logrotate.d %{buildroot}%{dst_cfg_dir} +mkdir %{buildroot}%{dst_cfg_dir}/systemd +cp pkg/etc/systemd/system/weewx.service %{buildroot}%{dst_cfg_dir}/systemd +cp pkg/etc/systemd/system/weewx@.service %{buildroot}%{dst_cfg_dir}/systemd +mkdir %{buildroot}%{dst_cfg_dir}/udev +cp pkg/etc/udev/rules.d/weewx.rules %{buildroot}%{dst_cfg_dir}/udev + +# create the weewx configuration +sed \ + -e 's:HTML_ROOT = public_html:HTML_ROOT = %{html_root}:' \ + -e 's:SQLITE_ROOT = .*:SQLITE_ROOT = %{sqlite_root}:' \ + src/weewx_data/weewx.conf > %{buildroot}%{dst_cfg_dir}/weewx.conf + +# make a copy of the generic configuration file +cp %{buildroot}%{dst_cfg_dir}/weewx.conf %{buildroot}%{dst_cfg_dir}/weewx.conf-%{weewx_version} + +# create the entry points +for f in %{entry_points}; do \ + sed \ + -e 's%WEEWX_BINDIR=.*%WEEWX_BINDIR=/usr/share/weewx%' \ + -e 's%WEEWX_PYTHON=.*%WEEWX_PYTHON=%{python}%' \ + bin/$f > %{buildroot}%{_bindir}/$f; \ +done + + +%pre +# if there is already a database directory, then use ownership of that to +# determine what user/group we should use for permissions and running. +# otherwise, use 'weewx' for user and group. +WEEWX_HOME="${WEEWX_HOME:-/var/lib/weewx}" +WEEWX_USER="${WEEWX_USER:-weewx}" +WEEWX_GROUP="${WEEWX_GROUP:-weewx}" +if [ -d %{sqlite_root} ]; then + TMP_USER=$(stat -c "%%U" %{sqlite_root}) + if [ "$TMP_USER" != "root" -a "$TMP_USER" != "weewx" -a "$TMP_USER" != "UNKNOWN" ]; then + WEEWX_USER=$TMP_USER + WEEWX_GROUP=$(stat -c "%%G" %{sqlite_root}) + fi +fi + +# create the weewx user and group if they do not yet exist +if ! /usr/bin/getent group | grep -q "^$WEEWX_GROUP"; then + echo -n "Adding system group $WEEWX_GROUP..." + /usr/sbin/groupadd -r $WEEWX_GROUP > /dev/null + echo "done" +fi +if ! /usr/bin/getent passwd | grep -q "^$WEEWX_USER"; then + echo -n "Adding system user $WEEWX_USER..." + /usr/sbin/useradd -r -g $WEEWX_GROUP -M -d $WEEWX_HOME -s /sbin/nologin $WEEWX_USER > /dev/null + echo "done" +fi + +# add the user doing the install to the weewx group, if appropriate +if [ "$WEEWX_GROUP" != "root" ]; then + # see who is running the installation + inst_user=$USER + if [ "$SUDO_USER" != "" ]; then + inst_user=$SUDO_USER + fi + # put the user who is doing the installation into the weewx group, + # but only if it is not root or the weewx user. + if [ "$inst_user" != "root" -a "$inst_user" != "$WEEWX_USER" ]; then + # if user is already in the group, then skip it + if ! /usr/bin/getent group $WEEWX_GROUP | grep -q $inst_user; then + echo "Adding user $inst_user to group $WEEWX_GROUP" + usermod -aG $WEEWX_GROUP $inst_user + else + echo "User $inst_user is already in group $WEEWX_GROUP" + fi + fi +fi + +if [ $1 -gt 1 ]; then + # this is an upgrade + if [ -f %{cfg_file} ]; then + echo Saving previous config as %{cfg_file}.prev + cp -p %{cfg_file} %{cfg_file}.prev + fi +fi + + + +%post +precompile() { + rc=$(%{python} -m compileall -q -x 'user' %{dst_code_dir}) + if [ "$rc" != "" ]; then + echo "Pre-compile failed!" + echo "$rc" + fi +} + +# get the version number from the specified file, without the rpm revisions +get_conf_version() { + v=$(grep '^version.*=' $1 | sed -e 's/\s*version\s*=\s*//' | sed -e 's/-.*//') + if [ "$v" = "" ]; then + # someone might have messed with the version string + v="xxx" + fi + echo $v +} + +set_permissions() { + usr=$1 + grp=$2 + dir=$3 + find $3 -type f -exec chmod 664 {} \; + find $3 -type d -exec chmod 2775 {} \; + chmod 2775 $dir + chown -R $usr $dir + chgrp -R $grp $dir +} + +# timestamp for files we must move aside +ts=`/usr/bin/date +"%%Y%%m%%d%%H%%M%%S"` + +# figure out which user should own everything +WEEWX_USER="${WEEWX_USER:-weewx}" +WEEWX_GROUP="${WEEWX_GROUP:-weewx}" +if [ -d %{sqlite_root} ]; then + TMP_USER=$(stat -c "%%U" %{sqlite_root}) + if [ "$TMP_USER" != "root" -a "$TMP_USER" != "weewx" -a "$TMP_USER" != "UNKNOWN" ]; then + WEEWX_USER=$TMP_USER + WEEWX_GROUP=$(stat -c "%%G" %{sqlite_root}) + fi +fi + +# insert values into the defaults file (used by the entry points) +dflts=/etc/default/weewx +if [ -f $dflts ]; then + mv $dflts ${dflts}-$ts +fi +echo "WEEWX_PYTHON=python3" > $dflts +echo "WEEWX_BINDIR=/usr/share/weewx" >> $dflts + +# see which init system (if any) is running +pid1=none +if [ -d /run/systemd/system ]; then + pid1=systemd +fi + +# install the init files +if [ -d %{systemd_dir} ]; then + for f in weewx.service weewx@.service; do + sed \ + -e "s/User=.*/User=${WEEWX_USER}/" \ + -e "s/Group=.*/Group=${WEEWX_GROUP}/" \ + %{dst_cfg_dir}/systemd/$f > %{systemd_dir}/$f + done + if [ "$pid1" = "systemd" ]; then + systemctl daemon-reload > /dev/null 2>&1 || : + fi +fi + +# install the udev rules +if [ -d %{udev_dir} ]; then + sed \ + -e "s/GROUP=\"weewx\"/GROUP=\"${WEEWX_GROUP}\"/" \ + %{dst_cfg_dir}/udev/weewx.rules > %{udev_dir}/60-weewx.rules +fi + +# copy the skins if there are not already skins in place +if [ ! -d %{dst_cfg_dir}/skins ]; then + cp -rp %{dst_code_dir}/weewx_data/skins %{dst_cfg_dir} +fi + +# create the user extensions directory if one does not already exist +if [ ! -d %{dst_user_dir} ]; then + mkdir -p %{dst_user_dir} + cp %{dst_code_dir}/weewx_data/bin/user/__init__.py %{dst_user_dir} + cp %{dst_code_dir}/weewx_data/bin/user/extensions.py %{dst_user_dir} +fi + +# create database directory +mkdir -p %{sqlite_root} +set_permissions $WEEWX_USER $WEEWX_GROUP %{sqlite_root} + +# create the reports directory +mkdir -p %{html_root} +set_permissions $WEEWX_USER $WEEWX_GROUP %{html_root} + +if [ "$1" = "1" ]; then + # this is a new installation + # create a sane configuration file with simulator as the station type + /usr/bin/weectl station reconfigure --config=%{cfg_file} --driver=weewx.drivers.simulator --no-prompt --no-backup > /dev/null + # pre-compile the python code + precompile + # ensure correct ownership of configuration, skins, and extensions + set_permissions $WEEWX_USER $WEEWX_GROUP %{dst_cfg_dir} + if [ "$pid1" = "systemd" ]; then + systemctl enable weewx > /dev/null 2>&1 || : + systemctl start weewx > /dev/null 2>&1 || : + fi +elif [ $1 -gt 1 ]; then + # this is an upgrade + # upgrade a copy of the previous config to create the upgraded version, but + # do not touch the user's configuration. + # weewx.conf - user's conf (old) + # weewx.conf-new - new conf for this weewx version + # weewx.conf-old-new - user's conf upgraded to this weewx version + # weewx.conf.rpmnew - new conf from this rpm (created by rpm rules) + if [ -f %{cfg_file}.prev ]; then + OLDVER=$(get_conf_version %{cfg_file}.prev) + if [ -f %{cfg_file}-%{weewx_version} ]; then + MNT=${OLDVER}-%{weewx_version} + echo Creating maintainer config file as %{cfg_file}-$MNT + cp -p %{cfg_file}.prev %{cfg_file}-$MNT + /usr/bin/weectl station upgrade --config=%{cfg_file}-$MNT --dist-config=%{cfg_file}-%{weewx_version} --what=config --no-backup --yes > /dev/null + fi + fi + # if this is an upgrade from V4, copy any extensions to the V5 location + if [ -d /usr/share/weewx/user ]; then + echo "Copying old extensions to /etc/weewx/bin/user" + cp -rp /usr/share/weewx/user/* /etc/weewx/bin/user + echo "Moving old extensions to /usr/share/weewx/user-$ts" + mv /usr/share/weewx/user /usr/share/weewx/user-$ts + fi + # pre-compile the python code + precompile + # ensure correct ownership of configuration, skins, and extensions + set_permissions $WEEWX_USER $WEEWX_GROUP %{dst_cfg_dir} + # do a full restart of weewx + if [ "$pid1" = "systemd" ]; then + systemctl stop weewx > /dev/null 2>&1 || : + systemctl start weewx > /dev/null 2>&1 || : + fi +fi + + +%preun +# 0 remove last version +# 1 first install +# 2 upgrade + +# see which init system (if any) is running +pid1=none +if [ -d /run/systemd/system ]; then + pid1=systemd +fi + +if [ "$1" = "0" ]; then + # this is an uninstall, so stop and remove everything + if [ "$pid1" = "systemd" ]; then + systemctl stop weewx > /dev/null 2>&1 || : + systemctl disable weewx > /dev/null 2>&1 || : + for f in weewx.service weewx@.service; do + if [ -f %{systemd_dir}/$f ]; then + rm -f %{systemd_dir}/$f + fi + done + fi + # remove udev rules + if [ -f %{udev_dir}/60-weewx.rules ]; then + rm -f %{udev_dir}/60-weewx.rules + fi + # remove any bytecompiled code + find /usr/share/weewx -name '*.pyc' -delete + find /usr/share/weewx -name __pycache__ -delete + find /etc/weewx/bin -name '*.pyc' -delete + find /etc/weewx/bin -name __pycache__ -delete +fi +# otherwise this is a first install or upgrade, so do nothing + + +%clean +rm -rf %{buildroot} + +%files +%defattr(-,root,root) +%attr(0755,root,root) %{_bindir}/weewxd +%attr(0755,root,root) %{_bindir}/weectl +%{dst_code_dir}/ +%doc %{dst_doc_dir}/ +%config(noreplace) %{dst_cfg_dir}/ + +%changelog diff --git a/dist/weewx-5.0.2/poetry.lock b/dist/weewx-5.0.2/poetry.lock new file mode 100644 index 0000000..05eefa5 --- /dev/null +++ b/dist/weewx-5.0.2/poetry.lock @@ -0,0 +1,297 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "configobj" +version = "5.0.8" +description = "Config file reading, writing and validation." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "configobj-5.0.8-py2.py3-none-any.whl", hash = "sha256:a7a8c6ab7daade85c3f329931a807c8aee750a2494363934f8ea84d8a54c87ea"}, + {file = "configobj-5.0.8.tar.gz", hash = "sha256:6f704434a07dc4f4dc7c9a745172c1cad449feb548febd9f7fe362629c627a97"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "ct3" +version = "3.3.3" +description = "Cheetah is a template engine and code generation tool" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "CT3-3.3.3-cp27-cp27m-macosx_10_6_x86_64.whl", hash = "sha256:bcff08ccf08c4477a8709a05d54af9c2ede1a7c7836f082493d341fa112ef658"}, + {file = "CT3-3.3.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4bd551058f6c6ba052b3ab809df4b5cd11ab8be3a496172ab9f5c3bdfe1ad205"}, + {file = "CT3-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:526aafb89fe4438eb75737d095fc2421fe57ee57b2ff96d8d8d1119c9049d5e4"}, + {file = "CT3-3.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c98988f2ebacafba7c55db44f043d2b059dcec1e89faad60d362420864fb56b"}, + {file = "CT3-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:2853607288223781d1b245d97cc8d412936f83ad6f89885fc3c9ce0588ec154a"}, + {file = "CT3-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:acb7b38ebbf2f7062287a0e6a521e8cdda194b68d337f8301ec785799112eb49"}, + {file = "CT3-3.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44e9a055574de6af5908f8b8c4d1cf62a78f43b72d415e9765fcae468cff48d7"}, + {file = "CT3-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:232cf77df8c4bec5c9d025dbd2e1fbdd12547e26f7f6ea62a4b43702725518e0"}, + {file = "CT3-3.3.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bae62162c27fd615add57e47ec5587d41fb10f022d45b283d7973744cc4928c0"}, + {file = "CT3-3.3.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22097660706d047eee00842406d3aea04bc3a4b1f22c841cb49cc508ce5da07b"}, + {file = "CT3-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:ad296aba70a71f3c6fc0885d35085e3dbda5abdf750bbcec04e1e2fbc41d3dd1"}, + {file = "CT3-3.3.3-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:774142aeb1c6cbf38dfd865a0d4527019aa3feda045b4db704d44fe5a6d33a59"}, + {file = "CT3-3.3.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:32ebd360162a1aaf483a803ff3e20fe8e99e6b5e364ad1a2ec93a44e7ef949e4"}, + {file = "CT3-3.3.3-cp35-cp35m-win_amd64.whl", hash = "sha256:d1c27fbdb733e25b6cd5bdc87071e3332aba6e0b8bb1d245d94ad8be48db32ea"}, + {file = "CT3-3.3.3-cp36-cp36m-macosx_10_7_x86_64.whl", hash = "sha256:f52944b394b27129348056030c40a2398882dc06a1b0bcd90218acd40e06ed73"}, + {file = "CT3-3.3.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ffeaed89384f85d5758d715d8f3bbd97ffef864f62ad53355d5a6b05a609866c"}, + {file = "CT3-3.3.3-cp36-cp36m-win_amd64.whl", hash = "sha256:694c54e1c48293555910d8d11291f6c6fa2a5fb236197db0c31c0d156be28ac8"}, + {file = "CT3-3.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e4e17ff1bea90cf0a4e3ee3ef48274f6fe2149d5526578acad66576b84d8a3e9"}, + {file = "CT3-3.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2f24442c56538f236799142c3aa57bb4cc42c93f32e7b9895f8c278fab836d9b"}, + {file = "CT3-3.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:3156a352272470b8d2749fd1f08ad156b7c4b671597581746e0d6fe6d4cc446d"}, + {file = "CT3-3.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505c0b4391ca299a28468cc281ef76de901fdabd640e6b72a7799b988d1fdfa6"}, + {file = "CT3-3.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a8d6b4676c11a0d9e7a554297d2c74c2a88f0eda1b33cb5305d5dde6cd94be3f"}, + {file = "CT3-3.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:c69e76902c751938e7c6b3ec9e8678046d9a78e54b96461e88b670e8b25f070e"}, + {file = "CT3-3.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0e00e9a1086b8b7aa340e0aafd508c92bb1f39b97a9d1c8d4dd1eaa548efdc3"}, + {file = "CT3-3.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cde30b9b584aad1f97b7c4e228a5acc21cb01316ec51ae224c55342544310101"}, + {file = "CT3-3.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:da06a9bee3dcc5ab045d15e966b79494a2fb59ada1d078f9183614101b2804fe"}, + {file = "CT3-3.3.3-py2-none-any.whl", hash = "sha256:7608035bdebc970d034a3c9503c41e023ab21f5e3f53bc1845e85740b73e6fe9"}, + {file = "CT3-3.3.3.tar.gz", hash = "sha256:32b6edf228d1243787a67d3731ce4c26a05bc9a3c9458e893573d700eecc6c5c"}, +] + +[package.extras] +filters = ["markdown"] +markdown = ["markdown"] + +[[package]] +name = "ephem" +version = "4.1.5" +description = "Compute positions of the planets and stars" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "ephem-4.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0796dbcd24f76af0e81c22e1f709b42873ef81d2c4dfa962f8d346f11489785d"}, + {file = "ephem-4.1.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0266cf69d594bd94034bd13c18dbcef13b301acd7357d7cf7d1bb8acaf7f00b4"}, + {file = "ephem-4.1.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3db8d53a37c772e1b132f5ed3535027d73e8dca4ead99a7563f09c4308996b63"}, + {file = "ephem-4.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c6e7523d8856caa4ac53db1988693ce7a516fb25c1cd3c74a3472f951691c0e"}, + {file = "ephem-4.1.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203bd3f00c49012bd07eb2627bcf9538f608b3431cb8053a7d3115e1df396312"}, + {file = "ephem-4.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec7ddf15c319a83fb5dadff58bb6e5fbcaff09338f5c2a1b2c7b1445b87002b5"}, + {file = "ephem-4.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:13f800e03aad215ae6a4217122e64d0ce90e914574f8b5afa381d81dad0eecaa"}, + {file = "ephem-4.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:83921b341b4f4725e59c1463b9613f3fd3991392e17904170b146f5d6945afcb"}, + {file = "ephem-4.1.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6219412800138f489733cb3a8b4fab0a8b3ef2dd5ec143aff35950dca9a71c36"}, + {file = "ephem-4.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b95bf8c92a5eae665d63371440b93778b2508ad448907d12f72ed6ed08ea476c"}, + {file = "ephem-4.1.5-cp310-cp310-win32.whl", hash = "sha256:afefe022448f09c3e15472bd3ad30fa4824f8c4a9de65216b9952b9dfcec8750"}, + {file = "ephem-4.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:53df72aa3e2f1359ed56587b1930d0030fec030db71744564a83c10ea54316a7"}, + {file = "ephem-4.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b3e385d8447d908be6dd8c53c4452fee7d61040c9b6bb5a60195b57c85545ca6"}, + {file = "ephem-4.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:be6c4ced150d79ab6b5ff3bb7545fee26cf7f4f64ccf476a14850a63bbf125e7"}, + {file = "ephem-4.1.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c8a70e36632e88227c4ba8184fd91dbd32d0140f799202b5fb8510508f4c8e4"}, + {file = "ephem-4.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ee2a4966167a0300db2c9155e4b64fc1a6bb67ca869659d4c0342869c44d4f3"}, + {file = "ephem-4.1.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b45b9b15e65c7f26c1342b70e06e49bd83a9c527affe9de0fb5953a3a25a0cda"}, + {file = "ephem-4.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e21a8120569f315ce181f2e654528cd10c18cb92382248de90574effed57d227"}, + {file = "ephem-4.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:864d6aba1699f26e7768ed424d5218396300dce6d8deccb1c97164a424343675"}, + {file = "ephem-4.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8f8f5dd5b1343dc77ea891ab423677a631fd7b820bbbeac7ec53ee332e8a4ca5"}, + {file = "ephem-4.1.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:8a2347ad88aad026dff6f00b931bd2b0d6b4f184bf65ffd4b62f557526bf335d"}, + {file = "ephem-4.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5496ebc21b2873e986836ad0756818bee80ed6deee58e1167a9876e4a64f0bbf"}, + {file = "ephem-4.1.5-cp311-cp311-win32.whl", hash = "sha256:f319ca58ee2ee27c18e42657ad8d3d1b0251dd369c9fd60af45b2bc2c42ded0a"}, + {file = "ephem-4.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:cc6a49fd3250cf67305230da962d779632b13da2a8fae5c383e973fa113baa97"}, + {file = "ephem-4.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:51c17c62b031ec246546aa482d98e9a5573405385eb7adf7154f5a1af309ac8a"}, + {file = "ephem-4.1.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ff1436219d2dad1433c5f0a61f3a947ab6d4c622cc6a90d7cb5c2e28a8669fba"}, + {file = "ephem-4.1.5-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9495d515a6f49bd7a90f58320af1a95e4c3ab89307fbf31045cf05570f779eaf"}, + {file = "ephem-4.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7952e8b54c771be6a58ce9aca91d729bcdace2293c68fe825df3433995aedafc"}, + {file = "ephem-4.1.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e99ba9fbd5c136fc18b94fe5e709a7c4a19f13ed1c6a16bdec448cbd7d1bcb1"}, + {file = "ephem-4.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d2922e6cb66434e9edd3764b103b6bdd394a701bd39395c46f0a7751b9829ee"}, + {file = "ephem-4.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:692f8dea9e91447c6406e9ee7001075d5794948677f5f8ee4601c146509b15b1"}, + {file = "ephem-4.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7122f46a0ce2c2551360bf43b693a3f1f543c0aa6e736b2513bc3203df27df7c"}, + {file = "ephem-4.1.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:a68dd823d823ace3ecd94b3cf926d51ae8aa88ce248159850a8db45547720843"}, + {file = "ephem-4.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cf745e4f02455d6c0f1d85a61206c420ee2c909b392266109e2da2d3a8c023db"}, + {file = "ephem-4.1.5-cp312-cp312-win32.whl", hash = "sha256:e1772852072727e848b5fe035ce7d9dc4662c1baffbd9e4cd7467ecf384db1d9"}, + {file = "ephem-4.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:fd984e38c5078be8cb881ed75c69b2204a1d7fd2230571fc92112edfc5362bf6"}, + {file = "ephem-4.1.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:740a5950b31ac9a7ca40a95b7cfb1678e747e860f77ac0030984c10dc5464e7f"}, + {file = "ephem-4.1.5-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faf5b786ed28e7d00704d2cf3862dec75223790ed800c285a91acfa538b1755c"}, + {file = "ephem-4.1.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87e5be99ebc3bfa1dc70f31ba737dcdf9f6219570fa9a5729d0ff60134c7dc43"}, + {file = "ephem-4.1.5-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:261f82bd821cfbb8a056bb834da06d19d3afd3eb735c8cd549f775e45caef502"}, + {file = "ephem-4.1.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc612e5d1829f64a8db55d8b2566bef4ce479c1bbee110fedf3197170635f70c"}, + {file = "ephem-4.1.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9fa8ee5d77a8a27a21d39142ee7c6332915b9e79a416a941cbc6f3db64c32cfe"}, + {file = "ephem-4.1.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c109219a4728281dd4e8b1e9f449900d7a63e9400ef43c85151750d4725b0c9e"}, + {file = "ephem-4.1.5-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:cf81a3bfa15d84871c4f87ecbf92280184ce96619cacdfe55f761d510a7f81db"}, + {file = "ephem-4.1.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:39dcc1b00882a2782771b12731a943706da8990c89be75caa294f0574cca661f"}, + {file = "ephem-4.1.5-cp36-cp36m-win32.whl", hash = "sha256:7b6384a032897d3e47da92ccc3348b74517c0dcf478a63b8cdedf69c177bccd2"}, + {file = "ephem-4.1.5-cp36-cp36m-win_amd64.whl", hash = "sha256:3bea9cb5a4a98d9c51430a395c4ad4f6307402fa7d3ffbe1bb28f7508e094dd3"}, + {file = "ephem-4.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cac6ed1664e7db22f3aadd8bc6b7afed191b8d5ce3aab9a00d719890a7c344d2"}, + {file = "ephem-4.1.5-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02316f8ebb3f82273bc5fbb2970fd98bfb56ed8349197aff0f56c9b5fa04e47a"}, + {file = "ephem-4.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b044d4e3b11953b0543cbd10468bf84afaa840d165c6effbffff3c15d721234"}, + {file = "ephem-4.1.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a69b27eb82d7c0dccdd54e11ad27f3cadcddb02169a570ac7864dbaa9a10486"}, + {file = "ephem-4.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b01734c27ce38705418191322fd7bd0372d9709d326263991b1c7e1bc75a59f"}, + {file = "ephem-4.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:bcae4b30c4a7474cfc102610f23a4a114196c8d15165cbc0551fb0d542a768eb"}, + {file = "ephem-4.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6fde92b6cc7837c141506a09f721f983e31b32785a9680f4d075be9763dc931b"}, + {file = "ephem-4.1.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:0849f522d1ad4daac5407307be786a54283599cfe87f3fcfc677153db45d7e22"}, + {file = "ephem-4.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:82025d4a6d5f6d76a1f45b4eb940030be93e38a3ac3c589990172beac53c854e"}, + {file = "ephem-4.1.5-cp37-cp37m-win32.whl", hash = "sha256:d7c446228a8756fa39876701abf34d516cd9b49b867d52c2f661f1cf1d927335"}, + {file = "ephem-4.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:b1f509901dcf2b95cc175be39c90eb457e081f8ed9762f4aae74f45e092c7f8b"}, + {file = "ephem-4.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8d1ca9cec961115a6197faee6ae2231bc76f9a7090bfdf759c810718c7715db9"}, + {file = "ephem-4.1.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3f95e89ab21e5214dbadfd41f9c94ce538bcd4411c18400cc638a00df728d67c"}, + {file = "ephem-4.1.5-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:21e8239ec63c9c6a1ab4d8c578bf6b34c114a3754b80c2e02ce07de827925353"}, + {file = "ephem-4.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:972ff0942ddf0f4ac5124bd974608b8371b22e84ab5ed49b4634455331f41987"}, + {file = "ephem-4.1.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd85a8b5b20020e791273f9cfd80510e4f647c362e5a3b7aec2abcb604d8d02a"}, + {file = "ephem-4.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:543ddd9e6bf573c24c1ccf30257ec655d8be00e28279fbdadbc774927d949663"}, + {file = "ephem-4.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:beea8cccaf184cd2a16c091b0ce92293f1128e49fd43e3c81e79c9f8d051cea6"}, + {file = "ephem-4.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4f53899fbcdd14790b7a61aa70218337e1c6aabc26af99d2485ed6d98fec8cee"}, + {file = "ephem-4.1.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3733d751c015d28bc043d543c47cee8d0a699a8330d3d34c9afdbd7e96310842"}, + {file = "ephem-4.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a7c904e6df8511bfad2a3119d2ba2333d94953d0d212b31725f4f749267f21cc"}, + {file = "ephem-4.1.5-cp38-cp38-win32.whl", hash = "sha256:3b46aa3f576d528a9dd4357b18c3e59308d0a1e537e45fe91df3753fa63100bf"}, + {file = "ephem-4.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:37751e9bdb10ae109a2e7d7353911470ee5752f5caee4bbe0d764a39cdd57a5a"}, + {file = "ephem-4.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fd76c85313b44e4a9b2e8a14f8e5ffbf43541b3cd023d623664f1dca8c8bf407"}, + {file = "ephem-4.1.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b56f805aa26818e7efa46c2bf98417f677e4dc9ae1b6ef278747d823c7d4df6"}, + {file = "ephem-4.1.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e9c15b5e5386cbee128737465ad7b042f57603c3e75bdbab84cd71b416127cf"}, + {file = "ephem-4.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0f7f4a9de84a4ef8ce77ee980a963bb7725791caa3cb3389490ed35be1d06e8"}, + {file = "ephem-4.1.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b38d0033f7e4ba6e2f5c9ecfc0827d619796ff182d98cc0adb62c6bce701fe0"}, + {file = "ephem-4.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb55fc94512d0c46e610fa67af5fcad51e28aa2b30f7e88ef4cb38c7a443e81a"}, + {file = "ephem-4.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c4183bc20f5933c9fad3d754f9a9ac75dc9ea0d8c6a7faff63de5d28d6bf26d9"}, + {file = "ephem-4.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e067f98b6cd9da55a5de9ce7b2e1222574006b4c3083363eda507ce97ce9867b"}, + {file = "ephem-4.1.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b5cf0e29657001482f3ed8eeaf1757c67b8d8fdc9c90e57b8d544fd1e7e4d6b3"}, + {file = "ephem-4.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5058cfcf36643b11198d54266137d7db8476d66b2c9ff5e4e35638fc3254a2d"}, + {file = "ephem-4.1.5-cp39-cp39-win32.whl", hash = "sha256:c1eed128e5f6e551bc50cfaa87395cef7f03d65537fe4e8502704d200279e3b2"}, + {file = "ephem-4.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:f4ff7153ac577cb336e08fdc7a76ac91185b2af5dae9ba3ae9d0e87af3ac35c3"}, + {file = "ephem-4.1.5.tar.gz", hash = "sha256:0c64a8aa401574c75942045b9af70d1656e14c5366151c0cbb400cbeedc2362a"}, +] + +[[package]] +name = "importlib-resources" +version = "5.4.0" +description = "Read resources from Python packages" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, + {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] + +[[package]] +name = "pillow" +version = "8.4.0" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "Pillow-8.4.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:81f8d5c81e483a9442d72d182e1fb6dcb9723f289a57e8030811bac9ea3fef8d"}, + {file = "Pillow-8.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3f97cfb1e5a392d75dd8b9fd274d205404729923840ca94ca45a0af57e13dbe6"}, + {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb9fc393f3c61f9054e1ed26e6fe912c7321af2f41ff49d3f83d05bacf22cc78"}, + {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d82cdb63100ef5eedb8391732375e6d05993b765f72cb34311fab92103314649"}, + {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc1afda735a8d109007164714e73771b499768b9bb5afcbbee9d0ff374b43f"}, + {file = "Pillow-8.4.0-cp310-cp310-win32.whl", hash = "sha256:e3dacecfbeec9a33e932f00c6cd7996e62f53ad46fbe677577394aaa90ee419a"}, + {file = "Pillow-8.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:620582db2a85b2df5f8a82ddeb52116560d7e5e6b055095f04ad828d1b0baa39"}, + {file = "Pillow-8.4.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:1bc723b434fbc4ab50bb68e11e93ce5fb69866ad621e3c2c9bdb0cd70e345f55"}, + {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cbcfd54df6caf85cc35264c77ede902452d6df41166010262374155947460c"}, + {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70ad9e5c6cb9b8487280a02c0ad8a51581dcbbe8484ce058477692a27c151c0a"}, + {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a49dc2e2f74e65efaa32b153527fc5ac98508d502fa46e74fa4fd678ed6645"}, + {file = "Pillow-8.4.0-cp36-cp36m-win32.whl", hash = "sha256:93ce9e955cc95959df98505e4608ad98281fff037350d8c2671c9aa86bcf10a9"}, + {file = "Pillow-8.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2e4440b8f00f504ee4b53fe30f4e381aae30b0568193be305256b1462216feff"}, + {file = "Pillow-8.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8c803ac3c28bbc53763e6825746f05cc407b20e4a69d0122e526a582e3b5e153"}, + {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8a17b5d948f4ceeceb66384727dde11b240736fddeda54ca740b9b8b1556b29"}, + {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1394a6ad5abc838c5cd8a92c5a07535648cdf6d09e8e2d6df916dfa9ea86ead8"}, + {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792e5c12376594bfcb986ebf3855aa4b7c225754e9a9521298e460e92fb4a488"}, + {file = "Pillow-8.4.0-cp37-cp37m-win32.whl", hash = "sha256:d99ec152570e4196772e7a8e4ba5320d2d27bf22fdf11743dd882936ed64305b"}, + {file = "Pillow-8.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7b7017b61bbcdd7f6363aeceb881e23c46583739cb69a3ab39cb384f6ec82e5b"}, + {file = "Pillow-8.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:d89363f02658e253dbd171f7c3716a5d340a24ee82d38aab9183f7fdf0cdca49"}, + {file = "Pillow-8.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a0956fdc5defc34462bb1c765ee88d933239f9a94bc37d132004775241a7585"}, + {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b7bb9de00197fb4261825c15551adf7605cf14a80badf1761d61e59da347779"}, + {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72b9e656e340447f827885b8d7a15fc8c4e68d410dc2297ef6787eec0f0ea409"}, + {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5a4532a12314149d8b4e4ad8ff09dde7427731fcfa5917ff16d0291f13609df"}, + {file = "Pillow-8.4.0-cp38-cp38-win32.whl", hash = "sha256:82aafa8d5eb68c8463b6e9baeb4f19043bb31fefc03eb7b216b51e6a9981ae09"}, + {file = "Pillow-8.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:066f3999cb3b070a95c3652712cffa1a748cd02d60ad7b4e485c3748a04d9d76"}, + {file = "Pillow-8.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:5503c86916d27c2e101b7f71c2ae2cddba01a2cf55b8395b0255fd33fa4d1f1a"}, + {file = "Pillow-8.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4acc0985ddf39d1bc969a9220b51d94ed51695d455c228d8ac29fcdb25810e6e"}, + {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b052a619a8bfcf26bd8b3f48f45283f9e977890263e4571f2393ed8898d331b"}, + {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:493cb4e415f44cd601fcec11c99836f707bb714ab03f5ed46ac25713baf0ff20"}, + {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8831cb7332eda5dc89b21a7bce7ef6ad305548820595033a4b03cf3091235ed"}, + {file = "Pillow-8.4.0-cp39-cp39-win32.whl", hash = "sha256:5e9ac5f66616b87d4da618a20ab0a38324dbe88d8a39b55be8964eb520021e02"}, + {file = "Pillow-8.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3eb1ce5f65908556c2d8685a8f0a6e989d887ec4057326f6c22b24e8a172c66b"}, + {file = "Pillow-8.4.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ddc4d832a0f0b4c52fff973a0d44b6c99839a9d016fe4e6a1cb8f3eea96479c2"}, + {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3e5ddc44c14042f0844b8cf7d2cd455f6cc80fd7f5eefbe657292cf601d9ad"}, + {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70e94281588ef053ae8998039610dbd71bc509e4acbc77ab59d7d2937b10698"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:3862b7256046fcd950618ed22d1d60b842e3a40a48236a5498746f21189afbbc"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4901622493f88b1a29bd30ec1a2f683782e57c3c16a2dbc7f2595ba01f639df"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c471a734240653a0ec91dec0996696eea227eafe72a33bd06c92697728046b"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:244cf3b97802c34c41905d22810846802a3329ddcb93ccc432870243211c79fc"}, + {file = "Pillow-8.4.0.tar.gz", hash = "sha256:b8e2f83c56e141920c39464b852de3719dfbfb6e3c99a2d8da0edf4fb33176ed"}, +] + +[[package]] +name = "pymysql" +version = "1.0.2" +description = "Pure Python MySQL Driver" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyMySQL-1.0.2-py3-none-any.whl", hash = "sha256:41fc3a0c5013d5f039639442321185532e3e2c8924687abe6537de157d403641"}, + {file = "PyMySQL-1.0.2.tar.gz", hash = "sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36"}, +] + +[package.extras] +ed25519 = ["PyNaCl (>=1.4.0)"] +rsa = ["cryptography"] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] + +[[package]] +name = "pyusb" +version = "1.2.1" +description = "Python USB access module" +category = "main" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "pyusb-1.2.1-py3-none-any.whl", hash = "sha256:2b4c7cb86dbadf044dfb9d3a4ff69fd217013dbe78a792177a3feb172449ea36"}, + {file = "pyusb-1.2.1.tar.gz", hash = "sha256:a4cc7404a203144754164b8b40994e2849fde1cfff06b08492f12fff9d9de7b9"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "zipp" +version = "3.6.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, +] + +[package.extras] +docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.6" +content-hash = "dbc7bfff637837eb962d4dccd5dec8c8aee6ce7310c55031bfe8fe15c4601252" diff --git a/dist/weewx-5.0.2/pyproject.toml b/dist/weewx-5.0.2/pyproject.toml new file mode 100644 index 0000000..b0373ee --- /dev/null +++ b/dist/weewx-5.0.2/pyproject.toml @@ -0,0 +1,75 @@ +[tool.poetry] +name = "weewx" +version = "5.0.2" +description = "The WeeWX weather software system." +authors = [ + "Tom Keffer ", + "Matthew Wall " +] +license = "GPL3" +readme = 'README.md' +repository = "https://github.com/weewx/weewx" +homepage = "https://weewx.com" +documentation = "https://weewx.com/docs" +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Unix', + 'Operating System :: MacOS', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Topic :: Scientific/Engineering :: Physics' +] +packages = [ + { include = "schemas", from = "src" }, + { include = "weecfg", from = "src" }, + { include = "weectllib", from = "src" }, + { include = "weedb", from = "src" }, + { include = "weeimport", from = "src" }, + { include = "weeplot", from = "src" }, + { include = "weeutil", from = "src" }, + { include = "weewx", from = "src" }, + { include = "weewx_data", from = "src" }, + { include = "weectl.py", from = "src" }, + { include = "weewxd.py", from = "src" }, +] + +include = [ + { path = "LICENSE.txt" }, + { path = "README.md"}, +] + +exclude = [ + './src/**/tests/**/*', +] + +[tool.poetry.dependencies] +python = "^3.6" +configobj = "^5.0" +# This is the renamed "Cheetah" package: +CT3 = "^3.1" +Pillow = ">=5.2" +ephem = "^4.1" +PyMySQL = "^1.0" +pyserial = "^3.4" +pyusb = "^1.0.2" +importlib-resources = {version = ">=3.3", python = "3.6"} + +[tool.poetry.scripts] +weectl = 'weectl:main' +weewxd = 'weewxd:main' + +[build-system] +# Minimum requirements for the build system to execute. +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" + diff --git a/dist/weewx-5.0.2/src/schemas/__init__.py b/dist/weewx-5.0.2/src/schemas/__init__.py new file mode 100644 index 0000000..dc35849 --- /dev/null +++ b/dist/weewx-5.0.2/src/schemas/__init__.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +""" +Package of schemas used by weewx. + +This package consists of a set of modules, each containing a schema. +""" diff --git a/dist/weewx-5.0.2/src/schemas/wview.py b/dist/weewx-5.0.2/src/schemas/wview.py new file mode 100644 index 0000000..89d0796 --- /dev/null +++ b/dist/weewx-5.0.2/src/schemas/wview.py @@ -0,0 +1,75 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""The wview schema, which is also used by weewx.""" + +# ============================================================================= +# This is original schema for both weewx and wview, expressed using Python. +# It is only used for initialization --- afterwards, the schema is obtained +# dynamically from the database. +# +# Although a type may be listed here, it may not necessarily be supported by +# your weather station hardware. +# +# You may trim this list of any unused types if you wish, but it may not +# result in saving as much space as you might think --- most of the space is +# taken up by the primary key indexes (type "dateTime"). +# ============================================================================= +# NB: This schema is specified using the WeeWX V3 "old-style" schema. Starting +# with V4, a new style was added, which allows schema for the daily summaries +# to be expressed explicitly. +# ============================================================================= +schema = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('barometer', 'REAL'), + ('pressure', 'REAL'), + ('altimeter', 'REAL'), + ('inTemp', 'REAL'), + ('outTemp', 'REAL'), + ('inHumidity', 'REAL'), + ('outHumidity', 'REAL'), + ('windSpeed', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('rainRate', 'REAL'), + ('rain', 'REAL'), + ('dewpoint', 'REAL'), + ('windchill', 'REAL'), + ('heatindex', 'REAL'), + ('ET', 'REAL'), + ('radiation', 'REAL'), + ('UV', 'REAL'), + ('extraTemp1', 'REAL'), + ('extraTemp2', 'REAL'), + ('extraTemp3', 'REAL'), + ('soilTemp1', 'REAL'), + ('soilTemp2', 'REAL'), + ('soilTemp3', 'REAL'), + ('soilTemp4', 'REAL'), + ('leafTemp1', 'REAL'), + ('leafTemp2', 'REAL'), + ('extraHumid1', 'REAL'), + ('extraHumid2', 'REAL'), + ('soilMoist1', 'REAL'), + ('soilMoist2', 'REAL'), + ('soilMoist3', 'REAL'), + ('soilMoist4', 'REAL'), + ('leafWet1', 'REAL'), + ('leafWet2', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('txBatteryStatus', 'REAL'), + ('consBatteryVoltage', 'REAL'), + ('hail', 'REAL'), + ('hailRate', 'REAL'), + ('heatingTemp', 'REAL'), + ('heatingVoltage', 'REAL'), + ('supplyVoltage', 'REAL'), + ('referenceVoltage', 'REAL'), + ('windBatteryStatus', 'REAL'), + ('rainBatteryStatus', 'REAL'), + ('outTempBatteryStatus', 'REAL'), + ('inTempBatteryStatus', 'REAL')] diff --git a/dist/weewx-5.0.2/src/schemas/wview_extended.py b/dist/weewx-5.0.2/src/schemas/wview_extended.py new file mode 100644 index 0000000..20c3489 --- /dev/null +++ b/dist/weewx-5.0.2/src/schemas/wview_extended.py @@ -0,0 +1,138 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""The extended wview schema.""" + +# ============================================================================= +# This is a list containing the default schema of the archive database. It is +# only used for initialization --- afterwards, the schema is obtained +# dynamically from the database. Although a type may be listed here, it may +# not necessarily be supported by your weather station hardware. +# ============================================================================= +# NB: This schema is specified using the WeeWX V4 "new-style" schema. +# ============================================================================= +table = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('altimeter', 'REAL'), + ('appTemp', 'REAL'), + ('appTemp1', 'REAL'), + ('barometer', 'REAL'), + ('batteryStatus1', 'REAL'), + ('batteryStatus2', 'REAL'), + ('batteryStatus3', 'REAL'), + ('batteryStatus4', 'REAL'), + ('batteryStatus5', 'REAL'), + ('batteryStatus6', 'REAL'), + ('batteryStatus7', 'REAL'), + ('batteryStatus8', 'REAL'), + ('cloudbase', 'REAL'), + ('co', 'REAL'), + ('co2', 'REAL'), + ('consBatteryVoltage', 'REAL'), + ('dewpoint', 'REAL'), + ('dewpoint1', 'REAL'), + ('ET', 'REAL'), + ('extraHumid1', 'REAL'), + ('extraHumid2', 'REAL'), + ('extraHumid3', 'REAL'), + ('extraHumid4', 'REAL'), + ('extraHumid5', 'REAL'), + ('extraHumid6', 'REAL'), + ('extraHumid7', 'REAL'), + ('extraHumid8', 'REAL'), + ('extraTemp1', 'REAL'), + ('extraTemp2', 'REAL'), + ('extraTemp3', 'REAL'), + ('extraTemp4', 'REAL'), + ('extraTemp5', 'REAL'), + ('extraTemp6', 'REAL'), + ('extraTemp7', 'REAL'), + ('extraTemp8', 'REAL'), + ('forecast', 'REAL'), + ('hail', 'REAL'), + ('hailBatteryStatus', 'REAL'), + ('hailRate', 'REAL'), + ('heatindex', 'REAL'), + ('heatindex1', 'REAL'), + ('heatingTemp', 'REAL'), + ('heatingVoltage', 'REAL'), + ('humidex', 'REAL'), + ('humidex1', 'REAL'), + ('inDewpoint', 'REAL'), + ('inHumidity', 'REAL'), + ('inTemp', 'REAL'), + ('inTempBatteryStatus', 'REAL'), + ('leafTemp1', 'REAL'), + ('leafTemp2', 'REAL'), + ('leafWet1', 'REAL'), + ('leafWet2', 'REAL'), + ('lightning_distance', 'REAL'), + ('lightning_disturber_count', 'REAL'), + ('lightning_energy', 'REAL'), + ('lightning_noise_count', 'REAL'), + ('lightning_strike_count', 'REAL'), + ('luminosity', 'REAL'), + ('maxSolarRad', 'REAL'), + ('nh3', 'REAL'), + ('no2', 'REAL'), + ('noise', 'REAL'), + ('o3', 'REAL'), + ('outHumidity', 'REAL'), + ('outTemp', 'REAL'), + ('outTempBatteryStatus', 'REAL'), + ('pb', 'REAL'), + ('pm10_0', 'REAL'), + ('pm1_0', 'REAL'), + ('pm2_5', 'REAL'), + ('pressure', 'REAL'), + ('radiation', 'REAL'), + ('rain', 'REAL'), + ('rainBatteryStatus', 'REAL'), + ('rainRate', 'REAL'), + ('referenceVoltage', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('signal1', 'REAL'), + ('signal2', 'REAL'), + ('signal3', 'REAL'), + ('signal4', 'REAL'), + ('signal5', 'REAL'), + ('signal6', 'REAL'), + ('signal7', 'REAL'), + ('signal8', 'REAL'), + ('snow', 'REAL'), + ('snowBatteryStatus', 'REAL'), + ('snowDepth', 'REAL'), + ('snowMoisture', 'REAL'), + ('snowRate', 'REAL'), + ('so2', 'REAL'), + ('soilMoist1', 'REAL'), + ('soilMoist2', 'REAL'), + ('soilMoist3', 'REAL'), + ('soilMoist4', 'REAL'), + ('soilTemp1', 'REAL'), + ('soilTemp2', 'REAL'), + ('soilTemp3', 'REAL'), + ('soilTemp4', 'REAL'), + ('supplyVoltage', 'REAL'), + ('txBatteryStatus', 'REAL'), + ('UV', 'REAL'), + ('uvBatteryStatus', 'REAL'), + ('windBatteryStatus', 'REAL'), + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windrun', 'REAL'), + ('windSpeed', 'REAL'), + ] + +day_summaries = [(e[0], 'scalar') for e in table + if e[0] not in ('dateTime', 'usUnits', 'interval')] + [('wind', 'VECTOR')] + +schema = { + 'table': table, + 'day_summaries' : day_summaries +} diff --git a/dist/weewx-5.0.2/src/schemas/wview_small.py b/dist/weewx-5.0.2/src/schemas/wview_small.py new file mode 100644 index 0000000..7ae21f7 --- /dev/null +++ b/dist/weewx-5.0.2/src/schemas/wview_small.py @@ -0,0 +1,48 @@ +# +# Copyright (c) 2009-2020 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""A very small wview schema.""" + +# ============================================================================= +# This is a very severely restricted schema that includes only the basics. It +# is useful for testing, and for very small installations. Like other WeeWX +# schemas, it is used only for initialization --- afterwards, the schema is obtained +# dynamically from the database. Although a type may be listed here, it may +# not necessarily be supported by your weather station hardware. +# ============================================================================= +# NB: This schema is specified using the WeeWX V4 "new-style" schema. +# ============================================================================= +table = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('altimeter', 'REAL'), + ('barometer', 'REAL'), + ('dewpoint', 'REAL'), + ('ET', 'REAL'), + ('heatindex', 'REAL'), + ('inHumidity', 'REAL'), + ('inTemp', 'REAL'), + ('outHumidity', 'REAL'), + ('outTemp', 'REAL'), + ('pressure', 'REAL'), + ('radiation', 'REAL'), + ('rain', 'REAL'), + ('rainRate', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('UV', 'REAL'), + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windSpeed', 'REAL'), + ] + +day_summaries = [(e[0], 'scalar') for e in table + if e[0] not in ('dateTime', 'usUnits', 'interval')] + [('wind', 'VECTOR')] + +schema = { + 'table': table, + 'day_summaries' : day_summaries +} diff --git a/dist/weewx-5.0.2/src/weecfg/__init__.py b/dist/weewx-5.0.2/src/weecfg/__init__.py new file mode 100644 index 0000000..780b143 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/__init__.py @@ -0,0 +1,726 @@ +# coding: utf-8 +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Utilities used by the setup and configure programs""" + +import importlib +import os.path +import pkgutil +import shutil +import sys +import tempfile + +import configobj + +import weeutil.config +import weeutil.weeutil +from weeutil.printer import Printer +from weeutil.weeutil import bcolors + +major_comment_block = ["", + "#######################################" + "#######################################", + ""] + +default_weewx_root = os.path.expanduser('~/weewx-data') +default_config_path = os.path.join(default_weewx_root, 'weewx.conf') + + +class ExtensionError(IOError): + """Errors when installing or uninstalling an extension""" + + +# ============================================================================== +# Utilities that find and save ConfigObj objects +# ============================================================================== + +if sys.platform == "darwin": + DEFAULT_LOCATIONS = [default_weewx_root, '/etc/weewx', '/Users/Shared/weewx'] +else: + DEFAULT_LOCATIONS = [default_weewx_root, '/etc/weewx', '/home/weewx'] + + +def find_file(file_path=None, args=None, locations=None, file_name='weewx.conf'): + """Find and return a path to a file, looking in "the usual places." + + General strategy: + + First, if file_path is specified, then it will be used. + + Second, if file_path was not specified, then the first element of args that does not start + with a switch flag ("-") will be used. + + If there is no such element, then the list of directory locations is searched, looking for a + file with file name file_name. + + If after all that, the file still cannot be found, then an OSError exception will be raised. + + Args: + file_path (str|None): A path to the file. Typically, this is + from a --config option. None if no option was specified. + args (list[str]|None): command-line arguments. If file_path is None, then the first not + null value in args will be used. + locations (list[str]|None): A list of directories to be searched. + file_name (str): The name of the file to be found. This is used + only if the directories must be searched. Default is 'weewx.conf'. + + Returns: + str: full path to the file + + Raises: + OSError: If the configuration file cannot be found, or is not a file. + """ + + locations = locations or DEFAULT_LOCATIONS + + # If no file_path was supplied, then search args (if available): + if file_path is None and args: + for i, arg in enumerate(args): + # Ignore empty strings and None values: + if not arg: + continue + if not arg.startswith('-'): + file_path = arg + del args[i] + break + if file_path: + # We have a resolution. Resolve any tilde prefix: + file_path = os.path.expanduser(file_path) + else: + # Still don't have a resolution. Search in "the usual places." + for directory in locations: + # If this is a relative path, then prepend with the + # directory this file is in: + if not os.path.isabs(directory): + directory = os.path.join(os.path.dirname(__file__), directory) + candidate = os.path.abspath(os.path.join(directory, file_name)) + if os.path.isfile(candidate): + return candidate + + if file_path is None: + raise OSError(f"Unable to find file '{file_name}'. Tried directories {locations}") + elif not os.path.isfile(file_path): + raise OSError(f"{file_path} is not a file") + + return file_path + + +def read_config(config_path, args=None, locations=DEFAULT_LOCATIONS, + file_name='weewx.conf', interpolation='ConfigParser'): + """Read the specified configuration file, return an instance of ConfigObj + with the file contents. If no file is specified, look in the standard + locations for weewx.conf. Returns the filename of the actual configuration + file, as well as the ConfigObj. + + Special handling for key "WEEWX_ROOT": + - If it is missing from the config file, then it is added and set to the directory + where the configuration file was found. + - If it is a relative path, then it is converted to an absolute path by + prepending the directory where the configuration file was found. + + This version also adds two possible entries to the returned ConfigObj: + config_path: Location of the actual configuration file that was used. + WEEWX_ROOT_CONFIG: If the original file included a value of WEEWX_ROOT, this is included + and set to it. Otherwise, it is not included. + + Args: + config_path (str|None): configuration filename. + args (list[str]|None): command-line arguments. + locations (list[str]): A list of directories to search. + file_name (str): The name of the config file. Default is 'weewx.conf' + interpolation (str): The type of interpolation to use when reading the config file. + Default is 'ConfigParser'. See the ConfigObj documentation https://bit.ly/3L593vH + + Returns: + (str, configobj.ConfigObj): path-to-file, instance-of-ConfigObj + + Raises: + SyntaxError: If there is a syntax error in the file + IOError: If the file cannot be found + """ + # Find the config file: + config_path = find_file(config_path, args, + locations=locations, file_name=file_name) + # Now open it up and parse it. + try: + config_dict = configobj.ConfigObj(config_path, + interpolation=interpolation, + file_error=True, + encoding='utf-8', + default_encoding='utf-8') + except configobj.ConfigObjError as e: + # Add on the path of the offending file, then reraise. + e.msg += " File '%s'." % config_path + raise + + # Remember where we found the config file + config_dict['config_path'] = os.path.realpath(config_path) + + # Process WEEWX_ROOT + if 'WEEWX_ROOT' in config_dict: + # There is a value for WEEWX_ROOT. Save it. + config_dict['WEEWX_ROOT_CONFIG'] = config_dict['WEEWX_ROOT'] + # Check for an older config file. If found, patch to new location + if config_dict['WEEWX_ROOT'] == '/': + config_dict['WEEWX_ROOT'] = '/etc/weewx' + else: + # No WEEWX_ROOT in the config dictionary. Supply a default. + config_dict['WEEWX_ROOT'] = os.path.dirname(config_path) + + # If the result of all that is not an absolute path, join it with the location of the config + # file, which will make it into an absolute path. + if not os.path.abspath(config_dict['WEEWX_ROOT']): + config_dict['WEEWX_ROOT'] = os.path.normpath(os.path.join(os.path.dirname(config_path), + config_dict['WEEWX_ROOT'])) + + return config_path, config_dict + + +def save_with_backup(config_dict, config_path): + return save(config_dict, config_path, backup=True) + + +def save(config_dict, config_path, backup=False): + """Save the config file, backing up as necessary. + + Args: + config_dict(dict): A configuration dictionary. + config_path(str): Path to where the dictionary should be saved. + backup(bool): True to save a timestamped version of the old config file, False otherwise. + Returns: + str|None: The path to the backed up old config file. None otherwise + """ + + # We need to pop 'config_path' off the dictionary before writing. WeeWX v4.9.1 wrote + # 'entry_path' to the config file as well, so we need to get rid of that in case it snuck in. + # Make a deep copy first --- we're going to be modifying the dictionary. + write_dict = weeutil.config.deep_copy(config_dict) + write_dict.pop('config_path', None) + write_dict.pop('entry_path', None) + + # If there was a value for WEEWX_ROOT in the config file, restore it + if 'WEEWX_ROOT_CONFIG' in write_dict: + write_dict['WEEWX_ROOT'] = write_dict['WEEWX_ROOT_CONFIG'] + # Add a comment if it doesn't already have one + if not write_dict.comments['WEEWX_ROOT']: + write_dict.comments['WEEWX_ROOT'] = ['', 'Path to the station data area, relative to ' + 'the configuration file.'] + del write_dict['WEEWX_ROOT_CONFIG'] + else: + # There was no value for WEEWX_ROOT in the original config file, so delete it. + del write_dict['WEEWX_ROOT'] + + # If the final path is just '.', get rid of it --- that's the default. + if 'WEEWX_ROOT' in write_dict and os.path.normpath(write_dict['WEEWX_ROOT']) == '.': + del write_dict['WEEWX_ROOT'] + + # Check to see if the file exists, and we are supposed to make a backup: + if os.path.exists(config_path) and backup: + + # Yes. We'll have to back it up. + backup_path = weeutil.weeutil.move_with_timestamp(config_path) + + # Now we can save the file. Get a temporary file: + with tempfile.NamedTemporaryFile() as tmpfile: + # Write the configuration dictionary to it: + write_dict.write(tmpfile) + tmpfile.flush() + + # Now move the temporary file into the proper place: + shutil.copyfile(tmpfile.name, config_path) + + else: + + # No existing file or no backup required. Just write. + with open(config_path, 'wb') as fd: + write_dict.write(fd) + backup_path = None + + return backup_path + + +def inject_station_url(config_dict, url): + """Inject the option station_url into the [Station] section""" + + if 'station_url' in config_dict['Station']: + # Already injected. Just set the value + config_dict['Station']['station_url'] = url + return + + # Isolate just the [Station] section. This simplifies what follows + station_dict = config_dict['Station'] + + # In the configuration file that ships with WeeWX, station_url is commented out, so its + # comments are part of the following option, which is 'rain_year_start'. So, just set + # the comments for 'rain_year_start' to something sensible, which will make the 'station_url' + # comment go away. + station_dict.comments['rain_year_start'] = [ + "", + "The start of the rain year (1=January; 10=October, etc.). This is", + "downloaded from the station if the hardware supports it." + ] + + # Add the new station_url, plus its comments + station_dict['station_url'] = url + station_dict.comments['station_url'] \ + = ['', 'If you have a website, you may specify an URL'] + + # Reorder to match the canonical ordering. + reorder_scalars(station_dict.scalars, 'station_url', 'rain_year_start') + + +# ============================================================================== +# Utilities that extract from ConfigObj objects +# ============================================================================== + +def get_version_info(config_dict): + # Get the version number. If it does not appear at all, then + # assume a very old version: + config_version = config_dict.get('version') or '1.0.0' + + # Updates only care about the major and minor numbers + parts = config_version.split('.') + major = parts[0] + minor = parts[1] + + # Take care of the collation problem when comparing things like + # version '1.9' to '1.10' by prepending a '0' to the former: + if len(minor) < 2: + minor = '0' + minor + + return major, minor + + +# ============================================================================== +# Utilities that manipulate ConfigObj objects +# ============================================================================== + +def reorder_sections(config_dict, src, dst, after=False): + """Move the section with key src to just before (after=False) or after + (after=True) the section with key dst. """ + bump = 1 if after else 0 + # We need both keys to procede: + if src not in config_dict.sections or dst not in config_dict.sections: + return + # If index raises an exception, we want to fail hard. + # Find the source section (the one we intend to move): + src_idx = config_dict.sections.index(src) + # Save the key + src_key = config_dict.sections[src_idx] + # Remove it + config_dict.sections.pop(src_idx) + # Find the destination + dst_idx = config_dict.sections.index(dst) + # Now reorder the attribute 'sections', putting src just before dst: + config_dict.sections = config_dict.sections[:dst_idx + bump] + [src_key] + \ + config_dict.sections[dst_idx + bump:] + + +def reorder_scalars(scalars, src, dst): + """Reorder so the src item is just before the dst item""" + try: + src_index = scalars.index(src) + except ValueError: + return + scalars.pop(src_index) + # If the destination cannot be found, but the src object at the end + try: + dst_index = scalars.index(dst) + except ValueError: + dst_index = len(scalars) + + scalars.insert(dst_index, src) + + +def remove_and_prune(a_dict, b_dict): + """Remove fields from a_dict that are present in b_dict""" + for k in b_dict: + if isinstance(b_dict[k], dict): + if k in a_dict and type(a_dict[k]) is configobj.Section: + remove_and_prune(a_dict[k], b_dict[k]) + if not a_dict[k].sections: + a_dict.pop(k) + elif k in a_dict: + a_dict.pop(k) + + +# ============================================================================== +# Utilities that work on drivers +# ============================================================================== + + +def get_all_driver_infos(): + # first look in the drivers directory + infos = get_driver_infos() + # then add any drivers in the user directory + user_drivers = get_driver_infos('user') + infos.update(user_drivers) + return infos + + +def get_driver_infos(driver_pkg_name='weewx.drivers'): + """Scan the driver's folder, extracting information about each available + driver. Return as a dictionary, keyed by the driver module name. + + Valid drivers must be importable, and must have attribute "DRIVER_NAME" + defined. + + Args: + driver_pkg_name (str): The name of the package holder the drivers. + Default is 'weewx.drivers' + + Returns + dict: The key is the driver module name, value is information about the driver. + Typical entry: + 'weewx.drivers.acurite': {'module_name': 'weewx.drivers.acurite', + 'driver_name': 'AcuRite', + 'version': '0.4', + 'status': ''} + + """ + driver_info_dict = {} + # Import the package, so we can find the modules contained within it. It's possible we are + # trying to import the not-yet-created 'user' subdirectory, which would cause a + # ModuleNotFoundError. Be prepared to catch it. + try: + driver_pkg = importlib.import_module(driver_pkg_name) + except ModuleNotFoundError: + return driver_info_dict + + # This guards against namespace packages. + if not driver_pkg or not driver_pkg.__file__: + return {} + + driver_path = os.path.dirname(driver_pkg.__file__) + + # Iterate over all the modules in the package. + for driver_module_info in pkgutil.iter_modules([driver_path]): + # Form the importable name of the module. This will be something + # like 'weewx.drivers.acurite' + driver_module_name = f"{driver_pkg_name}.{driver_module_info.name}" + + # Try importing the module. Be prepared for an exception if the import fails. + try: + driver_module = importlib.import_module(driver_module_name) + except (SyntaxError, ImportError) as e: + # If the import fails, report it in the status + driver_info_dict[driver_module_name] = { + 'module_name': driver_module_name, + 'driver_name': '?', + 'version': '?', + 'status': e} + else: + # The import succeeded. + # A valid driver will define the attribute "DRIVER_NAME" + if hasattr(driver_module, 'DRIVER_NAME'): + # A driver might define the attribute DRIVER_VERSION + driver_module_version = getattr(driver_module, 'DRIVER_VERSION', '?') + # Create an entry for it, keyed by the driver module name + driver_info_dict[driver_module_name] = { + 'module_name': driver_module_name, + 'driver_name': driver_module.DRIVER_NAME, + 'version': driver_module_version, + 'status': ''} + + return driver_info_dict + + +def print_drivers(): + """Get information about all the available drivers, then print it out.""" + driver_info_dict = get_all_driver_infos() + keys = sorted(driver_info_dict) + print("%-25s%-15s%-9s%-25s" % ("Module name", "Driver name", "Version", "Status")) + for d in keys: + print(" %(module_name)-25s%(driver_name)-15s%(version)-9s%(status)-25s" + % driver_info_dict[d]) + + +def load_driver_editor(driver_module_name): + """Load the configuration editor from the driver file + + Args: + driver_module_name (str): A string holding the driver name, for + example, 'weewx.drivers.fousb' + + Returns: + tuple: A 3-way tuple: (editor, driver_name, driver_version) + """ + driver_module = importlib.import_module(driver_module_name) + editor = None + if hasattr(driver_module, 'confeditor_loader'): + # Retrieve the loader function + loader_function = getattr(driver_module, 'confeditor_loader') + # Call it to get the actual editor + editor = loader_function() + driver_name = getattr(driver_module, 'DRIVER_NAME', None) + driver_version = getattr(driver_module, 'DRIVER_VERSION', 'undefined') + return editor, driver_name, driver_version + + +# ============================================================================== +# Utilities that seek info from the command line +# ============================================================================== + + +def prompt_for_driver(dflt_driver=None): + """Get the information about each driver, return as a dictionary. + + Args: + dflt_driver (str): The default driver to offer. If not given, 'weewx.drivers.simulator' + will be used + + Returns: + str: The selected driver. This will be something like 'weewx.drivers.vantage'. + """ + + if dflt_driver is None: + dflt_driver = 'weewx.drivers.simulator' + infos = get_all_driver_infos() + keys = sorted(infos) + dflt_idx = None + print("\nChoose a driver. Installed drivers include:") + for i, d in enumerate(keys): + print(" %s%2d%s) %-15s %-25s %s" % (bcolors.BOLD, i, bcolors.ENDC, + infos[d].get('driver_name', '?'), + "(%s)" % d, infos[d].get('status', ''))) + if dflt_driver == d: + dflt_idx = i + if dflt_idx is None: + msg = "driver: " + else: + msg = f"driver [{dflt_idx:d}]: " + idx = 0 + ans = None + while ans is None: + ans = input(msg).strip() + if not ans: + ans = dflt_idx + try: + idx = int(ans) + if not 0 <= idx < len(keys): + ans = None + except (ValueError, TypeError): + ans = None + return keys[idx] + + +def prompt_for_driver_settings(driver, stanza_dict): + """Let the driver prompt for any required settings. If the driver does + not define a method for prompting, return an empty dictionary.""" + settings = configobj.ConfigObj(interpolation=False) + try: + driver_module = importlib.import_module(driver) + loader_function = getattr(driver_module, 'confeditor_loader') + editor = loader_function() + editor.existing_options = stanza_dict + settings = editor.prompt_for_settings() + except AttributeError: + pass + return settings + + +def get_languages(skin_dir): + """ Return all languages supported by the skin + + Args: + skin_dir (str): The path to the skin subdirectory. + + Returns: + dict|None: A dictionary where the key is the language code, and the value is the natural + language name of the language. The value 'None' is returned if skin_dir does not exist. + """ + # Get the path to the "./lang" subdirectory + lang_dir = os.path.join(skin_dir, './lang') + # Get all the files in the subdirectory. If the subdirectory does not exist, an exception + # will be raised. Be prepared to catch it. + try: + lang_files = os.listdir(lang_dir) + except OSError: + # No 'lang' subdirectory. Return None + return None + + languages = {} + + # Go through the files... + for lang_file in lang_files: + # ... get its full path ... + lang_full_path = os.path.join(lang_dir, lang_file) + # ... make sure it's a file ... + if os.path.isfile(lang_full_path): + # ... then get the language code for that file. + code = lang_file.split('.')[0] + # Retrieve the ConfigObj for this language + lang_dict = configobj.ConfigObj(lang_full_path, encoding='utf-8') + # See if it has a natural language version of the language code: + try: + language = lang_dict['Texts']['Language'] + except KeyError: + # It doesn't. Just label it 'Unknown' + language = 'Unknown' + # Add the code, plus the language + languages[code] = language + return languages + + +def pick_language(languages, default='en'): + """ + Given a choice of languages, pick one. + + Args: + languages (dict): As returned by function get_languages() above + default (str): The language code of the default + + Returns: + str: The chosen language code + """ + keys = sorted(languages.keys()) + if default not in keys: + default = None + msg = "Available languages\nCode | Language\n" + for code in keys: + msg += "%4s | %-20s\n" % (code, languages[code]) + msg += "Pick a code" + value = prompt_with_options(msg, default, keys) + + return value + + +def prompt_with_options(prompt, default=None, options=None): + """Ask the user for an input with an optional default value. + + Args: + prompt(str): A string to be used for a prompt. + default(str|None): A default value. If the user simply hits , this + is the value returned. Optional. + options(list[str]|None): A list of possible choices. The returned value must be in + this list. Optional. + + Returns: + str: The chosen option + """ + + msg = f"{prompt} [{default}]: " if default is not None else f"{prompt}: " + value = None + while value is None: + value = input(msg).strip() + if value: + if options and value not in options: + value = None + elif default is not None: + value = default + + return value + + +def prompt_with_limits(prompt, default=None, low_limit=None, high_limit=None): + """Ask the user for an input with an optional default value. The + returned value must lie between optional upper and lower bounds. + + prompt: A string to be used for a prompt. + + default: A default value. If the user simply hits , this + is the value returned. Optional. + + low_limit: The value must be equal to or greater than this value. + Optional. + + high_limit: The value must be less than or equal to this value. + Optional. + """ + msg = "%s [%s]: " % (prompt, default) if default is not None else "%s: " % prompt + value = None + while value is None: + value = input(msg).strip() + if value: + try: + v = float(value) + if (low_limit is not None and v < low_limit) or \ + (high_limit is not None and v > high_limit): + value = None + except (ValueError, TypeError): + value = None + elif default is not None: + value = default + + return value + + +# ============================================================================== +# Miscellaneous utilities +# ============================================================================== + + +def extract_tar(filename, target_dir, printer=None): + """Extract files from a tar archive into a given directory + + Args: + filename (str): Path to the tarfile + target_dir (str): Path to the directory to which the contents will be extracted + printer (Printer): Indenting Printer to use + + Returns: + list[str]: A list of the extracted files + """ + import tarfile + printer = printer or Printer() + printer.out(f"Extracting from tar archive {filename}", level=1) + + with tarfile.open(filename, mode='r') as tar_archive: + member_names = [os.path.normpath(x.name) for x in tar_archive.getmembers()] + # If the version of Python offers data filtering, use it. + if hasattr(tarfile, 'data_filter'): + tar_archive.extractall(target_dir, filter='data') + else: + tar_archive.extractall(target_dir) + + del tarfile + return member_names + + +def extract_zip(filename, target_dir, printer=None): + """Extract files from a zip archive into the specified directory. + + Args: + filename (str): Path to the zip file + target_dir (str): Path to the directory to which the contents will be extracted + printer (Printer): Indenting printer to use + + Returns: + list[str]: A list of the extracted files + """ + import zipfile + printer = printer or Printer() + printer.out(f"Extracting from zip archive {filename}", level=1) + + with zipfile.ZipFile(filename) as zip_archive: + member_names = zip_archive.namelist() + zip_archive.extractall(target_dir) + + del zipfile + return member_names + + +def get_extension_installer(extension_installer_dir): + """Get the installer in the given extension installer subdirectory""" + old_path = sys.path + try: + # Inject the location of the installer directory into the path + sys.path.insert(0, extension_installer_dir) + try: + # Now I can import the extension's 'install' module: + __import__('install') + except ImportError: + raise ExtensionError("Cannot find 'install' module in %s" % extension_installer_dir) + install_module = sys.modules['install'] + loader = getattr(install_module, 'loader') + # Get rid of the module: + sys.modules.pop('install', None) + installer = loader() + finally: + # Restore the path + sys.path = old_path + + return install_module.__file__, installer diff --git a/dist/weewx-5.0.2/src/weecfg/database.py b/dist/weewx-5.0.2/src/weecfg/database.py new file mode 100644 index 0000000..eb74292 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/database.py @@ -0,0 +1,567 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Classes to support fixes or other bulk corrections of weewx data.""" + +# standard python imports +import datetime +import logging +import sys +import time + +# weewx imports +import weedb +import weeutil.weeutil +import weewx.engine +import weewx.manager +import weewx.units +import weewx.wxservices +from weeutil.weeutil import timestamp_to_string, to_bool + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class DatabaseFix +# ============================================================================ + + +class DatabaseFix(object): + """Base class for fixing bulk data in the weewx database. + + Classes for applying different fixes the weewx database data should be + derived from this class. Derived classes require: + + run() method: The entry point to apply the fix. + fix config dict: Dictionary containing config data specific to + the fix. Minimum fields required are: + + name. The name of the fix. String. + """ + + def __init__(self, config_dict, fix_config_dict): + """A generic initialisation.""" + + # save our weewx config dict + self.config_dict = config_dict + # save our fix config dict + self.fix_config_dict = fix_config_dict + # get our name + self.name = fix_config_dict['name'] + # is this a dry run + self.dry_run = to_bool(fix_config_dict.get('dry_run', True)) + # Get the binding for the archive we are to use. If we received an + # explicit binding then use that otherwise use the binding that + # StdArchive uses. + try: + db_binding = fix_config_dict['binding'] + except KeyError: + if 'StdArchive' in config_dict: + db_binding = config_dict['StdArchive'].get('data_binding', + 'wx_binding') + else: + db_binding = 'wx_binding' + self.binding = db_binding + # get a database manager object + self.dbm = weewx.manager.open_manager_with_config(config_dict, + self.binding) + + def run(self): + raise NotImplementedError("Method 'run' not implemented") + + def genSummaryDaySpans(self, start_ts, stop_ts, obs='outTemp'): + """Generator to generate a sequence of daily summary day TimeSpans. + + Given an observation that has a daily summary table, generate a + sequence of TimeSpan objects for each row in the daily summary table. + In this way the generated sequence includes only rows included in the + daily summary rather than any 'missing' rows. + + Args: + start_ts (float): Include daily summary rows with a dateTime >= start_ts. + stop_ts (float): Include daily summary rows with a dateTime <= start_ts. + obs (str): The weewx observation whose daily summary table is to be + used as the source of the TimeSpan objects + + Yields: + weeutil.weeutil.TimeSpan: A sequence with the start of each day. + """ + + _sql = "SELECT dateTime FROM %s_day_%s " \ + "WHERE dateTime >= ? AND dateTime <= ?" % (self.dbm.table_name, obs) + + for _row in self.dbm.genSql(_sql, (start_ts, stop_ts)): + yield weeutil.weeutil.daySpan(_row[0]) + + def first_summary_ts(self, obs_type): + """Obtain the timestamp of the earliest daily summary entry for an + observation type. + + Imput: + obs_type: The observation type whose daily summary is to be checked. + + Returns: + The timestamp of the earliest daily summary entry for obs_tpye + observation. None is returned if no record culd be found. + """ + + _sql_str = "SELECT MIN(dateTime) FROM %s_day_%s" % (self.dbm.table_name, + obs_type) + _row = self.dbm.getSql(_sql_str) + return _row[0] if _row else None + + @staticmethod + def _progress(record, ts): + """Utility function to show our progress while processing the fix. + + Override in derived class to provide a different progress display. + To do nothing override with a pass statement. + """ + + _msg = "Fixing database record: %d; Timestamp: %s\r" % (record, timestamp_to_string(ts)) + print(_msg, end='', file=sys.stdout) + sys.stdout.flush() + + +# ============================================================================ +# class WindSpeedRecalculation +# ============================================================================ + + +class WindSpeedRecalculation(DatabaseFix): + """Class to recalculate windSpeed daily maximum value. To recalculate the + windSpeed daily maximum values: + + 1. Create a dictionary of parameters required by the fix. The + WindSpeedRecalculation class uses the following parameters as indicated: + + name: Name of the fix, for the windSpeed recalculation fix + this is 'windSpeed Recalculation'. String. Mandatory. + + binding: The binding of the database to be fixed. Default is + the binding specified in weewx.conf [StdArchive]. + String, eg 'binding_name'. Optional. + + trans_days: Number of days of data used in each database + transaction. Integer, default is 50. Optional. + + dry_run: Process the fix as if it was being applied but do not + write to the database. Boolean, default is True. + Optional. + + 2. Create an WindSpeedRecalculation object passing it a weewx config dict + and a fix config dict. + + 3. Call the resulting object's run() method to apply the fix. + """ + + def __init__(self, config_dict, fix_config_dict): + """Initialise our WindSpeedRecalculation object.""" + + # call our parents __init__ + super().__init__(config_dict, fix_config_dict) + + # log if a dry run + if self.dry_run: + log.info("maxwindspeed: This is a dry run. " + "Maximum windSpeed will be recalculated but not saved.") + + log.debug("maxwindspeed: Using database binding '%s', " + "which is bound to database '%s'." % + (self.binding, self.dbm.database_name)) + # number of days per db transaction, default to 50. + self.trans_days = int(fix_config_dict.get('trans_days', 50)) + log.debug("maxwindspeed: Database transactions will use %s days of data." + % self.trans_days) + + def run(self): + """Main entry point for applying the windSpeed Calculation fix. + + Recalculating the windSpeed daily summary max field from archive data + is idempotent so there is no need to check whether the fix has already + been applied. Just go ahead and do it catching any exceptions we know + may be raised. + """ + + # apply the fix but be prepared to catch any exceptions + try: + self.do_fix() + except weedb.NoTableError: + raise + except weewx.ViolatedPrecondition as e: + log.error("maxwindspeed: %s not applied: %s" % (self.name, e)) + # raise the error so caller can deal with it if they want + raise + + def do_fix(self): + """Recalculate windSpeed daily summary max field from archive data. + + Step through each row in the windSpeed daily summary table and replace + the max field with the max value for that day based on archive data. + Database transactions are done in self.trans_days days at a time. + """ + + t1 = time.time() + log.info("maxwindspeed: Applying %s..." % self.name) + # get the start and stop Gregorian day number + start_ts = self.first_summary_ts('windSpeed') + if not start_ts: + print("Database empty. Nothing done.") + return + start_greg = weeutil.weeutil.toGregorianDay(start_ts) + stop_greg = weeutil.weeutil.toGregorianDay(self.dbm.last_timestamp) + # initialise a few things + day = start_greg + n_days = 0 + last_start = None + while day <= stop_greg: + # get the start and stop timestamps for this tranche + tr_start_ts = weeutil.weeutil.startOfGregorianDay(day) + tr_stop_ts = weeutil.weeutil.startOfGregorianDay(day + self.trans_days - 1) + # start the transaction + with weedb.Transaction(self.dbm.connection) as _cursor: + # iterate over the rows in the windSpeed daily summary table + for day_span in self.genSummaryDaySpans(tr_start_ts, tr_stop_ts, 'windSpeed'): + # get the days max windSpeed and the time it occurred from + # the archive + (day_max_ts, day_max) = self.get_archive_span_max(day_span, 'windSpeed') + # now save the value and time in the applicable row in the + # windSpeed daily summary, but only if it's not a dry run + if not self.dry_run: + self.write_max('windSpeed', day_span.start, + day_max, day_max_ts) + # increment our days done counter + n_days += 1 + # give the user some information on progress + if n_days % 50 == 0: + self._progress(n_days, day_span.start) + last_start = day_span.start + # advance to the next tranche + day += self.trans_days + + # we have finished, give the user some final information on progress, + # mainly so the total tallies with the log + self._progress(n_days, last_start) + print(file=sys.stdout) + tdiff = time.time() - t1 + # We are done so log and inform the user + log.info("maxwindspeed: Maximum windSpeed calculated " + "for %s days in %0.2f seconds." % (n_days, tdiff)) + if self.dry_run: + log.info("maxwindspeed: This was a dry run. %s was not applied." % self.name) + + def get_archive_span_max(self, span, obs): + """Gets the max value of an observation and the timestamp at which it occurred from a + TimeSpan of archive records. Raises a weewx.ViolatedPrecondition error if the max value + of the observation field could not be determined. + + Args: + span(weeutil.weeutil.TimeSpan): TimesSpan object of the period from which to determine + the interval value. + obs(str): The observation to be used. + + Returns: + tuple: A tuple of the format: (timestamp, value), where timestamp is the epoch + timestamp when the max value occurred, and value is the max value of the + observation over the time span + + Raises: + weewx.ViolatedPrecondition: If no observation field values are found. + """ + + select_str = "SELECT dateTime, %(obs_type)s FROM %(table_name)s " \ + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND " \ + "%(obs_type)s = (SELECT MAX(%(obs_type)s) FROM %(table_name)s " \ + "WHERE dateTime > %(start)s and dateTime <= %(stop)s) AND " \ + "%(obs_type)s IS NOT NULL" + interpolate_dict = {'obs_type': obs, + 'table_name': self.dbm.table_name, + 'start': span.start, + 'stop': span.stop} + + _row = self.dbm.getSql(select_str % interpolate_dict) + if _row: + try: + return _row[0], _row[1] + except IndexError: + _msg = "'%s' field not found in archive day %s." % (obs, span) + raise weewx.ViolatedPrecondition(_msg) + else: + return None, None + + def write_max(self, obs, row_ts, value, when_ts, cursor=None): + """Update the max and maxtime fields in an existing daily summary row. + + Updates the max and maxtime fields in a row in a daily summary table. + + Args: + obs(str): The observation to be used. the daily summary updated will be xxx_day_obs + where xxx is the database archive table name. + row_ts(float): Timestamp of the row to be updated. + value(float): The value to be saved in field max + when_ts(float): The timestamp to be saved in field maxtime + cursor(weedb.Cursor|None): Cursor object for the database connection being used. + """ + + _cursor = cursor or self.dbm.connection.cursor() + + max_update_str = "UPDATE %s_day_%s SET %s=?,%s=? " \ + "WHERE datetime=?" % (self.dbm.table_name, obs, 'max', 'maxtime') + _cursor.execute(max_update_str, (value, when_ts, row_ts)) + if cursor is None: + _cursor.close() + + @staticmethod + def _progress(ndays, last_time): + """Utility function to show our progress while processing the fix.""" + + _msg = "Updating 'windSpeed' daily summary: %d; " \ + "Timestamp: %s\r" % (ndays, timestamp_to_string(last_time, format_str="%Y-%m-%d")) + print(_msg, end='', file=sys.stdout) + sys.stdout.flush() + + +# ============================================================================ +# class CalcMissing +# ============================================================================ + +class CalcMissing(DatabaseFix): + """Class to calculate and store missing derived observations. + + The following algorithm is used to calculate and store missing derived + observations: + + 1. Obtain a wxservices.WXCalculate() object to calculate the derived obs + fields for each record + 2. Iterate over each day and record in the period concerned augmenting + each record with derived fields. Any derived fields that are missing + or == None are calculated. Days are processed in tranches and each + updated derived fields for each tranche are processed as a single db + transaction. + 4. Once all days/records have been processed the daily summaries for the + period concerned are recalculated. + """ + + def __init__(self, config_dict, calc_missing_config_dict): + """Initialise a CalcMissing object. + + Args: + config_dict(dict): WeeWX config file as a dict + calc_missing_config_dict(dict): A config dict with the following structure: + name: A descriptive name for the class + binding: data binding to use + start_ts: start ts of timespan over which missing derived fields + will be calculated + stop_ts: stop ts of timespan over which missing derived fields + will be calculated + trans_days: number of days of records per db transaction + dry_run: is this a dry run (boolean) + """ + + # call our parent's __init__ + super().__init__(config_dict, calc_missing_config_dict) + + # the start timestamp of the period to calc missing + self.start_ts = int(calc_missing_config_dict.get('start_ts')) + # the stop timestamp of the period to calc missing + self.stop_ts = int(calc_missing_config_dict.get('stop_ts')) + # number of days per db transaction, default to 10. + self.trans_days = int(calc_missing_config_dict.get('trans_days', 10)) + # is this a dry run, default to true + self.dry_run = to_bool(calc_missing_config_dict.get('dry_run', True)) + + self.config_dict = config_dict + + def run(self): + """Main entry point for calculating missing derived fields. + + Calculate the missing derived fields for the timespan concerned, save + the calculated data to archive and recalculate the daily summaries. + """ + + # record the current time + t1 = time.time() + + # Instantiate a dummy engine, to be used to calculate derived variables. This will + # cause all the xtype services to get loaded. + engine = weewx.engine.DummyEngine(self.config_dict) + # While the above instantiated an instance of StdWXCalculate, we have no way of + # retrieving it. So, instantiate another one, then use that to calculate derived types. + wxcalculate = weewx.wxservices.StdWXCalculate(engine, self.config_dict) + + # initialise some counters so we know what we have processed + days_updated = 0 + days_processed = 0 + total_records_processed = 0 + total_records_updated = 0 + + # obtain gregorian days for our start and stop timestamps + start_greg = weeutil.weeutil.toGregorianDay(self.start_ts) + stop_greg = weeutil.weeutil.toGregorianDay(self.stop_ts) + # start at the first day + day = start_greg + while day <= stop_greg: + # get the start and stop timestamps for this tranche + tr_start_ts = weeutil.weeutil.startOfGregorianDay(day) + tr_stop_ts = min(weeutil.weeutil.startOfGregorianDay(stop_greg + 1), + weeutil.weeutil.startOfGregorianDay(day + self.trans_days)) + # start the transaction + with weedb.Transaction(self.dbm.connection) as _cursor: + # iterate over each day in the tranche we are to work in + for tranche_day in weeutil.weeutil.genDaySpans(tr_start_ts, tr_stop_ts): + # initialise a counter for records processed on this day + records_updated = 0 + # iterate over each record in this day + for record in self.dbm.genBatchRecords(startstamp=tranche_day.start, + stopstamp=tranche_day.stop): + # but we are only concerned with records after the + # start and before or equal to the stop timestamps + if self.start_ts < record['dateTime'] <= self.stop_ts: + # first obtain a list of the fields that may be calculated + extras_list = [] + for obs in wxcalculate.calc_dict: + directive = wxcalculate.calc_dict[obs] + if directive == 'software' \ + or directive == 'prefer_hardware' \ + and (obs not in record or record[obs] is None): + extras_list.append(obs) + + # calculate the missing derived fields for the record + wxcalculate.do_calculations(record) + + # Obtain a new record dictionary that contains only those items + # that wxcalculate calculated. Use dictionary comprehension. + extras_dict = {k: v for (k, v) in record.items() if k in extras_list} + + # update the archive with the calculated data + records_updated += self.update_record_fields(record['dateTime'], + extras_dict) + # update the total records processed + total_records_processed += 1 + # Give the user some information on progress + if total_records_processed % 1000 == 0: + p_msg = "Processing record: %d; Last record: %s" \ + % (total_records_processed, + timestamp_to_string(record['dateTime'])) + self._progress(p_msg) + # update the total records updated + total_records_updated += records_updated + # if we updated any records on this day increment the count + # of days updated + days_updated += 1 if records_updated > 0 else 0 + days_processed += 1 + # advance to the next tranche + day += self.trans_days + # finished, so give the user some final information on progress, mainly + # so the total tallies with the log + p_msg = "Processing record: %d; Last record: %s" % (total_records_processed, + timestamp_to_string(tr_stop_ts)) + self._progress(p_msg, overprint=False) + # now update the daily summaries, but only if this is not a dry run + if not self.dry_run: + print("Recalculating daily summaries...") + # first we need a start and stop date object + start_d = datetime.date.fromtimestamp(self.start_ts) + # Since each daily summary is identified by the midnight timestamp + # for that day, we need to make sure our stop timestamp is not on + # a midnight boundary, or we will rebuild the following days sumamry + # as well. if it is on a midnight boundary just subtract 1 second + # and use that. + summary_stop_ts = self.stop_ts + if weeutil.weeutil.isMidnight(self.stop_ts): + summary_stop_ts -= 1 + stop_d = datetime.date.fromtimestamp(summary_stop_ts) + # do the update + self.dbm.backfill_day_summary(start_d=start_d, stop_d=stop_d) + print(file=sys.stdout) + print("Finished recalculating daily summaries") + else: + # it's a dry run so say the rebuild was skipped + print("This is a dry run, recalculation of daily summaries was skipped") + tdiff = time.time() - t1 + # we are done so log and inform the user + _day_processed_str = "day" if days_processed == 1 else "days" + _day_updated_str = "day" if days_updated == 1 else "days" + if not self.dry_run: + log.info("Processed %d %s consisting of %d records. " + "%d %s consisting of %d records were updated " + "in %0.2f seconds." % (days_processed, + _day_processed_str, + total_records_processed, + days_updated, + _day_updated_str, + total_records_updated, + tdiff)) + else: + # this was a dry run + log.info("Processed %d %s consisting of %d records. " + "%d %s consisting of %d records would have been updated " + "in %0.2f seconds." % (days_processed, + _day_processed_str, + total_records_processed, + days_updated, + _day_updated_str, + total_records_updated, + tdiff)) + + def update_record_fields(self, ts, record, cursor=None): + """Updates multiple fields in an archive record via an update query. + + Args: + ts (float): epoch timestamp of the record to be updated + record (dict): dictionary containing the updated data in field name-value pairs + cursor (weedb.Cursor): sqlite cursor + + Returns: + int: The number of records updated. + """ + + # Only data types that appear in the database schema can be + # updated. To find them, form the intersection between the set of + # all record keys and the set of all sql keys + record_key_set = set(record.keys()) + update_key_set = record_key_set.intersection(self.dbm.sqlkeys) + # only update if we have data for at least one field that is in the schema + if len(update_key_set) > 0: + # convert to an ordered list + key_list = list(update_key_set) + # get the values in the same order + value_list = [record[k] for k in key_list] + + # Construct the SQL update statement. First construct the 'SET' + # argument, we want a string of comma separated `field_name`=? + # entries. Each ? will be replaced by a value from update value list + # when the SQL statement is executed. We should not see any field + # names that are SQLite/MySQL reserved words (e.g., interval) but just + # in case enclose field names in backquotes. + set_str = ','.join(["`%s`=?" % k for k in key_list]) + # form the SQL update statement + sql_update_stmt = "UPDATE %s SET %s WHERE dateTime=%s" % (self.dbm.table_name, + set_str, + ts) + # obtain a cursor if we don't have one + _cursor = cursor or self.dbm.connection.cursor() + # execute the update statement but only if it's not a dry run + if not self.dry_run: + _cursor.execute(sql_update_stmt, value_list) + # close the cursor is we opened one + if cursor is None: + _cursor.close() + # if we made it here the record was updated so return the number of + # records updated which will always be 1 + return 1 + # there were no fields to update so return 0 + return 0 + + @staticmethod + def _progress(message, overprint=True): + """Utility function to show our progress.""" + + if overprint: + print(message + "\r", end='') + else: + print(message) + sys.stdout.flush() diff --git a/dist/weewx-5.0.2/src/weecfg/extension.py b/dist/weewx-5.0.2/src/weecfg/extension.py new file mode 100644 index 0000000..fa450ed --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/extension.py @@ -0,0 +1,568 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your full rights. +# +"""Utilities for installing and removing extensions""" + +import glob +import os +import shutil +import sys +import tempfile + +import configobj + +import weecfg +import weeutil.config +import weeutil.startup +import weeutil.weeutil +import weewx +from weeutil.printer import Printer + +# Very old extensions did: +# from setup import ExtensionInstaller +# Redirect references to 'setup' to me instead. +sys.modules['setup'] = sys.modules[__name__] + + +class InstallError(Exception): + """Exception raised when installing an extension.""" + + +class ExtensionInstaller(dict): + """Base class for extension installers.""" + + def configure(self, engine): + """Can be overridden by installers. It should return True if the installer modifies + the configuration dictionary.""" + return False + + +class ExtensionEngine(object): + """Engine that manages extensions.""" + # Extension components can be installed to these locations + target_dirs = { + 'bin': 'BIN_DIR', + 'skins': 'SKIN_DIR' + } + + def __init__(self, config_path, config_dict, dry_run=False, printer=None): + """Initializer for ExtensionEngine. + + Args: + config_path (str): Path to the configuration file. For example, something + like /home/weewx/weewx.conf. + config_dict (dict): The configuration dictionary, i.e., the contents of the + file at config_path. + dry_run (bool): If Truthy, all the steps will be printed out, but nothing will + actually be done. + printer (Printer): An instance of weeutil.printer.Printer. This will be used to print + things to the console while honoring verbosity levels. + """ + self.config_path = config_path + self.config_dict = config_dict + self.printer = printer or Printer() + self.dry_run = dry_run + + self.root_dict = weeutil.startup.extract_roots(self.config_dict) + self.printer.out("root dictionary: %s" % self.root_dict, 4) + + def enumerate_extensions(self): + """Print info about all installed extensions to the logger.""" + ext_dir = self.root_dict['EXT_DIR'] + try: + exts = sorted(os.listdir(ext_dir)) + if exts: + self.printer.out("%-18s%-10s%s" % ("Extension Name", "Version", "Description"), + level=0) + for f in exts: + try: + info = self.get_extension_info(f) + except weecfg.ExtensionError as e: + info = {'name': f, 'version': '???', 'description': str(e)} + msg = "%(name)-18s%(version)-10s%(description)s" % info + self.printer.out(msg, level=0) + else: + self.printer.out("Extension cache is '%s'." % ext_dir, level=2) + self.printer.out("No extensions installed.", level=0) + except OSError: + self.printer.out("No extension cache '%s'." % ext_dir, level=2) + self.printer.out("No extensions installed.", level=0) + + def get_extension_info(self, ext_name): + ext_cache_dir = os.path.join(self.root_dict['EXT_DIR'], ext_name) + _, installer = weecfg.get_extension_installer(ext_cache_dir) + return installer + + def install_extension(self, extension_path, no_confirm=False): + """Install an extension. + + Args: + extension_path(str): Either a file path, a directory path, or an URL. + no_confirm(bool): If False, ask for a confirmation before installing. Otherwise, + just do it. + """ + ans = weeutil.weeutil.y_or_n(f"Install extension '{extension_path}' (y/n)? ", + noprompt=no_confirm) + if ans == 'n': + self.printer.out("Nothing done.") + return + + # Figure out what extension_path is + if extension_path.startswith('http'): + # It's an URL. Download, then install + import urllib.request + import tempfile + # Download the file into a temporary file + with tempfile.NamedTemporaryFile() as test_fd: + # "filename" is a string with the path to the downloaded file; + # "info" is an instance of http.client.HTTPMessage. + filename, info = urllib.request.urlretrieve(extension_path, test_fd.name) + downloaded_name = info.get_filename() + if not downloaded_name: + raise IOError(f"Unknown extension type found at '{extension_path}'") + # Something like weewx-loopdata-3.3.2.zip + if downloaded_name.endswith('.zip'): + filetype = 'zip' + else: + filetype = 'tar' + extension_name = self._install_from_file(test_fd.name, filetype) + elif os.path.isfile(extension_path): + # It's a file. Figure out what kind, then install. If it's not a zipfile, assume + # it's a tarfile. + if extension_path.endswith('.zip'): + filetype = 'zip' + else: + filetype = 'tar' + extension_name = self._install_from_file(extension_path, filetype) + elif os.path.isdir(extension_path): + # It's a directory. Install directly. + extension_name = self.install_from_dir(extension_path) + else: + raise InstallError(f"Unrecognized type for {extension_path}") + + self.printer.out(f"Finished installing extension {extension_name} from {extension_path}") + + def _install_from_file(self, filepath, filetype): + """Install an extension from a file. + + Args: + filepath(str): A path to the file holding the extension. + filetype(str): The type of file. If 'zip', it's assumed to be a zipfile. Anything else, + and it's assumed to be a tarfile. + """ + # Make a temporary directory into which to extract the file. + with tempfile.TemporaryDirectory() as dir_name: + if filetype == 'zip': + member_names = weecfg.extract_zip(filepath, dir_name, self.printer) + else: + # Assume it's a tarfile + member_names = weecfg.extract_tar(filepath, dir_name, self.printer) + extension_reldir = os.path.commonprefix(member_names) + if not extension_reldir: + raise InstallError(f"Unable to install from {filepath}: no common path " + "(the extension archive contains more than a " + "single root directory)") + extension_dir = os.path.join(dir_name, extension_reldir) + extension_name = self.install_from_dir(extension_dir) + + return extension_name + + def install_from_dir(self, extension_dir): + """Install the extension whose components are in extension_dir""" + self.printer.out(f"Request to install extension found in directory {extension_dir}", + level=2) + + # The "installer" is actually a dictionary containing what is to be installed and where. + # The "installer_path" is the path to the file containing that dictionary. + installer_path, installer = weecfg.get_extension_installer(extension_dir) + extension_name = installer.get('name', 'Unknown') + self.printer.out(f"Found extension with name '{extension_name}'", level=2) + + # Install any files: + if 'files' in installer: + self._install_files(installer['files'], extension_dir) + + save_config = False + + # Go through all the possible service groups and see if the extension + # includes any services that belong in any of them. + self.printer.out("Adding services to service lists.", level=2) + for service_group in weewx.all_service_groups: + if service_group in installer: + extension_svcs = weeutil.weeutil.option_as_list(installer[service_group]) + # Check to make sure the service group is in the configuration dictionary + if service_group not in self.config_dict['Engine']['Services']: + self.config_dict['Engine']['Services'][service_group] = [] + # Be sure it's actually a list + svc_list = weeutil.weeutil.option_as_list( + self.config_dict['Engine']['Services'][service_group]) + for svc in extension_svcs: + # See if this service is already in the service group + if svc not in svc_list: + if not self.dry_run: + # Add the new service into the appropriate service group + svc_list.append(svc) + self.config_dict['Engine']['Services'][service_group] = svc_list + save_config = True + self.printer.out(f"Added new service {svc} to {service_group}.", level=3) + + # Give the installer a chance to do any customized configuration + save_config |= installer.configure(self) + + # Look for options that have to be injected into the configuration file + if 'config' in installer: + save_config |= self._inject_config(installer['config'], extension_name) + + # Save the extension's install.py file in the extension's installer + # directory for later use enumerating and uninstalling + extension_installer_dir = os.path.join(self.root_dict['EXT_DIR'], extension_name) + self.printer.out(f"Saving installer file to {extension_installer_dir}") + if not self.dry_run: + try: + os.makedirs(os.path.join(extension_installer_dir)) + except OSError: + pass + shutil.copy2(installer_path, extension_installer_dir) + + if save_config: + backup_path = weecfg.save_with_backup(self.config_dict, self.config_path) + self.printer.out(f"Saved copy of configuration as {backup_path}") + + return extension_name + + def _install_files(self, file_list, extension_dir): + """ Install any files included in the extension + + Args: + file_list (list[tuple]): A list of two-way tuples. The first element of each tuple is + the relative path to a destination directory, the second element is a list of + relative paths to files to be put in that directory. For example, + (bin/user/, [src/foo.py, ]) means the relative path for the destination directory + is 'bin/user'. The file foo.py can be found in src/foo.py, and should be put + in bin/user/foo.py. + extension_dir (str): Path to the directory holding the downloaded extension. + + Returns: + int: How many files were installed. + """ + + self.printer.out("Copying new files...", level=2) + N = 0 + + for source_path, destination_path in ExtensionEngine._gen_file_paths( + self.root_dict['WEEWX_ROOT'], + extension_dir, + file_list): + + if self.dry_run: + self.printer.out(f"Fake copying from '{source_path}' to '{destination_path}'", + level=3) + else: + self.printer.out(f"Copying from '{source_path}' to '{destination_path}'", + level=3) + try: + os.makedirs(os.path.dirname(destination_path)) + except OSError: + pass + shutil.copy(source_path, destination_path) + N += 1 + + if self.dry_run: + self.printer.out(f"Fake copied {N:d} files.", level=2) + else: + self.printer.out(f"Copied {N:d} files.", level=2) + return N + + @staticmethod + def _gen_file_paths(weewx_root, extension_dir, file_list): + """Generate tuples of (source, destination) from a file_list""" + + # Go through all the files used by the extension. A "source tuple" is something like + # (bin/user, [bin/user/myext.py, bin/user/otherext.py]). + for source_tuple in file_list: + # Expand the source tuple + dest_dir, source_files = source_tuple + for source_file in source_files: + common = os.path.commonpath([dest_dir, source_file]) + rest = os.path.relpath(source_file, common) + abs_source_path = os.path.join(extension_dir, source_file) + abs_dest_path = os.path.abspath(os.path.join(weewx_root, + dest_dir, + rest)) + + yield abs_source_path, abs_dest_path + + def get_lang_code(self, skin_path, default_code): + """Convenience function for picking a language code + + Args: + skin_path (str): The path to the directory holding the skin. + default_code (str): In the absence of a locale directory, what language to pick. + """ + languages = weecfg.get_languages(skin_path) + code = weecfg.pick_language(languages, default_code) + return code + + def _inject_config(self, extension_config, extension_name): + """Injects any additions to the configuration file that the extension might have. + + Returns True if it modified the config file, False otherwise. + """ + self.printer.out("Adding sections to configuration file", level=2) + # Make a copy, so we can modify the sections to fit the existing configuration + if isinstance(extension_config, configobj.Section): + cfg = weeutil.config.deep_copy(extension_config) + else: + cfg = dict(extension_config) + + save_config = False + + # Extensions can specify where their HTML output goes relative to HTML_ROOT. So, we must + # prepend the installation's HTML_ROOT to get a final location that the reporting engine + # can use. For example, if an extension specifies "HTML_ROOT=forecast", the final location + # might be public_html/forecast, or /var/www/html/forecast, depending on the installation + # method. + ExtensionEngine.prepend_path(cfg, 'HTML_ROOT', self.config_dict['StdReport']['HTML_ROOT']) + + # If the extension uses a database, massage it so that it's compatible with the new V3.2 + # way of specifying database options + if 'Databases' in cfg: + for db in cfg['Databases']: + db_dict = cfg['Databases'][db] + # Does this extension use the V3.2+ 'database_type' option? + if 'database_type' not in db_dict: + # There is no database type specified. In this case, the driver type better + # appear. Fail hard, with a KeyError, if it does not. Also, if the driver is + # not for sqlite or MySQL, then we don't know anything about it. Assume the + # extension author knows what s/he is doing, and leave it be. + if db_dict['driver'] == 'weedb.sqlite': + db_dict['database_type'] = 'SQLite' + db_dict.pop('driver') + elif db_dict['driver'] == 'weedb.mysql': + db_dict['database_type'] = 'MySQL' + db_dict.pop('driver') + + if not self.dry_run: + # Inject any new config data into the configuration file + weeutil.config.conditional_merge(self.config_dict, cfg) + + self._reorder(cfg) + save_config = True + + self.printer.out("Merged extension settings into configuration file", level=3) + return save_config + + def _reorder(self, cfg): + """Reorder the resultant config_dict""" + # Patch up the location of any reports so that they appear before FTP/RSYNC + + # First, find the FTP or RSYNC reports. This has to be done on the basis of the skin type, + # rather than the report name, in case there are multiple FTP or RSYNC reports to be run. + try: + for report in self.config_dict['StdReport'].sections: + if self.config_dict['StdReport'][report]['skin'] in ['Ftp', 'Rsync']: + target_name = report + break + else: + # No FTP or RSYNC. Nothing to do. + return + except KeyError: + return + + # Now shuffle things so any reports that appear in the extension appear just before FTP (or + # RSYNC) and in the same order they appear in the extension manifest. + try: + for report in cfg['StdReport']: + weecfg.reorder_sections(self.config_dict['StdReport'], report, target_name) + except KeyError: + pass + + def uninstall_extension(self, extension_name, no_confirm=False): + """Uninstall an extension. + Args: + extension_name(str): The name of the extension. Use 'weectl extension list' to find + its name. + no_confirm(bool): If False, ask for a confirmation before uninstalling. Otherwise, + just do it. + """ + + ans = weeutil.weeutil.y_or_n(f"Uninstall extension '{extension_name}' (y/n)? ", + noprompt=no_confirm) + if ans == 'n': + self.printer.out("Nothing done.") + return + + # Find the subdirectory containing this extension's installer + extension_installer_dir = os.path.join(self.root_dict['EXT_DIR'], extension_name) + try: + # Retrieve it + _, installer = weecfg.get_extension_installer(extension_installer_dir) + except weecfg.ExtensionError: + sys.exit(f"Unable to find extension '{extension_name}'.") + + # Remove any files that were added: + if 'files' in installer: + self.uninstall_files(installer['files']) + + save_config = False + + # Remove any services we added + for service_group in weewx.all_service_groups: + if service_group in installer: + new_list = [x for x in self.config_dict['Engine']['Services'][service_group] \ + if x not in installer[service_group]] + if not self.dry_run: + self.config_dict['Engine']['Services'][service_group] = new_list + save_config = True + + # Remove any sections we added + if 'config' in installer and not self.dry_run: + weecfg.remove_and_prune(self.config_dict, installer['config']) + save_config = True + + if not self.dry_run: + # Finally, remove the extension's installer subdirectory: + shutil.rmtree(extension_installer_dir) + + if save_config: + weecfg.save_with_backup(self.config_dict, self.config_path) + + self.printer.out(f"Finished removing extension '{extension_name}'") + + def uninstall_files(self, file_list): + """Delete files that were installed for this extension + Args: + file_list (list[tuple]): A list of two-way tuples. The first element of each tuple is + the relative path to the destination directory, the second element is a list of + relative paths to files that are used by the extension. + """ + + self.printer.out("Removing files.", level=2) + + directory_set = set() + N = 0 + # Go through all the listed files + for _, destination_path in ExtensionEngine._gen_file_paths( + self.root_dict['WEEWX_ROOT'], + '', + file_list): + file_name = os.path.basename(destination_path) + # There may be a versioned skin.conf. Delete it by adding a wild card. + # Similarly, be sure to delete Python files with .pyc or .pyo extensions. + if file_name == 'skin.conf' or file_name.endswith('py'): + destination_path += "*" + # Delete the file + N += self.delete_file(destination_path) + # Add its directory to the set of directories we've encountered + directory_set.add(os.path.dirname(destination_path)) + + self.printer.out(f"Removed {N:d} files.", level=2) + + N_dir = 0 + # Now delete all the empty directories. Start by finding the directory closest to root + most_root = os.path.commonprefix(list(directory_set)) + # Now delete the directories under it, from the bottom up. + for dirpath, _, _ in os.walk(most_root, topdown=False): + if dirpath in directory_set: + N_dir += self.delete_directory(dirpath) + self.printer.out(f"Removed {N_dir:d} directores.", level=2) + + def delete_file(self, filename, report_errors=True): + """ + Delete files from the file system. + + Args: + filename (str): The path to the file(s) to be deleted. Can include wildcards. + + report_errors (bool): If truthy, report an error if the file is missing or cannot be + deleted. Otherwise, don't. In neither case will an exception be raised. + Returns: + int: The number of files deleted + """ + n_deleted = 0 + for fn in glob.glob(filename): + self.printer.out("Deleting file %s" % fn, level=2) + if not self.dry_run: + try: + os.remove(fn) + n_deleted += 1 + except OSError as e: + if report_errors: + self.printer.out("Delete failed: %s" % e, level=4) + return n_deleted + + def delete_directory(self, directory, report_errors=True): + """ + Delete the given directory from the file system. + + Args: + + directory (str): The path to the directory to be deleted. If the directory is not + empty, nothing is done. + + report_errors (bool); If truthy, report an error. Otherwise, don't. In neither case will + an exception be raised. + """ + n_deleted = 0 + try: + if os.listdir(directory): + self.printer.out(f"Directory '{directory}' not empty.", level=2) + else: + self.printer.out(f"Deleting directory '{directory}'.", level=2) + if not self.dry_run: + shutil.rmtree(directory) + n_deleted += 1 + except OSError as e: + if report_errors: + self.printer.out(f"Delete failed on directory '{directory}': {e}", level=2) + return n_deleted + + @staticmethod + def _strip_leading_dir(path): + idx = path.find('/') + if idx >= 0: + return path[idx + 1:] + + @staticmethod + def prepend_path(a_dict: dict, label: str, value: str) -> None: + """Prepend the value to every instance of the label in dict a_dict""" + for k in a_dict: + if isinstance(a_dict[k], dict): + ExtensionEngine.prepend_path(a_dict[k], label, value) + elif k == label: + a_dict[k] = os.path.join(value, a_dict[k]) + + # def transfer(self, root_src_dir): + # """For transfering contents of an old 'user' directory into the new one.""" + # if not os.path.isdir(root_src_dir): + # sys.exit(f"{root_src_dir} is not a directory") + # root_dst_dir = self.root_dict['USER_DIR'] + # self.printer.out(f"Transferring contents of {root_src_dir} to {root_dst_dir}", 1) + # if self.dry_run: + # self.printer.out(f"This is a {bcolors.BOLD}dry run{bcolors.ENDC}. " + # f"Nothing will actually be done.") + # + # for dirpath, dirnames, filenames in os.walk(root_src_dir): + # if os.path.basename(dirpath) in {'__pycache__', '.init'}: + # self.printer.out(f"Skipping {dirpath}.", 3) + # continue + # dst_dir = dirpath.replace(root_src_dir, root_dst_dir, 1) + # self.printer.out(f"Making directory {dst_dir}", 3) + # if not self.dry_run: + # os.makedirs(dst_dir, exist_ok=True) + # for f in filenames: + # if ".pyc" in f: + # self.printer.out(f"Skipping {f}", 3) + # continue + # dst_file = os.path.join(dst_dir, f) + # if os.path.exists(dst_file): + # self.printer.out(f"File {dst_file} already exists. Not replacing.", 2) + # else: + # src_file = os.path.join(dirpath, f) + # self.printer.out(f"Copying file {src_file} to {dst_dir}", 3) + # if not self.dry_run: + # shutil.copy(src_file, dst_dir) + # if self.dry_run: + # self.printer.out("This was a dry run. Nothing was actually done") diff --git a/dist/weewx-5.0.2/src/weecfg/tests/__init__.py b/dist/weewx-5.0.2/src/weecfg/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx25_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx25_expected.conf new file mode 100644 index 0000000..c31b512 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx25_expected.conf @@ -0,0 +1,413 @@ +############################################################################################ +# # +# # +# WEEWX CONFIGURATION FILE # +# # +# # +############################################################################################ +# # +# Copyright (c) 2009, 2010, 2011, 2012 Tom Keffer # +# # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################################ +# +# $Revision: 737 $ +# $Author: tkeffer $ +# $Date: 2012-11-04 09:05:51 -0800 (Sun, 04 Nov 2012) $ +# +############################################################################################ + +# +# This section is for general configuration information +# + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Current version +version = 2.5.0 + +############################################################################################ + +[Station] + + # + # This section is for information about your station + # + + location = "Hood River, Oregon" + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. Normally this is + # downloaded from the station, but not all hardware supports this. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). Normally + # this is downloaded from the station, but not all hardware supports this. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # Set to type of station hardware (e.g., 'Vantage'). + # Must match a section name below. + station_type = Vantage +# station_type = WMR-USB +# station_type = Simulator + +############################################################################################ + +[Vantage] + + # + # This section is for configuration info for a Davis VantagePro2, VantageVue + # or WeatherLinkIP + # + + # Connection type. + # Choose one of "serial" (the classic VantagePro) or "ethernet" (the WeatherLinkIP): + type = serial + #type = ethernet + + # If you chose "serial", then give its port name + # + # Ubuntu and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 a common serial port name + port = /dev/ttyUSB0 + #port = /dev/ttyS0 + + # If you chose "ethernet", then give its IP Address/hostname + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.drivers.vantage + +############################################################################################ + +[WMR100] + + # + # This section is for configuration info for an Oregon Scientific WMR100 + # + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # How long a wind record can be used to calculate wind chill (in seconds) + stale_wind = 30 + + # The driver to use + driver = weewx.drivers.wmr100 + +############################################################################################ + +[Simulator] + + # + # This section for the weewx weather station simulator + # + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # One of either: + mode = simulator # Real-time simulator. It will sleep between emitting LOOP packets. + #mode = generator # Emit packets as fast as it can (useful for testing). + + # The start time. [Optional. Default is to use the present time] + # start = 2011-01-01 00:00 + + driver = weewx.drivers.simulator + +############################################################################################ + +[StdRESTful] + # + # This section if for uploading data to sites using RESTful protocols. + # + + [[Wunderground]] + + # + # This section is for configuring posts to the Weather Underground + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + # station = your Weather Underground station ID here (eg, KORHOODR3) + # password = your password here + + driver = weewx.restful.Ambient + + [[PWSweather]] + + # + # This section is for configuring posts to PWSweather.com + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + # station = your PWSweather station ID here (eg, KORHOODR3) + # password = your password here + + driver = weewx.restful.Ambient + + [[CWOP]] + # + # This section is for configuring posts to CWOP. + # + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID + # station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + # passcode = your passcode here eg, 12345 (APRS stations only) + + # Comma separated list of server:ports to try: + server = cwop.aprs.net:14580, cwop.aprs.net:23 + + # How often we should post in seconds. 0=with every archive record + interval = 600 + + driver = weewx.restful.CWOP + [[StationRegistry]] + # Uncomment the following line to register this weather station. + #register_this_station = True + + # Specify a station URL, otherwise the station_url from [Station] + # will be used. + #station_url = http://example.com/weather/ + + # Specify a description of the station, otherwise the location from + # [Station] will be used. + #description = The greatest station on earth + + driver = weewx.restful.StationRegistry + +############################################################################################ + +[StdReport] + + # + # This section specifies what reports, using which skins, are to be generated. + # + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file from here. + # For example, uncommenting the next 3 lines would have pressure reported + # in millibars, irregardless of what was in the skin configuration file + # [[[Units]]] + # [[[[Groups]]]] + # group_pressure=mbar + + # + # Here is an example where we create a custom report, still using the standard + # skin, but where the image size is overridden, and the results are put in a + # separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[Images]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + # user = replace with your username + # password = replace with your password + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination root directory on your server (e.g., '/weather) + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + +# If you wish to upload files from something other than what HTML_ROOT is set to +# above, then reset it here: +# HTML_ROOT = public_html + +############################################################################################ + +[StdConvert] + + # + # This service can convert measurements to a chosen target unit system, which + # will then be used for the databases. + # THIS VALUE CANNOT BE CHANGED LATER! + # + + target_unit = US # Choices are 'US' or 'METRIC' + +############################################################################################ + +[StdCalibrate] + + # + # This section can adjust data using calibration expressions. + # + + [[Corrections]] +# For each type, an arbitrary calibration expression can be given. +# It should be in the native units of the weather station hardware. +# For example: +# outTemp = outTemp - 0.2 + +############################################################################################ + +[StdQC] + + # + # This section for quality control checks. + # It should be in the same units as specified in StdConvert, above. + # + + [[MinMax]] + outTemp = -40, 120 + barometer = 28, 32.5 + outHumidity = 0, 100 + +############################################################################################ + +[StdArchive] + + # + # This section is for configuring the archive databases. + # + + # The database to be used for archive data. + # This should match a section given in section [Databases] below. + archive_database = archive_sqlite + + # The database to be used for stats data. + # This should match a section given in section [Databases] below. + stats_database = stats_sqlite + + # If your station hardware supports data logging (such as the Davis Vantage + # series), then the archive interval will be downloaded off the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console hardware. + # If the console does not support this, then software record generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + +############################################################################################ + +[StdTimeSynch] + + # How often to check the clock on the weather station for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds): + max_drift = 5 + +############################################################################################ + +[Databases] + + # + # This section lists possible databases. + # + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/weewx.sdb + driver = weedb.sqlite + + [[stats_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/stats.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database = weewx + driver = weedb.mysql + + [[stats_mysql]] + host = localhost + user = weewx + password = weewx + database = stats + driver = weedb.mysql + +############################################################################################ + +[Engines] + + # + # This section configures the internal weewx engines. It is for advanced customization. + # + + [[WxEngine]] + # The list of services the main weewx engine should run: + service_list = weewx.wxengine.StdConvert, weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC, weewx.wxengine.StdArchive, weewx.wxengine.StdTimeSynch, weewx.wxengine.StdPrint, weewx.wxengine.StdRESTful, weewx.wxengine.StdReport + diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx26_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx26_expected.conf new file mode 100644 index 0000000..8b6cf1f --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx26_expected.conf @@ -0,0 +1,538 @@ +############################################################################## +# # +# WEEWX CONFIGURATION FILE # +# # +############################################################################## +# # +# Copyright (c) 2009-2013 Tom Keffer # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################## +# +# $Id: weewx.conf 1582 2013-10-29 15:07:42Z tkeffer $ +# +############################################################################## + +# +# This section is for general configuration information +# + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 2.6.0 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. If there is a comma in the + # description, then put the description in quotes. + location = "Hood River, Oregon" + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # Set to type of station hardware. Supported stations include: + # Vantage + # WMR100 + # WMR200 + # WMR9x8 + # FineOffsetUSB + # WS28xx + # Simulator + station_type = Vantage + +# If you have a website, you may optionally specify an URL for +# its HTML server. +#station_url = http://www.threefools.org + +############################################################################## + +[Vantage] + # This section is for configuration info for a Davis VantagePro2, + # VantageVue or WeatherLinkIP + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.drivers.vantage + +############################################################################## + +[WMR100] + # This section is for the Oregon Scientific WMR100 + + # How long a wind record can be used to calculate wind chill (in seconds) + stale_wind = 30 + + # The driver to use + driver = weewx.drivers.wmr100 + + # The station model, e.g., WMR100, WMR100N, WMRS200 + model = WMR100 + +############################################################################## + +[WMR200] + # This section is for the Oregon Scientific WMR200 + + # The driver to use + driver = weewx.drivers.wmr200 + + # The station model, e.g., WMR200, WMR200A, Radio Shack W200 + model = WMR200 + +############################################################################## + +[WMR9x8] + # This section is for the Oregon Scientific WMR918/968 + + # Connection type. For now, 'serial' is the only option. + type = serial + + # A port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # The driver to use + driver = weewx.drivers.wmr9x8 + + # The station model, e.g., WMR918, Radio Shack 63-1016 + model = WMR968 + +############################################################################## + +[FineOffsetUSB] + # This section is for the Fine Offset series of weather stations. + + # The station model, e.g., WH1080, WS1090, WS2080, WH3081 + model = WS2080 + + # The polling mode can be PERIODIC or ADAPTIVE + polling_mode = PERIODIC + + # How often to poll the station for data, in seconds + polling_interval = 60 + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use + driver = weewx.drivers.fousb + +############################################################################## + +[WS28xx] + # This section is for the La Crosse WS-2800 series of weather stations. + + # Radio frequency to use between USB transceiver and console: US or EU + # US uses 915 MHz, EU uses 868.3 MHz. Default is US. + transceiver_frequency = US + + # The WS28xx is branded by various vendors. Use the model parameter to + # indicate the brand, e.g., 'LaCrosse C86234' or 'TFA Primus' + model = LaCrosse WS28xx + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use + driver = weewx.drivers.ws28xx + +############################################################################## + +[Simulator] + # This section for the weewx weather station simulator + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. If not specified, the default is to use the present time. + #start = 2011-01-01 00:00 + + driver = weewx.drivers.simulator + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your Weather Underground station ID (eg, KORHOODR3) + #password = your Weather Underground password + + log_success = True + log_failure = True + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol + rapidfire = False + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your PWSweather station ID (eg, KORHOODR3) + #password = your PWSweather password + + log_success = True + log_failure = True + + [[CWOP]] + # This section is for configuring posts to CWOP + + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID: + #station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + #passcode = your passcode here eg, 12345 (APRS stations only) + + log_success = True + log_failure = True + + # How often we should post in seconds. 0=with every archive record + post_interval = 600 + + [[StationRegistry]] + # Uncomment the following line to register this weather station. + #register_this_station = True + + # Specify a station URL, otherwise the station_url from [Station] + # will be used. + #station_url = http://example.com/weather/ + + # Specify a description of the station, otherwise the location from + # [Station] will be used. + #description = The greatest station on earth + + log_success = True + log_failure = True + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + log_success = True + log_failure = True + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + log_success = True + log_failure = True + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file from here. + # For example, uncommenting the next 3 lines would have pressure + # reported in millibars, irregardless of what was in the skin + # configuration file + # + # [[[Units]]] + # [[[[Groups]]]] + # group_pressure=mbar + + # Here is an example where we create a custom report, still using the + # standard skin, but where the image size is overridden, and the results + # are put in a separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[Images]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + # user = replace with your username + # password = replace with your password + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination directory (e.g., /weather) + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + # HTML_ROOT = public_html + + [[RSYNC]] + skin = Rsync + +# rsync'ing the results to a webserver is treated as just another +# report, much like the FTP report. +# +# The following configure what system and remote path the files are +# sent to: +# server = replace with your server name, e.g, www.threefools.org +# path = replace with the destination directory (e.g., /weather) +# If you wish to use rsync, you must configure passwordless ssh using +# public/private key authentication from the user account that weewx +# runs as to the user account on the remote machine where the files +# will be copied. +# user = replace with your username +# Rsync can be configured to remove files from the remote server if +# they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you +# make a mistake in the remote path, you could could unintentionally +# cause unrelated files to be deleted. Set to 1 to enable remote file +# deletion, zero to allow files to accumulate remotely. +# delete = 1 + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a target output unit system. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of + # units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] +# For each type, an arbitrary calibration expression can be given. +# It should be in the native units of the weather station hardware. +# For example: +# outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. + # Values must be in the units defined in the StdConvert section. + + [[MinMax]] + outTemp = -40, 120 + barometer = 28, 32.5 + outHumidity = 0, 100 + inHumidity = 0, 100 + rain = 0, 60, inch + windSpeed = 0, 120, mile_per_hour + inTemp = 10, 20, degree_F + +############################################################################## + +[StdArchive] + # This section is for configuring the archive databases. + + # The database to be used for archive data. + # This should match a section given in section [Databases] below. + archive_database = archive_sqlite + + # The database to be used for stats data. + # This should match a section given in section [Databases] below. + stats_database = stats_sqlite + + # If your station hardware supports data logging then the archive interval + # will be downloaded from the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console + # hardware. If the console does not support this, then software record + # generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # The schema to be used for the archive database. This is used only when + # it is initialized. + # Thereafter, the types are retrieved from the database. + archive_schema = user.schemas.defaultArchiveSchema + + # The schema to be used for the stats database. This is used only when + # it is initialized. + # Thereafter, the types are retrieved from the database. + stats_schema = user.schemas.defaultStatsSchema + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds): + max_drift = 5 + +############################################################################## + +[Databases] + # This section lists possible databases. + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/weewx.sdb + driver = weedb.sqlite + + [[stats_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/stats.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database = weewx + driver = weedb.mysql + + [[stats_mysql]] + host = localhost + user = weewx + password = weewx + database = stats + driver = weedb.mysql + +############################################################################## + +[Engines] + # This section configures the internal weewx engines. + # It is for advanced customization. + + [[WxEngine]] + + # The list of services the main weewx engine should run: + prep_services = weewx.wxengine.StdTimeSynch, + data_services = , + process_services = weewx.wxengine.StdConvert, weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC + archive_services = weewx.wxengine.StdArchive, + restful_services = weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdStationRegistry + report_services = weewx.wxengine.StdPrint, weewx.wxengine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx30_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx30_expected.conf new file mode 100644 index 0000000..53fc795 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx30_expected.conf @@ -0,0 +1,620 @@ +############################################################################## +# # +# WEEWX CONFIGURATION FILE # +# # +# Copyright (c) 2009-2013 Tom Keffer # +# $Id: weewx.conf 2394 2014-10-11 16:20:03Z tkeffer $ +############################################################################## + +# This section is for general configuration information + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 3.0.0 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. + location = Hood River, Oregon + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # If you have a website, you may optionally specify an URL for + # its HTML server. + #station_url = http://www.example.com + + # Set to type of station hardware. Supported stations include: + # Vantage FineOffsetUSB Ultimeter + # WMR100 WS28xx WS1 + # WMR200 WS23xx CC3000 + # WMR9x8 TE923 Simulator + station_type = Vantage + +############################################################################## + +[Vantage] + # This section is for a Davis VantagePro2, VantageVue or WeatherLinkIP + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.drivers.vantage + +############################################################################## + +[WMR100] + # This section is for the Oregon Scientific WMR100 + + # The station model, e.g., WMR100, WMR100N, WMRS200 + model = WMR100 + + # How long a wind record can be used to calculate wind chill (in seconds) + stale_wind = 30 + + # The driver to use: + driver = weewx.drivers.wmr100 + +############################################################################## + +[WMR200] + # This section is for the Oregon Scientific WMR200 + + # The station model, e.g., WMR200, WMR200A, Radio Shack W200 + model = WMR200 + + # The driver to use: + driver = weewx.drivers.wmr200 + +############################################################################## + +[WMR9x8] + # This section is for the Oregon Scientific WMR918/968 + + # Connection type. For now, 'serial' is the only option. + type = serial + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., WMR918, Radio Shack 63-1016 + model = WMR968 + + # The driver to use: + driver = weewx.drivers.wmr9x8 + +############################################################################## + +[FineOffsetUSB] + # This section is for the Fine Offset series of weather stations. + + # The station model, e.g., WH1080, WS1090, WS2080, WH3081 + model = WS2080 + + # The polling mode can be PERIODIC or ADAPTIVE + polling_mode = PERIODIC + + # How often to poll the station for data, in seconds + polling_interval = 60 + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use: + driver = weewx.drivers.fousb + +############################################################################## + +[WS23xx] + # This section is for the La Crosse WS-2300 series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., 'LaCrosse WS2317' or 'TFA Primus' + model = LaCrosse WS23xx + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use: + driver = weewx.drivers.ws23xx + +############################################################################## + +[WS28xx] + # This section is for the La Crosse WS-2800 series of weather stations. + + # Radio frequency to use between USB transceiver and console: US or EU + # US uses 915 MHz, EU uses 868.3 MHz. Default is US. + transceiver_frequency = US + + # The station model, e.g., 'LaCrosse C86234' or 'TFA Primus' + model = LaCrosse WS28xx + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use: + driver = weewx.drivers.ws28xx + +############################################################################## + +[TE923] + # This section is for the Hideki TE923 series of weather stations. + + # The station model, e.g., 'Meade TE923W' or 'TFA Nexus' + model = TE923 + + # The driver to use: + driver = weewx.drivers.te923 + + # The default configuration associates the channel 1 sensor with outTemp + # and outHumidity. To change this, or to associate other channels with + # specific columns in the database schema, use the following maps. + [[sensor_map]] + # Map the remote sensors to columns in the database schema. + outTemp = t_1 + outHumidity = h_1 + extraTemp1 = t_2 + extraHumid1 = h_2 + extraTemp2 = t_3 + extraHumid2 = h_3 + extraTemp3 = t_4 + # WARNING: the following are not in the default schema + extraHumid3 = h_4 + extraTemp4 = t_5 + extraHumid4 = h_5 + + [[battery_map]] + txBatteryStatus = batteryUV + windBatteryStatus = batteryWind + rainBatteryStatus = batteryRain + outTempBatteryStatus = battery1 + # WARNING: the following are not in the default schema + extraBatteryStatus1 = battery2 + extraBatteryStatus2 = battery3 + extraBatteryStatus3 = battery4 + extraBatteryStatus4 = battery5 + +############################################################################## + +[Ultimeter] + # This section is for the PeetBros Ultimeter series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., Ultimeter 2000, Ultimeter 100 + model = Ultimeter + + # The driver to use: + driver = weewx.drivers.ultimeter + +############################################################################## + +[WS1] + # This section is for the ADS WS1 series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The driver to use: + driver = weewx.drivers.ws1 + +############################################################################## + +[CC3000] + # This section is for RainWise MarkIII weather stations and CC3000 logger. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., CC3000 or CC3000R + model = CC3000 + + # The driver to use: + driver = weewx.drivers.cc3000 + +############################################################################## + +[Simulator] + # This section for the weewx weather station simulator + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. If not specified, the default is to use the present time. + #start = 2011-01-01 00:00 + + # The driver to use: + driver = weewx.drivers.simulator + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[StationRegistry]] + # To register this weather station, set this to True: + register_this_station = False + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your Weather Underground station ID (eg, KORHOODR3) + #password = your Weather Underground password + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol + rapidfire = False + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your PWSweather station ID (eg, KORHOODR3) + #password = your PWSweather password + + log_success = True + log_failure = True + + [[CWOP]] + # This section is for configuring posts to CWOP + + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID: + #station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + #passcode = your passcode here eg, 12345 (APRS stations only) + + # How often we should post in seconds. 0=with every archive record + post_interval = 600 + + log_success = True + log_failure = True + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + log_success = True + log_failure = True + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + log_success = True + log_failure = True + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports + data_binding = wx_binding + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file here. For + # example, uncomment the following lines to display metric units + # throughout the report, regardless of what is defined in the skin. + # + #[[[Units]]] + # [[[[Groups]]]] + # group_altitude = meter + # group_degree_day = degree_C_day + # group_pressure = mbar + # group_radiation = watt_per_meter_squared + # group_rain = mm + # group_rainrate = mm_per_hour + # group_speed = meter_per_second + # group_speed2 = meter_per_second2 + # group_temperature = degree_C + + # Here is an example where we create a custom report, still using the + # standard skin, but where the image size is overridden, and the results + # are put in a separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[ImageGenerator]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + #user = replace with your username + #password = replace with your password + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + #HTML_ROOT = public_html + + # Most FTP servers use port 21, but if yours is different, you can + # change it here + port = 21 + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + + [[RSYNC]] + skin = Rsync + + # rsync'ing the results to a webserver is treated as just another + # report, much like the FTP report. + # + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The following configure what system and remote path the files are + # sent to: + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + #user = replace with your username + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a target output unit system. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of + # units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] +# For each type, an arbitrary calibration expression can be given. +# It should be in the native units of the weather station hardware. +# For example: +# outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. If units are not specified, + # values must be in the units defined in the StdConvert section. + + [[MinMax]] + barometer = 26, 32.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + rain = 0, 60, inch + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +[StdWXCalculate] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +[StdArchive] + + # If your station hardware supports data logging then the archive interval + # will be downloaded from the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console + # hardware. If the console does not support this, then software record + # generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # The data binding to be used + data_binding = wx_binding + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds): + max_drift = 5 + +############################################################################## + +[DataBindings] + # This section binds a data store to a database + + [[wx_binding]] + # The database must match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +[Databases] + # This section lists possible databases. + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database_name = archive/weewx.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database_name = weewx + driver = weedb.mysql + +############################################################################## + +[Engine] + # This section configures the internal weewx engines. + # It is for advanced customization. + + [[Services]] + + # The list of services the main weewx engine should run: + prep_services = weewx.engine.StdTimeSynch, + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive, + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx32_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx32_expected.conf new file mode 100644 index 0000000..4b24227 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx32_expected.conf @@ -0,0 +1,358 @@ +# $Id: weewx.conf 2795 2014-12-06 17:59:35Z mwall $ +# +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2014 Tom Keffer +# See the file LICENSE.txt for your full rights. + +############################################################################## + +# This section is for general configuration information + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 3.2.0 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. + location = Hood River, Oregon + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[StationRegistry]] + # To register this weather station, set this to True: + register_this_station = False + + [[AWEKAS]] + + # Set to true to enable this uploader + enable = false + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + [[CWOP]] + + # Set to true to enable this uploader + enable = false + # This section is for configuring posts to CWOP + + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID: + #station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode: + #passcode = your passcode here eg, 12345 (APRS stations only) + + [[PWSweather]] + + # Set to true to enable this uploader + enable = false + # This section is for configuring posts to PWSweather.com + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your PWSweather station ID (eg, KORHOODR3) + #password = your PWSweather password + + [[WOW]] + + # Set to true to enable this uploader + enable = false + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your Weather Underground station ID (eg, KORHOODR3) + #password = your Weather Underground password + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + + # Set to true to enable this uploader + enable = false + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports + data_binding = wx_binding + + # Each subsection represents a report you wish to run. + + [[StandardReport]] + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + # The StandardReport uses the 'Standard' skin, which contains the + # images, templates and plots for the report. + skin = Standard + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines: + #user = replace with your username + #password = replace with your password + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + #HTML_ROOT = public_html + + # Most FTP servers use port 21, but if yours is different, you can + # change it here + port = 21 + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The following determine where files will be sent: + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + #user = replace with your username + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a unit system in the database. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] +# For each type, an arbitrary calibration expression can be given. +# It should be in the native units of the weather station hardware. +# For example: +# outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. If units are not specified, + # values must be in the units defined in the StdConvert section. + + [[MinMax]] + barometer = 26, 32.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + rain = 0, 60, inch + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +[StdWXCalculate] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +[StdArchive] + # This section is for configuring the archive service. + + # If your station hardware supports data logging then the archive interval + # will be downloaded from the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console + # hardware. If the console does not support this, then software record + # generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # The data binding to be used: + data_binding = wx_binding + +############################################################################## + +[DataBindings] + # This section binds a data store to a database + + [[wx_binding]] + # The database must match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +[Databases] + # This section defines the actual databases + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + # The host where the database is located + host = localhost + # The user name for logging into the host + user = weewx + # The password for the user name + password = weewx + driver = weedb.mysql + +############################################################################## + +[Engine] + # This section configures the engine. + + [[Services]] + # These are the services the engine should run: + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx36_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx36_expected.conf new file mode 100644 index 0000000..368ceb4 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx36_expected.conf @@ -0,0 +1,369 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2015 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 3.6.0 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = Hood River, Oregon + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + enable = false + station = replace_me + password = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Each of the following subsections defines a report that will be run. + + [[StandardReport]] + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + # The StandardReport uses the 'Standard' skin, which contains the + # images, templates and plots for the report. + skin = Standard + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines. + #user = replace with the ftp username + #password = replace with the ftp password + #server = replace with the ftp server name, e.g, www.threefools.org + #path = replace with the ftp destination directory (e.g., /weather) + + # Set to True for a secure FTP (SFTP) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The following three lines determine where files will be sent. + #server = replace with the rsync server name, e.g, www.threefools.org + #path = replace with the rsync destination directory (e.g., /weather) + #user = replace with the rsync username + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + [[Calculations]] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx39_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx39_expected.conf new file mode 100644 index 0000000..ba62cb9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx39_expected.conf @@ -0,0 +1,468 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2015 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 3.9.0 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = Hood River, Oregon + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + #### + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[StandardReport]] + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + # The StandardReport uses the 'Standard' skin, which contains the + # images, templates and plots for the report. + skin = Standard + enable = True + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = false + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + #user = replace with the ftp username + #password = replace with the ftp password + #server = replace with the ftp server name, e.g, www.threefools.org + #path = replace with the ftp destination directory (e.g., /weather) + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The server, user, and path determine where files will be sent. + # The server is the server name, such as www.threefools.org + # The user is the username, such as weewx + # The path is the destination directory, such as /var/www/html/weather + # Be sure that the user has write permissions on the destination! + #server = replace_me + #user = replace_me + #path = replace_me + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Options in the [[Defaults]] section below will apply to all reports. + # What follows are a few of the more popular options you may want to + # uncomment, then change. + [[Defaults]] + + # Which language to use for all reports. Not all skins support all languages. + # You can override this for individual reports. + lang = en + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us + + [[[Units]]] + # Option "unit_system" above sets the general unit system, but overriding specific unit + # groups is possible. These are popular choices. Uncomment and set as appropriate. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_pressure = mbar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + # Uncommenting the following section frequently results in more + # attractive formatting of times and dates, but may not work in + # your locale. + [[[[TimeFormats]]]] + # day = %H:%M + # week = %H:%M on %A + # month = %d-%b-%Y %H:%M + # year = %d-%b-%Y %H:%M + # rainyear = %d-%b-%Y %H:%M + # current = %d-%b-%Y %H:%M + # ephem_day = %H:%M + # ephem_year = %d-%b-%Y %H:%M + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + [[[Labels]]] + # Users frequently change the labels for these observation types + [[[[Generic]]]] + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # extraTemp1 = Temperature1 + # extraTemp2 = Temperature2 + # extraTemp3 = Temperature3 + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx40_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx40_expected.conf new file mode 100644 index 0000000..17cf3cb --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx40_expected.conf @@ -0,0 +1,580 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2019 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 4.0.0 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude and longitude in decimal degrees + latitude = 44.136 + longitude = -122.211 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Whether to log a successful operation + log_success = True + + # Whether to log an unsuccessful operation + log_failure = False + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = replace_me + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Various options for customizing your reports. + + [[Defaults]] + + [[[Units]]] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + + # The following section sets the formatting for each type of unit. + [[[[StringFormats]]]] + + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + degree_C = %.1f + degree_F = %.1f + degree_compass = %.0f + foot = %.0f + hPa = %.1f + hour = %.1f + inHg = %.3f + inch = %.2f + inch_per_hour = %.2f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + mbar = %.1f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + mm = %.1f + mmHg = %.1f + mm_per_hour = %.1f + percent = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt_per_meter_squared = %.0f + NONE = " N/A" + + # The following section sets the label for each type of unit + [[[[Labels]]]] + + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + NONE = "" + + # The following section sets the format for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. + [[[[TimeFormats]]]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[[[Ordinates]]]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating and cooling degree-days. + [[[[DegreeDays]]]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50.0, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[[[Trend]]]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + + # The labels to be used for each observation type + [[[Labels]]] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = %02d, %03d, %05.2f + + # Generic labels, keyed by an observation type. + [[[[Generic]]]] + barometer = Barometer + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + [[[Almanac]]] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + maxSolarRad = prefer_hardware + cloudbase = prefer_hardware + humidex = prefer_hardware + appTemp = prefer_hardware + ET = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx42_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx42_expected.conf new file mode 100644 index 0000000..99f47e1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx42_expected.conf @@ -0,0 +1,589 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2019 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 4.2.0 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude in decimal degrees. Negative for southern hemisphere + latitude = 0.00 + # Longitude in decimal degrees. Negative for western hemisphere. + longitude = 0.00 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + # If you plan on using wunderfixer, set the following + # to your API key: + api_key = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Whether to log a successful operation + log_success = True + + # Whether to log an unsuccessful operation + log_failure = False + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = replace_me + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Various options for customizing your reports. + + [[Defaults]] + + [[[Units]]] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_distance = mile # Options are 'mile' or 'km' + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + + # The following section sets the formatting for each type of unit. + [[[[StringFormats]]]] + + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + degree_C = %.1f + degree_F = %.1f + degree_compass = %.0f + foot = %.0f + hPa = %.1f + hour = %.1f + inHg = %.3f + inch = %.2f + inch_per_hour = %.2f + km = %.1f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + mbar = %.1f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + mile = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + mm = %.1f + mmHg = %.1f + mm_per_hour = %.1f + percent = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt_per_meter_squared = %.0f + NONE = " N/A" + + # The following section overrides the label used for each type of unit + [[[[Labels]]]] + + meter = " meter", " meters" # You may prefer "metre". + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + NONE = "" + + # The following section sets the format for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. + [[[[TimeFormats]]]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[[[Ordinates]]]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating, cooling, and growing degree-days. + [[[[DegreeDays]]]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[[[Trend]]]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + + # The labels to be used for each observation type + [[[Labels]]] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = %02d, %03d, %05.2f + + # Generic labels, keyed by an observation type. + [[[[Generic]]]] + barometer = Barometer + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + [[[Almanac]]] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + beaufort = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + # This section specifies which services should be run and in what order. + [[Services]] + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx43_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx43_expected.conf new file mode 100644 index 0000000..dcb5dad --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx43_expected.conf @@ -0,0 +1,589 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2020 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 4.3.0 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude in decimal degrees. Negative for southern hemisphere + latitude = 0.00 + # Longitude in decimal degrees. Negative for western hemisphere. + longitude = 0.00 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = replace_me + + # If you plan on using wunderfixer, set the following + # to your API key: + api_key = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Whether to log a successful operation + log_success = True + + # Whether to log an unsuccessful operation + log_failure = True + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = replace_me + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Various options for customizing your reports. + + [[Defaults]] + + [[[Units]]] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_distance = mile # Options are 'mile' or 'km' + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', 'hPa', or 'kPa' + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + + # The following section sets the formatting for each type of unit. + [[[[StringFormats]]]] + + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + degree_C = %.1f + degree_F = %.1f + degree_compass = %.0f + foot = %.0f + hPa = %.1f + hour = %.1f + inHg = %.3f + inch = %.2f + inch_per_hour = %.2f + km = %.1f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + kPa = %.2f + mbar = %.1f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + mile = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + mm = %.1f + mmHg = %.1f + mm_per_hour = %.1f + percent = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt_per_meter_squared = %.0f + NONE = " N/A" + + # The following section overrides the label used for each type of unit + [[[[Labels]]]] + + meter = " meter", " meters" # You may prefer "metre". + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + NONE = "" + + # The following section sets the format for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. + [[[[TimeFormats]]]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[[[Ordinates]]]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating, cooling, and growing degree-days. + [[[[DegreeDays]]]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[[[Trend]]]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + + # The labels to be used for each observation type + [[[Labels]]] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = %02d, %03d, %05.2f + + # Generic labels, keyed by an observation type. + [[[[Generic]]]] + barometer = Barometer + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + [[[Almanac]]] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + # The following section specifies which services should be run and in what order. + [[Services]] + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx43_user_expected.conf b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx43_user_expected.conf new file mode 100644 index 0000000..1588696 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/expected/weewx43_user_expected.conf @@ -0,0 +1,528 @@ +# +# WEEWX CONFIGURATION FILE +# +# This is a test configuration file, modified as a typical V2.0 user would modify it. +# +# Copyright (c) 2009-2014 Tom Keffer +# See the file LICENSE.txt for your full rights. + +############################################################################## + +# This section is for general configuration information + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 5.0.2 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. + location = "A small town, someplace north" + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = Vantage + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 10 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +[Vantage] + # This section is for a Davis VantagePro2, VantageVue or WeatherLinkIP + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/vpro + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The type of LOOP packet to request: 1 = LOOP1; 2 = LOOP2; 3 = both + loop_request = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.drivers.vantage + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[Wunderground]] + + # + # This section is for configuring posts to the Weather Underground + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + station = KCAACME3 + password = mypassword + + log_success = True + log_failure = True + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol + rapidfire = False + + # Set to true to enable this uploader + enable = true + + [[PWSweather]] + + # + # This section is for configuring posts to PWSweather.com + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + # station = your PWSweather station ID here (eg, KORHOODR3) + # password = your password here + station = SMALLTOWN + password = smalltown + + log_success = True + log_failure = True + + # Set to true to enable this uploader + enable = true + + [[CWOP]] + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + # passcode = your passcode here eg, 12345 (APRS stations only) + + station = CW1234 + + log_success = True + log_failure = True + + # How often we should post in seconds. 0=with every archive record + post_interval = 600 + + # Set to true to enable this uploader + enable = true + [[StationRegistry]] + # Uncomment the following line to register this weather station. + #register_this_station = True + + # Specify a station URL, otherwise the station_url from [Station] + # will be used. + #station_url = http://example.com/weather/ + + # Specify a description of the station, otherwise the location from + # [Station] will be used. + #description = The greatest station on earth + + log_success = True + log_failure = True + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + log_success = True + log_failure = True + + # Set to true to enable this uploader + enable = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + log_success = True + log_failure = True + + # Set to true to enable this uploader + enable = false + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports + data_binding = wx_binding + + #### + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = false + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines: + #user = replace with your username + #password = replace with your password + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + #HTML_ROOT = public_html + + # Most FTP servers use port 21, but if yours is different, you can + # change it here + port = 21 + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + max_tries = 3 + user = max + password = smalltown_usa + server = www.smalltown.us + path = /weewx + + #### + + # Options in the [[Defaults]] section below will apply to all reports. + # What follows are a few of the more popular options you may want to + # uncomment, then change. + [[Defaults]] + + # Which language to use for all reports. Not all skins support all languages. + # You can override this for individual reports. + lang = en + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us + + [[[Units]]] + # Option "unit_system" above sets the general unit system, but overriding specific unit + # groups is possible. These are popular choices. Uncomment and set as appropriate. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_pressure = mbar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + # Uncommenting the following section frequently results in more + # attractive formatting of times and dates, but may not work in + # your locale. + [[[[TimeFormats]]]] + # day = %H:%M + # week = %H:%M on %A + # month = %d-%b-%Y %H:%M + # year = %d-%b-%Y %H:%M + # rainyear = %d-%b-%Y %H:%M + # current = %d-%b-%Y %H:%M + # ephem_day = %H:%M + # ephem_year = %d-%b-%Y %H:%M + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + [[[Labels]]] + # Users frequently change the labels for these observation types + [[[[Generic]]]] + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # extraTemp1 = Temperature1 + # extraTemp2 = Temperature2 + # extraTemp3 = Temperature3 + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a unit system in the database. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] +# For each type, an arbitrary calibration expression can be given. +# It should be in the units defined in the StdConvert section. +# For example: +# outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. If units are not specified, + # values must be in the units defined in the StdConvert section. + + [[MinMax]] + barometer = 28, 32.5 + outTemp = -40, 120 + inTemp = 45, 120 + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 100 + rain = 0, 60, inch + heatindex = -40, 120 + windchill = -40, 120 + dewpoint = -40, 120 + windDir = 0, 360 + extraTemp1 = 30, 80 + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +[StdWXCalculate] + [[Calculations]] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + maxSolarRad = prefer_hardware + cloudbase = prefer_hardware + humidex = prefer_hardware + appTemp = prefer_hardware + ET = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +[StdArchive] + + # If your station hardware supports data logging (such as the Davis Vantage + # series), then the archive interval will be downloaded off the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console hardware. + # If the console does not support this, then software record generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # The data binding to be used + data_binding = wx_binding + +############################################################################## + +[DataBindings] + # This section binds a data store to a database + + [[wx_binding]] + # The database must match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +[Databases] + # + # This section lists possible databases. + # + + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + # The host where the database is located + host = localhost + # The user name for logging into the host + user = weewx + # The password for the user name + password = weewx + driver = weedb.mysql + +############################################################################## + +[Engine] + + # This section specifies which services should be run and in what order. + [[Services]] + prep_services = weewx.engine.StdTimeSynch, + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + archive_services = weewx.engine.StdArchive, + restful_services = weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdStationRegistry + report_services = weewx.engine.StdPrint, weewx.engine.StdReport + +############################################################################## + +# Add a user-supplied service, such as the Alarm service + +[Alarm] + time_wait = 86400 + count_threshold = 50 + smtp_host = mail.smalltown.net + smtp_user = smallguy + smtp_password = smalltown_us + mailto = smallguy@smalltown.net diff --git a/dist/weewx-5.0.2/src/weecfg/tests/pmon.tar b/dist/weewx-5.0.2/src/weecfg/tests/pmon.tar new file mode 100644 index 0000000..9532beb Binary files /dev/null and b/dist/weewx-5.0.2/src/weecfg/tests/pmon.tar differ diff --git a/dist/weewx-5.0.2/src/weecfg/tests/pmon.tgz b/dist/weewx-5.0.2/src/weecfg/tests/pmon.tgz new file mode 100644 index 0000000..19bb35e Binary files /dev/null and b/dist/weewx-5.0.2/src/weecfg/tests/pmon.tgz differ diff --git a/dist/weewx-5.0.2/src/weecfg/tests/pmon.zip b/dist/weewx-5.0.2/src/weecfg/tests/pmon.zip new file mode 100644 index 0000000..e140002 Binary files /dev/null and b/dist/weewx-5.0.2/src/weecfg/tests/pmon.zip differ diff --git a/dist/weewx-5.0.2/src/weecfg/tests/test_config.py b/dist/weewx-5.0.2/src/weecfg/tests/test_config.py new file mode 100644 index 0000000..c5a1d18 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/test_config.py @@ -0,0 +1,471 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the configuration utilities.""" +import contextlib +import io +import os.path +import shutil +import sys +import tempfile +import unittest +from unittest.mock import patch + +import configobj + +import weecfg.extension +import weecfg.update_config +import weeutil.config +import weeutil.weeutil +from weeutil.printer import Printer + +# Redirect the import of setup: +sys.modules['setup'] = weecfg.extension + +# Change directory so we can find things dependent on the location of +# this file, such as config files and expected values: +this_file = os.path.join(os.getcwd(), __file__) +this_dir = os.path.abspath(os.path.dirname(this_file)) +os.chdir(this_dir) + +X_STR = """ + [section_a] + a = 1 + [section_b] + b = 2 + [section_c] + c = 3 + [section_d] + d = 4""" + +Y_STR = """ + [section_a] + a = 11 + [section_b] + b = 12 + [section_e] + c = 15""" + +import weewx_data + +RESOURCE_DIR = os.path.dirname(weewx_data.__file__) +current_config_dict_path = os.path.join(RESOURCE_DIR, 'weewx.conf') + + +def suppress_stdout(func): + def wrapper(*args, **kwargs): + with open(os.devnull, 'w') as devnull: + with contextlib.redirect_stdout(devnull): + return func(*args, **kwargs) + + return wrapper + + +class ConfigTest(unittest.TestCase): + + def test_find_file(self): + # Test the utility function weecfg.find_file() + + with tempfile.NamedTemporaryFile() as test_fd: + # Get info about the temp file: + full_path = test_fd.name + dir_path = os.path.dirname(full_path) + filename = os.path.basename(full_path) + # Find the file with an explicit path: + result = weecfg.find_file(full_path) + self.assertEqual(result, full_path) + # Find the file with an explicit, but wrong, path: + with self.assertRaises(IOError): + weecfg.find_file(full_path + "foo") + # Find the file using the "args" optional list: + result = weecfg.find_file(None, [full_path]) + self.assertEqual(result, full_path) + # Find the file using the "args" optional list, but with a wrong name: + with self.assertRaises(IOError): + weecfg.find_file(None, [full_path + "foo"]) + # Now search a list of directory locations given a file name: + result = weecfg.find_file(None, file_name=filename, locations=['/usr/bin', dir_path]) + self.assertEqual(result, full_path) + # Do the same, but with a non-existent file name: + with self.assertRaises(IOError): + weecfg.find_file(None, file_name=filename + "foo", + locations=['/usr/bin', dir_path]) + + def test_reorder_before(self): + global X_STR + + xio = io.StringIO(X_STR) + x_dict = configobj.ConfigObj(xio, encoding='utf-8') + weecfg.reorder_sections(x_dict, 'section_c', 'section_b') + x_dict_str = convert_to_str(x_dict) + self.assertEqual(x_dict_str, u""" +[section_a] + a = 1 +[section_c] + c = 3 +[section_b] + b = 2 +[section_d] + d = 4 +""") + + def test_reorder_after(self): + global X_STR + + xio = io.StringIO(X_STR) + x_dict = configobj.ConfigObj(xio, encoding='utf-8') + weecfg.reorder_sections(x_dict, 'section_c', 'section_b', after=True) + x_dict_str = convert_to_str(x_dict) + self.assertEqual(x_dict_str, u""" +[section_a] + a = 1 +[section_b] + b = 2 +[section_c] + c = 3 +[section_d] + d = 4 +""") + + def test_conditional_merge(self): + global X_STR, Y_STR + + xio = io.StringIO(X_STR) + yio = io.StringIO(Y_STR) + x_dict = configobj.ConfigObj(xio, encoding='utf-8') + y_dict = configobj.ConfigObj(yio, encoding='utf-8') + weeutil.config.conditional_merge(x_dict, y_dict) + x_dict_str = convert_to_str(x_dict) + self.assertEqual(x_dict_str, u""" +[section_a] + a = 1 +[section_b] + b = 2 +[section_c] + c = 3 +[section_d] + d = 4 +[section_e] + c = 15 +""") + + def test_remove_and_prune(self): + global X_STR, Y_STR + + xio = io.StringIO(X_STR) + yio = io.StringIO(Y_STR) + x_dict = configobj.ConfigObj(xio, encoding='utf-8') + y_dict = configobj.ConfigObj(yio, encoding='utf-8') + weecfg.remove_and_prune(x_dict, y_dict) + x_dict_str = convert_to_str(x_dict) + self.assertEqual(x_dict_str, u""" +[section_c] + c = 3 +[section_d] + d = 4 +""") + + def test_reorder_scalars(self): + test_list = ['a', 'b', 'd', 'c'] + weecfg.reorder_scalars(test_list, 'c', 'd') + self.assertEqual(test_list, ['a', 'b', 'c', 'd']) + + test_list = ['a', 'b', 'c', 'd'] + weecfg.reorder_scalars(test_list, 'c', 'e') + self.assertEqual(test_list, ['a', 'b', 'd', 'c']) + + test_list = ['a', 'b', 'd'] + weecfg.reorder_scalars(test_list, 'x', 'd') + self.assertEqual(test_list, ['a', 'b', 'd']) + + @suppress_stdout + def test_prompt_with_options(self): + with patch('weecfg.input', return_value="yes"): + response = weecfg.prompt_with_options("Say yes or no", "yes", ["yes", "no"]) + self.assertEqual(response, "yes") + with patch('weecfg.input', return_value="no"): + response = weecfg.prompt_with_options("Say yes or no", "yes", ["yes", "no"]) + self.assertEqual(response, "no") + with patch('weecfg.input', return_value=""): + response = weecfg.prompt_with_options("Say yes or no", "yes", ["yes", "no"]) + self.assertEqual(response, "yes") + with patch('weecfg.input', side_effect=["make me", "no"]): + response = weecfg.prompt_with_options("Say yes or no", "yes", ["yes", "no"]) + self.assertEqual(response, "no") + + @suppress_stdout + def test_prompt_with_limits(self): + with patch('weecfg.input', return_value="45"): + response = weecfg.prompt_with_limits("latitude", "0.0", -90, 90) + self.assertEqual(response, "45") + with patch('weecfg.input', return_value=""): + response = weecfg.prompt_with_limits("latitude", "0.0", -90, 90) + self.assertEqual(response, "0.0") + with patch('weecfg.input', side_effect=["-120", "-45"]): + response = weecfg.prompt_with_limits("latitude", "0.0", -90, 90) + self.assertEqual(response, "-45") + + def test_driver_info(self): + """Test the discovery and listing of drivers.""" + driver_info_dict = weecfg.get_driver_infos() + self.assertEqual(driver_info_dict['weewx.drivers.ws1']['module_name'], 'weewx.drivers.ws1') + # Test for the driver name + self.assertEqual(driver_info_dict['weewx.drivers.ws1']['driver_name'], 'WS1') + # Cannot really test for version numbers of all drivers. Pick one. Import it... + import weewx.drivers.ws1 + # ... and see if the version number matches + self.assertEqual(driver_info_dict['weewx.drivers.ws1']['version'], + weewx.drivers.ws1.DRIVER_VERSION) + del weewx.drivers.ws1 + + +class ExtensionUtilityTest(unittest.TestCase): + """Tests of utility functions used by the extension installer.""" + + INSTALLED_NAMES = ['/var/tmp/pmon/bin/user/pmon.py', + '/var/tmp/pmon/changelog', + '/var/tmp/pmon/install.py', + '/var/tmp/pmon/readme.txt', + '/var/tmp/pmon/skins/pmon/index.html.tmpl', + '/var/tmp/pmon/skins/pmon/skin.conf'] + + def setUp(self): + shutil.rmtree('/var/tmp/pmon', ignore_errors=True) + + def tearDown(self): + shutil.rmtree('/var/tmp/pmon', ignore_errors=True) + + def test_tar_extract(self): + member_names = weecfg.extract_tar('./pmon.tar', '/var/tmp') + self.assertEqual(member_names, ['pmon', + 'pmon/readme.txt', + 'pmon/skins', + 'pmon/skins/pmon', + 'pmon/skins/pmon/index.html.tmpl', + 'pmon/skins/pmon/skin.conf', + 'pmon/changelog', + 'pmon/install.py', + 'pmon/bin', + 'pmon/bin/user', + 'pmon/bin/user/pmon.py']) + actual_files = [] + for direc in os.walk('/var/tmp/pmon'): + for filename in direc[2]: + actual_files.append(os.path.join(direc[0], filename)) + self.assertEqual(sorted(actual_files), self.INSTALLED_NAMES) + + def test_tgz_extract(self): + member_names = weecfg.extract_tar('./pmon.tgz', '/var/tmp') + self.assertEqual(member_names, ['pmon', + 'pmon/bin', + 'pmon/bin/user', + 'pmon/bin/user/pmon.py', + 'pmon/changelog', + 'pmon/install.py', + 'pmon/readme.txt', + 'pmon/skins', + 'pmon/skins/pmon', + 'pmon/skins/pmon/index.html.tmpl', + 'pmon/skins/pmon/skin.conf']) + actual_files = [] + for direc in os.walk('/var/tmp/pmon'): + for filename in direc[2]: + actual_files.append(os.path.join(direc[0], filename)) + self.assertEqual(sorted(actual_files), self.INSTALLED_NAMES) + + def test_zip_extract(self): + member_names = weecfg.extract_zip('./pmon.zip', '/var/tmp') + self.assertEqual(member_names, ['pmon/', + 'pmon/bin/', + 'pmon/bin/user/', + 'pmon/bin/user/pmon.py', + 'pmon/changelog', + 'pmon/install.py', + 'pmon/readme.txt', + 'pmon/skins/', + 'pmon/skins/pmon/', + 'pmon/skins/pmon/index.html.tmpl', + 'pmon/skins/pmon/skin.conf']) + actual_files = [] + for direc in os.walk('/var/tmp/pmon'): + for filename in direc[2]: + actual_files.append(os.path.join(direc[0], filename)) + self.assertEqual(sorted(actual_files), self.INSTALLED_NAMES) + + def test_gen_file_paths_common(self): + file_list = [ + ('skins/Basic', + ['skins/Basic/index.html.tmpl', + 'skins/Basic/skin.conf', + 'skins/Basic/lang/en.conf', + 'skins/Basic/lang/fr.conf', + ])] + out_list = [x for x in weecfg.extension.ExtensionEngine._gen_file_paths('/etc/weewx', + '/bar/baz', + file_list)] + self.assertEqual(out_list, [('/bar/baz/skins/Basic/index.html.tmpl', + '/etc/weewx/skins/Basic/index.html.tmpl'), + ('/bar/baz/skins/Basic/skin.conf', + '/etc/weewx/skins/Basic/skin.conf'), + ('/bar/baz/skins/Basic/lang/en.conf', + '/etc/weewx/skins/Basic/lang/en.conf'), + ('/bar/baz/skins/Basic/lang/fr.conf', + '/etc/weewx/skins/Basic/lang/fr.conf')]) + + def test_gen_file_paths_user(self): + file_list = [ + ('bin/user', + ['bin/user/foo.py', + 'bin/user/bar.py'])] + out_list = [x for x in weecfg.extension.ExtensionEngine._gen_file_paths('/etc/weewx', + '/bar/baz', + file_list)] + self.assertEqual(out_list, [('/bar/baz/bin/user/foo.py', '/etc/weewx/bin/user/foo.py'), + ('/bar/baz/bin/user/bar.py', '/etc/weewx/bin/user/bar.py')]) + + +class ExtensionInstallTest(unittest.TestCase): + """Tests of the extension installer.""" + + @staticmethod + def _build_mini_weewx(weewx_root): + """Build a "mini-WeeWX" in the given root directory. + + This function makes a simple version of weewx that looks like + weewx_root + ├── skins + ├── user + │ ├── __init__.py + │ └── extensions.py + └── weewx.conf + """ + + # First remove anything there + shutil.rmtree(weewx_root, ignore_errors=True) + os.makedirs(os.path.join(weewx_root, 'skins')) + # Copy over the current version of the 'user' package + shutil.copytree(os.path.join(RESOURCE_DIR, 'bin/user'), + os.path.join(weewx_root, 'user')) + # Copy over the current version of weewx.conf + shutil.copy(current_config_dict_path, weewx_root) + + def setUp(self): + self.weewx_root = '/var/tmp/wee_test' + + # Install the "mini-weewx" + ExtensionInstallTest._build_mini_weewx(self.weewx_root) + + # Retrieve the configuration file from the mini-weewx + config_path = os.path.join(self.weewx_root, 'weewx.conf') + self.config_path, self.config_dict = weecfg.read_config(config_path) + + # Initialize the install engine. + self.engine = weecfg.extension.ExtensionEngine(self.config_path, + self.config_dict, + printer=Printer(verbosity=-1)) + + def tearDown(self): + "Remove any installed test configuration" + shutil.rmtree(self.weewx_root, ignore_errors=True) + + def test_file_install(self): + # Make sure the root dictionary got calculated correctly: + self.assertEqual(self.engine.root_dict['WEEWX_ROOT'], '/var/tmp/wee_test') + self.assertEqual(self.engine.root_dict['USER_DIR'], '/var/tmp/wee_test/bin/user') + self.assertEqual(self.engine.root_dict['EXT_DIR'], '/var/tmp/wee_test/bin/user/installer') + self.assertEqual(self.engine.root_dict['SKIN_DIR'], '/var/tmp/wee_test/skins') + + # Now install the extension... + self.engine.install_extension('./pmon.tgz', no_confirm=True) + + # ... and assert that it got installed correctly + self.assertTrue(os.path.isfile(os.path.join(self.engine.root_dict['USER_DIR'], + 'pmon.py'))) + self.assertTrue(os.path.isfile(os.path.join(self.engine.root_dict['USER_DIR'], + 'installer', + 'pmon', + 'install.py'))) + self.assertTrue(os.path.isdir(os.path.join(self.engine.root_dict['SKIN_DIR'], + 'pmon'))) + self.assertTrue(os.path.isfile(os.path.join(self.engine.root_dict['SKIN_DIR'], + 'pmon', + 'index.html.tmpl'))) + self.assertTrue(os.path.isfile(os.path.join(self.engine.root_dict['SKIN_DIR'], + 'pmon', + 'skin.conf'))) + + # Get, then check the new config dict: + test_dict = configobj.ConfigObj(self.config_path, encoding='utf-8') + self.assertEqual(test_dict['StdReport']['pmon'], + {'HTML_ROOT': 'public_html/pmon', 'skin': 'pmon'}) + self.assertEqual(test_dict['Databases']['pmon_sqlite'], + {'database_name': 'pmon.sdb', + 'database_type': 'SQLite'}) + self.assertEqual(test_dict['DataBindings']['pmon_binding'], + {'manager': 'weewx.manager.DaySummaryManager', + 'schema': 'user.pmon.schema', + 'table_name': 'archive', + 'database': 'pmon_sqlite'}) + self.assertEqual(test_dict['ProcessMonitor'], + {'data_binding': 'pmon_binding', + 'process': 'weewxd'}) + + self.assertTrue( + 'user.pmon.ProcessMonitor' in test_dict['Engine']['Services']['process_services']) + + def test_http_install(self): + self.engine.install_extension( + 'https://github.com/chaunceygardiner/weewx-loopdata/releases/download/v3.3.2/weewx-loopdata-3.3.2.zip', + no_confirm=True) + # Test that it got installed correctly + self.assertTrue(os.path.isfile(os.path.join(self.engine.root_dict['USER_DIR'], + 'loopdata.py'))) + self.assertTrue(os.path.isfile(os.path.join(self.engine.root_dict['USER_DIR'], + 'installer', + 'loopdata', + 'install.py'))) + + def test_uninstall(self): + # First install... + self.engine.install_extension('./pmon.tgz', no_confirm=True) + # ... then uninstall it: + self.engine.uninstall_extension('pmon', no_confirm=True) + + # Assert that everything got removed correctly: + self.assertFalse(os.path.exists(os.path.join(self.engine.root_dict['USER_DIR'], + 'pmon.py'))) + self.assertFalse(os.path.exists(os.path.join(self.engine.root_dict['USER_DIR'], + 'installer', + 'pmon', + 'install.py'))) + self.assertFalse(os.path.exists(os.path.join(self.engine.root_dict['SKIN_DIR'], + 'pmon'))) + self.assertFalse(os.path.exists(os.path.join(self.engine.root_dict['SKIN_DIR'], + 'pmon', + 'index.html.tmpl'))) + self.assertFalse(os.path.exists(os.path.join(self.engine.root_dict['SKIN_DIR'], + 'pmon', + 'skin.conf'))) + + # Get the modified config dict, which had the extension removed from it + test_path, test_dict = weecfg.read_config(self.config_path) + + # It should be the same as our original: + self.assertEqual(test_dict, self.config_dict) + + +# ############# Utilities ################# + +def convert_to_str(x_dict): + """Convert a ConfigObj to a unicode string, using its write function.""" + with io.BytesIO() as s: + x_dict.write(s) + s.seek(0) + x = s.read().decode() + return x + + +if __name__ == "__main__": + unittest.main() diff --git a/dist/weewx-5.0.2/src/weecfg/tests/test_upgrade_config.py b/dist/weewx-5.0.2/src/weecfg/tests/test_upgrade_config.py new file mode 100644 index 0000000..b695976 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/test_upgrade_config.py @@ -0,0 +1,199 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the utilities that upgrade the configuration file.""" +import io +import os.path +import unittest +import tempfile + +import configobj + +import weecfg.update_config + + +def check_fileend(out_str): + """Early versions of ConfigObj did not terminate files with a newline. + This function will add one if it's missing""" + if configobj.__version__ <= '4.4.0': + out_str.seek(-1, os.SEEK_END) + x = out_str.read(1) + if x != '\n': + out_str.write('\n') + + +# Change directory so we can find things dependent on the location of +# this file, such as config files and expected values: +this_file = os.path.join(os.getcwd(), __file__) +this_dir = os.path.abspath(os.path.dirname(this_file)) +os.chdir(this_dir) + + +current_config_dict_path = "../../weewx_data/weewx.conf" + + +class ConfigTest(unittest.TestCase): + + def test_upgrade_v25(self): + # Start with the Version 2.0 weewx.conf file: + config_dict = configobj.ConfigObj('weewx20.conf', encoding='utf-8') + + # Upgrade the V2.0 configuration dictionary to V2.5: + weecfg.update_config.update_to_v25(config_dict) + + self._check_against_expected(config_dict, 'expected/weewx25_expected.conf') + + def test_upgrade_v26(self): + # Start with the Version 2.5 weewx.conf file: + config_dict = configobj.ConfigObj('weewx25.conf', encoding='utf-8') + + # Upgrade the V2.5 configuration dictionary to V2.6: + weecfg.update_config.update_to_v26(config_dict) + + self._check_against_expected(config_dict, 'expected/weewx26_expected.conf') + + def test_upgrade_v30(self): + # Start with the Version 2.7 weewx.conf file: + config_dict = configobj.ConfigObj('weewx27.conf', encoding='utf-8') + + # Upgrade the V2.7 configuration dictionary to V3.0: + weecfg.update_config.update_to_v30(config_dict) + + # with open('expected/weewx30_expected.conf', 'wb') as fd: + # config_dict.write(fd) + + self._check_against_expected(config_dict, 'expected/weewx30_expected.conf') + + def test_upgrade_v32(self): + # Start with the Version 3.0 weewx.conf file: + config_dict = configobj.ConfigObj('weewx30.conf', encoding='utf-8') + + # Upgrade the V3.0 configuration dictionary to V3.2: + weecfg.update_config.update_to_v32(config_dict) + + # with open('expected/weewx32_expected.conf', 'wb') as fd: + # config_dict.write(fd) + + self._check_against_expected(config_dict, 'expected/weewx32_expected.conf') + + def test_upgrade_v36(self): + # Start with the Version 3.2 weewx.conf file: + config_dict = configobj.ConfigObj('weewx32.conf', encoding='utf-8') + + # Upgrade the V3.2 configuration dictionary to V3.6: + weecfg.update_config.update_to_v36(config_dict) + + self._check_against_expected(config_dict, 'expected/weewx36_expected.conf') + + def test_upgrade_v39(self): + # Start with the Version 3.8 weewx.conf file: + config_dict = configobj.ConfigObj('weewx38.conf', encoding='utf-8') + + # Upgrade the V3.8 configuration dictionary to V3.9: + weecfg.update_config.update_to_v39(config_dict) + + # with open('expected/weewx39_expected.conf', 'wb') as fd: + # config_dict.write(fd) + + self._check_against_expected(config_dict, 'expected/weewx39_expected.conf') + + def test_upgrade_v40(self): + """Test an upgrade of the stock v3.9 weewx.conf to V4.0""" + + # Start with the Version 3.9 weewx.conf file: + config_dict = configobj.ConfigObj('weewx39.conf', encoding='utf-8') + + # Upgrade the V3.9 configuration dictionary to V4.0: + weecfg.update_config.update_to_v40(config_dict) + + # with open('expected/weewx40_expected.conf', 'wb') as fd: + # config_dict.write(fd) + + self._check_against_expected(config_dict, 'expected/weewx40_expected.conf') + + def test_upgrade_v42(self): + """Test an upgrade of the stock v4.1 weewx.conf to V4.2""" + + # Start with the Version 4.1 weewx.conf file: + config_dict = configobj.ConfigObj('weewx41.conf', encoding='utf-8') + + # Upgrade the V4.1 configuration dictionary to V4.2: + weecfg.update_config.update_to_v42(config_dict) + + # with open('expected/weewx42_expected.conf', 'wb') as fd: + # config_dict.write(fd) + + self._check_against_expected(config_dict, 'expected/weewx42_expected.conf') + + def test_upgrade_v43(self): + """Test an upgrade of the stock v4.1 weewx.conf to V4.2""" + + # Start with the Version 4.2 weewx.conf file: + config_dict = configobj.ConfigObj('weewx42.conf', encoding='utf-8') + + # Upgrade the V4.2 configuration dictionary to V4.3: + weecfg.update_config.update_to_v43(config_dict) + + # with open('expected/weewx43_expected.conf', 'wb') as fd: + # config_dict.write(fd) + + self._check_against_expected(config_dict, 'expected/weewx43_expected.conf') + + def test_merge(self): + """Test an upgrade against a typical user's configuration file""" + + # Start with a typical V2.0 user file: + _, config_dict = weecfg.read_config('weewx20_user.conf') + # The current config file becomes the template: + _, template = weecfg.read_config(current_config_dict_path) + + # First update, then merge: + weecfg.update_config.update_and_merge(config_dict, template) + + with tempfile.NamedTemporaryFile() as fd: + # Save it to the temporary file: + weecfg.save(config_dict, fd.name) + # Now read it back in again: + check_dict = configobj.ConfigObj(fd, encoding='utf-8') + + # with open('expected/weewx43_user_expected.conf', 'wb') as fd: + # check_dict.write(fd) + + # Check the results. + self._check_against_expected(check_dict, 'expected/weewx43_user_expected.conf') + + def _check_against_expected(self, config_dict, expected): + """Check a ConfigObj against an expected version + + config_dict: The ConfigObj that is to be checked + + expected: The name of a file holding the expected version + """ + # Writing a ConfigObj to a file-like object always writes in bytes, + # so we cannot write to a StringIO (which accepts only Unicode under Python 3). + # Use a BytesIO object instead, which accepts byte strings. + with io.BytesIO() as fd_actual: + config_dict.write(fd_actual) + check_fileend(fd_actual) + fd_actual.seek(0) + + # When we read the BytesIO object back in, the results will be in byte strings. + # To compare apples-to-apples, we need to open the file with expected + # strings in binary, so when we read it, we get byte-strings: + with open(expected, 'rb') as fd_expected: + N = 0 + for expected in fd_expected: + actual = fd_actual.readline() + N += 1 + self.assertEqual(actual.strip(), expected.strip(), + "[%d] '%s' vs '%s'" % (N, actual, expected)) + + # Make sure there are no extra lines in the updated config: + more = fd_actual.readline() + self.assertEqual(more, b'') + + +if __name__ == "__main__": + unittest.main() diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx20.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx20.conf new file mode 100644 index 0000000..787d9fc --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx20.conf @@ -0,0 +1,404 @@ +############################################################################################ +# # +# # +# WEEWX CONFIGURATION FILE # +# # +# # +############################################################################################ +# # +# Copyright (c) 2009, 2010, 2011, 2012 Tom Keffer # +# # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################################ +# +# $Revision: 737 $ +# $Author: tkeffer $ +# $Date: 2012-11-04 09:05:51 -0800 (Sun, 04 Nov 2012) $ +# +############################################################################################ + +# +# This section is for general configuration information +# + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Current version +version = 2.0.0 + +############################################################################################ + +[Station] + + # + # This section is for information about your station + # + + location = "Hood River, Oregon" + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. Normally this is + # downloaded from the station, but not all hardware supports this. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). Normally + # this is downloaded from the station, but not all hardware supports this. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # Set to type of station hardware (e.g., 'Vantage'). + # Must match a section name below. + station_type = Vantage + # station_type = WMR-USB + # station_type = Simulator + +############################################################################################ + +[Vantage] + + # + # This section is for configuration info for a Davis VantagePro2, VantageVue + # or WeatherLinkIP + # + + # Connection type. + # Choose one of "serial" (the classic VantagePro) or "ethernet" (the WeatherLinkIP): + type = serial + #type = ethernet + + # If you chose "serial", then give its port name + # + # Ubuntu and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 a common serial port name + port = /dev/ttyUSB0 + #port = /dev/ttyS0 + + # If you chose "ethernet", then give its IP Address/hostname + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.VantagePro + +############################################################################################ + +[WMR-USB] + + # + # This section is for configuration info for an Oregon Scientific WMR100 + # + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # How long a wind record can be used to calculate wind chill (in seconds) + stale_wind = 30 + + # The driver to use + driver = weewx.wmrx + +############################################################################################ + +[Simulator] + + # + # This section for the weewx weather station simulator + # + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # One of either: + mode = simulator # Real-time simulator. It will sleep between emitting LOOP packets. + #mode = generator # Emit packets as fast as it can (useful for testing). + + # The start time. [Optional. Default is to use the present time] + # start = 2011-01-01 00:00 + + driver = weewx.simulator + +############################################################################################ + +[StdRESTful] + # + # This section if for uploading data to sites using RESTful protocols. + # + + [[Wunderground]] + + # + # This section is for configuring posts to the Weather Underground + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + # station = your Weather Underground station ID here (eg, KORHOODR3) + # password = your password here + + driver = weewx.restful.Ambient + + [[PWSweather]] + + # + # This section is for configuring posts to PWSweather.com + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + # station = your PWSweather station ID here (eg, KORHOODR3) + # password = your password here + + driver = weewx.restful.Ambient + + [[CWOP]] + # + # This section is for configuring posts to CWOP. + # + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID + # station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + # passcode = your passcode here eg, 12345 (APRS stations only) + + # Comma separated list of server:ports to try: + server = cwop.aprs.net:14580, cwop.aprs.net:23 + + # How often we should post in seconds. 0=with every archive record + interval = 600 + + driver = weewx.restful.CWOP + +############################################################################################ + +[StdReport] + + # + # This section specifies what reports, using which skins, are to be generated. + # + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file from here. + # For example, uncommenting the next 3 lines would have pressure reported + # in millibars, irregardless of what was in the skin configuration file + # [[[Units]]] + # [[[[Groups]]]] + # group_pressure=mbar + + # + # Here is an example where we create a custom report, still using the standard + # skin, but where the image size is overridden, and the results are put in a + # separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[Images]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + # user = replace with your username + # password = replace with your password + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination root directory on your server (e.g., '/weather) + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + + # If you wish to upload files from something other than what HTML_ROOT is set to + # above, then reset it here: + # HTML_ROOT = public_html + +############################################################################################ + +[StdConvert] + + # + # This service can convert measurements to a chosen target unit system, which + # will then be used for the databases. + # THIS VALUE CANNOT BE CHANGED LATER! + # + + target_unit = US # Choices are 'US' or 'METRIC' + +############################################################################################ + +[StdCalibrate] + + # + # This section can adjust data using calibration expressions. + # + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the native units of the weather station hardware. + # For example: + # outTemp = outTemp - 0.2 + +############################################################################################ + +[StdQC] + + # + # This section for quality control checks. + # It should be in the same units as specified in StdConvert, above. + # + + [[MinMax]] + outTemp = -40, 120 + barometer = 28, 32.5 + outHumidity = 0, 100 + +############################################################################################ + +[StdArchive] + + # + # This section is for configuring the archive databases. + # + + # The database to be used for archive data. + # This should match a section given in section [Databases] below. + archive_database = archive_sqlite + + # The database to be used for stats data. + # This should match a section given in section [Databases] below. + stats_database = stats_sqlite + + # If your station hardware supports data logging (such as the Davis Vantage + # series), then the archive interval will be downloaded off the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console hardware. + # If the console does not support this, then software record generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # The types for which statistics will be kept. This list is used only when the + # stats database is initialized. Thereafter, the types are retrieved from the database. + stats_types = wind, barometer, inTemp, outTemp, inHumidity, outHumidity, rainRate, rain, dewpoint, windchill, heatindex, ET, radiation, UV, extraTemp1, rxCheckPercent + +############################################################################################ + +[StdTimeSynch] + + # How often to check the clock on the weather station for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds): + max_drift = 5 + +############################################################################################ + +[Databases] + + # + # This section lists possible databases. + # + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/weewx.sdb + driver = weedb.sqlite + + [[stats_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/stats.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database = weewx + driver = weedb.mysql + + [[stats_mysql]] + host = localhost + user = weewx + password = weewx + database = stats + driver = weedb.mysql + +############################################################################################ + +[Engines] + + # + # This section configures the internal weewx engines. It is for advanced customization. + # + + [[WxEngine]] + # The list of services the main weewx engine should run: + service_list = weewx.wxengine.StdConvert, weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC, weewx.wxengine.StdArchive, weewx.wxengine.StdTimeSynch, weewx.wxengine.StdPrint, weewx.wxengine.StdRESTful, weewx.wxengine.StdReport + \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx20_user.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx20_user.conf new file mode 100644 index 0000000..14959cb --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx20_user.conf @@ -0,0 +1,349 @@ +# +# WEEWX CONFIGURATION FILE +# +# This is a test configuration file, modified as a typical V2.0 user would modify it. +# +# Copyright (c) 2009-2014 Tom Keffer +# See the file LICENSE.txt for your full rights. + +############################################################################## + +# This section is for general configuration information + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx/ + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 2.0.0 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. + location = "A small town, someplace north" + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = Vantage + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 10 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +[Vantage] + # This section is for a Davis VantagePro2, VantageVue or WeatherLinkIP + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/vpro + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.VantagePro + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[Wunderground]] + + # + # This section is for configuring posts to the Weather Underground + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + station = KCAACME3 + password = mypassword + + driver = weewx.restful.Ambient + + [[PWSweather]] + + # + # This section is for configuring posts to PWSweather.com + # + + # If you wish to do this, make sure the following two lines are uncommented + # and filled out with your station and password information: + # station = your PWSweather station ID here (eg, KORHOODR3) + # password = your password here + station = SMALLTOWN + password = smalltown + + driver = weewx.restful.Ambient + + [[CWOP]] + # + # This section is for configuring posts to CWOP. + # + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID + station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + # passcode = your passcode here eg, 12345 (APRS stations only) + + # Comma separated list of server:ports to try: + server = cwop.aprs.net:14580, cwop.aprs.net:23 + + # How often we should post in seconds. 0=with every archive record + interval = 600 + + driver = weewx.restful.CWOP + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run. + # This version is missing a [[StandardReport]] section, as if the user + # had removed it in favor of a custom skin. + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines: + #user = replace with your username + #password = replace with your password + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + #HTML_ROOT = public_html + + # Most FTP servers use port 21, but if yours is different, you can + # change it here + port = 21 + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + max_tries = 3 + user = max + password = smalltown_usa + server = www.smalltown.us + path = /weewx + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a unit system in the database. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] +# For each type, an arbitrary calibration expression can be given. +# It should be in the units defined in the StdConvert section. +# For example: +# outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. If units are not specified, + # values must be in the units defined in the StdConvert section. + + [[MinMax]] + barometer = 28, 32.5 + outTemp = -40, 120 + inTemp = 45, 120 + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 100 + rain = 0, 60, inch + heatindex = -40, 120 + windchill = -40, 120 + dewpoint = -40, 120 + windDir = 0, 360 + extraTemp1 = 30, 80 + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +[StdArchive] + + # + # This section is for configuring the archive databases. + # + + # The database to be used for archive data. + # This should match a section given in section [Databases] below. + archive_database = archive_sqlite + + # The database to be used for stats data. + # This should match a section given in section [Databases] below. + stats_database = stats_sqlite + + # If your station hardware supports data logging (such as the Davis Vantage + # series), then the archive interval will be downloaded off the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console hardware. + # If the console does not support this, then software record generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # The types for which statistics will be kept. This list is used only when the + # stats database is initialized. Thereafter, the types are retrieved from the database. + stats_types = wind, barometer, inTemp, outTemp, inHumidity, outHumidity, rainRate, rain, dewpoint, windchill, heatindex, ET, radiation, UV, extraTemp1, rxCheckPercent + +############################################################################## + +[Databases] + # + # This section lists possible databases. + # + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/weewx.sdb + driver = weedb.sqlite + + [[stats_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/stats.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database = weewx + driver = weedb.mysql + + [[stats_mysql]] + host = localhost + user = weewx + password = weewx + database = stats + driver = weedb.mysql + +############################################################################## + +[Engines] + + # + # This section configures the internal weewx engines. It is for advanced customization. + # + + [[WxEngine]] + # The list of services the main weewx engine should run: + service_list = weewx.wxengine.StdConvert, weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC, weewx.wxengine.StdArchive, weewx.wxengine.StdTimeSynch, weewx.wxengine.StdPrint, weewx.wxengine.StdRESTful, weewx.wxengine.StdReport + +############################################################################## + +# Add a user-supplied service, such as the Alarm service + +[Alarm] + time_wait = 86400 + count_threshold = 50 + smtp_host = mail.smalltown.net + smtp_user = smallguy + smtp_password = smalltown_us + mailto = smallguy@smalltown.net diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx25.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx25.conf new file mode 100644 index 0000000..887091a --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx25.conf @@ -0,0 +1,492 @@ +############################################################################## +# # +# WEEWX CONFIGURATION FILE # +# # +############################################################################## +# # +# Copyright (c) 2009-2013 Tom Keffer # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################## +# +# $Id: weewx.conf 1582 2013-10-29 15:07:42Z tkeffer $ +# +############################################################################## + +# +# This section is for general configuration information +# + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 2.5.0 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. If there is a comma in the + # description, then put the description in quotes. + location = "Hood River, Oregon" + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # Set to type of station hardware. Supported stations include: + # Vantage + # WMR100 + # WMR200 + # WMR9x8 + # FineOffsetUSB + # WS28xx + # Simulator + station_type = Vantage + + # If you have a website, you may optionally specify an URL for + # its HTML server. + #station_url = http://www.threefools.org + +############################################################################## + +[Vantage] + # This section is for configuration info for a Davis VantagePro2, + # VantageVue or WeatherLinkIP + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.drivers.vantage + +############################################################################## + +[WMR100] + # This section is for the Oregon Scientific WMR100 + + # How long a wind record can be used to calculate wind chill (in seconds) + stale_wind = 30 + + # The driver to use + driver = weewx.drivers.wmr100 + +############################################################################## + +[WMR200] + # This section is for the Oregon Scientific WMR200 + + # The driver to use + driver = weewx.drivers.wmr200 + +############################################################################## + +[WMR9x8] + # This section is for the Oregon Scientific WMR918/968 + + # Connection type. For now, 'serial' is the only option. + type = serial + + # A port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # The driver to use + driver = weewx.drivers.wmr9x8 + +############################################################################## + +[FineOffsetUSB] + # This section is for the Fine Offset series of weather stations. + + # The station model, e.g., WH1080, WS1090, WS2080, WH3081 + model = WS2080 + + # The polling mode can be PERIODIC or ADAPTIVE + polling_mode = PERIODIC + + # How often to poll the station for data, in seconds + polling_interval = 60 + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use + driver = weewx.drivers.fousb + +############################################################################## + +[WS28xx] + # This section is for the La Crosse WS-2800 series of weather stations. + + # Radio frequency to use between USB transceiver and console: US or EU + # US uses 915 MHz, EU uses 868.3 MHz. Default is US. + transceiver_frequency = US + + # The WS28xx is branded by various vendors. Use the model parameter to + # indicate the brand, e.g., 'LaCrosse C86234' or 'TFA Primus' + model = LaCrosse WS28xx + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use + driver = weewx.drivers.ws28xx + +############################################################################## + +[Simulator] + # This section for the weewx weather station simulator + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. If not specified, the default is to use the present time. + #start = 2011-01-01 00:00 + + driver = weewx.drivers.simulator + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your Weather Underground station ID (eg, KORHOODR3) + #password = your Weather Underground password + + driver = weewx.restful.Ambient + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your PWSweather station ID (eg, KORHOODR3) + #password = your PWSweather password + + driver = weewx.restful.Ambient + + [[CWOP]] + # This section is for configuring posts to CWOP + + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID: + #station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + #passcode = your passcode here eg, 12345 (APRS stations only) + + # Comma separated list of server:ports to try: + server = cwop.aprs.net:14580, cwop.aprs.net:23 + + # How often we should post in seconds. 0=with every archive record + interval = 600 + + driver = weewx.restful.CWOP + + [[StationRegistry]] + # Uncomment the following line to register this weather station. + #register_this_station = True + + # Specify a station URL, otherwise the station_url from [Station] + # will be used. + #station_url = http://example.com/weather/ + + # Specify a description of the station, otherwise the location from + # [Station] will be used. + #description = The greatest station on earth + + driver = weewx.restful.StationRegistry + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file from here. + # For example, uncommenting the next 3 lines would have pressure + # reported in millibars, irregardless of what was in the skin + # configuration file + # + # [[[Units]]] + # [[[[Groups]]]] + # group_pressure=mbar + + # Here is an example where we create a custom report, still using the + # standard skin, but where the image size is overridden, and the results + # are put in a separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[Images]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + # user = replace with your username + # password = replace with your password + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination directory (e.g., /weather) + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + # HTML_ROOT = public_html + + [[RSYNC]] + skin = Rsync + + # rsync'ing the results to a webserver is treated as just another + # report, much like the FTP report. + # + # The following configure what system and remote path the files are + # sent to: + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination directory (e.g., /weather) + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # user = replace with your username + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + # delete = 1 + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a target output unit system. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of + # units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Choices are 'US' or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the native units of the weather station hardware. + # For example: + # outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. + # Values must be in the units defined in the StdConvert section. + + [[MinMax]] + outTemp = -40, 120 + barometer = 28, 32.5 + outHumidity = 0, 100 + +############################################################################## + +[StdArchive] + # This section is for configuring the archive databases. + + # The database to be used for archive data. + # This should match a section given in section [Databases] below. + archive_database = archive_sqlite + + # The database to be used for stats data. + # This should match a section given in section [Databases] below. + stats_database = stats_sqlite + + # If your station hardware supports data logging then the archive interval + # will be downloaded from the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console + # hardware. If the console does not support this, then software record + # generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # The schema to be used for the archive database. This is used only when + # it is initialized. + # Thereafter, the types are retrieved from the database. + archive_schema = user.schemas.defaultArchiveSchema + + # The schema to be used for the stats database. This is used only when + # it is initialized. + # Thereafter, the types are retrieved from the database. + stats_schema = user.schemas.defaultStatsSchema + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds): + max_drift = 5 + +############################################################################## + +[Databases] + # This section lists possible databases. + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/weewx.sdb + driver = weedb.sqlite + + [[stats_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/stats.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database = weewx + driver = weedb.mysql + + [[stats_mysql]] + host = localhost + user = weewx + password = weewx + database = stats + driver = weedb.mysql + +############################################################################## + +[Engines] + # This section configures the internal weewx engines. + # It is for advanced customization. + + [[WxEngine]] + # The list of services the main weewx engine should run: + service_list = weewx.wxengine.StdTimeSynch, weewx.wxengine.StdConvert, weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC, weewx.wxengine.StdArchive, weewx.wxengine.StdPrint, weewx.wxengine.StdRESTful, weewx.wxengine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx27.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx27.conf new file mode 100644 index 0000000..ac58dcc --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx27.conf @@ -0,0 +1,613 @@ +############################################################################## +# # +# WEEWX CONFIGURATION FILE # +# # +# Copyright (c) 2009-2013 Tom Keffer # +# $Id: weewx.conf 2394 2014-10-11 16:20:03Z tkeffer $ +############################################################################## + +# This section is for general configuration information + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 2.7.0 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. + location = Hood River, Oregon + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # If you have a website, you may optionally specify an URL for + # its HTML server. + #station_url = http://www.example.com + + # Set to type of station hardware. Supported stations include: + # Vantage FineOffsetUSB Ultimeter + # WMR100 WS28xx WS1 + # WMR200 WS23xx CC3000 + # WMR9x8 TE923 Simulator + station_type = Vantage + +############################################################################## + +[Vantage] + # This section is for a Davis VantagePro2, VantageVue or WeatherLinkIP + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 1 + + # The id of your ISS station (usually 1) + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 5 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # The driver to use: + driver = weewx.drivers.vantage + +############################################################################## + +[WMR100] + # This section is for the Oregon Scientific WMR100 + + # The station model, e.g., WMR100, WMR100N, WMRS200 + model = WMR100 + + # How long a wind record can be used to calculate wind chill (in seconds) + stale_wind = 30 + + # The driver to use: + driver = weewx.drivers.wmr100 + +############################################################################## + +[WMR200] + # This section is for the Oregon Scientific WMR200 + + # The station model, e.g., WMR200, WMR200A, Radio Shack W200 + model = WMR200 + + # The driver to use: + driver = weewx.drivers.wmr200 + +############################################################################## + +[WMR9x8] + # This section is for the Oregon Scientific WMR918/968 + + # Connection type. For now, 'serial' is the only option. + type = serial + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., WMR918, Radio Shack 63-1016 + model = WMR968 + + # The driver to use: + driver = weewx.drivers.wmr9x8 + +############################################################################## + +[FineOffsetUSB] + # This section is for the Fine Offset series of weather stations. + + # The station model, e.g., WH1080, WS1090, WS2080, WH3081 + model = WS2080 + + # The polling mode can be PERIODIC or ADAPTIVE + polling_mode = PERIODIC + + # How often to poll the station for data, in seconds + polling_interval = 60 + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use: + driver = weewx.drivers.fousb + +############################################################################## + +[WS23xx] + # This section is for the La Crosse WS-2300 series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., 'LaCrosse WS2317' or 'TFA Primus' + model = LaCrosse WS23xx + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use: + driver = weewx.drivers.ws23xx + +############################################################################## + +[WS28xx] + # This section is for the La Crosse WS-2800 series of weather stations. + + # Radio frequency to use between USB transceiver and console: US or EU + # US uses 915 MHz, EU uses 868.3 MHz. Default is US. + transceiver_frequency = US + + # The station model, e.g., 'LaCrosse C86234' or 'TFA Primus' + model = LaCrosse WS28xx + + # The pressure calibration offset, in hPa (millibars) + pressure_offset = 0 + + # The driver to use: + driver = weewx.drivers.ws28xx + +############################################################################## + +[TE923] + # This section is for the Hideki TE923 series of weather stations. + + # The station model, e.g., 'Meade TE923W' or 'TFA Nexus' + model = TE923 + + # The driver to use: + driver = weewx.drivers.te923 + + # The default configuration associates the channel 1 sensor with outTemp + # and outHumidity. To change this, or to associate other channels with + # specific columns in the database schema, use the following maps. + [[sensor_map]] + # Map the remote sensors to columns in the database schema. + outTemp = t_1 + outHumidity = h_1 + extraTemp1 = t_2 + extraHumid1 = h_2 + extraTemp2 = t_3 + extraHumid2 = h_3 + extraTemp3 = t_4 + # WARNING: the following are not in the default schema + extraHumid3 = h_4 + extraTemp4 = t_5 + extraHumid4 = h_5 + + [[battery_map]] + txBatteryStatus = batteryUV + windBatteryStatus = batteryWind + rainBatteryStatus = batteryRain + outTempBatteryStatus = battery1 + # WARNING: the following are not in the default schema + extraBatteryStatus1 = battery2 + extraBatteryStatus2 = battery3 + extraBatteryStatus3 = battery4 + extraBatteryStatus4 = battery5 + +############################################################################## + +[Ultimeter] + # This section is for the PeetBros Ultimeter series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., Ultimeter 2000, Ultimeter 100 + model = Ultimeter + + # The driver to use: + driver = weewx.drivers.ultimeter + +############################################################################## + +[WS1] + # This section is for the ADS WS1 series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The driver to use: + driver = weewx.drivers.ws1 + +############################################################################## + +[CC3000] + # This section is for RainWise MarkIII weather stations and CC3000 logger. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., CC3000 or CC3000R + model = CC3000 + + # The driver to use: + driver = weewx.drivers.cc3000 + +############################################################################## + +[Simulator] + # This section for the weewx weather station simulator + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. If not specified, the default is to use the present time. + #start = 2011-01-01 00:00 + + # The driver to use: + driver = weewx.drivers.simulator + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[StationRegistry]] + # To register this weather station, set this to True: + register_this_station = False + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your Weather Underground station ID (eg, KORHOODR3) + #password = your Weather Underground password + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol + rapidfire = False + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your PWSweather station ID (eg, KORHOODR3) + #password = your PWSweather password + + log_success = True + log_failure = True + + [[CWOP]] + # This section is for configuring posts to CWOP + + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID: + #station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode + # as well: + #passcode = your passcode here eg, 12345 (APRS stations only) + + # How often we should post in seconds. 0=with every archive record + post_interval = 600 + + log_success = True + log_failure = True + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + log_success = True + log_failure = True + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + log_success = True + log_failure = True + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file here. For + # example, uncomment the following lines to display metric units + # throughout the report, regardless of what is defined in the skin. + # + #[[[Units]]] + # [[[[Groups]]]] + # group_altitude = meter + # group_degree_day = degree_C_day + # group_pressure = mbar + # group_radiation = watt_per_meter_squared + # group_rain = mm + # group_rainrate = mm_per_hour + # group_speed = meter_per_second + # group_speed2 = meter_per_second2 + # group_temperature = degree_C + + # Here is an example where we create a custom report, still using the + # standard skin, but where the image size is overridden, and the results + # are put in a separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[ImageGenerator]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + #user = replace with your username + #password = replace with your password + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + #HTML_ROOT = public_html + + # Most FTP servers use port 21, but if yours is different, you can + # change it here + port = 21 + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + + [[RSYNC]] + skin = Rsync + + # rsync'ing the results to a webserver is treated as just another + # report, much like the FTP report. + # + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The following configure what system and remote path the files are + # sent to: + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + #user = replace with your username + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a target output unit system. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of + # units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the native units of the weather station hardware. + # For example: + # outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. If units are not specified, + # values must be in the units defined in the StdConvert section. + + [[MinMax]] + barometer = 26, 32.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + rain = 0, 60, inch + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +[StdArchive] + # This section is for configuring the archive databases. + + # The database to be used for archive data. + # This should match a section given in section [Databases] below. + archive_database = archive_sqlite + + # The database to be used for stats data. + # This should match a section given in section [Databases] below. + stats_database = stats_sqlite + + # If your station hardware supports data logging then the archive interval + # will be downloaded from the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console + # hardware. If the console does not support this, then software record + # generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # The schema to be used for the archive database. This is used only when + # it is initialized. + # Thereafter, the types are retrieved from the database. + archive_schema = user.schemas.defaultArchiveSchema + + # The schema to be used for the stats database. This is used only when + # it is initialized. + # Thereafter, the types are retrieved from the database. + stats_schema = user.schemas.defaultStatsSchema + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds): + max_drift = 5 + +############################################################################## + +[Databases] + # This section lists possible databases. + + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/weewx.sdb + driver = weedb.sqlite + + [[stats_sqlite]] + root = %(WEEWX_ROOT)s + database = archive/stats.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database = weewx + driver = weedb.mysql + + [[stats_mysql]] + host = localhost + user = weewx + password = weewx + database = stats + driver = weedb.mysql + +############################################################################## + +[Engines] + # This section configures the internal weewx engines. + # It is for advanced customization. + + [[WxEngine]] + + # The list of services the main weewx engine should run: + prep_services = weewx.wxengine.StdTimeSynch + data_services = , + process_services = weewx.wxengine.StdConvert, weewx.wxengine.StdCalibrate, weewx.wxengine.StdQC + archive_services = weewx.wxengine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.wxengine.StdPrint, weewx.wxengine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx30.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx30.conf new file mode 100644 index 0000000..ba9c144 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx30.conf @@ -0,0 +1,326 @@ +# $Id: weewx.conf 2795 2014-12-06 17:59:35Z mwall $ +# +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2014 Tom Keffer +# See the file LICENSE.txt for your full rights. + +############################################################################## + +# This section is for general configuration information + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Do not modify this - it is used by setup.py when installing and updating. +version = 3.0.1 + +############################################################################## + +[Station] + # This section is for information about your station + + # Description of the station location. + location = Hood River, Oregon + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +[StdRESTful] + # This section is for uploading data to sites using RESTful protocols. + + [[StationRegistry]] + # To register this weather station, set this to True: + register_this_station = False + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + [[CWOP]] + # This section is for configuring posts to CWOP + + # If you wish to do this, make sure the following line is uncommented + # and filled out with your station ID: + #station = CW1234 + + # If you are an APRS (radio amateur) station, you will need a passcode: + #passcode = your passcode here eg, 12345 (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your PWSweather station ID (eg, KORHOODR3) + #password = your PWSweather password + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your Weather Underground station ID (eg, KORHOODR3) + #password = your Weather Underground password + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +[StdReport] + # This section specifies what reports, using which skins, to generate. + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports + data_binding = wx_binding + + # Each subsection represents a report you wish to run. + + [[StandardReport]] + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + # The StandardReport uses the 'Standard' skin, which contains the + # images, templates and plots for the report. + skin = Standard + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines: + #user = replace with your username + #password = replace with your password + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + + # If you wish to upload files from something other than what HTML_ROOT + # is set to above, then reset it here: + #HTML_ROOT = public_html + + # Most FTP servers use port 21, but if yours is different, you can + # change it here + port = 21 + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The following determine where files will be sent: + #server = replace with your server name, e.g, www.threefools.org + #path = replace with the destination directory (e.g., /weather) + #user = replace with your username + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +[StdConvert] + + # This service acts as a filter, converting the unit system coming from + # the hardware to a unit system in the database. + # + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +[StdCalibrate] + # This section can adjust data using calibration expressions. + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the native units of the weather station hardware. + # For example: + # outTemp = outTemp - 0.2 + +############################################################################## + +[StdQC] + # This section is for quality control checks. If units are not specified, + # values must be in the units defined in the StdConvert section. + + [[MinMax]] + barometer = 26, 32.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + rain = 0, 60, inch + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +[StdWXCalculate] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +[StdArchive] + # This section is for configuring the archive service. + + # If your station hardware supports data logging then the archive interval + # will be downloaded from the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console + # hardware. If the console does not support this, then software record + # generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # The data binding to be used: + data_binding = wx_binding + +############################################################################## + +[DataBindings] + # This section binds a data store to a database + + [[wx_binding]] + # The database must match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +[Databases] + # This section defines the actual databases + + # A SQLite database is simply a single file + [[archive_sqlite]] + root = %(WEEWX_ROOT)s + database_name = archive/weewx.sdb + driver = weedb.sqlite + + # MySQL require a server (host) with name and password for access + [[archive_mysql]] + host = localhost + user = weewx + password = weewx + database_name = weewx + driver = weedb.mysql + +############################################################################## + +[Engine] + # This section configures the engine. + + [[Services]] + # These are the services the engine should run: + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx32.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx32.conf new file mode 100644 index 0000000..4f08c98 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx32.conf @@ -0,0 +1,368 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2015 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 3.2.1 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = Hood River, Oregon + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + enable = false + station = replace_me + password = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Each of the following subsections defines a report that will be run. + + [[StandardReport]] + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + # The StandardReport uses the 'Standard' skin, which contains the + # images, templates and plots for the report. + skin = Standard + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines. + #user = replace with the ftp username + #password = replace with the ftp password + #server = replace with the ftp server name, e.g, www.threefools.org + #path = replace with the ftp destination directory (e.g., /weather) + + # Set to True for a secure FTP (SFTP) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The following three lines determine where files will be sent. + #server = replace with the rsync server name, e.g, www.threefools.org + #path = replace with the rsync destination directory (e.g., /weather) + #user = replace with the rsync username + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data. Must + # be greater than zero. + archive_delay = 15 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx38.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx38.conf new file mode 100644 index 0000000..5e48732 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx38.conf @@ -0,0 +1,376 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2015 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 3.8.2 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = Hood River, Oregon + + # Latitude and longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = "replace_me" + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Each of the following subsections defines a report that will be run. + + [[StandardReport]] + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + # The StandardReport uses the 'Standard' skin, which contains the + # images, templates and plots for the report. + skin = Standard + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, uncomment and fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + #user = replace with the ftp username + #password = replace with the ftp password + #server = replace with the ftp server name, e.g, www.threefools.org + #path = replace with the ftp destination directory (e.g., /weather) + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs as to the user account on the remote machine where the files + # will be copied. + # + # The server, user, and path determine where files will be sent. + # The server is the server name, such as www.threefools.org + # The user is the username, such as weewx + # The path is the destination directory, such as /var/www/html/weather + # Be sure that the user has write permissions on the destination! + #server = replace_me + #user = replace_me + #path = replace_me + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = "weewx" + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx39.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx39.conf new file mode 100644 index 0000000..e00221d --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx39.conf @@ -0,0 +1,572 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2019 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 3.9.2 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude and longitude in decimal degrees + latitude = 44.136 + longitude = -122.211 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = "replace_me" + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Whether to log a successful operation + log_success = True + + # Whether to log an unsuccessful operation + log_failure = False + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = "replace_me" + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Various options for customizing your reports. + + [[Defaults]] + + [[[Units]]] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + + # The following section sets the formatting for each type of unit. + [[[[StringFormats]]]] + + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + degree_C = %.1f + degree_F = %.1f + degree_compass = %.0f + foot = %.0f + hPa = %.1f + hour = %.1f + inHg = %.3f + inch = %.2f + inch_per_hour = %.2f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + mbar = %.1f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + mm = %.1f + mmHg = %.1f + mm_per_hour = %.1f + percent = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt_per_meter_squared = %.0f + NONE = " N/A" + + # The following section sets the label for each type of unit + [[[[Labels]]]] + + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + NONE = "" + + # The following section sets the format for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. + [[[[TimeFormats]]]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[[[Ordinates]]]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating and cooling degree-days. + [[[[[DegreeDays]]]]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[[[[Trend]]]]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + + # The labels to be used for each observation type + [[[Labels]]] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = "%02d", "%03d", "%05.2f" + + # Generic labels, keyed by an observation type. + [[[[Generic]]]] + barometer = Barometer + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + [[[Almanac]]] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.wxmanager.WXDaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = "weewx" + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx41.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx41.conf new file mode 100644 index 0000000..171ca98 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx41.conf @@ -0,0 +1,590 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2019 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 4.1.1 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude in decimal degrees. Negative for southern hemisphere + latitude = 0.00 + # Longitude in decimal degrees. Negative for western hemisphere. + longitude = 0.00 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = "replace_me" + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + # If you plan on using wunderfixer, set the following + # to your API key: + api_key = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Whether to log a successful operation + log_success = True + + # Whether to log an unsuccessful operation + log_failure = False + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = "replace_me" + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Various options for customizing your reports. + + [[Defaults]] + + [[[Units]]] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_distance = mile # Options are 'mile' or 'km' + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + + # The following section sets the formatting for each type of unit. + [[[[StringFormats]]]] + + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + degree_C = %.1f + degree_F = %.1f + degree_compass = %.0f + foot = %.0f + hPa = %.1f + hour = %.1f + inHg = %.3f + inch = %.2f + inch_per_hour = %.2f + km = %.1f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + mbar = %.1f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + mile = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + mm = %.1f + mmHg = %.1f + mm_per_hour = %.1f + percent = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt_per_meter_squared = %.0f + NONE = " N/A" + + # The following section overrides the label used for each type of unit + [[[[Labels]]]] + + meter = " meter", " meters" # You may prefer "metre". + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + NONE = "" + + # The following section sets the format for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. + [[[[TimeFormats]]]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[[[Ordinates]]]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating, cooling, and growing degree-days. + [[[[DegreeDays]]]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[[[Trend]]]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + + # The labels to be used for each observation type + [[[Labels]]] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = "%02d", "%03d", "%05.2f" + + # Generic labels, keyed by an observation type. + [[[[Generic]]]] + barometer = Barometer + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + [[[Almanac]]] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + beaufort = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = "weewx" + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + [[Services]] + # This section specifies the services that should be run. They are + # grouped by type, and the order of services within each group + # determines the order in which the services will be run. + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/tests/weewx42.conf b/dist/weewx-5.0.2/src/weecfg/tests/weewx42.conf new file mode 100644 index 0000000..c677d91 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/tests/weewx42.conf @@ -0,0 +1,589 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2020 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero +debug = 0 + +# Root directory of the weewx data file hierarchy for this station +WEEWX_ROOT = /home/weewx + +# Whether to log successful operations +log_success = True + +# Whether to log unsuccessful operations +log_failure = True + +# How long to wait before timing out a socket (FTP, HTTP) connection +socket_timeout = 20 + +# Do not modify this. It is used when installing and updating weewx. +version = 4.3.0 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location + location = "My Little Town, Oregon" + + # Latitude in decimal degrees. Negative for southern hemisphere + latitude = 0.00 + # Longitude in decimal degrees. Negative for western hemisphere. + longitude = 0.00 + + # Altitude of the station, with unit it is in. This is downloaded from + # from the station if the hardware supports it. + altitude = 700, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file with a 'driver' parameter indicating the driver to be used. + station_type = unspecified + + # If you have a website, you may specify an URL + #station_url = http://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + [[StationRegistry]] + # To register this weather station with weewx, set this to true + register_this_station = false + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to do this, set the option 'enable' to true, + # and specify a username and password. + # To guard against parsing errors, put the password in quotes. + enable = false + username = replace_me + password = "replace_me" + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to do this, set the option 'enable' to true, + # and specify the station ID (e.g., CW1234). + enable = false + station = replace_me + + # If this is an APRS (radio amateur) station, uncomment + # the following and replace with a passcode (e.g., 12345). + #passcode = replace_me (APRS stations only) + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to do this, set the option 'enable' to true, + # and specify a station (e.g., 'KORHOODR3') and password. + # To guard against parsing errors, put the password in quotes. + enable = false + station = replace_me + password = "replace_me" + + # If you plan on using wunderfixer, set the following + # to your API key: + api_key = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Whether to log a successful operation + log_success = True + + # Whether to log an unsuccessful operation + log_failure = False + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then + # fill out the next four lines. + # Use quotes around passwords to guard against parsing errors. + enable = false + user = replace_me + password = "replace_me" + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21 + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + #### + + # Various options for customizing your reports. + + [[Defaults]] + + [[[Units]]] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_distance = mile # Options are 'mile' or 'km' + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', 'hPa', or 'kPa' + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + + # The following section sets the formatting for each type of unit. + [[[[StringFormats]]]] + + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + degree_C = %.1f + degree_F = %.1f + degree_compass = %.0f + foot = %.0f + hPa = %.1f + hour = %.1f + inHg = %.3f + inch = %.2f + inch_per_hour = %.2f + km = %.1f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + kPa = %.2f + mbar = %.1f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + mile = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + mm = %.1f + mmHg = %.1f + mm_per_hour = %.1f + percent = %.0f + second = %.0f + uv_index = %.1f + volt = %.1f + watt_per_meter_squared = %.0f + NONE = " N/A" + + # The following section overrides the label used for each type of unit + [[[[Labels]]]] + + meter = " meter", " meters" # You may prefer "metre". + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + NONE = "" + + # The following section sets the format for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. + [[[[TimeFormats]]]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[[[Ordinates]]]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating, cooling, and growing degree-days. + [[[[DegreeDays]]]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[[[Trend]]]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + + # The labels to be used for each observation type + [[[Labels]]] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = "%02d", "%03d", "%05.2f" + + # Generic labels, keyed by an observation type. + [[[[Generic]]]] + barometer = Barometer + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + [[[Almanac]]] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################## + +# This service acts as a filter, converting the unit system coming from +# the hardware to a unit system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics + loop_hilo = True + + # The data binding used to save archive records + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases + [[SQLite]] + driver = weedb.sqlite + # Directory in which the database files are located + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + + # Defaults for MySQL databases + [[MySQL]] + driver = weedb.mysql + # The host where the database is located + host = localhost + # The user name for logging in to the host + user = weewx + # The password for the user name (quotes guard against parsing errors) + password = "weewx" + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + # The following section specifies which services should be run and in what order. + [[Services]] + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weecfg/update_config.py b/dist/weewx-5.0.2/src/weecfg/update_config.py new file mode 100644 index 0000000..a980750 --- /dev/null +++ b/dist/weewx-5.0.2/src/weecfg/update_config.py @@ -0,0 +1,1061 @@ +# coding: utf-8 +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Utilities that update and merge ConfigObj objects""" + +import os.path +import sys + +import weecfg + +import weeutil.config + + +def update_and_merge(config_dict, template_dict): + """First update a configuration file, then merge it with the distribution template""" + + update_config(config_dict) + merge_config(config_dict, template_dict) + + +def update_config(config_dict): + """Update a (possibly old) configuration dictionary to the latest format. + + Raises exception of type ValueError if it cannot be done. + """ + + major, minor = weecfg.get_version_info(config_dict) + + # I don't know how to merge older, V1.X configuration files, only + # newer V2.X ones. + if major == '1': + raise ValueError("Cannot update version V%s.%s. Too old" % (major, minor)) + + update_to_v25(config_dict) + + update_to_v26(config_dict) + + update_to_v30(config_dict) + + update_to_v32(config_dict) + + update_to_v36(config_dict) + + update_to_v39(config_dict) + + update_to_v40(config_dict) + + update_to_v42(config_dict) + + update_to_v43(config_dict) + + update_to_v50(config_dict) + + +def merge_config(config_dict, template_dict): + """Merge the template (distribution) dictionary into the user's dictionary. + + config_dict: An existing, older configuration dictionary. + + template_dict: A newer dictionary supplied by the installer. + """ + + # All we need to do is update the version number: + config_dict['version'] = template_dict['version'] + + +def update_to_v25(config_dict): + """Major changes for V2.5: + + - Option webpath is now station_url + - Drivers are now in their own package + - Introduction of the station registry + + """ + major, minor = weecfg.get_version_info(config_dict) + + if major + minor >= '205': + return + + try: + # webpath is now station_url + webpath = config_dict['Station'].get('webpath') + station_url = config_dict['Station'].get('station_url') + if webpath is not None and station_url is None: + config_dict['Station']['station_url'] = webpath + config_dict['Station'].pop('webpath', None) + except KeyError: + pass + + # Drivers are now in their own Python package. Change the names. + + # --- Davis Vantage series --- + try: + if config_dict['Vantage']['driver'].strip() == 'weewx.VantagePro': + config_dict['Vantage']['driver'] = 'weewx.drivers.vantage' + except KeyError: + pass + + # --- Oregon Scientific WMR100 --- + + # The section name has changed from WMR-USB to WMR100 + if 'WMR-USB' in config_dict: + if 'WMR100' in config_dict: + sys.exit("\n*** Configuration file has both a 'WMR-USB' " + "section and a 'WMR100' section. Aborting ***\n\n") + config_dict.rename('WMR-USB', 'WMR100') + # If necessary, reflect the section name in the station type: + try: + if config_dict['Station']['station_type'].strip() == 'WMR-USB': + config_dict['Station']['station_type'] = 'WMR100' + except KeyError: + pass + # Finally, the name of the driver has been changed + try: + if config_dict['WMR100']['driver'].strip() == 'weewx.wmrx': + config_dict['WMR100']['driver'] = 'weewx.drivers.wmr100' + except KeyError: + pass + + # --- Oregon Scientific WMR9x8 series --- + + # The section name has changed from WMR-918 to WMR9x8 + if 'WMR-918' in config_dict: + if 'WMR9x8' in config_dict: + sys.exit("\n*** Configuration file has both a 'WMR-918' " + "section and a 'WMR9x8' section. Aborting ***\n\n") + config_dict.rename('WMR-918', 'WMR9x8') + # If necessary, reflect the section name in the station type: + try: + if config_dict['Station']['station_type'].strip() == 'WMR-918': + config_dict['Station']['station_type'] = 'WMR9x8' + except KeyError: + pass + # Finally, the name of the driver has been changed + try: + if config_dict['WMR9x8']['driver'].strip() == 'weewx.WMR918': + config_dict['WMR9x8']['driver'] = 'weewx.drivers.wmr9x8' + except KeyError: + pass + + # --- Fine Offset instruments --- + + try: + if config_dict['FineOffsetUSB']['driver'].strip() == 'weewx.fousb': + config_dict['FineOffsetUSB']['driver'] = 'weewx.drivers.fousb' + except KeyError: + pass + + # --- The weewx Simulator --- + + try: + if config_dict['Simulator']['driver'].strip() == 'weewx.simulator': + config_dict['Simulator']['driver'] = 'weewx.drivers.simulator' + except KeyError: + pass + + if 'StdArchive' in config_dict: + # Option stats_types is no longer used. Get rid of it. + config_dict['StdArchive'].pop('stats_types', None) + + try: + # V2.5 saw the introduction of the station registry: + if 'StationRegistry' not in config_dict['StdRESTful']: + stnreg_dict = weeutil.config.config_from_str("""[StdRESTful] + + [[StationRegistry]] + # Uncomment the following line to register this weather station. + #register_this_station = True + + # Specify a station URL, otherwise the station_url from [Station] + # will be used. + #station_url = http://example.com/weather/ + + # Specify a description of the station, otherwise the location from + # [Station] will be used. + #description = The greatest station on earth + + driver = weewx.restful.StationRegistry + + """) + config_dict.merge(stnreg_dict) + except KeyError: + pass + + config_dict['version'] = '2.5.0' + + +def update_to_v26(config_dict): + """Update a configuration diction to V2.6. + + Major changes: + + - Addition of "model" option for WMR100, WMR200, and WMR9x8 + - New option METRICWX + - Engine service list now broken up into separate sublists + - Introduction of 'log_success' and 'log_failure' options + - Introduction of rapidfire + - Support of uploaders for WOW and AWEKAS + - CWOP option 'interval' changed to 'post_interval' + - CWOP option 'server' changed to 'server_list' (and is not in default weewx.conf) + """ + + major, minor = weecfg.get_version_info(config_dict) + + if major + minor >= '206': + return + + try: + if 'model' not in config_dict['WMR100']: + config_dict['WMR100']['model'] = 'WMR100' + config_dict['WMR100'].comments['model'] = \ + ["", " # The station model, e.g., WMR100, WMR100N, WMRS200"] + except KeyError: + pass + + try: + if 'model' not in config_dict['WMR200']: + config_dict['WMR200']['model'] = 'WMR200' + config_dict['WMR200'].comments['model'] = \ + ["", " # The station model, e.g., WMR200, WMR200A, Radio Shack W200"] + except KeyError: + pass + + try: + if 'model' not in config_dict['WMR9x8']: + config_dict['WMR9x8']['model'] = 'WMR968' + config_dict['WMR9x8'].comments['model'] = \ + ["", " # The station model, e.g., WMR918, Radio Shack 63-1016"] + except KeyError: + pass + + # Option METRICWX was introduced. Include it in the inline comment + try: + config_dict['StdConvert'].inline_comments[ + 'target_unit'] = "# Options are 'US', 'METRICWX', or 'METRIC'" + except KeyError: + pass + + # New default values for inHumidity, rain, and windSpeed Quality Controls + try: + if 'inHumidity' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['inHumidity'] = [0, 100] + if 'rain' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['rain'] = [0, 60, "inch"] + if 'windSpeed' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['windSpeed'] = [0, 120, "mile_per_hour"] + if 'inTemp' not in config_dict['StdQC']['MinMax']: + config_dict['StdQC']['MinMax']['inTemp'] = [10, 20, "degree_F"] + except KeyError: + pass + + service_map_v2 = {'weewx.wxengine.StdTimeSynch': 'prep_services', + 'weewx.wxengine.StdConvert': 'process_services', + 'weewx.wxengine.StdCalibrate': 'process_services', + 'weewx.wxengine.StdQC': 'process_services', + 'weewx.wxengine.StdArchive': 'archive_services', + 'weewx.wxengine.StdPrint': 'report_services', + 'weewx.wxengine.StdReport': 'report_services'} + + # See if the engine configuration section has the old-style "service_list": + if 'Engines' in config_dict and 'service_list' in config_dict['Engines']['WxEngine']: + # It does. Break it up into five, smaller lists. If a service + # does not appear in the dictionary "service_map_v2", meaning we + # do not know what it is, then stick it in the last group we + # have seen. This should get its position about right. + last_group = 'prep_services' + + # Set up a bunch of empty groups in the right order. Option 'data_services' was actually introduced + # in v3.0, but it can be included without harm here. + for group in ['prep_services', 'data_services', 'process_services', 'archive_services', + 'restful_services', 'report_services']: + config_dict['Engines']['WxEngine'][group] = list() + + # Add a helpful comment + config_dict['Engines']['WxEngine'].comments['prep_services'] = \ + ['', ' # The list of services the main weewx engine should run:'] + + # Now map the old service names to the right group + for _svc_name in config_dict['Engines']['WxEngine']['service_list']: + svc_name = _svc_name.strip() + # Skip the no longer needed StdRESTful service: + if svc_name == 'weewx.wxengine.StdRESTful': + continue + # Do we know about this service? + if svc_name in service_map_v2: + # Yes. Get which group it belongs to, and put it there + group = service_map_v2[svc_name] + config_dict['Engines']['WxEngine'][group].append(svc_name) + last_group = group + else: + # No. Put it in the last group. + config_dict['Engines']['WxEngine'][last_group].append(svc_name) + + # Now add the restful services, using the old driver name to help us + for section in config_dict['StdRESTful'].sections: + svc = config_dict['StdRESTful'][section]['driver'] + # weewx.restful has changed to weewx.restx + if svc.startswith('weewx.restful'): + svc = 'weewx.restx.Std' + section + # awekas is in weewx.restx since 2.6 + if svc.endswith('AWEKAS'): + svc = 'weewx.restx.AWEKAS' + config_dict['Engines']['WxEngine']['restful_services'].append(svc) + + # Depending on how old a version the user has, the station registry + # may have to be included: + if 'weewx.restx.StdStationRegistry' not in config_dict['Engines']['WxEngine'][ + 'restful_services']: + config_dict['Engines']['WxEngine']['restful_services'].append( + 'weewx.restx.StdStationRegistry') + + # Get rid of the no longer needed service_list: + config_dict['Engines']['WxEngine'].pop('service_list', None) + + # V2.6 introduced "log_success" and "log_failure" options. + # The "driver" option was removed. + for section in config_dict['StdRESTful']: + # Save comments before popping driver + comments = config_dict['StdRESTful'][section].comments.get('driver', []) + if 'log_success' not in config_dict['StdRESTful'][section]: + config_dict['StdRESTful'][section]['log_success'] = True + if 'log_failure' not in config_dict['StdRESTful'][section]: + config_dict['StdRESTful'][section]['log_failure'] = True + config_dict['StdRESTful'][section].comments['log_success'] = comments + config_dict['StdRESTful'][section].pop('driver', None) + + # Option 'rapidfire' was new: + try: + if 'rapidfire' not in config_dict['StdRESTful']['Wunderground']: + config_dict['StdRESTful']['Wunderground']['rapidfire'] = False + config_dict['StdRESTful']['Wunderground'].comments['rapidfire'] = \ + ['', + ' # Set the following to True to have weewx use the WU "Rapidfire"', + ' # protocol'] + except KeyError: + pass + + # Support for the WOW uploader was introduced + try: + if 'WOW' not in config_dict['StdRESTful']: + config_dict.merge(weeutil.config.config_from_str("""[StdRESTful] + + [[WOW]] + # This section is for configuring posts to WOW + + # If you wish to do this, uncomment the following station and password + # lines and fill them with your station and password: + #station = your WOW station ID + #password = your WOW password + + log_success = True + log_failure = True + + """)) + config_dict['StdRESTful'].comments['WOW'] = [''] + except KeyError: + pass + + # Support for the AWEKAS uploader was introduced + try: + if 'AWEKAS' not in config_dict['StdRESTful']: + config_dict.merge(weeutil.config.config_from_str("""[StdRESTful] + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS + + # If you wish to do this, uncomment the following username and password + # lines and fill them with your username and password: + #username = your AWEKAS username + #password = your AWEKAS password + + log_success = True + log_failure = True + + """)) + config_dict['StdRESTful'].comments['AWEKAS'] = [''] + except KeyError: + pass + + # The CWOP option "interval" has changed to "post_interval" + try: + if 'interval' in config_dict['StdRESTful']['CWOP']: + comment = config_dict['StdRESTful']['CWOP'].comments['interval'] + config_dict['StdRESTful']['CWOP']['post_interval'] = \ + config_dict['StdRESTful']['CWOP']['interval'] + config_dict['StdRESTful']['CWOP'].pop('interval') + config_dict['StdRESTful']['CWOP'].comments['post_interval'] = comment + except KeyError: + pass + + try: + if 'server' in config_dict['StdRESTful']['CWOP']: + # Save the old comments, as they are useful for setting up CWOP + comments = [c for c in config_dict['StdRESTful']['CWOP'].comments.get('server') if + 'Comma' not in c] + # Option "server" has become "server_list". It is also no longer + # included in the default weewx.conf, so just pop it. + config_dict['StdRESTful']['CWOP'].pop('server', None) + # Put the saved comments in front of the first scalar. + key = config_dict['StdRESTful']['CWOP'].scalars[0] + config_dict['StdRESTful']['CWOP'].comments[key] = comments + except KeyError: + pass + + config_dict['version'] = '2.6.0' + + +def update_to_v30(config_dict): + """Update a configuration file to V3.0 + + - Introduction of the new database structure + - Introduction of StdWXCalculate + """ + + major, minor = weecfg.get_version_info(config_dict) + + if major + minor >= '300': + return + + old_database = None + + if 'StdReport' in config_dict: + # The key "data_binding" is now used instead of these: + config_dict['StdReport'].pop('archive_database', None) + config_dict['StdReport'].pop('stats_database', None) + if 'data_binding' not in config_dict['StdReport']: + config_dict['StdReport']['data_binding'] = 'wx_binding' + config_dict['StdReport'].comments['data_binding'] = \ + ['', " # The database binding indicates which data should be used in reports"] + + if 'Databases' in config_dict: + # The stats database no longer exists. Remove it from the [Databases] + # section: + config_dict['Databases'].pop('stats_sqlite', None) + config_dict['Databases'].pop('stats_mysql', None) + # The key "database" changed to "database_name" + for stanza in config_dict['Databases']: + if 'database' in config_dict['Databases'][stanza]: + config_dict['Databases'][stanza].rename('database', + 'database_name') + + if 'StdArchive' in config_dict: + # Save the old database, if it exists + old_database = config_dict['StdArchive'].pop('archive_database', None) + # Get rid of the no longer needed options + config_dict['StdArchive'].pop('stats_database', None) + config_dict['StdArchive'].pop('archive_schema', None) + config_dict['StdArchive'].pop('stats_schema', None) + # Add the data_binding option + if 'data_binding' not in config_dict['StdArchive']: + config_dict['StdArchive']['data_binding'] = 'wx_binding' + config_dict['StdArchive'].comments['data_binding'] = \ + ['', " # The data binding to be used"] + + if 'DataBindings' not in config_dict: + # Insert a [DataBindings] section. First create it + c = weeutil.config.config_from_str("""[DataBindings] + # This section binds a data store to a database + + [[wx_binding]] + # The database must match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview.schema + + """) + # Now merge it in: + config_dict.merge(c) + # For some reason, ConfigObj strips any leading comments. Put them back: + config_dict.comments['DataBindings'] = weecfg.major_comment_block + # Move the new section to just before [Databases] + weecfg.reorder_sections(config_dict, 'DataBindings', 'Databases') + # No comments between the [DataBindings] and [Databases] sections: + config_dict.comments['Databases'] = [""] + config_dict.inline_comments['Databases'] = [] + + # If there was an old database, add it in the new, correct spot: + if old_database: + try: + config_dict['DataBindings']['wx_binding']['database'] = old_database + except KeyError: + pass + + # StdWXCalculate is new + if 'StdWXCalculate' not in config_dict: + c = weeutil.config.config_from_str("""[StdWXCalculate] + # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + barometer = prefer_hardware + altimeter = prefer_hardware + windchill = prefer_hardware + heatindex = prefer_hardware + dewpoint = prefer_hardware + inDewpoint = prefer_hardware + rainRate = prefer_hardware""") + # Now merge it in: + config_dict.merge(c) + # For some reason, ConfigObj strips any leading comments. Put them back: + config_dict.comments['StdWXCalculate'] = weecfg.major_comment_block + # Move the new section to just before [StdArchive] + weecfg.reorder_sections(config_dict, 'StdWXCalculate', 'StdArchive') + + # Section ['Engines'] got renamed to ['Engine'] + if 'Engine' not in config_dict and 'Engines' in config_dict: + config_dict.rename('Engines', 'Engine') + # Subsection [['WxEngine']] got renamed to [['Services']] + if 'WxEngine' in config_dict['Engine']: + config_dict['Engine'].rename('WxEngine', 'Services') + + # Finally, module "wxengine" got renamed to "engine". Go through + # each of the service lists, making the change + for list_name in config_dict['Engine']['Services']: + service_list = config_dict['Engine']['Services'][list_name] + # If service_list is not already a list (it could be just a + # single name), then make it a list: + if not isinstance(service_list, (tuple, list)): + service_list = [service_list] + config_dict['Engine']['Services'][list_name] = \ + [this_item.replace('wxengine', 'engine') for this_item in service_list] + try: + # Finally, make sure the new StdWXCalculate service is in the list: + if 'weewx.wxservices.StdWXCalculate' not in config_dict['Engine']['Services'][ + 'process_services']: + config_dict['Engine']['Services']['process_services'].append( + 'weewx.wxservices.StdWXCalculate') + except KeyError: + pass + + config_dict['version'] = '3.0.0' + + +def update_to_v32(config_dict): + """Update a configuration file to V3.2 + + - Introduction of section [DatabaseTypes] + - New option in [Databases] points to DatabaseType + """ + + major, minor = weecfg.get_version_info(config_dict) + + if major + minor >= '302': + return + + # For interpolation to work, it's critical that WEEWX_ROOT not end + # with a trailing slash ('/'). Convert it to the normative form: + if 'WEEWX_ROOT_CONFIG' in config_dict: + config_dict['WEEWX_ROOT_CONFIG'] = os.path.normpath(config_dict['WEEWX_ROOT_CONFIG']) + config_dict['WEEWX_ROOT'] = os.path.normpath(config_dict['WEEWX_ROOT']) + + # Add a default database-specific top-level stanzas if necessary + if 'DatabaseTypes' not in config_dict: + # Do SQLite first. Start with a sanity check: + try: + assert (config_dict['Databases']['archive_sqlite']['driver'] == 'weedb.sqlite') + except KeyError: + pass + # Set the default [[SQLite]] section. Turn off interpolation first, so the + # symbol for WEEWX_ROOT does not get lost. + save, config_dict.interpolation = config_dict.interpolation, False + # The section must be built step-by-step so that we get the order of the entries correct + config_dict['DatabaseTypes'] = {} + config_dict['DatabaseTypes']['SQLite'] = {} + config_dict['DatabaseTypes']['SQLite']['driver'] = 'weedb.sqlite' + config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] = '%(WEEWX_ROOT)s/archive' + config_dict['DatabaseTypes'].comments['SQLite'] = \ + ['', ' # Defaults for SQLite databases'] + config_dict['DatabaseTypes']['SQLite'].comments['SQLITE_ROOT'] \ + = " # Directory in which the database files are located" + config_dict.interpolation = save + try: + root = config_dict['Databases']['archive_sqlite']['root'] + database_name = config_dict['Databases']['archive_sqlite']['database_name'] + fullpath = os.path.join(root, database_name) + dirname = os.path.dirname(fullpath) + # By testing to see if they end up resolving to the same thing, + # we can keep the interpolation used to specify SQLITE_ROOT above. + if dirname != config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT']: + config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] = dirname + config_dict['DatabaseTypes']['SQLite'].comments['SQLITE_ROOT'] = \ + [' # Directory in which the database files are located'] + config_dict['Databases']['archive_sqlite']['database_name'] = os.path.basename( + fullpath) + config_dict['Databases']['archive_sqlite']['database_type'] = 'SQLite' + config_dict['Databases']['archive_sqlite'].pop('root', None) + config_dict['Databases']['archive_sqlite'].pop('driver', None) + except KeyError: + pass + + # Now do MySQL. Start with a sanity check: + try: + assert (config_dict['Databases']['archive_mysql']['driver'] == 'weedb.mysql') + except KeyError: + pass + config_dict['DatabaseTypes']['MySQL'] = {} + config_dict['DatabaseTypes'].comments['MySQL'] = ['', ' # Defaults for MySQL databases'] + try: + config_dict['DatabaseTypes']['MySQL']['host'] = \ + config_dict['Databases']['archive_mysql'].get('host', 'localhost') + config_dict['DatabaseTypes']['MySQL']['user'] = \ + config_dict['Databases']['archive_mysql'].get('user', 'weewx') + config_dict['DatabaseTypes']['MySQL']['password'] = \ + config_dict['Databases']['archive_mysql'].get('password', 'weewx') + config_dict['DatabaseTypes']['MySQL']['driver'] = 'weedb.mysql' + config_dict['DatabaseTypes']['MySQL'].comments['host'] = [ + " # The host where the database is located"] + config_dict['DatabaseTypes']['MySQL'].comments['user'] = [ + " # The user name for logging into the host"] + config_dict['DatabaseTypes']['MySQL'].comments['password'] = [ + " # The password for the user name"] + config_dict['Databases']['archive_mysql'].pop('host', None) + config_dict['Databases']['archive_mysql'].pop('user', None) + config_dict['Databases']['archive_mysql'].pop('password', None) + config_dict['Databases']['archive_mysql'].pop('driver', None) + config_dict['Databases']['archive_mysql']['database_type'] = 'MySQL' + config_dict['Databases'].comments['archive_mysql'] = [''] + except KeyError: + pass + + # Move the new section to just before [Engine] + weecfg.reorder_sections(config_dict, 'DatabaseTypes', 'Engine') + # Add a major comment deliminator: + config_dict.comments['DatabaseTypes'] = \ + weecfg.major_comment_block + \ + ['# This section defines defaults for the different types of databases', ''] + + # Version 3.2 introduces the 'enable' keyword for RESTful protocols. Set + # it appropriately + def set_enable(c, service, keyword): + # Check to see whether this config file has the service listed + try: + c['StdRESTful'][service] + except KeyError: + # It does not. Nothing to do. + return + + # Now check to see whether it already has the option 'enable': + if 'enable' in c['StdRESTful'][service]: + # It does. No need to proceed + return + + # The option 'enable' is not present. Add it, + # and set based on whether the keyword is present: + if keyword in c['StdRESTful'][service]: + c['StdRESTful'][service]['enable'] = 'true' + else: + c['StdRESTful'][service]['enable'] = 'false' + # Add a comment for it + c['StdRESTful'][service].comments['enable'] = ['', + ' # Set to true to enable this uploader'] + + set_enable(config_dict, 'AWEKAS', 'username') + set_enable(config_dict, 'CWOP', 'station') + set_enable(config_dict, 'PWSweather', 'station') + set_enable(config_dict, 'WOW', 'station') + set_enable(config_dict, 'Wunderground', 'station') + + config_dict['version'] = '3.2.0' + + +def update_to_v36(config_dict): + """Update a configuration file to V3.6 + + - New subsection [[Calculations]] + """ + + major, minor = weecfg.get_version_info(config_dict) + + if major + minor >= '306': + return + + # Perform the following only if the dictionary has a StdWXCalculate section + if 'StdWXCalculate' in config_dict: + # No need to update if it already has a 'Calculations' section: + if 'Calculations' not in config_dict['StdWXCalculate']: + # Save the comment attached to the first scalar + try: + first = config_dict['StdWXCalculate'].scalars[0] + comment = config_dict['StdWXCalculate'].comments[first] + config_dict['StdWXCalculate'].comments[first] = '' + except IndexError: + comment = """ # Derived quantities are calculated by this service. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx""" + # Create a new 'Calculations' section: + config_dict['StdWXCalculate']['Calculations'] = {} + # Now transfer over the options. Make a copy of them first: we will be + # deleting some of them. + scalars = list(config_dict['StdWXCalculate'].scalars) + for scalar in scalars: + # These scalars don't get moved: + if not scalar in ['ignore_zero_wind', 'rain_period', + 'et_period', 'wind_height', 'atc', + 'nfac', 'max_delta_12h']: + config_dict['StdWXCalculate']['Calculations'][scalar] = \ + config_dict['StdWXCalculate'][scalar] + config_dict['StdWXCalculate'].pop(scalar) + # Insert the old comment at the top of the new stanza: + try: + first = config_dict['StdWXCalculate']['Calculations'].scalars[0] + config_dict['StdWXCalculate']['Calculations'].comments[first] = comment + except IndexError: + pass + + config_dict['version'] = '3.6.0' + + +def update_to_v39(config_dict): + """Update a configuration file to V3.9 + + - New top-level options log_success and log_failure + - New subsections [[SeasonsReport]], [[SmartphoneReport]], and [[MobileReport]] + - New section [StdReport][[Defaults]]. Prior to V4.6, it had lots of entries. With the + introduction of V4.6, it has been pared back to the minimum. + """ + + major, minor = weecfg.get_version_info(config_dict) + + if major + minor >= '309': + return + + # Add top-level log_success and log_failure if missing + if 'log_success' not in config_dict: + config_dict['log_success'] = True + config_dict.comments['log_success'] = ['', '# Whether to log successful operations'] + weecfg.reorder_scalars(config_dict.scalars, 'log_success', 'socket_timeout') + if 'log_failure' not in config_dict: + config_dict['log_failure'] = True + config_dict.comments['log_failure'] = ['', '# Whether to log unsuccessful operations'] + weecfg.reorder_scalars(config_dict.scalars, 'log_failure', 'socket_timeout') + + if 'StdReport' in config_dict: + + # + # The logic below will put the subsections in the following order: + # + # [[StandardReport]] + # [[SeasonsReport]] + # [[SmartphoneReport]] + # [[MobileReport]] + # [[FTP]] + # [[RSYNC] + # [[Defaults]] + # + # NB: For an upgrade, we want StandardReport first, because that's + # what the user is already using. + # + + # Work around a ConfigObj limitation that can cause comments to be dropped. + # Save the original comment, then restore it later. + std_report_comment = config_dict.comments['StdReport'] + + if 'Defaults' not in config_dict['StdReport']: + defaults_dict = weeutil.config.config_from_str(DEFAULTS) + weeutil.config.merge_config(config_dict, defaults_dict) + weecfg.reorder_sections(config_dict['StdReport'], 'Defaults', 'RSYNC', after=True) + + if 'SeasonsReport' not in config_dict['StdReport']: + seasons_options_dict = weeutil.config.config_from_str(SEASONS_REPORT) + weeutil.config.merge_config(config_dict, seasons_options_dict) + weecfg.reorder_sections(config_dict['StdReport'], 'SeasonsReport', 'FTP') + + if 'SmartphoneReport' not in config_dict['StdReport']: + smartphone_options_dict = weeutil.config.config_from_str(SMARTPHONE_REPORT) + weeutil.config.merge_config(config_dict, smartphone_options_dict) + weecfg.reorder_sections(config_dict['StdReport'], 'SmartphoneReport', 'FTP') + + if 'MobileReport' not in config_dict['StdReport']: + mobile_options_dict = weeutil.config.config_from_str(MOBILE_REPORT) + weeutil.config.merge_config(config_dict, mobile_options_dict) + weecfg.reorder_sections(config_dict['StdReport'], 'MobileReport', 'FTP') + + if 'StandardReport' in config_dict['StdReport'] \ + and 'enable' not in config_dict['StdReport']['StandardReport']: + config_dict['StdReport']['StandardReport']['enable'] = True + + # Put the comment for [StdReport] back in + config_dict.comments['StdReport'] = std_report_comment + + # Remove all comments before each report section + for report in config_dict['StdReport'].sections: + if report == 'Defaults': + continue + config_dict['StdReport'].comments[report] = [''] + + # Special comment for the first report section: + first_section_name = config_dict['StdReport'].sections[0] + config_dict['StdReport'].comments[first_section_name] \ + = ['', + '####', + '', + '# Each of the following subsections defines a report that will be run.', + '# See the customizing guide to change the units, plot types and line', + '# colors, modify the fonts, display additional sensor data, and other', + '# customizations. Many of those changes can be made here by overriding', + '# parameters, or by modifying templates within the skin itself.', + '' + ] + + config_dict['version'] = '3.9.0' + + +def update_to_v40(config_dict): + """Update a configuration file to V4.0 + + - Add option loop_request for Vantage users. + - Fix problems with DegreeDays and Trend in weewx.conf + - Add new option growing_base + - Add new option WU api_key + - Add options to [StdWXCalculate] that were formerly defaults + """ + + # No need to check for the version of weewx for these changes. + + if 'Vantage' in config_dict \ + and 'loop_request' not in config_dict['Vantage']: + config_dict['Vantage']['loop_request'] = 1 + config_dict['Vantage'].comments['loop_request'] = \ + ['', 'The type of LOOP packet to request: 1 = LOOP1; 2 = LOOP2; 3 = both'] + weecfg.reorder_scalars(config_dict['Vantage'].scalars, 'loop_request', 'iss_id') + + if 'StdReport' in config_dict \ + and 'Defaults' in config_dict['StdReport'] \ + and 'Units' in config_dict['StdReport']['Defaults']: + + # Both the DegreeDays and Trend subsections accidentally ended up + # in the wrong section + for key in ['DegreeDays', 'Trend']: + + # Proceed only if the key has not already been moved, and exists in the incorrect spot: + if key not in config_dict['StdReport']['Defaults']['Units'] \ + and 'Ordinates' in config_dict['StdReport']['Defaults']['Units'] \ + and key in config_dict['StdReport']['Defaults']['Units']['Ordinates']: + # Save the old comment + old_comment = config_dict['StdReport']['Defaults']['Units']['Ordinates'].comments[ + key] + + # Shallow copy the subsection + config_dict['StdReport']['Defaults']['Units'][key] = \ + config_dict['StdReport']['Defaults']['Units']['Ordinates'][key] + # Delete it in from its old location + del config_dict['StdReport']['Defaults']['Units']['Ordinates'][key] + + # Unfortunately, ConfigObj can't fix these things when doing a shallow copy: + config_dict['StdReport']['Defaults']['Units'][key].depth = \ + config_dict['StdReport']['Defaults']['Units'].depth + 1 + config_dict['StdReport']['Defaults']['Units'][key].parent = \ + config_dict['StdReport']['Defaults']['Units'] + config_dict['StdReport']['Defaults']['Units'].comments[key] = old_comment + + # Now add the option "growing_base" if it hasn't already been added: + if 'StdReport' in config_dict \ + and 'Defaults' in config_dict['StdReport'] \ + and 'Units' in config_dict['StdReport']['Defaults'] \ + and 'DegreeDays' in config_dict['StdReport']['Defaults']['Units'] \ + and 'growing_base' not in config_dict['StdReport']['Defaults']['Units']['DegreeDays']: + config_dict['StdReport']['Defaults']['Units']['DegreeDays']['growing_base'] = [50.0, + 'degree_F'] + config_dict['StdReport']['Defaults']['Units']['DegreeDays'].comments['growing_base'] = \ + ["Base temperature for growing days, with unit:"] + + # The following types were never listed in weewx.conf and, instead, depended on defaults. + if 'StdWXCalculate' in config_dict \ + and 'Calculations' in config_dict['StdWXCalculate']: + config_dict['StdWXCalculate']['Calculations'].setdefault('maxSolarRad', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('cloudbase', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('humidex', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('appTemp', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('ET', 'prefer_hardware') + config_dict['StdWXCalculate']['Calculations'].setdefault('windrun', 'prefer_hardware') + + # This section will inject a [Logging] section. Leave it commented out for now, + # until we gain more experience with it. + + # if 'Logging' not in config_dict: + # logging_dict = configobj.ConfigObj(StringIO(weeutil.logger.LOGGING_STR), interpolation=False) + # + # # Delete some not needed (and dangerous) entries + # try: + # del logging_dict['Logging']['version'] + # del logging_dict['Logging']['disable_existing_loggers'] + # except KeyError: + # pass + # + # config_dict.merge(logging_dict) + # + # # Move the new section to just before [Engine] + # reorder_sections(config_dict, 'Logging', 'Engine') + # config_dict.comments['Logging'] = \ + # major_comment_block + \ + # ['# This section customizes logging', ''] + + # Make sure the version number is at least 4.0 + major, minor = weecfg.get_version_info(config_dict) + + if major + minor < '400': + config_dict['version'] = '4.0.0' + + +def update_to_v42(config_dict): + """Update a configuration file to V4.2 + + - Add new engine service group xtype_services + """ + + if 'Engine' in config_dict and 'Services' in config_dict['Engine']: + # If it's not there already, inject 'xtype_services' + if 'xtype_services' not in config_dict['Engine']['Services']: + config_dict['Engine']['Services']['xtype_services'] = \ + ['weewx.wxxtypes.StdWXXTypes', + 'weewx.wxxtypes.StdPressureCooker', + 'weewx.wxxtypes.StdRainRater', + 'weewx.wxxtypes.StdDelta'] + + # V4.2.0 neglected to include StdDelta. If necessary, add it: + if 'weewx.wxxtypes.StdDelta' not in config_dict['Engine']['Services']['xtype_services']: + config_dict['Engine']['Services']['xtype_services'].append('weewx.wxxtypes.StdDelta') + + # Make sure xtype_services are located just before the 'archive_services' + weecfg.reorder_scalars(config_dict['Engine']['Services'].scalars, + 'xtype_services', + 'archive_services') + config_dict['Engine']['Services'].comments['prep_services'] = [] + config_dict['Engine']['Services'].comments['xtype_services'] = [] + config_dict['Engine'].comments['Services'] = ['', 'This section specifies which services ' + 'should be run and in what order.'] + config_dict['version'] = '4.2.0' + + +def update_to_v43(config_dict): + """Update a configuration file to V4.3 + + - Set [StdReport] / log_failure to True + """ + if 'StdReport' in config_dict and 'log_failure' in config_dict['StdReport']: + config_dict['StdReport']['log_failure'] = True + + config_dict['version'] = '4.3.0' + + +def update_to_v50(config_dict): + """Update a configuration file to V5.0 + + - If the config file uses '/' for WEEWX_ROOT, set it to '/etc/weewx' + """ + if 'WEEWX_ROOT_CONFIG' in config_dict: + if config_dict['WEEWX_ROOT_CONFIG'] == '/': + config_dict['WEEWX_ROOT_CONFIG'] = '/etc/weewx' + # Some older versions of weewx.conf use a trailing slash on WEEWX_ROOT. Standardize. + config_dict['WEEWX_ROOT_CONFIG'] = os.path.normpath(config_dict['WEEWX_ROOT_CONFIG']) + + config_dict['version'] = '5.0.0' + + +# ============================================================================== +# Various config sections +# ============================================================================== + + +SEASONS_REPORT = """[StdReport] + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = false""" + +SMARTPHONE_REPORT = """[StdReport] + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone""" + +MOBILE_REPORT = """[StdReport] + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile""" + +DEFAULTS = """[StdReport] + + #### + + # Options in the [[Defaults]] section below will apply to all reports. + # What follows are a few of the more popular options you may want to + # uncomment, then change. + [[Defaults]] + + # Which language to use for all reports. Not all skins support all languages. + # You can override this for individual reports. + lang = en + + # Which unit system to use for all reports. Choices are 'us', 'metric', or 'metricwx'. + # You can override this for individual reports. + unit_system = us + + [[[Units]]] + # Option "unit_system" above sets the general unit system, but overriding specific unit + # groups is possible. These are popular choices. Uncomment and set as appropriate. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[[[Groups]]]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_pressure = mbar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + # Uncommenting the following section frequently results in more + # attractive formatting of times and dates, but may not work in + # your locale. + [[[[TimeFormats]]]] + # day = %H:%M + # week = %H:%M on %A + # month = %d-%b-%Y %H:%M + # year = %d-%b-%Y %H:%M + # rainyear = %d-%b-%Y %H:%M + # current = %d-%b-%Y %H:%M + # ephem_day = %H:%M + # ephem_year = %d-%b-%Y %H:%M + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + + [[[Labels]]] + # Users frequently change the labels for these observation types + [[[[Generic]]]] + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # extraTemp1 = Temperature1 + # extraTemp2 = Temperature2 + # extraTemp3 = Temperature3 + # The following line is used to keep the above lines indented properly. + # It can be ignored. + unused = unused + +""" diff --git a/dist/weewx-5.0.2/src/weectl.py b/dist/weewx-5.0.2/src/weectl.py new file mode 100644 index 0000000..9aee462 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectl.py @@ -0,0 +1,74 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Entry point to the weewx configuration program 'weectl'.""" + +import argparse +import importlib +import sys + +import weewx + +usagestr = """%(prog)s -v|--version + %(prog)s -h|--help + %(prog)s database --help + %(prog)s debug --help + %(prog)s device --help + %(prog)s extension --help + %(prog)s import --help + %(prog)s report --help + %(prog)s station --help +""" + +description = """%(prog)s is the master utility used by WeeWX. It can invoke several different +subcommands, listed below. You can explore their utility by using the --help option. For example, +to find out what the 'database' subcommand can do, use '%(prog)s database --help'.""" + +SUBCOMMANDS = ['database', 'debug', 'device', 'extension', 'import', 'report', 'station', ] + + +# =============================================================================== +# Main entry point +# =============================================================================== + +def main(): + try: + # The subcommand 'weectl device' uses the old optparse, so we have to intercept any + # calls to it and call it directly. + if sys.argv[1] == 'device': + import weectllib.device_actions + weectllib.device_actions.device() + return + except IndexError: + pass + + # Everything else uses argparse. Proceed. + parser = argparse.ArgumentParser(usage=usagestr, description=description) + parser.add_argument("-v", "--version", action='version', + version=f"weectl {weewx.__version__}") + + # Add a subparser to handle the various subcommands. + subparsers = parser.add_subparsers(dest='subcommand', + title="Available subcommands") + + # Import the "cmd" module for each subcommand, then add its individual subparser. + for subcommand in SUBCOMMANDS: + module = importlib.import_module(f'weectllib.{subcommand}_cmd') + module.add_subparser(subparsers) + + # Time to parse the whole tree + namespace = parser.parse_args() + + if hasattr(namespace, 'func'): + # Call the appropriate action function: + namespace.func(namespace) + else: + # Shouldn't get here. Some sub-subparser failed to include a 'func' argument. + parser.print_help() + + +if __name__ == "__main__": + # Start up the program + main() diff --git a/dist/weewx-5.0.2/src/weectllib/__init__.py b/dist/weewx-5.0.2/src/weectllib/__init__.py new file mode 100644 index 0000000..81307ab --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/__init__.py @@ -0,0 +1,125 @@ +# +# Copyright (c) 2023-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""The 'weectllib' package.""" + +import datetime +import logging +import sys + +import configobj + +import weecfg +import weeutil.logger +import weeutil.startup +import weewx +from weeutil.weeutil import bcolors + + +def parse_dates(date=None, from_date=None, to_date=None, as_datetime=False): + """Parse --date, --from and --to command line options. + + Parses --date or --from and --to. + + Args: + date(str|None): In the form YYYY-mm-dd + from_date(str|None): Any ISO 8601 acceptable date or datetime. + to_date(str|None): Any ISO 8601 acceptable date or datetime. + as_datetime(bool): True, return a datetime.datetime object. Otherwise, return + a datetime.date object. + + Returns: + tuple: A two-way tuple (from_val, to_d) representing the from and to dates + as datetime.datetime objects (as_datetime==True), or datetime.date (False). + """ + + # default is None, unless user has specified an option + from_val = to_val = None + + # first look for --date + if date: + # we have a --date option, make sure we are not over specified + if from_date or to_date: + raise ValueError("Specify either --date or a --from and --to combination; not both") + + # there is a --date but is it valid + try: + if as_datetime: + from_val = to_val = datetime.datetime.fromisoformat(date) + else: + from_val = to_val = datetime.date.fromisoformat(date) + except ValueError: + raise ValueError("Invalid --date option specified.") + + else: + # we don't have --date. Look for --from and/or --to + if from_date: + try: + if as_datetime: + from_val = datetime.datetime.fromisoformat(from_date) + else: + from_val = datetime.date.fromisoformat(from_date) + except ValueError: + raise ValueError("Invalid --from option specified.") + + if to_date: + try: + if as_datetime: + to_val = datetime.datetime.fromisoformat(to_date) + else: + to_val = datetime.date.fromisoformat(to_date) + except ValueError: + raise ValueError("Invalid --to option specified.") + + if from_date and from_val > to_val: + raise weewx.ViolatedPrecondition("--from value is later than --to value.") + + return from_val, to_val + + +def dispatch(namespace): + """All weectl commands come here. This function reads the configuration file, sets up logging, + then dispatches to the actual action. + """ + # Read the configuration file + try: + config_path, config_dict = weecfg.read_config(namespace.config) + except (IOError, configobj.ConfigObjError) as e: + print(f"Error parsing config file: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(weewx.CONFIG_ERROR) + + print(f"Using configuration file {bcolors.BOLD}{config_path}{bcolors.ENDC}") + + try: + # Customize the logging with user settings. + weeutil.logger.setup('weectl', config_dict) + except Exception as e: + print(f"Unable to set up logger: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(weewx.CONFIG_ERROR) + + # Get a logger. This one will have the requested configuration. + log = logging.getLogger(__name__) + # Announce the startup + log.info("Initializing weectl version %s", weewx.__version__) + log.info("Command line: %s", ' '.join(sys.argv)) + + # Add USER_ROOT to PYTHONPATH, read user.extensions: + weeutil.startup.initialize(config_dict) + + # Note a dry-run, if applicable: + if hasattr(namespace, 'dry_run') and namespace.dry_run: + print("This is a dry run. Nothing will actually be done.") + log.info("This is a dry run. Nothing will actually be done.") + + # Call the specified action: + namespace.action_func(config_dict, namespace) + + if hasattr(namespace, 'dry_run') and namespace.dry_run: + print("This was a dry run. Nothing was actually done.") + log.info("This was a dry run. Nothing was actually done.") diff --git a/dist/weewx-5.0.2/src/weectllib/database_actions.py b/dist/weewx-5.0.2/src/weectllib/database_actions.py new file mode 100644 index 0000000..79f5eba --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/database_actions.py @@ -0,0 +1,680 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Various high-level, interactive, database actions""" + +import logging +import sys +import time + +import weectllib +import weedb +import weewx +import weewx.manager +import weewx.units +from weeutil.weeutil import y_or_n, timestamp_to_string + +log = logging.getLogger('weectl-database') + + +def create_database(config_dict, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Create a new database.""" + + # Try a simple open. If it succeeds, that means the database + # exists and is initialized. Otherwise, an exception will be raised. + try: + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + print(f"Database '{dbmanager.database_name}' already exists. Nothing done.") + except weedb.OperationalError: + if not dry_run: + ans = y_or_n("Create database (y/n)? ", noprompt=no_confirm) + if ans == 'n': + print("Nothing done") + return + # Database does not exist. Try again, but allow initialization: + with weewx.manager.open_manager_with_config(config_dict, + db_binding, + initialize=True) as dbmanager: + print(f"Created database '{dbmanager.database_name}'.") + + +def drop_daily(config_dict, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Drop the daily summary from a WeeWX database.""" + + try: + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + print("Proceeding will delete all your daily summaries from " + f"database '{dbmanager.database_name}'") + ans = y_or_n("Are you sure you want to proceed (y/n)? ", noprompt=no_confirm) + if ans == 'n': + print("Nothing done") + return + t1 = time.time() + try: + if not dry_run: + dbmanager.drop_daily() + except weedb.OperationalError as e: + print("Error '%s'" % e, file=sys.stderr) + print(f"Drop daily summary tables failed for database '{dbmanager.database_name}'") + else: + tdiff = time.time() - t1 + print("Daily summary tables dropped from " + f"database '{dbmanager.database_name}' in {tdiff:.2f} seconds") + except weedb.OperationalError: + # No daily summaries. Nothing to be done. + print(f"No daily summaries found. Nothing done.") + + +def rebuild_daily(config_dict, + date=None, + from_date=None, + to_date=None, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Rebuild the daily summaries.""" + + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + database_name = manager_dict['database_dict']['database_name'] + + # Get any dates the user might have specified. + from_d, to_d = weectllib.parse_dates(date, from_date, to_date) + + # Advise the user/log what we will do + if not from_d and not to_d: + msg = "All daily summaries will be rebuilt." + elif from_d and not to_d: + msg = f"Daily summaries starting with {from_d} will be rebuilt." + elif not from_d and to_d: + msg = f"Daily summaries through {to_d} will be rebuilt." + elif from_d == to_d: + msg = f"Daily summary for {from_d} will be rebuilt." + else: + msg = f"Daily summaries from {from_d} through {to_d}, inclusive, will be rebuilt." + + log.info(msg) + print(msg) + ans = y_or_n(f"Rebuild the daily summaries in the database '{database_name}' (y/n)? ", + noprompt=no_confirm) + if ans == 'n': + log.info("Nothing done.") + print("Nothing done.") + return + + t1 = time.time() + + msg = f"Rebuilding daily summaries in database '{database_name}' ..." + log.info(msg) + print(msg) + + # Open the database using the Manager object, which does not use daily summaries. This allows + # us to retrieve the SQL keys without triggering an exception because of the missing daily + # summaries. + with weewx.manager.Manager.open(manager_dict['database_dict'], + manager_dict['table_name']) as db: + sqlkeys = db.sqlkeys + # From the keys, build a schema for the daily summaries that reflects what is in the database + day_summaries_schemas = [(e, 'scalar') for e in sqlkeys + if e not in ('dateTime', 'usUnits', 'interval')] + if 'windSpeed' in sqlkeys: + # For backwards compatibility, include 'wind' + day_summaries_schemas += [('wind', 'vector')] + # Replace the static schema with the one we just built: + manager_dict['schema'] = {'day_summaries': day_summaries_schemas} + + # Open up the database. Use initialize=True, so the daily summary tables will be created: + with weewx.manager.open_manager(manager_dict, initialize=True) as dbm: + if dry_run: + nrecs = ndays = 0 + else: + # Do the actual rebuild + nrecs, ndays = dbm.backfill_day_summary(start_d=from_d, + stop_d=to_d, + trans_days=20) + tdiff = time.time() - t1 + # advise the user/log what we did + log.info(f"Rebuild of daily summaries in database '{database_name}' complete.") + if nrecs: + sys.stdout.flush() + # fix a bit of formatting inconsistency if less than 1000 records + # processed + if nrecs >= 1000: + print() + if ndays == 1: + msg = f"Processed {nrecs} records to rebuild 1 daily summary in {tdiff:.2f} seconds." + else: + msg = f"Processed {nrecs} records to rebuild {ndays} daily summaries in " \ + f"{tdiff:.2f} seconds." + print(msg) + print(f"Rebuild of daily summaries in database '{database_name}' complete.") + elif dry_run: + print("Dry run: no records processed.") + else: + print(f"Daily summaries up to date in '{database_name}'.") + + +def add_column(config_dict, + column_name=None, + column_type=None, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Add a single column to the database. + column_name: The name of the new column. + column_type: The type ("REAL"|"INTEGER") of the new column. + """ + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + database_name = manager_dict['database_dict']['database_name'] + + column_type = column_type or 'REAL' + ans = y_or_n( + f"Add new column '{column_name}' of type '{column_type}' " + f"to database '{database_name}' (y/n)? ", noprompt=no_confirm) + if ans == 'y': + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbm: + if not dry_run: + dbm.add_column(column_name, column_type) + print(f'New column {column_name} of type {column_type} added to database.') + else: + print("Nothing done.") + + +def rename_column(config_dict, + from_name=None, + to_name=None, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + database_name = manager_dict['database_dict']['database_name'] + + ans = y_or_n(f"Rename column '{from_name}' to '{to_name}' " + f"in database {database_name} (y/n)? ", noprompt=no_confirm) + if ans == 'y': + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbm: + if not dry_run: + dbm.rename_column(from_name, to_name) + print(f"Column '{from_name}' renamed to '{to_name}'.") + else: + print("Nothing done.") + + +def drop_columns(config_dict, + column_names=None, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Drop a set of columns from the database""" + ans = y_or_n(f"Drop column(s) '{', '.join(column_names)}' from the database (y/n)? ", + noprompt=no_confirm) + if ans == 'y': + drop_set = set(column_names) + # Now drop the columns. If one is missing, a NoColumnError will be raised. Be prepared + # to catch it. + print("This may take a while...") + if not dry_run: + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbm: + try: + dbm.drop_columns(drop_set) + except weedb.NoColumnError as e: + print(e, file=sys.stderr) + print("Nothing done.") + else: + print(f"Column(s) '{', '.join(column_names)}' dropped from the database.") + else: + print("Nothing done.") + + +def reconfigure_database(config_dict, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Create a new database, then populate it with the contents of an old database, but use + the current configuration options. The reconfigure action will create a new database with the + same name as the old, except with the suffix _new attached to the end.""" + + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + # Make a copy for the new database (we will be modifying it) + new_database_dict = dict(manager_dict['database_dict']) + + # Now modify the database name + new_database_dict['database_name'] = manager_dict['database_dict']['database_name'] + '_new' + + # First check and see if the new database already exists. If it does, check + # with the user whether it's ok to delete it. + try: + if not dry_run: + weedb.create(new_database_dict) + except weedb.DatabaseExists: + ans = y_or_n("New database '%s' already exists. " + "Delete it first (y/n)? " % new_database_dict['database_name'], + noprompt=no_confirm) + if ans == 'y': + weedb.drop(new_database_dict) + else: + print("Nothing done.") + return + + # Get the unit system of the old archive: + with weewx.manager.Manager.open(manager_dict['database_dict']) as old_dbmanager: + old_unit_system = old_dbmanager.std_unit_system + + if old_unit_system is None: + print("Old database has not been initialized. Nothing to be done.") + return + + # Get the unit system of the new archive: + try: + target_unit_nickname = config_dict['StdConvert']['target_unit'] + except KeyError: + target_unit_system = None + else: + target_unit_system = weewx.units.unit_constants[target_unit_nickname.upper()] + + print("Copying database '%s' to '%s'" % (manager_dict['database_dict']['database_name'], + new_database_dict['database_name'])) + if target_unit_system is None or old_unit_system == target_unit_system: + print("The new database will use the same unit system as the old ('%s')." % + weewx.units.unit_nicknames[old_unit_system]) + else: + print("Units will be converted from the '%s' system to the '%s' system." % + (weewx.units.unit_nicknames[old_unit_system], + weewx.units.unit_nicknames[target_unit_system])) + + ans = y_or_n("Are you sure you wish to proceed (y/n)? ", noprompt=no_confirm) + if ans == 'y': + t1 = time.time() + weewx.manager.reconfig(manager_dict['database_dict'], + new_database_dict, + new_unit_system=target_unit_system, + new_schema=manager_dict['schema'], + dry_run=dry_run) + tdiff = time.time() - t1 + print("Database '%s' copied to '%s' in %.2f seconds." + % (manager_dict['database_dict']['database_name'], + new_database_dict['database_name'], + tdiff)) + else: + print("Nothing done.") + + +def transfer_database(config_dict, + dest_binding=None, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Transfer 'archive' data from one database to another""" + + # do we have enough to go on, must have a dest binding + if not dest_binding: + print("Destination binding not specified. Nothing Done. Aborting.", file=sys.stderr) + return + + # get manager dict for our source binding + src_manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + # get manager dict for our dest binding + try: + dest_manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, dest_binding) + except weewx.UnknownBinding: + # if we can't find the binding display a message then return + print(f"Unknown destination binding '{dest_binding}'. " + f"Please confirm the destination binding.") + print("Nothing Done. Aborting.", file=sys.stderr) + return + except weewx.UnknownDatabase as e: + # if we can't find the database display a message then return + print(f"Error accessing destination database: {e}", file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + except (ValueError, AttributeError): + # maybe a schema issue + print("Error accessing destination database.", file=sys.stderr) + print("Maybe the destination schema is incorrectly specified " + "in binding '%s' in weewx.conf?" % dest_binding, file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + except weewx.UnknownDatabaseType: + # maybe a [Databases] issue + print("Error accessing destination database.", file=sys.stderr) + print("Maybe the destination database is incorrectly defined in weewx.conf?", + file=sys.stderr) + print("Nothing Done. Aborting.", file=sys.stderr) + return + + # All looks good. Get a manager for our source + with weewx.manager.Manager.open(src_manager_dict['database_dict']) as src_manager: + # How many source records? + num_recs = src_manager.getSql("SELECT COUNT(dateTime) from %s;" + % src_manager.table_name)[0] + if not num_recs: + # we have no source records to transfer so abort with a message + print(f"No records found in source database '{src_manager.database_name}'.") + print("Nothing done. Aborting.") + return + + # not a dry run, actually do the transfer + ans = y_or_n("Transfer %s records from source database '%s' " + "to destination database '%s' (y/n)? " + % (num_recs, src_manager.database_name, + dest_manager_dict['database_dict']['database_name']), + noprompt=no_confirm) + if ans == 'n': + print("Nothing done.") + return + + t1 = time.time() + nrecs = 0 + # wrap in a try..except in case we have an error + try: + with weewx.manager.Manager.open_with_create( + dest_manager_dict['database_dict'], + table_name=dest_manager_dict['table_name'], + schema=dest_manager_dict['schema']) as dest_manager: + print("Transferring, this may take a while.... ") + sys.stdout.flush() + + if not dry_run: + # This could generate a *lot* of log entries. Temporarily disable logging + # for events at or below INFO + logging.disable(logging.INFO) + + # do the transfer, should be quick as it's done as a + # single transaction + nrecs = dest_manager.addRecord(src_manager.genBatchRecords(), + progress_fn=weewx.manager.show_progress) + + # Remove the temporary restriction + logging.disable(logging.NOTSET) + + tdiff = time.time() - t1 + print("\nCompleted.") + print("%s records transferred from source database '%s' to " + "destination database '%s' in %.2f seconds." + % (nrecs, src_manager.database_name, + dest_manager.database_name, tdiff)) + except ImportError as e: + # Probably when trying to load db driver + print("Error accessing destination database '%s'." + % (dest_manager_dict['database_dict']['database_name'],), + file=sys.stderr) + print("Nothing done. Aborting.", file=sys.stderr) + raise + except (OSError, weedb.OperationalError): + # probably a weewx.conf typo or MySQL db not created + print("Error accessing destination database '%s'." + % dest_manager_dict['database_dict']['database_name'], file=sys.stderr) + print("Maybe it does not exist (MySQL) or is incorrectly " + "defined in weewx.conf?", file=sys.stderr) + print("Nothing done. Aborting.", file=sys.stderr) + raise + + +def calc_missing(config_dict, + date=None, + from_date=None, + to_date=None, + db_binding='wx_binding', + tranche=10, + dry_run=False, + no_confirm=False): + """Calculate any missing derived observations and save to database.""" + import weecfg.database + + log.info("Preparing to calculate missing derived observations...") + + # get a db manager dict given the config dict and binding + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, + db_binding) + # Get the table_name used by the binding, it could be different to the + # default 'archive'. If for some reason it is not specified then fail hard. + table_name = manager_dict['table_name'] + # get the first and last good timestamps from the archive, these represent + # our overall bounds for calculating missing derived obs + with weewx.manager.Manager.open(manager_dict['database_dict'], + table_name=table_name) as dbmanager: + first_ts = dbmanager.firstGoodStamp() + last_ts = dbmanager.lastGoodStamp() + # process any command line options that may limit the period over which + # missing derived obs are calculated + start_dt, stop_dt = weectllib.parse_dates(date, + from_date=from_date, to_date=to_date, + as_datetime=True) + # we now have a start and stop date for processing, we need to obtain those + # as epoch timestamps, if we have no start and/or stop date then use the + # first or last good timestamp instead + start_ts = time.mktime(start_dt.timetuple()) if start_dt is not None else first_ts - 1 + stop_ts = time.mktime(stop_dt.timetuple()) if stop_dt is not None else last_ts + + _head = "Missing derived observations will be calculated " + # advise the user/log what we will do + if start_dt is None and stop_dt is None: + _tail = "for all records." + elif start_dt and not stop_dt: + _tail = "from %s through to the end (%s)." % (timestamp_to_string(start_ts), + timestamp_to_string(stop_ts)) + elif not start_dt and stop_dt: + _tail = "from the beginning (%s) through to %s." % (timestamp_to_string(start_ts), + timestamp_to_string(stop_ts)) + else: + _tail = "from %s through to %s inclusive." % (timestamp_to_string(start_ts), + timestamp_to_string(stop_ts)) + msg = "%s%s" % (_head, _tail) + log.info(msg) + print(msg) + ans = y_or_n("Proceed (y/n)? ", noprompt=no_confirm) + if ans == 'n': + msg = "Nothing done." + log.info(msg) + print(msg) + return + + t1 = time.time() + + # construct a CalcMissing config dict + calc_missing_config_dict = {'name': 'Calculate Missing Derived Observations', + 'binding': db_binding, + 'start_ts': start_ts, + 'stop_ts': stop_ts, + 'trans_days': tranche, + 'dry_run': dry_run} + + # obtain a CalcMissing object + calc_missing_obj = weecfg.database.CalcMissing(config_dict, + calc_missing_config_dict) + log.info("Calculating missing derived observations...") + print("Calculating missing derived observations...") + # Calculate and store any missing observations. Be prepared to + # catch any exceptions from CalcMissing. + try: + calc_missing_obj.run() + except weewx.UnknownBinding as e: + # We have an unknown binding, this could occur if we are using a + # non-default binding and StdWXCalculate has not been told (via + # weewx.conf) to use the same binding. Log it and notify the user then + # exit. + msg = "Error: '%s'" % e + print(msg) + log.error(msg) + print("Perhaps StdWXCalculate is using a different binding. Check " + "configuration file [StdWXCalculate] stanza") + sys.exit("Nothing done. Aborting.") + else: + msg = "Missing derived observations calculated in %0.2f seconds" % (time.time() - t1) + log.info(msg) + print(msg) + + +def check(config_dict, db_binding='wx_binding'): + """Check the database for any issues.""" + + print("Checking daily summary tables version...") + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbm: + daily_summary_version = dbm._read_metadata('Version') + + msg = f"Daily summary tables are at version {daily_summary_version}." + log.info(msg) + print(msg) + + if daily_summary_version is not None and daily_summary_version >= '2.0': + # interval weighting fix has been applied + msg = "Interval Weighting Fix is not required." + log.info(msg) + print(msg) + else: + print("Recommend running --update to recalculate interval weightings.") + + +def update_database(config_dict, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Apply any required database fixes. + + Applies the following fixes: + - checks if database version is 3.0, if not interval weighting fix is + applied + - recalculates windSpeed daily summary max and maxtime fields from + archive + """ + + ans = y_or_n("The update process does not affect archive data, " + "but does alter the database.\nContinue (y/n)? ", noprompt=no_confirm) + if ans == 'n': + log.info("Update cancelled.") + print("Update cancelled.") + return + + log.info("Preparing interval weighting fix...") + print("Preparing interval weighting fix...") + + # Get a database manager object + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbm: + # check the daily summary version + msg = f"Daily summary tables are at version {dbm.version}." + log.info(msg) + print(msg) + + if dbm.version is not None and dbm.version >= '4.0': + # interval weighting fix has been applied + log.info("Interval weighting fix is not required.") + print("Interval weighting fix is not required.") + else: + # apply the interval weighting + log.info("Calculating interval weights...") + print("Calculating interval weights. This could take awhile.") + t1 = time.time() + if not dry_run: + dbm.update() + msg = "Interval Weighting Fix completed in %0.2f seconds." % (time.time() - t1) + print() + print(msg) + sys.stdout.flush() + log.info(msg) + + # recalc the max/maxtime windSpeed values + _fix_wind(config_dict, db_binding, dry_run) + + +def _fix_wind(config_dict, db_binding, dry_run): + """Recalculate the windSpeed daily summary max and maxtime fields. + + Create a WindSpeedRecalculation object and call its run() method to + recalculate the max and maxtime fields from archive data. This process is + idempotent, so it can be called repeatedly with no ill effect. + """ + import weecfg.database + + msg = "Preparing maximum windSpeed fix..." + log.info(msg) + print(msg) + + # notify if this is a dry run + if dry_run: + print("This is a dry run: maximum windSpeed will be calculated but not saved.") + + # construct a windSpeed recalculation config dict + wind_config_dict = {'name': 'Maximum windSpeed fix', + 'binding': db_binding, + 'trans_days': 100, + 'dry_run': dry_run} + + # create a windSpeedRecalculation object + wind_obj = weecfg.database.WindSpeedRecalculation(config_dict, + wind_config_dict) + # perform the recalculation, wrap in a try..except to catch any db errors + t1 = time.time() + + try: + wind_obj.run() + except weedb.NoTableError: + msg = "Maximum windSpeed fix applied: no windSpeed found" + log.info(msg) + print(msg) + else: + msg = "Maximum windSpeed fix completed in %0.2f seconds" % (time.time() - t1) + log.info(msg) + print(msg) + + +def reweight_daily(config_dict, + date=None, + from_date=None, + to_date=None, + db_binding='wx_binding', + dry_run=False, + no_confirm=False): + """Recalculate the weighted sums in the daily summaries.""" + + manager_dict = weewx.manager.get_manager_dict_from_config(config_dict, db_binding) + database_name = manager_dict['database_dict']['database_name'] + + # Determine the period over which we are rebuilding from any command line date parameters + from_d, to_d = weectllib.parse_dates(date, + from_date=from_date, to_date=to_date) + + # advise the user/log what we will do + if from_d is None and to_d is None: + msg = "The weighted sums in all the daily summaries will be recalculated." + elif from_d and not to_d: + msg = "The weighted sums in the daily summaries from %s through the end " \ + "will be recalculated." % from_d + elif not from_d and to_d: + msg = "The weighted sums in the daily summaries from the beginning through %s" \ + "will be recalculated." % to_d + elif from_d == to_d: + msg = "The weighted sums in the daily summary for %s will be recalculated." % from_d + else: + msg = "The weighted sums in the daily summaries from %s through %s, " \ + "inclusive, will be recalculated." % (from_d, to_d) + + log.info(msg) + print(msg) + ans = y_or_n("Proceed (y/n)? ", noprompt=no_confirm) + if ans == 'n': + log.info("Nothing done.") + print("Nothing done.") + return + + t1 = time.time() + + # Open up the database. + with weewx.manager.open_manager_with_config(config_dict, db_binding) as dbmanager: + msg = f"Recalculating the weighted summaries in database '{database_name}' ..." + log.info(msg) + print(msg) + if not dry_run: + # Do the actual recalculations + dbmanager.recalculate_weights(start_d=from_d, stop_d=to_d) + + msg = "Finished reweighting in %.1f seconds." % (time.time() - t1) + log.info(msg) + print() + print(msg) diff --git a/dist/weewx-5.0.2/src/weectllib/database_cmd.py b/dist/weewx-5.0.2/src/weectllib/database_cmd.py new file mode 100644 index 0000000..149b993 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/database_cmd.py @@ -0,0 +1,435 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Manage a WeeWX database.""" +import argparse + +import weecfg +import weecfg.database +import weectllib +import weectllib.database_actions +from weeutil.weeutil import bcolors + +create_usage = f"""{bcolors.BOLD}weectl database create + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +drop_daily_usage = f"""{bcolors.BOLD}weectl database drop-daily + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +rebuild_usage = f"""{bcolors.BOLD}weectl database rebuild-daily + [[--date=YYYY-mm-dd] | [--from=YYYY-mm-dd] [--to=YYYY-mm-dd]] + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +add_column_usage = f"""{bcolors.BOLD}weectl database add-column NAME + [--type=COLUMN-DEF] + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +rename_column_usage = f"""{bcolors.BOLD}weectl database rename-column FROM-NAME TO-NAME + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +drop_columns_usage = f"""{bcolors.BOLD}weectl database drop-columns NAME... + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +reconfigure_usage = f"""{bcolors.BOLD}weectl database reconfigure + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +transfer_usage = f"""{bcolors.BOLD}weectl database transfer --dest-binding=BINDING-NAME + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +calc_missing_usage = f"""{bcolors.BOLD}weectl database calc-missing + [--date=YYYY-mm-dd | [--from=YYYY-mm-dd[THH:MM]] [--to=YYYY-mm-dd[THH:MM]]] + [--config=FILENAME] [--binding=BINDING-NAME] [--tranche=INT] + [--dry-run] [-y]{bcolors.ENDC}""" +check_usage = f"""{bcolors.BOLD}weectl database check + [--config=FILENAME] [--binding=BINDING-NAME]{bcolors.ENDC}""" +update_usage = f"""{bcolors.BOLD}weectl database update + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" +reweight_usage = f"""{bcolors.BOLD}weectl database reweight + [[--date=YYYY-mm-dd] | [--from=YYYY-mm-dd] [--to=YYYY-mm-dd]] + [--config=FILENAME] [--binding=BINDING-NAME] + [--dry-run] [-y]{bcolors.ENDC}""" + +database_usage = '\n '.join((create_usage, + drop_daily_usage, + rebuild_usage, + add_column_usage, + rename_column_usage, + drop_columns_usage, + reconfigure_usage, + transfer_usage, + calc_missing_usage, + check_usage, + update_usage, + reweight_usage + )) + +drop_columns_description = """Drop (remove) one or more columns from a WeeWX database. +This command allows you to drop more than one column at once. +For example: + weectl database drop-columns soilTemp1 batteryStatus5 leafWet1 +""" + +reconfigure_description = """Create a new database using the current configuration information +found in the configuration file. This can be used to change the unit system of a database. The new + database will have the same name as the old database, except with a '_new' on the end.""" + +transfer_description = """Copy a database to a new database. +The option "--dest-binding" should hold a database binding +to the target database.""" + +update_description = """Update the database to the current version. This is only necessary for +databases created before v3.7 and never updated. Before updating, this utility will check +whether it is necessary.""" + +epilog = "Before taking a mutating action, make a backup!" + + +def add_subparser(subparsers): + database_parser = subparsers.add_parser('database', + usage=database_usage, + description='Manages WeeWX databases', + help="Manage WeeWX databases.", + epilog=epilog) + # In the following, the 'prog' argument is necessary to get a proper error message. + # See Python issue https://bugs.python.org/issue42297 + action_parser = database_parser.add_subparsers(dest='action', + prog='weectl database', + title="Which action to take") + + # ---------- Action 'create' ---------- + create_parser = action_parser.add_parser('create', + description="Create a new WeeWX database", + usage=create_usage, + help='Create a new WeeWX database.', + epilog=epilog) + _add_common_args(create_parser) + create_parser.set_defaults(func=weectllib.dispatch) + create_parser.set_defaults(action_func=create_database) + + # ---------- Action 'drop-daily' ---------- + drop_daily_parser = action_parser.add_parser('drop-daily', + description="Drop the daily summary from a " + "WeeWX database", + usage=drop_daily_usage, + help="Drop the daily summary from a " + "WeeWX database.", + epilog=epilog) + _add_common_args(drop_daily_parser) + drop_daily_parser.set_defaults(func=weectllib.dispatch) + drop_daily_parser.set_defaults(action_func=drop_daily) + + # ---------- Action 'rebuild-daily' ---------- + rebuild_parser = action_parser.add_parser('rebuild-daily', + description="Rebuild the daily summary in " + "a WeeWX database", + usage=rebuild_usage, + help="Rebuild the daily summary in " + "a WeeWX database.", + epilog=epilog) + rebuild_parser.add_argument("--date", + metavar="YYYY-mm-dd", + help="Rebuild for this date only.") + rebuild_parser.add_argument("--from", + metavar="YYYY-mm-dd", + dest='from_date', + help="Rebuild starting with this date.") + rebuild_parser.add_argument("--to", + metavar="YYYY-mm-dd", + dest='to_date', + help="Rebuild ending with this date.") + _add_common_args(rebuild_parser) + rebuild_parser.set_defaults(func=weectllib.dispatch) + rebuild_parser.set_defaults(action_func=rebuild_daily) + + # ---------- Action 'add-column' ---------- + add_column_parser = action_parser.add_parser('add-column', + description="Add a column to an " + "existing WeeWX database.", + usage=add_column_usage, + help="Add a column to an " + "existing WeeWX database.", + epilog=epilog) + add_column_parser.add_argument('column_name', + metavar='NAME', + help="Add new column NAME to database.") + add_column_parser.add_argument('--type', + choices=['REAL', 'INTEGER', 'real', 'integer', 'int'], + default='REAL', + metavar='COLUMN-DEF', + dest='column_type', + help="Any valid SQL column definition. Default is 'REAL'.") + _add_common_args(add_column_parser) + add_column_parser.set_defaults(func=weectllib.dispatch) + add_column_parser.set_defaults(action_func=add_column) + + # ---------- Action 'rename-column' ---------- + rename_column_parser = action_parser.add_parser('rename-column', + description="Rename a column in an " + "existing WeeWX database.", + usage=rename_column_usage, + help="Rename a column in an " + "existing WeeWX database.", + epilog=epilog) + rename_column_parser.add_argument('from_name', + metavar='FROM-NAME', + help="Column to be renamed.") + rename_column_parser.add_argument('to_name', + metavar='TO-NAME', + help="New name of the column.") + _add_common_args(rename_column_parser) + rename_column_parser.set_defaults(func=weectllib.dispatch) + rename_column_parser.set_defaults(action_func=rename_column) + + # ---------- Action 'drop-columns' ---------- + drop_columns_parser = action_parser.add_parser('drop-columns', + description=drop_columns_description, + usage=drop_columns_usage, + help="Drop (remove) one or more columns " + "from a WeeWX database.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=epilog) + drop_columns_parser.add_argument('column_names', + nargs="+", + metavar='NAME', + help="Column(s) to be dropped. " + "More than one NAME can be specified.") + _add_common_args(drop_columns_parser) + drop_columns_parser.set_defaults(func=weectllib.dispatch) + drop_columns_parser.set_defaults(action_func=drop_columns) + + # ---------- Action 'reconfigure' ---------- + reconfigure_parser = action_parser.add_parser('reconfigure', + description=reconfigure_description, + usage=reconfigure_usage, + help="Reconfigure a database, using the current " + "configuration information in the config " + "file.", + epilog=epilog) + _add_common_args(reconfigure_parser) + reconfigure_parser.set_defaults(func=weectllib.dispatch) + reconfigure_parser.set_defaults(action_func=reconfigure_database) + + # ---------- Action 'transfer' ---------- + transfer_parser = action_parser.add_parser('transfer', + description=transfer_description, + usage=transfer_usage, + help="Copy a database to a new database.", + epilog=epilog) + transfer_parser.add_argument('--dest-binding', + metavar='BINDING-NAME', + required=True, + help="A database binding pointing to the destination " + "database. Required.") + _add_common_args(transfer_parser) + transfer_parser.set_defaults(func=weectllib.dispatch) + transfer_parser.set_defaults(action_func=transfer_database) + + # ---------- Action 'calc-missing' ---------- + calc_missing_parser = action_parser.add_parser('calc-missing', + description="Calculate and store any missing " + "derived observations.", + usage=calc_missing_usage, + help="Calculate and store any missing " + "derived observations.", + epilog=epilog) + calc_missing_parser.add_argument("--date", + metavar="YYYY-mm-dd", + help="Calculate for this date only.") + calc_missing_parser.add_argument("--from", + metavar="YYYY-mm-ddTHH:MM:SS", + dest='from_date', + help="Calculate starting with this datetime.") + calc_missing_parser.add_argument("--to", + metavar="YYYY-mm-ddTHH:MM:SS", + dest='to_date', + help="Calculate ending with this datetime.") + calc_missing_parser.add_argument("--tranche", + metavar="INT", + type=int, + default=10, + help="Perform database transactions on INT days " + "of records at a time. Default is 10.") + _add_common_args(calc_missing_parser) + calc_missing_parser.set_defaults(func=weectllib.dispatch) + calc_missing_parser.set_defaults(action_func=calc_missing) + + # ---------- Action 'check' ---------- + check_parser = action_parser.add_parser('check', + description="Check the database for any issues.", + usage=check_usage, + help="Check the database for any issues.") + check_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + check_parser.add_argument("--binding", metavar="BINDING-NAME", + default='wx_binding', + help="The data binding to use. Default is 'wx_binding'.") + check_parser.set_defaults(func=weectllib.dispatch) + check_parser.set_defaults(action_func=check) + + # ---------- Action 'update' ---------- + update_parser = action_parser.add_parser('update', + description=update_description, + usage=update_usage, + help="Update the database to the current version.", + epilog=epilog) + + _add_common_args(update_parser) + update_parser.set_defaults(func=weectllib.dispatch) + update_parser.set_defaults(action_func=update_database) + + # ---------- Action 'reweight' ---------- + reweight_parser = action_parser.add_parser('reweight', + description="Recalculate the weighted sums in " + "the daily summaries.", + usage=reweight_usage, + help="Recalculate the weighted sums in " + "the daily summaries.", + epilog=epilog) + reweight_parser.add_argument("--date", + metavar="YYYY-mm-dd", + help="Reweight for this date only.") + reweight_parser.add_argument("--from", + metavar="YYYY-mm-dd", + dest='from_date', + help="Reweight starting with this date.") + reweight_parser.add_argument("--to", + metavar="YYYY-mm-dd", + dest='to_date', + help="Reweight ending with this date.") + _add_common_args(reweight_parser) + reweight_parser.set_defaults(func=weectllib.dispatch) + reweight_parser.set_defaults(action_func=reweight_daily) + + +# ------------------ Shims for calling database action functions ---------------- # +def create_database(config_dict, namespace): + """Create the WeeWX database""" + + weectllib.database_actions.create_database(config_dict, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def drop_daily(config_dict, namespace): + """Drop the daily summary from a WeeWX database""" + weectllib.database_actions.drop_daily(config_dict, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def rebuild_daily(config_dict, namespace): + """Rebuild the daily summary in a WeeWX database""" + weectllib.database_actions.rebuild_daily(config_dict, + date=namespace.date, + from_date=namespace.from_date, + to_date=namespace.to_date, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def add_column(config_dict, namespace): + """Add a column to a WeeWX database""" + column_type = namespace.column_type.upper() + if column_type == 'INT': + column_type = "INTEGER" + weectllib.database_actions.add_column(config_dict, + column_name=namespace.column_name, + column_type=column_type, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def rename_column(config_dict, namespace): + """Rename a column in a WeeWX database.""" + weectllib.database_actions.rename_column(config_dict, + from_name=namespace.from_name, + to_name=namespace.to_name, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def drop_columns(config_dict, namespace): + """Drop (remove) one or more columns in a WeeWX database.""" + weectllib.database_actions.drop_columns(config_dict, + column_names=namespace.column_names, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def reconfigure_database(config_dict, namespace): + """Replicate a database, using current configuration settings.""" + weectllib.database_actions.reconfigure_database(config_dict, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def transfer_database(config_dict, namespace): + """Copy a database to a new database.""" + weectllib.database_actions.transfer_database(config_dict, + dest_binding=namespace.dest_binding, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def calc_missing(config_dict, namespace): + """Calculate derived variables in a database.""" + weectllib.database_actions.calc_missing(config_dict, + date=namespace.date, + from_date=namespace.from_date, + to_date=namespace.to_date, + db_binding=namespace.binding, + tranche=namespace.tranche, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def check(config_dict, namespace): + """Check the integrity of a WeeWX database.""" + weectllib.database_actions.check(config_dict, + namespace.binding) + + +def update_database(config_dict, namespace): + weectllib.database_actions.update_database(config_dict, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def reweight_daily(config_dict, namespace): + """Recalculate the weights in a WeeWX database.""" + weectllib.database_actions.reweight_daily(config_dict, + date=namespace.date, + from_date=namespace.from_date, + to_date=namespace.to_date, + db_binding=namespace.binding, + dry_run=namespace.dry_run, + no_confirm=namespace.yes) + + +def _add_common_args(subparser): + """Add options used by most of the subparsers""" + subparser.add_argument('--config', + metavar='FILENAME', + help='Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + subparser.add_argument("--binding", metavar="BINDING-NAME", default='wx_binding', + help="The data binding to use. Default is 'wx_binding'.") + subparser.add_argument('--dry-run', + action='store_true', + help='Print what would happen, but do not actually do it.') + subparser.add_argument('-y', '--yes', action='store_true', + help="Don't ask for confirmation. Just do it.") diff --git a/dist/weewx-5.0.2/src/weectllib/debug_actions.py b/dist/weewx-5.0.2/src/weectllib/debug_actions.py new file mode 100644 index 0000000..ff3645d --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/debug_actions.py @@ -0,0 +1,299 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Debug command actions""" +import contextlib +import os +import platform +import sys +from io import BytesIO + +import weecfg +import weecfg.extension +import weedb +import weeutil.config +import weeutil.printer +import weewx +import weewx.manager +import weewx.units +import weewx.xtypes +from weeutil.weeutil import timestamp_to_string, TimeSpan, bcolors + +# keys/setting names to obfuscate in weewx.conf, key value will be obfuscated +# if the key starts any element in the list. Can add additional string elements +# to list if required +OBFUSCATE_MAP = { + "obfuscate": [ + "apiKey", "api_key", "app_key", "archive_security_key", "id", "key", + "oauth_token", "password", "raw_security_key", "token", "user", + "server_url", "station", "passcode", "server"], + "do_not_obfuscate": [ + "station_type"] +} + + +def debug(config_dict, output=None): + """Generate information about the user's WeeWX environment + + Args: + config_dict (dict): Configuration dictionary. + output (str|None): Path to where the output will be put. Default is stdout. + """ + + if output: + # If a file path has been specified, then open it up and use the resultant "file-like + # object" as the file descriptor + sink = open(output, 'wt') + else: + # Otherwise, use stdout. It's already open, so use a null context, so that + # we don't open it again + sink = contextlib.nullcontext(sys.stdout) + + with sink as fd: + # system/OS info + generate_sys_info(fd) + # WeeWX info + generate_weewx_info(fd) + # info about extensions + generate_extension_info(config_dict['config_path'], config_dict, fd) + # info about the archive database + generate_archive_info(config_dict, fd) + # generate our obfuscated weewx.conf + generate_debug_conf(config_dict['config_path'], config_dict, fd) + + +def generate_sys_info(fd): + """Generate general information about the system + + Args: + fd (typing.TextIO): An open file-like object. + """ + + print("\nSystem info", file=fd) + + print(f" Platform: {platform.platform()}", file=fd) + print(f" Python Version: {platform.python_version()}", file=fd) + + # load info + try: + loadavg = '%.2f %.2f %.2f' % os.getloadavg() + (load1, load5, load15) = loadavg.split(" ") + + print("\nLoad Information", file=fd) + print(f" 1 minute load average: {load1:8}", file=fd) + print(f" 5 minute load average: {load5:8}", file=fd) + print(f" 15 minute load average: {load15:8}", file=fd) + except OSError: + print(" The load average is not available on this platform.", file=fd) + + +def generate_weewx_info(fd): + """Generate information about WeeWX, such as the version. + + Args: + fd (typing.TextIO): An open file-like object. + """ + # weewx version info + print("\nGeneral Weewx info", file=fd) + print(f" Weewx version {weewx.__version__} detected.", file=fd) + + +def generate_extension_info(config_path, config_dict, fd): + """Generate information about any installed extensions + + Args: + config_path(str): The path toe the configuration dictionary + config_dict(dict): The configuration dictionary. + fd (typing.TextIO): An open file-like object. + """ + # installed extensions info + print("\nCurrently installed extensions", file=fd) + ext = weecfg.extension.ExtensionEngine(config_path=config_path, + config_dict=config_dict, + printer=weeutil.printer.Printer(fd=fd)) + ext.enumerate_extensions() + + +def generate_archive_info(config_dict, fd): + """Information about the archive database + + Args: + config_dict(dict): The configuration dictionary. + fd (typing.TextIO): An open file-like object. + """ + # weewx archive info + print("\nArchive info", file=fd) + + try: + manager_info_dict = get_manager_info(config_dict) + except weedb.CannotConnect as e: + print(" Unable to connect to database:", e, file=fd) + except weedb.OperationalError as e: + print(" Error hitting database. It may not be properly initialized:", file=fd) + print(f" {e}", file=fd) + else: + units_nickname = weewx.units.unit_nicknames.get(manager_info_dict['units'], + "Unknown unit constant") + print(f" Database name: {manager_info_dict['db_name']}", file=fd) + print(f" Table name: {manager_info_dict['table_name']}", file=fd) + print(f" Version {manager_info_dict['version']}", file=fd) + print(f" Unit system: {manager_info_dict['units']} ({units_nickname})", file=fd) + print(f" First good timestamp: {timestamp_to_string(manager_info_dict['first_ts'])}", + file=fd) + print(f" Last good timestamp: {timestamp_to_string(manager_info_dict['last_ts'])}", + file=fd) + if manager_info_dict['ts_count']: + print(f" Number of records: {manager_info_dict['ts_count']}", file=fd) + else: + print(" (no archive records found)", file=fd) + # if we have a database and a table but no start or stop ts and no records + # inform the user that the database/table exists but appears empty + if (manager_info_dict['db_name'] and manager_info_dict['table_name']) \ + and not (manager_info_dict['ts_count'] + or manager_info_dict['units'] + or manager_info_dict['first_ts'] + or manager_info_dict['last_ts']): + print(f" It is likely that the database " + f"({manager_info_dict['db_name']}) " + f"archive table ({manager_info_dict['table_name']})", + file=fd) + print(" exists but contains no data.", file=fd) + print(f" weewx (weewx.conf) is set to use an archive interval of " + f"{config_dict['StdArchive']['archive_interval']} seconds.", file=fd) + print(" The station hardware was not interrogated to determine the archive interval.", + file=fd) + + # sqlkeys/obskeys info + print("\nSupported SQL keys", file=fd) + format_list_cols(manager_info_dict['sqlkeys'], 3, fd) + + + # weewx database info + print("\nDatabases configured in weewx.conf:", file=fd) + for db_keys in config_dict['Databases']: + database_dict = weewx.manager.get_database_dict_from_config(config_dict, + db_keys) + print(f" {db_keys}:", file=fd) + for k in database_dict: + print(f"{k:>18s} {database_dict[k]:<20s}", file=fd) + +def generate_debug_conf(config_path, config_dict, fd): + """Generate a parsed and obfuscated weewx.conf and write to the open file descriptor 'fd'. + + Args: + config_path(str): The path toe the configuration dictionary + config_dict(dict): The configuration dictionary. + fd (typing.TextIO): An open file-like object. + """ + + # Make a deep copy first, as we may be altering values. + config_dict_copy = weeutil.config.deep_copy(config_dict) + + # Turn off interpolation on the copy, so it doesn't interfere with faithful representation of + # the values + config_dict_copy.interpolation = False + + # Now obfuscate any sensitive keys + obfuscate_dict(config_dict_copy, + OBFUSCATE_MAP['obfuscate'], + OBFUSCATE_MAP['do_not_obfuscate']) + + print(f"\n--- Start configuration file {config_path} ---", file=fd) + + # put obfuscated config_dict into weewx.conf form. + with BytesIO() as buf: + # First write it to a bytes buffer... + config_dict_copy.write(buf) + # ... rewind the buffer ... + buf.seek(0) + # ... then print it out line, by line, converting to strings as we go. Each line does + # not need a '\n' because that was already done by the write above. + for l in buf: + print(l.decode('utf-8'), file=fd, end='') + + +def obfuscate_dict(src_dict, obfuscate_list, retain_list): + """Obfuscate any dictionary items whose key is contained in passed list. + + Args: + src_dict (dict): The configuration dictionary to be obfuscated. + obfuscate_list (list): A list of keys that should be obfuscated. + retain_list(list): A list of keys that should not be obfuscated. + """ + + # We need a function to be passed on to the 'walk()' function. + def obfuscate_value(section, key): + # Check to see if the key is in the obfuscation list. If so, then obfuscate it. + if any(key.startswith(k) for k in obfuscate_list) and key not in retain_list: + section[key] = "XXXXXX" + + # Now walk the configuration dictionary, using the function + src_dict.walk(obfuscate_value) + + +def get_manager_info(config_dict): + """Get info from the manager of a weewx archive for inclusion in debug report. + + Args: + config_dict(dict): The configuration dictionary + """ + + db_binding_wx = get_binding(config_dict) + with weewx.manager.open_manager_with_config(config_dict, db_binding_wx) as dbmanager_wx: + info = { + 'db_name': dbmanager_wx.database_name, + 'table_name': dbmanager_wx.table_name, + 'version': getattr(dbmanager_wx, 'version', 'unknown'), + 'units': dbmanager_wx.std_unit_system, + 'first_ts': dbmanager_wx.first_timestamp, + 'last_ts': dbmanager_wx.last_timestamp, + 'sqlkeys': dbmanager_wx.sqlkeys, + } + # do we have any records in our archive? + if info['first_ts'] and info['last_ts']: + # We have some records so count them. + result = dbmanager_wx.getSql(f"SELECT COUNT(*) FROM {dbmanager_wx.table_name};") + info['ts_count'] = result[0] + else: + info['ts_count'] = None + return info + + +def get_binding(config_dict): + """Get the data binding used by the weewx database. + + Args: + config_dict (dict): The configuration dictionary. + + Returns: + str: The binding used by the StdArchive database. Default is 'wx_binding' + """ + + # Extract our binding from the StdArchive section of the config file. If + # it's missing, return 'wx_binding'. + if 'StdArchive' in config_dict: + db_binding_wx = config_dict['StdArchive'].get('data_binding', 'wx_binding') + else: + db_binding_wx = 'wx_binding' + + return db_binding_wx + + +def format_list_cols(the_list, cols, fd): + """Format a list of strings into a given number of columns, respecting the + width of the largest list entry + + Args: + the_list (list): A list of strings + cols (int): The number of columns to be used. + fd (typing.TextIO): An open file-like object. + """ + + max_width = max([len(x) for x in the_list]) + justifyList = [x.ljust(max_width) for x in the_list] + lines = (' '.join(justifyList[i:i + cols]) + for i in range(0, len(justifyList), cols)) + for line in lines: + print(" ", line, file=fd) diff --git a/dist/weewx-5.0.2/src/weectllib/debug_cmd.py b/dist/weewx-5.0.2/src/weectllib/debug_cmd.py new file mode 100644 index 0000000..aebb695 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/debug_cmd.py @@ -0,0 +1,54 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Generate weewx debug info""" + +import weecfg +import weecfg.extension +import weectllib.debug_actions +from weeutil.weeutil import bcolors + +debug_usage = f"""{bcolors.BOLD}weectl debug + [--config=FILENAME] + [--output=FILENAME]{bcolors.ENDC} +""" + +debug_description = """ +Generate a standard suite of system/weewx information to aid in remote +debugging. The debug output consists of two parts, the first part containing +a snapshot of relevant system/weewx information and the second part a parsed and +obfuscated copy of weewx.conf. This output can be redirected to a file and posted +when seeking assistance via forums or email. +""" + +debug_epilog = """ +weectl debug will attempt to obfuscate obvious personal/private information in +weewx.conf such as user names, passwords and API keys; however, the user +should thoroughly check the generated output for personal/private information +before posting the information publicly. +""" + + +def add_subparser(subparsers): + debug_parser = subparsers.add_parser('debug', + usage=debug_usage, + description=debug_description, + epilog=debug_epilog, + help="Generate debug info.") + + debug_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + debug_parser.add_argument('--output', + metavar="FILENAME", + help="Redirect output to FILENAME. Default is " + "standard output.") + debug_parser.set_defaults(func=weectllib.dispatch) + debug_parser.set_defaults(action_func=debug) + + +def debug(config_dict, namespace): + weectllib.debug_actions.debug(config_dict, output=namespace.output) diff --git a/dist/weewx-5.0.2/src/weectllib/device_actions.py b/dist/weewx-5.0.2/src/weectllib/device_actions.py new file mode 100644 index 0000000..09b78c1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/device_actions.py @@ -0,0 +1,119 @@ +# +# Copyright (c) 2019-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +# +# +# See the file LICENSE.txt for your full rights. +# +"""weectl device actions""" +import importlib +import logging +import sys + +import configobj + +import weecfg +import weeutil.logger +import weeutil.startup +import weewx +from weeutil.weeutil import to_int, bcolors + +log = logging.getLogger('weectl-device') + + +def device(): + # Find the configuration file. The user may have used a --config option, or may have + # specified it on the command line. Search for either one: + config_path = _find_config(sys.argv) + + # Load the configuration file + try: + config_fn, config_dict = weecfg.read_config(config_path, sys.argv[2:]) + except (IOError, configobj.ConfigObjError) as e: + print(f"Error parsing config file: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(weewx.CONFIG_ERROR) + + print(f"Using configuration file {bcolors.BOLD}{config_fn}{bcolors.ENDC}") + + # Set weewx.debug as necessary: + weewx.debug = to_int(config_dict.get('debug', 0)) + + # Customize the logging with user settings. + weeutil.logger.setup('weectl', config_dict) + + # Set up debug, add USER_ROOT to PYTHONPATH, read user.extensions: + weeutil.startup.initialize(config_dict) + + try: + # Find the device driver + device_type = config_dict['Station']['station_type'] + driver = config_dict[device_type]['driver'] + except KeyError as e: + sys.exit(f"Unable to determine driver: {e}") + + print(f"Using driver {driver}.") + + # Try to load the driver + try: + driver_module = importlib.import_module(driver) + loader_function = getattr(driver_module, 'configurator_loader') + except ImportError as e: + msg = f"Unable to import driver {driver}: {e}." + log.error(msg) + sys.exit(msg) + except AttributeError as e: + msg = f"The driver {driver} does not include a configuration tool." + log.info(f"{msg}: {e}") + sys.exit(msg) + except Exception as e: + msg = f"Cannot load configurator for {device_type}." + log.error(f"{msg}: {e}") + sys.exit(msg) + + configurator = loader_function(config_dict) + + # Try to determine driver name and version. + try: + driver_name = driver_module.DRIVER_NAME + except AttributeError: + driver_name = '?' + try: + driver_vers = driver_module.DRIVER_VERSION + except AttributeError: + driver_vers = '?' + print(f'Using {driver_name} driver version {driver_vers} ({driver})') + + configurator.configure(config_dict) + + +def _find_config(args): + """Mini parser that looks for constructs such as + --config=foo + or + --config foo + """ + for idx, arg in enumerate(args): + # Look for a --config option + if arg.startswith('--config'): + # Found one. Now see if it uses an equal sign: + equals = arg.find('=') + if equals == -1: + # No equal sign. The file path must be the next argument + path = args[idx + 1] + # Delete the next argument + del args[idx + 1] + else: + # Found an equal sign. The file is the rest of the argument. + path = arg[equals + 1:] + + # Delete the argument with the --config option + del args[idx] + return path + + # No --config option. Return None + return None diff --git a/dist/weewx-5.0.2/src/weectllib/device_cmd.py b/dist/weewx-5.0.2/src/weectllib/device_cmd.py new file mode 100644 index 0000000..00a7c59 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/device_cmd.py @@ -0,0 +1,15 @@ +# +# Copyright (c) 2019-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +# +# +# See the file LICENSE.txt for your rights. +# +"""Register a minimal subparser for purposes of providing a response to 'weectl --help'. """ + + +def add_subparser(subparsers): + subparsers.add_parser('device', help="Manage your hardware.") diff --git a/dist/weewx-5.0.2/src/weectllib/extension_cmd.py b/dist/weewx-5.0.2/src/weectllib/extension_cmd.py new file mode 100644 index 0000000..7e028aa --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/extension_cmd.py @@ -0,0 +1,130 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Install and remove extensions.""" +import weecfg +import weecfg.extension +import weectllib +from weeutil.printer import Printer +from weeutil.weeutil import bcolors + +extension_list_usage = f"""{bcolors.BOLD}weectl extension list + [--config=FILENAME]{bcolors.ENDC} +""" +extension_install_usage = f""" {bcolors.BOLD}weectl extension install (FILE|DIR|URL) + [--config=FILENAME] + [--dry-run] [--yes] [--verbosity=N]{bcolors.ENDC} +""" +extension_uninstall_usage = f""" {bcolors.BOLD}weectl extension uninstall NAME + [--config=FILENAME] + [--dry-run] [--yes] [--verbosity=N]{bcolors.ENDC} +""" +extension_usage = '\n '.join((extension_list_usage, + extension_install_usage, + extension_uninstall_usage)) + + +def add_subparser(subparsers): + extension_parser = subparsers.add_parser('extension', + usage=extension_usage, + description='Manages WeeWX extensions', + help="List, install, or uninstall extensions.") + # In the following, the 'prog' argument is necessary to get a proper error message. + # See Python issue https://bugs.python.org/issue42297 + action_parser = extension_parser.add_subparsers(dest='action', + prog='weectl extension', + title="Which action to take") + + # ---------- Action 'list' ---------- + list_extension_parser = action_parser.add_parser('list', + description="List all installed extensions", + usage=extension_list_usage, + help='List all installed extensions') + list_extension_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + list_extension_parser.add_argument('--verbosity', type=int, default=1, metavar='N', + help="How much information to display (0|1|2|3).") + list_extension_parser.set_defaults(func=weectllib.dispatch) + list_extension_parser.set_defaults(action_func=list_extensions) + + # ---------- Action 'install' ---------- + install_extension_parser = \ + action_parser.add_parser('install', + description="Install an extension contained in FILE " + " (such as pmon.tar.gz), directory (DIR), or from " + " an URL.", + usage=extension_install_usage, + help="Install an extension contained in FILE " + " (such as pmon.tar.gz), directory (DIR), or from " + " an URL.") + install_extension_parser.add_argument('source', + help="Location of the extension. It can be a path to a " + "zipfile or tarball, a path to an unpacked " + "directory, or an URL pointing to a zipfile " + "or tarball.") + install_extension_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + install_extension_parser.add_argument('--dry-run', + action='store_true', + help='Print what would happen, but do not actually ' + 'do it.') + install_extension_parser.add_argument('-y', '--yes', action='store_true', + help="Don't ask for confirmation. Just do it.") + install_extension_parser.add_argument('--verbosity', type=int, default=1, metavar='N', + help="How much information to display (0|1|2|3).") + install_extension_parser.set_defaults(func=weectllib.dispatch) + install_extension_parser.set_defaults(action_func=install_extension) + + # ---------- Action uninstall' ---------- + uninstall_extension_parser = \ + action_parser.add_parser('uninstall', + description="Uninstall an extension", + usage=extension_uninstall_usage, + help="Uninstall an extension") + uninstall_extension_parser.add_argument('name', + metavar='NAME', + help="Name of the extension to uninstall.") + uninstall_extension_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + uninstall_extension_parser.add_argument('--dry-run', + action='store_true', + help='Print what would happen, but do not actually ' + 'do it.') + uninstall_extension_parser.add_argument('-y', '--yes', action='store_true', + help="Don't ask for confirmation. Just do it.") + uninstall_extension_parser.add_argument('--verbosity', type=int, default=1, metavar='N', + help="How much information to display (0|1|2|3).") + uninstall_extension_parser.set_defaults(func=weectllib.dispatch) + uninstall_extension_parser.set_defaults(action_func=uninstall_extension) + + +def list_extensions(config_dict, _): + ext = _get_extension_engine(config_dict) + ext.enumerate_extensions() + + +def install_extension(config_dict, namespace): + ext = _get_extension_engine(config_dict, namespace.dry_run, namespace.verbosity) + ext.install_extension(namespace.source, no_confirm=namespace.yes) + + +def uninstall_extension(config_dict, namespace): + ext = _get_extension_engine(config_dict, namespace.dry_run, namespace.verbosity) + ext.uninstall_extension(namespace.name, no_confirm=namespace.yes) + + +def _get_extension_engine(config_dict, dry_run=False, verbosity=1): + ext = weecfg.extension.ExtensionEngine(config_path=config_dict['config_path'], + config_dict=config_dict, + dry_run=dry_run, + printer=Printer(verbosity=verbosity)) + + return ext diff --git a/dist/weewx-5.0.2/src/weectllib/import_actions.py b/dist/weewx-5.0.2/src/weectllib/import_actions.py new file mode 100644 index 0000000..45dadaf --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/import_actions.py @@ -0,0 +1,119 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Import command actions""" +import importlib +import logging + +import weecfg +import weecfg.extension +import weeimport +import weeimport.weeimport +import weeutil.config +import weeutil.logger +import weewx +import weewx.manager +import weewx.units +import weewx.xtypes + +from weeutil.weeutil import timestamp_to_string, TimeSpan, bcolors + +log = logging.getLogger(__name__) + +# minimum WeeWX version required for this version of wee_import +REQUIRED_WEEWX = "5.0.0" + +def obs_import(config_dict, import_config, **kwargs): + """Generate information about the user's WeeWX environment + + Args: + config_dict (dict): The configuration dictionary + output (str|None): Path to where the output will be put. Default is stdout. + """ + + # check WeeWX version number for compatibility + if weeutil.weeutil.version_compare(weewx.__version__, REQUIRED_WEEWX) < 0: + print("WeeWX %s or greater is required, found %s. Nothing done, exiting." + % (REQUIRED_WEEWX, weewx.__version__)) + exit(1) + + # to do anything we need an import config file, check if one was provided + if import_config: + # we have something so try to start + + # advise the user we are starting up + print("Starting weectl import...") + log.info("Starting weectl import...") + + # If we got this far we must want to import something so get a Source + # object from our factory and try to import. Be prepared to catch any + # errors though. + try: + source_obj = weeimport.weeimport.Source.source_factory(config_dict['config_path'], + config_dict, + import_config, + **kwargs) + source_obj.run() + except weeimport.weeimport.WeeImportOptionError as e: + print(f"{bcolors.BOLD}**** Command line option error.{bcolors.ENDC}") + log.info("**** Command line option error.") + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except weeimport.weeimport.WeeImportIOError as e: + print(f"{bcolors.BOLD}**** Unable to load source data.{bcolors.ENDC}") + log.info("**** Unable to load source data.") + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except weeimport.weeimport.WeeImportFieldError as e: + print(f"{bcolors.BOLD}**** Unable to map source data.{bcolors.ENDC}") + log.info("**** Unable to map source data.") + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except weeimport.weeimport.WeeImportMapError as e: + print(f"{bcolors.BOLD}**** Unable to parse source-to-WeeWX field map.{bcolors.ENDC}") + log.info("**** Unable to parse source-to-WeeWX field map.") + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except (weewx.ViolatedPrecondition, weewx.UnsupportedFeature) as e: + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except SystemExit as e: + print(e) + exit(0) + except (ValueError, weewx.UnitError) as e: + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** %s" % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + except IOError as e: + print(f"{bcolors.BOLD}**** Unable to load config file.{bcolors.ENDC}") + log.info("**** Unable to load config file.") + print(f"{bcolors.BOLD}**** {e}{bcolors.ENDC}") + log.info(f"**** " % e) + print("**** Nothing done, exiting.") + log.info("**** Nothing done.") + exit(1) + else: + # we have no import config file so display a suitable message followed + # by the help text then exit + print(f"{bcolors.BOLD}**** No import config file specified.{bcolors.ENDC}") + print("**** Nothing done.") + exit(1) diff --git a/dist/weewx-5.0.2/src/weectllib/import_cmd.py b/dist/weewx-5.0.2/src/weectllib/import_cmd.py new file mode 100644 index 0000000..e057976 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/import_cmd.py @@ -0,0 +1,90 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Import observation data""" + +import weecfg +import weecfg.extension +import weectllib.import_actions +from weeutil.weeutil import bcolors + +import_usage = f"""{bcolors.BOLD}weectl import --help + weectl import --import-config=IMPORT_CONFIG_FILE + [--config=CONFIG_FILE] + [[--date=YYYY-mm-dd] | [[--from=YYYY-mm-dd[THH:MM]] [--to=YYYY-mm-dd[THH:MM]]]] + [--dry-run][--verbose] + [--no-prompt][--suppress-warnings]{bcolors.ENDC} +""" + +import_description = """ +Import observation data into a WeeWX archive. +""" + +import_epilog = """ +Import data from an external source into a WeeWX archive. Daily summaries are +updated as each archive record is imported so there should be no need to +separately rebuild the daily summaries. +""" + + +def add_subparser(subparsers): + import_parser = subparsers.add_parser('import', + usage=import_usage, + description=import_description, + epilog=import_epilog, + help="Import observation data.") + + import_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + import_parser.add_argument('--import-config', + metavar='IMPORT_CONFIG_FILE', + dest='import_config', + help=f'Path to import configuration file.') + import_parser.add_argument('--dry-run', + action='store_true', + dest='dry_run', + help=f'Print what would happen but do not do it.') + import_parser.add_argument('--date', + metavar='YYYY-mm-dd', + # dest='d_date', + help=f'Import data for this date. Format is YYYY-mm-dd.') + import_parser.add_argument('--from', + metavar='YYYY-mm-dd[THH:MM]', + dest='from_datetime', + help=f'Import data starting at this date or date-time. ' + f'Format is YYYY-mm-dd[THH:MM].') + import_parser.add_argument('--to', + metavar='YYYY-mm-dd[THH:MM]', + dest='to_datetime', + help=f'Import data up until this date or date-time. Format ' + f'is YYYY-mm-dd[THH:MM].') + import_parser.add_argument('--verbose', + action='store_true', + help=f'Print and log useful extra output.') + import_parser.add_argument('--no-prompt', + action='store_true', + dest='no_prompt', + help=f'Do not prompt. Accept relevant defaults ' + f'and all y/n prompts.') + import_parser.add_argument('--suppress-warnings', + action='store_true', + dest='suppress_warnings', + help=f'Suppress warnings to stdout. Warnings are still logged.') + import_parser.set_defaults(func=weectllib.dispatch) + import_parser.set_defaults(action_func=import_func) + + +def import_func(config_dict, namespace): + weectllib.import_actions.obs_import(config_dict, + namespace.import_config, + dry_run=namespace.dry_run, + date=namespace.date, + from_datetime=namespace.from_datetime, + to_datetime=namespace.to_datetime, + verbose=namespace.verbose, + no_prompt=namespace.no_prompt, + suppress_warning=namespace.suppress_warnings) diff --git a/dist/weewx-5.0.2/src/weectllib/report_actions.py b/dist/weewx-5.0.2/src/weectllib/report_actions.py new file mode 100644 index 0000000..58f7040 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/report_actions.py @@ -0,0 +1,123 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Actions related to running and managing reports""" + +import logging +import socket +import sys +import time + +import weewx +import weewx.engine +import weewx.manager +import weewx.reportengine +import weewx.station +from weeutil.weeutil import bcolors, timestamp_to_string, to_bool + +log = logging.getLogger('weectl-report') + + +def list_reports(config_dict): + + # Print a header + print( + f"\n{bcolors.BOLD}{'Report' : >20} {'Skin':<12} {'Enabled':^8} {'Units':^8} {'Language':^8}{bcolors.ENDC}") + + # Then go through all the reports: + for report in config_dict['StdReport'].sections: + if report == 'Defaults': + continue + enabled = to_bool(config_dict['StdReport'][report].get('enable', True)) + # Fetch and build the skin_dict: + try: + skin_dict = weewx.reportengine.build_skin_dict(config_dict, report) + except SyntaxError as e: + unit_system = "N/A" + lang = "N/A" + skin = "N/A" + else: + unit_system = skin_dict.get("unit_system", "N/A").upper() + lang = skin_dict.get("lang", "N/A") + skin = skin_dict.get('skin', "Unknown") + + print(f"{report : >20} {skin:<12} {'Y' if enabled else 'N':^8} " + f"{unit_system:^8} {lang:^8}") + + +def run_reports(config_dict, + epoch=None, + report_date=None, report_time=None, + reports=None): + if reports: + print(f"The following reports will be run: {', '.join(reports)}") + else: + print("All enabled reports will be run.") + # If the user has not specified any reports, then "reports" will be an empty list. + # Convert it to None + reports = None + + # If the user specified a time, retrieve it. Otherwise, set to None + if epoch: + gen_ts = int(epoch) + elif report_date: + gen_ts = get_epoch_time(report_date, report_time) + else: + gen_ts = None + + if gen_ts: + print(f"Generating for requested time {timestamp_to_string(gen_ts)}") + else: + print("Generating as of last timestamp in the database.") + + # We want to generate all reports irrespective of any report_timing settings that may exist. + # The easiest way to do this is walk the config dict, resetting any report_timing settings + # found. + config_dict.walk(disable_timing) + + socket.setdefaulttimeout(10) + + # Instantiate the dummy engine. This will cause services to get loaded, which will make + # the type extensions (xtypes) system available. + engine = weewx.engine.DummyEngine(config_dict) + + stn_info = weewx.station.StationInfo(**config_dict['Station']) + + try: + binding = config_dict['StdArchive']['data_binding'] + except KeyError: + binding = 'wx_binding' + + # Retrieve the appropriate record from the database + with weewx.manager.DBBinder(config_dict) as db_binder: + db_manager = db_binder.get_manager(binding) + ts = gen_ts or db_manager.lastGoodStamp() + record = db_manager.getRecord(ts) + + # Instantiate the report engine with the retrieved record and required timestamp + t = weewx.reportengine.StdReportEngine(config_dict, stn_info, record=record, gen_ts=ts) + + try: + # Although the report engine inherits from Thread, we can just run it in the main thread: + t.run(reports) + except KeyError as e: + print(f"Unknown report: {e}", file=sys.stderr) + + # Shut down any running services, + engine.shutDown() + + print("Done.") + + +def get_epoch_time(d_tt, t_tt): + tt = (d_tt.tm_year, d_tt.tm_mon, d_tt.tm_mday, + t_tt.tm_hour, t_tt.tm_min, 0, 0, 0, -1) + return time.mktime(tt) + + +def disable_timing(section, key): + """Function to effectively disable report_timing option""" + if key == 'report_timing': + section['report_timing'] = "* * * * *" diff --git a/dist/weewx-5.0.2/src/weectllib/report_cmd.py b/dist/weewx-5.0.2/src/weectllib/report_cmd.py new file mode 100644 index 0000000..93ea19c --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/report_cmd.py @@ -0,0 +1,94 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +"""Manage and run WeeWX reports.""" +import sys +import time + +import weecfg +import weectllib +import weectllib.report_actions +from weeutil.weeutil import bcolors + +report_list_usage = f"""{bcolors.BOLD}weectl report list + [--config=FILENAME]{bcolors.ENDC}""" +report_run_usage = f""" {bcolors.BOLD}weectl report run [NAME ...] + [--config=FILENAME] + [--epoch=EPOCH_TIME | --date=YYYY-mm-dd --time=HH:MM]{bcolors.ENDC}""" + +report_usage = '\n '.join((report_list_usage, report_run_usage)) + +run_epilog = """You may specify either an epoch time (option --epoch), or a date and time combo +(options --date and --time together), but not both.""" + + +def add_subparser(subparsers): + report_parser = subparsers.add_parser('report', + usage=report_usage, + description='Manages and runs WeeWX reports', + help="List and run WeeWX reports.") + # In the following, the 'prog' argument is necessary to get a proper error message. + # See Python issue https://bugs.python.org/issue42297 + action_parser = report_parser.add_subparsers(dest='action', + prog='weectl report', + title="Which action to take") + + # ---------- Action 'list' ---------- + list_report_parser = action_parser.add_parser('list', + description="List all installed reports", + usage=report_list_usage, + help='List all installed reports') + list_report_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + list_report_parser.set_defaults(func=weectllib.dispatch) + list_report_parser.set_defaults(action_func=list_reports) + + # ---------- Action 'run' ---------- + run_report_parser = action_parser.add_parser('run', + description="Runs reports", + usage=report_run_usage, + help='Run one or more reports', + epilog=run_epilog) + run_report_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + run_report_parser.add_argument("--epoch", metavar="EPOCH_TIME", + help="Time of the report in unix epoch time") + run_report_parser.add_argument("--date", metavar="YYYY-mm-dd", + type=lambda d: time.strptime(d, '%Y-%m-%d'), + help="Date for the report") + run_report_parser.add_argument("--time", metavar="HH:MM", + type=lambda t: time.strptime(t, '%H:%M'), + help="Time of day for the report") + run_report_parser.add_argument('reports', + nargs="*", + metavar='NAME', + help="Reports to be run, separated by spaces. " + "Names are case sensitive. " + "If not given, all enabled reports will be run.") + run_report_parser.set_defaults(func=weectllib.dispatch) + run_report_parser.set_defaults(action_func=run_reports) + + +def list_reports(config_dict, _): + weectllib.report_actions.list_reports(config_dict) + + +def run_reports(config_dict, namespace): + # Presence of --date requires --time and v.v. + if namespace.date and not namespace.time or namespace.time and not namespace.date: + sys.exit("Must specify both --date and --time.") + # Can specify the time as either unix epoch time, or explicit date and time, but not both + if namespace.epoch and namespace.date: + sys.exit("The time of the report must be specified either as unix epoch time, " + "or with an explicit date and time, but not both.") + + weectllib.report_actions.run_reports(config_dict, + epoch=namespace.epoch, + report_date=namespace.date, report_time=namespace.time, + reports=namespace.reports) diff --git a/dist/weewx-5.0.2/src/weectllib/station_actions.py b/dist/weewx-5.0.2/src/weectllib/station_actions.py new file mode 100644 index 0000000..33cc0bb --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/station_actions.py @@ -0,0 +1,873 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Install or reconfigure a configuration file""" + +import getpass +import grp +import logging +import os +import os.path +import re +import shutil +import stat +import sys +import urllib.parse + +# importlib.resources is 3.7 or later, importlib_resources is the backport +try: + import importlib.resources as importlib_resources +except: + import importlib_resources + +import configobj + +import weecfg.update_config +import weeutil.config +import weeutil.weeutil +import weewx +from weeutil.weeutil import to_float, to_bool, bcolors + +log = logging.getLogger('weectl-station') + + +def station_create(weewx_root=None, + rel_config_path=None, + driver=None, + location=None, + altitude=None, + latitude=None, longitude=None, + register=None, station_url=None, + unit_system=None, + skin_root=None, + sqlite_root=None, + html_root=None, + examples_root=None, + user_root=None, + dist_config_path=None, + no_prompt=False, + dry_run=False): + """Create a brand-new station data area at weewx_root, then equip it with a + configuration file.""" + + if dry_run: + print("This is a dry run. Nothing will actually be done.") + + # Invert the relationship between weewx_root and the relative path to the config file. + # When done, weewx_root will be relative to the directory the config file will be in. + config_path, rel_weewx_root = _calc_paths(weewx_root, rel_config_path) + + # Make sure there is not a configuration file at the designated location. + if os.path.exists(config_path): + raise weewx.ViolatedPrecondition(f"Config file {config_path} already exists") + + print(f"Creating configuration file {bcolors.BOLD}{config_path}{bcolors.ENDC}") + + # If a distribution configuration was specified, use the contents from that + # for the new configuration. Otherwise, extract the contents from the + # configuration file in the python package resources. + if dist_config_path: + dist_config_dict = configobj.ConfigObj(dist_config_path, encoding='utf-8', file_error=True) + else: + # Retrieve the new configuration file from package resources: + with weeutil.weeutil.get_resource_fd('weewx_data', 'weewx.conf') as fd: + dist_config_dict = configobj.ConfigObj(fd, encoding='utf-8', file_error=True) + + dist_config_dict['WEEWX_ROOT_CONFIG'] = rel_weewx_root + config_dir = os.path.dirname(config_path) + dist_config_dict['WEEWX_ROOT'] = os.path.abspath(os.path.join(config_dir, rel_weewx_root)) + + # Modify the configuration dictionary config_dict, prompting if necessary. + config_config(config_dict=dist_config_dict, + config_path=config_path, + driver=driver, + location=location, + altitude=altitude, + latitude=latitude, longitude=longitude, + register=register, station_url=station_url, + unit_system=unit_system, + skin_root=skin_root, + sqlite_root=sqlite_root, + html_root=html_root, + user_root=user_root, + no_prompt=no_prompt) + copy_skins(dist_config_dict, dry_run=dry_run) + copy_examples(dist_config_dict, examples_root=examples_root, dry_run=dry_run) + copy_user(dist_config_dict, user_root=user_root, dry_run=dry_run) + copy_util(config_path, dist_config_dict, dry_run=dry_run) + + print(f"Saving configuration file {config_path}") + if dry_run: + print("This was a dry run. Nothing was actually done.") + else: + # Save the results. No backup. + weecfg.save(dist_config_dict, config_path) + return dist_config_dict + + +def _calc_paths(weewx_root=None, rel_config_path=None): + """When creating a station, the config path is specified relative to WEEWX_ROOT. + However, within the configuration file, WEEWX_ROOT is relative to the location + of the config file. So, we need to invert their relationships. + + Args: + weewx_root (str): A path to the station data area. + rel_config_path (str): A path relative to weewx_root where the configuration + file will be located. + + Returns: + tuple[str, str]: A 2-way tuple containing the absolute path to the config file, and + the path to the station data area, relative to the config file + """ + # Apply defaults if necessary: + if not weewx_root: + weewx_root = weecfg.default_weewx_root + if not rel_config_path: + rel_config_path = 'weewx.conf' + + # Convert to absolute paths + abs_weewx_root = os.path.abspath(weewx_root) + abs_config_path = os.path.abspath(os.path.join(abs_weewx_root, rel_config_path)) + + # Get WEEWX_ROOT relative to the directory the configuration file is sitting in: + final_weewx_root = os.path.relpath(weewx_root, os.path.dirname(abs_config_path)) + # Don't let the relative path get out of hand. If we have to ascend more than two levels, + # use the absolute path: + if '../..' in final_weewx_root: + final_weewx_root = abs_weewx_root + return abs_config_path, final_weewx_root + + +def station_reconfigure(config_dict, + driver=None, + location=None, + altitude=None, + latitude=None, longitude=None, + register=None, station_url=None, + unit_system=None, + weewx_root=None, + skin_root=None, + sqlite_root=None, + html_root=None, + user_root=None, + no_prompt=False, + no_backup=False, + dry_run=False): + """Reconfigure an existing station""" + + if weewx_root: + config_dict['WEEWX_ROOT_CONFIG'] = weewx_root + config_dict['WEEWX_ROOT'] = os.path.abspath( + os.path.join(os.path.dirname(config_dict['config_path']), weewx_root)) + config_config(config_dict, + config_path=config_dict['config_path'], + driver=driver, + location=location, + altitude=altitude, + latitude=latitude, longitude=longitude, + register=register, station_url=station_url, + unit_system=unit_system, + skin_root=skin_root, + sqlite_root=sqlite_root, + html_root=html_root, + user_root=user_root, + no_prompt=no_prompt) + + print(f"Saving configuration file {config_dict['config_path']}") + if dry_run: + print("This was a dry run. Nothing was actually done.") + else: + # Save the results, possibly with a backup. + backup_path = weecfg.save(config_dict, config_dict['config_path'], not no_backup) + if backup_path: + print(f"Saved old configuration file as {backup_path}") + + +def config_config(config_dict, + config_path, + driver=None, + location=None, + altitude=None, + latitude=None, longitude=None, + register=None, station_url=None, + unit_system=None, + skin_root=None, + sqlite_root=None, + html_root=None, + user_root=None, + no_prompt=False): + """Set the various options in a configuration file""" + print(f"Processing configuration file {config_path}") + config_location(config_dict, location=location, no_prompt=no_prompt) + config_altitude(config_dict, altitude=altitude, no_prompt=no_prompt) + config_latlon(config_dict, latitude=latitude, longitude=longitude, no_prompt=no_prompt) + config_units(config_dict, unit_system=unit_system, no_prompt=no_prompt) + config_driver(config_dict, driver=driver, no_prompt=no_prompt) + config_registry(config_dict, register=register, station_url=station_url, no_prompt=no_prompt) + config_roots(config_dict, skin_root, html_root, sqlite_root, user_root) + + +def config_location(config_dict, location=None, no_prompt=False): + """Set the location option. """ + if 'Station' not in config_dict: + return + + default_location = config_dict['Station'].get('location', "WeeWX station") + + if location is not None: + final_location = location + elif not no_prompt: + print("\nGive a description of the station. This will be used for the title " + "of reports.") + ans = input(f"description [{default_location}]: ").strip() + final_location = ans if ans else default_location + else: + final_location = default_location + config_dict['Station']['location'] = final_location + + +def config_altitude(config_dict, altitude=None, no_prompt=False): + """Set a (possibly new) value and unit for altitude. + + Args: + config_dict (configobj.ConfigObj): The configuration dictionary. + altitude (str): A string with value and unit, separated with a comma. + For example, "50, meter". Optional. + no_prompt(bool): Do not prompt the user for a value. + """ + if 'Station' not in config_dict: + return + + # Start with assuming the existing value: + default_altitude = config_dict['Station'].get('altitude', ["0", 'foot']) + # Was a new value provided as an argument? + if altitude is not None: + # Yes. Extract and validate it. + value, unit = altitude.split(',') + # Fail hard if the value cannot be converted to a float + float(value) + # Fail hard if the unit is unknown: + unit = unit.strip().lower() + if unit not in ['foot', 'meter']: + raise ValueError(f"Unknown altitude unit {unit}") + # All is good. Use it. + final_altitude = [value, unit] + elif not no_prompt: + print("\nSpecify altitude, with units 'foot' or 'meter'. For example:") + print(" 35, foot") + print(" 12, meter") + msg = "altitude [%s]: " % weeutil.weeutil.list_as_string(default_altitude) + final_altitude = None + + while final_altitude is None: + ans = input(msg).strip() + if ans: + try: + value, unit = ans.split(',') + except ValueError: + print("You must specify a value and unit. For example: 200, meter") + continue + try: + # Test whether the first token can be converted into a + # number. If not, an exception will be raised. + float(value) + except ValueError: + print(f"Unable to convert '{value}' to an altitude.") + continue + unit = unit.strip().lower() + if unit == 'feet': + unit = 'foot' + elif unit == 'meters': + unit = 'meter' + if unit not in ['foot', 'meter']: + print(f"Unknown unit '{unit}'. Must be 'foot' or 'meter'.") + continue + final_altitude = [value.strip(), unit] + else: + # The user gave the null string. We're done + final_altitude = default_altitude + else: + # If we got here, there was no value in the args and we cannot prompt. Use the default. + final_altitude = default_altitude + + config_dict['Station']['altitude'] = final_altitude + + +def config_latlon(config_dict, latitude=None, longitude=None, no_prompt=False): + """Set a (possibly new) value for latitude and longitude + + Args: + config_dict (configobj.ConfigObj): The configuration dictionary. + latitude (str|None): The latitude. If specified, no prompting will happen. + longitude (str|None): The longitude. If specified no prompting will happen. + no_prompt(bool): Do not prompt the user for a value. + """ + + if "Station" not in config_dict: + return + + # Use the existing value, if any, as the default: + default_latitude = to_float(config_dict['Station'].get('latitude', 0.0)) + # Was a new value provided as an argument? + if latitude is not None: + # Yes. Use it + final_latitude = latitude + elif not no_prompt: + # No value provided as an argument. Prompt for a new value + print("\nSpecify latitude in decimal degrees, negative for south.") + final_latitude = weecfg.prompt_with_limits("latitude", default_latitude, -90, 90) + else: + # If we got here, there was no value provided as an argument, yet we cannot prompt. + # Use the default. + final_latitude = default_latitude + + # Set the value in the config file + config_dict['Station']['latitude'] = float(final_latitude) + + # Similar, except for longitude + default_longitude = to_float(config_dict['Station'].get('longitude', 0.0)) + # Was a new value provided on the command line? + if longitude is not None: + # Yes. Use it + final_longitude = longitude + elif not no_prompt: + # No command line value. Prompt for a new value + print("Specify longitude in decimal degrees, negative for west.") + final_longitude = weecfg.prompt_with_limits("longitude", default_longitude, -180, 180) + else: + # If we got here, there was no value provided as an argument, yet we cannot prompt. + # Use the default. + final_longitude = default_longitude + + # Set the value in the config file + config_dict['Station']['longitude'] = float(final_longitude) + + +def config_units(config_dict, unit_system=None, no_prompt=False): + """Determine the unit system to use""" + + default_unit_system = None + try: + # Look for option 'unit_system' in [StdReport] + default_unit_system = config_dict['StdReport']['unit_system'] + except KeyError: + try: + default_unit_system = config_dict['StdReport']['Defaults']['unit_system'] + except KeyError: + # Not there. It's a custom unit system + pass + + if unit_system: + final_unit_system = unit_system + elif not no_prompt: + print("\nChoose a unit system for the reports. Later, you can modify") + print("your choice, or choose a combination of units. Unit systems") + print("include:") + print(f" {bcolors.BOLD}us{bcolors.ENDC} (ºF, inHg, in, mph)") + print(f" {bcolors.BOLD}metricwx{bcolors.ENDC} (ºC, mbar, mm, m/s)") + print(f" {bcolors.BOLD}metric{bcolors.ENDC} (ºC, mbar, cm, km/h)") + + # Get what unit system the user wants + options = ['us', 'metricwx', 'metric'] + final_unit_system = weecfg.prompt_with_options(f"unit system", + default_unit_system, options) + else: + final_unit_system = default_unit_system + + if 'StdReport' in config_dict and final_unit_system: + # Make sure the default unit system sits under [[Defaults]]. First, get rid of anything + # under [StdReport] + config_dict['StdReport'].pop('unit_system', None) + # Then add it under [[Defaults]] + config_dict['StdReport']['Defaults']['unit_system'] = final_unit_system + + +def config_driver(config_dict, driver=None, no_prompt=False): + """Do what's necessary to create or reconfigure a driver in the configuration file. + + Args: + config_dict (configobj.ConfigObj): The configuration dictionary + driver (str): The driver to use. Something like 'weewx.drivers.fousb'. + Usually this comes from the command line. Default is None, which means prompt the user. + If no_prompt has been specified, then use the simulator. + no_prompt (bool): False to prompt the user. True to not allow prompts. Default is False. + """ + # The existing driver is the default. If there is no existing driver, use the simulator. + try: + station_type = config_dict['Station']['station_type'] + default_driver = config_dict[station_type]['driver'] + except KeyError: + default_driver = 'weewx.drivers.simulator' + + # Was a driver specified in the args? + if driver is not None: + # Yes. Use it + final_driver = driver + elif not no_prompt: + # Prompt for a suitable driver + final_driver = weecfg.prompt_for_driver(default_driver) + else: + # If we got here, a driver was not specified in the args, and we can't prompt the user. + # So, use the default. + final_driver = default_driver + + # We've selected a driver. Now we need to get a stanza to go along with it. + # Look up driver info, and get the driver editor. The editor provides a default stanza, + # and allows prompt-based editing of the stanza. Fail hard if the driver fails to load. + driver_editor, driver_name, driver_version = weecfg.load_driver_editor(final_driver) + # If the user has supplied a name for the driver stanza, then use it. Otherwise, use the one + # supplied by the driver. + log.info(f'Using {driver_name} version {driver_version} ({driver})') + + # Get a driver stanza, if possible + stanza = None + if driver_name: + if driver_editor: + # if a previous stanza exists for this driver, grab it + if driver_name in config_dict: + # We must get the original stanza as a long string with embedded newlines. + orig_stanza = configobj.ConfigObj(interpolation=False) + orig_stanza[driver_name] = config_dict[driver_name] + orig_stanza_text = '\n'.join(orig_stanza.write()) + else: + orig_stanza_text = None + + # let the driver process the stanza or give us a new one + stanza_text = driver_editor.get_conf(orig_stanza_text) + stanza = configobj.ConfigObj(stanza_text.splitlines()) + else: + # No editor. If the original config_dict has the stanza use that. Otherwise, a blank + # stanza. + stanza = configobj.ConfigObj(interpolation=False) + stanza[driver_name] = config_dict.get(driver_name, {}) + + # If we have a stanza, inject it into the configuration dictionary + if stanza and driver_name: + # Ensure that the driver field matches the path to the actual driver + stanza[driver_name]['driver'] = final_driver + # Insert the stanza in the configuration dictionary: + config_dict[driver_name] = stanza[driver_name] + # Add a major comment deliminator: + config_dict.comments[driver_name] = weecfg.major_comment_block + # If we have a [Station] section, move the new stanza to just after it + if 'Station' in config_dict: + weecfg.reorder_sections(config_dict, driver_name, 'Station', after=True) + # make the stanza the station type + config_dict['Station']['station_type'] = driver_name + # Give the user a chance to modify the stanza: + if not no_prompt: + settings = weecfg.prompt_for_driver_settings(final_driver, + config_dict.get(driver_name, {})) + config_dict[driver_name].merge(settings) + + if driver_editor: + # One final chance for the driver to modify other parts of the configuration + driver_editor.modify_config(config_dict) + + +def config_registry(config_dict, register=None, station_url=None, no_prompt=False): + """Configure whether to include the station in the weewx.com registry.""" + + try: + config_dict['Station'] + config_dict['StdRESTful']['StationRegistry'] + except KeyError: + print('No [[StationRegistry]] section found.') + return + + default_register = to_bool( + config_dict['StdRESTful']['StationRegistry'].get('register_this_station', False)) + default_station_url = config_dict['Station'].get('station_url') + + if register is not None: + final_register = to_bool(register) + final_station_url = station_url or default_station_url + elif not no_prompt: + print("\nYou can register the station on weewx.com, where it will be included in a") + print("map. If you choose to register, you will also need a unique URL to identify ") + print("the station (such as a website, or a WeatherUnderground link).") + default_prompt = 'y' if default_register else 'n' + ans = weeutil.weeutil.y_or_n(f"register this station (y/n)? " + f"[{default_prompt}] ", + default=default_register) + final_register = to_bool(ans) + if final_register: + print("\nNow give a unique URL for the station. A Weather Underground ") + print("URL such as https://www.wunderground.com/dashboard/pws/KORPORT12 will do.") + while True: + url = weecfg.prompt_with_options("Unique URL", default_station_url) + parts = urllib.parse.urlparse(url) + if 'example.com' in url or 'acme.com' in url: + print("The domain is not acceptable") + # Rudimentary validation of the station URL. + # 1. It must have a valid scheme (http or https); + # 2. The address cannot be empty; and + # 3. The address has to have at least one dot in it. + elif parts.scheme not in ['http', 'https'] \ + or not parts.netloc \ + or '.' not in parts.netloc: + print("Not a valid URL") + else: + final_station_url = url + break + else: + # --no-prompt is active. Just use the defaults. + final_register = default_register + final_station_url = default_station_url + + if final_register and not final_station_url: + raise weewx.ViolatedPrecondition("Registering the station requires " + "option 'station_url'.") + + config_dict['StdRESTful']['StationRegistry']['register_this_station'] = final_register + if final_register and final_station_url: + weecfg.inject_station_url(config_dict, final_station_url) + + +def config_roots(config_dict, skin_root=None, html_root=None, sqlite_root=None, user_root=None): + """Set the location of various root directories in the configuration dictionary.""" + + if user_root: + config_dict['USER_ROOT'] = user_root + + if 'StdReport' in config_dict: + if skin_root: + config_dict['StdReport']['SKIN_ROOT'] = skin_root + elif 'SKIN_ROOT' not in config_dict['StdReport']: + config_dict['StdReport']['SKIN_ROOT'] = 'skins' + if html_root: + config_dict['StdReport']['HTML_ROOT'] = html_root + elif 'HTML_ROOT' not in config_dict['StdReport']: + config_dict['StdReport']['HTML_ROOT'] = 'public_html' + + if 'DatabaseTypes' in config_dict and 'SQLite' in config_dict['DatabaseTypes']: + # Temporarily turn off interpolation + hold, config_dict.interpolation = config_dict.interpolation, False + if sqlite_root: + config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] = sqlite_root + elif 'SQLITE_ROOT' not in config_dict['DatabaseTypes']['SQLite']: + config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] = 'archive' + # Turn interpolation back on. + config_dict.interpolation = hold + + +def copy_skins(config_dict, dry_run=False): + """Copy any missing skins from the resource package to the skins directory""" + if 'StdReport' not in config_dict: + return + + # SKIN_ROOT is the location of the skins relative to WEEWX_ROOT. Find it's absolute location + skin_dir = os.path.join(config_dict['WEEWX_ROOT'], config_dict['StdReport']['SKIN_ROOT']) + # Make it if it doesn't already exist + if not dry_run and not os.path.exists(skin_dir): + print(f"Creating directory {skin_dir}") + os.makedirs(skin_dir) + + # Find the skins we already have + existing_skins = _get_existing_skins(skin_dir) + # Find the skins that are available in the resource package + available_skins = _get_core_skins() + + # The difference is what's missing + missing_skins = available_skins - existing_skins + + with weeutil.weeutil.get_resource_path('weewx_data', 'skins') as skin_resources: + # Copy over any missing skins + for skin in missing_skins: + src = os.path.join(skin_resources, skin) + dest = os.path.join(skin_dir, skin) + print(f"Copying new skin {skin} into {dest}") + if not dry_run: + shutil.copytree(src, dest) + + +def copy_examples(config_dict, examples_root=None, dry_run=False, force=False): + """Copy the examples to the EXAMPLES_ROOT directory. + + Args: + config_dict (dict): A configuration dictionary. + examples_root (str): Path to where the examples will be put, relative to WEEWX_ROOT. + dry_run (bool): True to not actually do anything. Just show what would happen. + force (bool): True to overwrite existing examples. Otherwise, do nothing if they exist. + + Returns: + str|None: Path to the freshly written examples, or None if they already exist and `force` + was False. + """ + + # If the user didn't specify a value, use a default + if not examples_root: + examples_root = 'examples' + + # examples_root is relative to WEEWX_PATH. Join them to get the absolute path. + examples_dir = os.path.join(config_dict['WEEWX_ROOT'], examples_root) + + if os.path.isdir(examples_dir): + if not force: + print(f"Example directory exists at {examples_dir}") + return None + else: + print(f"Removing example directory {examples_dir}") + if not dry_run: + shutil.rmtree(examples_dir, ignore_errors=True) + with weeutil.weeutil.get_resource_path('weewx_data', 'examples') as examples_resources: + print(f"Copying examples into {examples_dir}") + if not dry_run: + shutil.copytree(examples_resources, examples_dir) + return examples_dir + + +def copy_user(config_dict, user_root=None, dry_run=False): + """Copy the user directory to USER_ROOT""" + + # If the user didn't specify a value, use a default + if not user_root: + user_root = config_dict.get('USER_ROOT', 'bin/user') + + # USER_ROOT is relative to WEEWX_PATH. Join them to get the absolute path. + user_dir = os.path.join(config_dict['WEEWX_ROOT'], user_root) + + # Don't clobber an existing user subdirectory + if not os.path.isdir(user_dir): + with weeutil.weeutil.get_resource_path('weewx_data', 'bin') as lib_resources: + print(f"Creating a new 'user' directory at {user_dir}") + if not dry_run: + shutil.copytree(os.path.join(lib_resources, 'user'), + user_dir, + ignore=shutil.ignore_patterns('*.pyc', '__pycache__', )) + + +def copy_util(config_path, config_dict, dry_run=False, force=False): + import weewxd + weewxd_path = weewxd.__file__ + cfg_dir = os.path.dirname(config_path) + username = getpass.getuser() + groupname = grp.getgrgid(os.getgid()).gr_name + weewx_root = config_dict['WEEWX_ROOT'] + util_dir = os.path.join(weewx_root, 'util') + bin_dir = os.path.dirname(weewxd_path) + + # This is the set of substitutions to be performed, with a different set + # for each type of files. The key is a regular expression. If a match is + # found, the value will be substituted for the matched expression. Beware + # that the labels for user, group, config directory, and other parameters + # are consistent throughout the utility files. Be sure to test the + # patterns by using them to grep all of the files in the util directory to + # see what actually matches. + + re_patterns = { + 'scripts': { # daemon install scripts + r"^UTIL_ROOT=.*": rf"UTIL_ROOT={util_dir}", + }, + 'systemd': { # systemd unit files + r"User=WEEWX_USER": rf"User={username}", + r"Group=WEEWX_GROUP": rf"Group={groupname}", + r"ExecStart=WEEWX_PYTHON WEEWXD": rf"ExecStart={sys.executable} {weewxd_path}", + r" WEEWX_CFGDIR/": rf" {cfg_dir}/", + }, + 'launchd': { # macos launchd files + r"/usr/bin/python3": rf"{sys.executable}", + r"/Users/Shared/weewx/src/weewxd.py": rf"{weewxd_path}", + r"/Users/Shared/weewx/weewx.conf": rf"{config_path}", + }, + 'default': { # defaults file used by SysV init scripts + r"^WEEWX_PYTHON=.*": rf"WEEWX_PYTHON={sys.executable}", + r"^WEEWX_BINDIR=.*": rf"WEEWX_BINDIR={bin_dir}", + r"^WEEWX_CFGDIR=.*": rf"WEEWX_CFGDIR={cfg_dir}", + r"^WEEWX_RUNDIR=.*": rf"WEEWX_RUNDIR={cfg_dir}", + r"^WEEWX_USER=.*": rf"WEEWX_USER={username}", + r"^WEEWX_GROUP=.*": rf"WEEWX_GROUP={groupname}", + }, + } + + # Convert the patterns to a list of two-way tuples + for k in re_patterns: + re_patterns[k] = [(re.compile(key), re_patterns[k][key]) for key in re_patterns[k]] + + def _patch_file(srcpath, dstpath): + srcdir = os.path.basename(os.path.dirname(srcpath)) + if srcdir in re_patterns: + # Copy an individual file from srcpath to dstpath, while making + # substitutions using the list of regular expressions re_list + re_list = re_patterns[srcdir] + with open(srcpath, 'r') as rd, open(dstpath, 'w') as wd: + for line in rd: + # Lines starting with "#&" are comment lines. Ignore them. + if line.startswith("#&"): + continue + # Go through all the regular expressions, substituting the + # value for the key + for key, value in re_list: + line = key.sub(value, line) + wd.write(line) + else: + # Just copy the file + shutil.copyfile(srcpath, dstpath) + + # Create a callable using the shutil.ignore_patterns factory function. + # The files/directories that match items in this list will *not* be copied. + _ignore_function = shutil.ignore_patterns('*.pyc', '__pycache__') + + util_dir = os.path.join(weewx_root, 'util') + if os.path.isdir(util_dir): + if not force: + print(f"Utility directory exists at {util_dir}") + return None + else: + print(f"Removing utility directory {util_dir}") + if not dry_run: + shutil.rmtree(util_dir, ignore_errors=True) + + with weeutil.weeutil.get_resource_path('weewx_data', 'util') as util_resources: + print(f"Copying utility files into {util_dir}") + if not dry_run: + # Copy the tree rooted in 'util_resources' to 'dstdir', while + # ignoring files given by _ignore_function. While copying, use the + # function _patch_file() to massage the files. + shutil.copytree(util_resources, util_dir, + ignore=_ignore_function, copy_function=_patch_file) + + scripts_dir = os.path.join(weewx_root, 'scripts') + + # The 'scripts' subdirectory is a little different. We do not delete it + # first, because it's a comman name and a user might have put things there. + # Instead, just copy our files into it. First, make sure the subdirectory + # exists: + os.makedirs(scripts_dir, exist_ok=True) + # Then do the copying. + with weeutil.weeutil.get_resource_path('weewx_data', 'scripts') as scripts_resources: + print(f"Copying script files into {scripts_dir}") + if not dry_run: + for file in os.listdir(scripts_resources): + abs_src = os.path.join(scripts_resources, file) + abs_dst = os.path.join(scripts_dir, file) + _patch_file(abs_src, abs_dst) + status = os.stat(abs_dst) + # Because these files have been tailored to a particular user, + # they hould only be executable by that user. So, use S_IXUSR + # (instead of S_IXOTH): + os.chmod(abs_dst, status.st_mode | stat.S_IXUSR) + + return util_dir + + +def station_upgrade(config_dict, dist_config_path=None, examples_root=None, + skin_root=None, what=None, no_confirm=False, no_backup=False, dry_run=False): + """Upgrade the user data for the configuration file found at config_path""" + + if what is None: + what = ('config', 'examples', 'util') + + config_path = config_dict['config_path'] + abbrev = {'config': 'configuration file', 'util': 'utility files'} + choices = ', '.join([abbrev.get(p, p) for p in what]) + msg = f"\nUpgrade {choices} in {config_dict['WEEWX_ROOT']} (Y/n)? " + + ans = weeutil.weeutil.y_or_n(msg, noprompt=no_confirm, default='y') + + if ans != 'y': + print("Nothing done.") + return + + if 'config' in what: + # If a path to a distribution template has been provided, use that. Otherwise, retrieve + # from package resources + if dist_config_path: + dist_config_dict = configobj.ConfigObj(dist_config_path, + encoding='utf-8', + interpolation=False, + file_error=True) + else: + # Retrieve the new configuration file from package resources: + with importlib_resources.open_text('weewx_data', 'weewx.conf', encoding='utf-8') as fd: + dist_config_dict = configobj.ConfigObj(fd, + encoding='utf-8', + interpolation=False, + file_error=True) + + weecfg.update_config.update_and_merge(config_dict, dist_config_dict) + print(f"Finished upgrading configuration file {config_path}") + print(f"Saving configuration file {config_path}") + # Save the updated config file with backup + backup_path = weecfg.save(config_dict, config_path, not no_backup) + if backup_path: + print(f"Saved old configuration file as {backup_path}") + + if 'skins' in what: + upgrade_skins(config_dict, skin_root=skin_root, dry_run=dry_run) + + if 'examples' in what: + examples_dir = copy_examples(config_dict, examples_root=examples_root, + dry_run=dry_run, force=True) + print(f"Finished upgrading examples at {examples_dir}") + + if 'util' in what: + util_dir = copy_util(config_path, config_dict, dry_run=dry_run, force=True) + if util_dir: + print(f"Finished upgrading utilities directory at {util_dir}") + else: + print("Could not upgrade the utilities directory.") + + if dry_run: + print("This was a dry run. Nothing was actually done.") + + +def upgrade_skins(config_dict, skin_root=None, dry_run=False): + """Make a backup of the old skins, then copy over new skins.""" + + if not skin_root: + try: + skin_root = config_dict['StdReport']['SKIN_ROOT'] + except KeyError: + skin_root = 'skins' + + # SKIN_ROOT is the location of the skins relative to WEEWX_ROOT. Find the absolute + # location of the skins + skin_dir = os.path.join(config_dict['WEEWX_ROOT'], skin_root) + + available_skins = _get_core_skins() + existing_skins = _get_existing_skins(skin_dir) + upgradable_skins = available_skins.intersection(existing_skins) + + if os.path.exists(skin_dir): + if not dry_run: + for skin_name in upgradable_skins: + skin_path = os.path.join(skin_dir, skin_name) + backup = weeutil.weeutil.move_with_timestamp(skin_path) + print(f"Skin {skin_name} saved to {backup}") + else: + print(f"No skin directory found at {skin_dir}") + + copy_skins(config_dict, dry_run) + + +def _get_core_skins(): + """ Get the set of skins that come with weewx + + Returns: + set: A set containing the names of the core skins + """ + with weeutil.weeutil.get_resource_path('weewx_data', 'skins') as skin_resources: + # Find which skins are available in the resource package + with os.scandir(skin_resources) as resource_contents: + available_skins = {os.path.basename(d.path) for d in resource_contents if d.is_dir()} + + return available_skins + + +def _get_existing_skins(skin_dir): + """ Get the set of skins that already exist in the user's data area. + + Args: + skin_dir(str): Path to the skin directory + + Returns: + set: A set containing the names of the skins in the user's data area + """ + + with os.scandir(skin_dir) as existing_contents: + existing_skins = {os.path.basename(d.path) for d in existing_contents if d.is_dir()} + + return existing_skins diff --git a/dist/weewx-5.0.2/src/weectllib/station_cmd.py b/dist/weewx-5.0.2/src/weectllib/station_cmd.py new file mode 100644 index 0000000..394ef58 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/station_cmd.py @@ -0,0 +1,340 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Entry point for the "station" subcommand.""" +import os.path +import sys + +import weecfg +import weectllib +import weectllib.station_actions +import weewx +from weeutil.weeutil import bcolors + +station_create_usage = f"""{bcolors.BOLD}weectl station create [WEEWX-ROOT] + [--driver=DRIVER] + [--location=LOCATION] + [--altitude=ALTITUDE,(foot|meter)] + [--latitude=LATITUDE] [--longitude=LONGITUDE] + [--register=(y,n) [--station-url=URL]] + [--units=(us|metricwx|metric)] + [--skin-root=DIRECTORY] + [--sqlite-root=DIRECTORY] + [--html-root=DIRECTORY] + [--user-root=DIRECTORY] + [--examples-root=DIRECTORY] + [--no-prompt] + [--config=FILENAME] + [--dist-config=FILENAME] + [--dry-run]{bcolors.ENDC} +""" +station_reconfigure_usage = f"""{bcolors.BOLD}weectl station reconfigure + [--driver=DRIVER] + [--location=LOCATION] + [--altitude=ALTITUDE,(foot|meter)] + [--latitude=LATITUDE] [--longitude=LONGITUDE] + [--register=(y,n) [--station-url=URL]] + [--units=(us|metricwx|metric)] + [--skin-root=DIRECTORY] + [--sqlite-root=DIRECTORY] + [--html-root=DIRECTORY] + [--user-root=DIRECTORY] + [--weewx-root=DIRECTORY] + [--no-backup] + [--no-prompt] + [--config=FILENAME] + [--dry-run]{bcolors.ENDC} +""" +station_upgrade_usage = f"""{bcolors.BOLD}weectl station upgrade + [--examples-root=DIRECTORY] + [--skin-root=DIRECTORY] + [--what ITEM [ITEM ...] + [--no-backup] + [--yes] + [--config=FILENAME] + [--dist-config=FILENAME]] + [--dry-run]{bcolors.ENDC} +""" + +station_usage = '\n '.join((station_create_usage, station_reconfigure_usage, + station_upgrade_usage)) + +CREATE_DESCRIPTION = f"""Create a new station data area at the location WEEWX-ROOT. If WEEWX-ROOT +is not provided, the location {weecfg.default_weewx_root} will be used.""" + +RECONFIGURE_DESCRIPTION = f"""Reconfigure an existing configuration file at the location given +by the --config option. If the option is not provided, the location {weecfg.default_config_path} +will be used. Unless the --no-prompt option has been specified, the user will be prompted for +new values.""" + +UPGRADE_DESCRIPTION = f"""Upgrade an existing station data area managed by the configuration file +given by the --config option. If the option is not provided, the location +{weecfg.default_config_path} will be used. Any combination of the examples, utility files, +configuration file, and skins can be upgraded, """ + + +def add_subparser(subparsers): + """Add the parsers used to implement the 'station' command. """ + station_parser = subparsers.add_parser('station', + usage=station_usage, + description='Manages the station data area, including ' + 'the configuration file and skins.', + help='Create, modify, or upgrade a station data area.') + # In the following, the 'prog' argument is necessary to get a proper error message. + # See Python issue https://bugs.python.org/issue42297 + action_parser = station_parser.add_subparsers(dest='action', + prog='weectl station', + title='Which action to take') + + # ---------- Action 'create' ---------- + create_parser = action_parser.add_parser('create', + description=CREATE_DESCRIPTION, + usage=station_create_usage, + help='Create a new station data area, including a ' + 'configuration file.') + create_parser.add_argument('--driver', + help='Driver to use. Default is "weewx.drivers.simulator".') + create_parser.add_argument('--location', + help='A description of the station. This will be used for report ' + 'titles. Default is "WeeWX".') + create_parser.add_argument('--altitude', metavar='ALTITUDE,{foot|meter}', + help='The station altitude in either feet or meters. For example, ' + '"750,foot" or "320,meter". Default is "0, foot".') + create_parser.add_argument('--latitude', + help='The station latitude in decimal degrees. Default is "0.00".') + create_parser.add_argument('--longitude', + help='The station longitude in decimal degrees. Default is "0.00".') + create_parser.add_argument('--register', choices=['y', 'n'], + help='Register this station in the weewx registry? Default is "n" ' + '(do not register).') + create_parser.add_argument('--station-url', + metavar='URL', + help='Unique URL to be used if registering the station. Required ' + 'if the station is to be registered.') + create_parser.add_argument('--units', choices=['us', 'metricwx', 'metric'], + dest='unit_system', + help='Set display units to us, metricwx, or metric. ' + 'Default is "us".') + create_parser.add_argument('--skin-root', + metavar='DIRECTORY', + help='Where to put the skins, relatve to WEEWX_ROOT. ' + 'Default is "skins".') + create_parser.add_argument('--sqlite-root', + metavar='DIRECTORY', + help='Where to put the SQLite database, relative to WEEWX_ROOT. ' + 'Default is "archive".') + create_parser.add_argument('--html-root', + metavar='DIRECTORY', + help='Where to put the generated HTML and images, relative to ' + 'WEEWX_ROOT. Default is "public_html".') + create_parser.add_argument('--user-root', + metavar='DIRECTORY', + help='Where to put the user extensions, relative to WEEWX_ROOT. ' + 'Default is "bin/user".') + create_parser.add_argument('--examples-root', + metavar='DIRECTORY', + help='Where to put the examples, relative to WEEWX_ROOT. ' + 'Default is "examples".') + create_parser.add_argument('--no-prompt', action='store_true', + help='Do not prompt. Use default values.') + create_parser.add_argument('--config', + metavar='FILENAME', + help='Where to put the configuration file, relative to WEEWX-ROOT. ' + 'It must not already exist. Default is "./weewx.conf".') + create_parser.add_argument('--dist-config', + metavar='FILENAME', + help='Use configuration file DIST-CONFIG-PATH as the new ' + 'configuration file. Default is to retrieve it from package ' + 'resources. The average user is unlikely to need this option.') + create_parser.add_argument('--dry-run', + action='store_true', + help='Print what would happen, but do not actually do it.') + create_parser.add_argument('weewx_root', + nargs='?', + metavar='WEEWX_ROOT', + help='Path to the WeeWX station data area to be created. ' + f'Default is {weecfg.default_weewx_root}.') + create_parser.set_defaults(func=create_station) + + # ---------- Action 'reconfigure' ---------- + reconfigure_parser = \ + action_parser.add_parser('reconfigure', + description=RECONFIGURE_DESCRIPTION, + usage=station_reconfigure_usage, + help='Reconfigure an existing station configuration file.') + + reconfigure_parser.add_argument('--driver', + help='New driver to use. Default is the old driver.') + reconfigure_parser.add_argument('--location', + help='A new description for the station. This will be used ' + 'for report titles. Default is the old description.') + reconfigure_parser.add_argument('--altitude', metavar='ALTITUDE,{foot|meter}', + help='The new station altitude in either feet or meters. ' + 'For example, "750,foot" or "320,meter". ' + 'Default is the old altitude.') + reconfigure_parser.add_argument('--latitude', + help='The new station latitude in decimal degrees. ' + 'Default is the old latitude.') + reconfigure_parser.add_argument('--longitude', + help='The new station longitude in decimal degrees. ' + 'Default is the old longitude.') + reconfigure_parser.add_argument('--register', choices=['y', 'n'], + help='Register this station in the weewx registry? ' + 'Default is the old value.') + reconfigure_parser.add_argument('--station-url', + metavar='URL', + help='A new unique URL to be used if registering the . ' + 'station. Default is the old URL.') + reconfigure_parser.add_argument('--units', choices=['us', 'metricwx', 'metric'], + dest='unit_system', + help='New display units. Set to to us, metricwx, or metric. ' + 'Default is the old unit system.') + reconfigure_parser.add_argument('--skin-root', + metavar='DIRECTORY', + help='New location where to find the skins, relatve ' + 'to WEEWX_ROOT. Default is the old location.') + reconfigure_parser.add_argument('--sqlite-root', + metavar='DIRECTORY', + help='New location where to find the SQLite database, ' + 'relative to WEEWX_ROOT. Default is the old location.') + reconfigure_parser.add_argument('--html-root', + metavar='DIRECTORY', + help='New location where to put the generated HTML and ' + 'images, relative to WEEWX_ROOT. ' + 'Default is the old location.') + reconfigure_parser.add_argument('--user-root', + metavar='DIRECTORY', + help='New location where to find the user extensions, ' + 'relative to WEEWX_ROOT. Default is the old location.') + reconfigure_parser.add_argument('--weewx-root', + metavar='WEEWX_ROOT', + help='New path to the WeeWX station data area. ' + 'Default is the old path.') + reconfigure_parser.add_argument('--no-backup', action='store_true', + help='Do not backup the old configuration file.') + reconfigure_parser.add_argument('--no-prompt', action='store_true', + help='Do not prompt. Use default values.') + reconfigure_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}"') + reconfigure_parser.add_argument('--dry-run', + action='store_true', + help='Print what would happen, but do not actually ' + 'do it.') + reconfigure_parser.set_defaults(func=weectllib.dispatch) + reconfigure_parser.set_defaults(action_func=reconfigure_station) + + # ---------- Action 'upgrade' ---------- + station_upgrade_parser = \ + action_parser.add_parser('upgrade', + usage=station_upgrade_usage, + description=UPGRADE_DESCRIPTION, + help='Upgrade any combination of the examples, utility ' + 'files, configuration file, and skins.') + + station_upgrade_parser.add_argument('--examples-root', + metavar='DIRECTORY', + help='Where to put the new examples, relative to ' + 'WEEWX_ROOT. Default is "examples".') + station_upgrade_parser.add_argument('--skin-root', + metavar='DIRECTORY', + help='Where to put the skins, relative to ' + 'WEEWX_ROOT. Default is "skins".') + station_upgrade_parser.add_argument('--what', + choices=['examples', 'util', 'config', 'skins'], + default=['examples', 'util'], + nargs='+', + metavar='ITEM', + help="What to upgrade. Choose from 'examples', 'util', " + "'skins', 'config', or some combination, " + "separated by spaces. Default is to upgrade the " + "examples, and utility files.") + station_upgrade_parser.add_argument('--no-backup', action='store_true', + help='Do not backup the old configuration file.') + station_upgrade_parser.add_argument('-y', '--yes', action='store_true', + help="Don't ask for confirmation. Just do it.") + station_upgrade_parser.add_argument('--config', + metavar='FILENAME', + help=f'Path to configuration file. ' + f'Default is "{weecfg.default_config_path}"') + station_upgrade_parser.add_argument('--dist-config', + metavar='FILENAME', + help='Use configuration file FILENAME as the ' + 'new configuration file. Default is to retrieve it ' + 'from package resources. The average user is ' + 'unlikely to need this option.') + station_upgrade_parser.add_argument('--dry-run', + action='store_true', + help='Print what would happen, but do not actually ' + 'do it.') + station_upgrade_parser.set_defaults(func=weectllib.dispatch) + station_upgrade_parser.set_defaults(action_func=upgrade_station) + + +# ============================================================================== +# Action invocations +# ============================================================================== + + +def create_station(namespace): + """Map 'namespace' to a call to station_create()""" + try: + config_dict = weectllib.station_actions.station_create( + weewx_root=namespace.weewx_root, + rel_config_path=namespace.config, + driver=namespace.driver, + location=namespace.location, + altitude=namespace.altitude, + latitude=namespace.latitude, + longitude=namespace.longitude, + register=namespace.register, + station_url=namespace.station_url, + unit_system=namespace.unit_system, + skin_root=namespace.skin_root, + sqlite_root=namespace.sqlite_root, + html_root=namespace.html_root, + examples_root=namespace.examples_root, + user_root=namespace.user_root, + dist_config_path=namespace.dist_config, + no_prompt=namespace.no_prompt, + dry_run=namespace.dry_run) + except weewx.ViolatedPrecondition as e: + sys.exit(str(e)) + + +def reconfigure_station(config_dict, namespace): + """Map namespace to a call to station_reconfigure()""" + try: + weectllib.station_actions.station_reconfigure(config_dict=config_dict, + driver=namespace.driver, + location=namespace.location, + altitude=namespace.altitude, + latitude=namespace.latitude, + longitude=namespace.longitude, + register=namespace.register, + station_url=namespace.station_url, + unit_system=namespace.unit_system, + weewx_root=namespace.weewx_root, + skin_root=namespace.skin_root, + sqlite_root=namespace.sqlite_root, + html_root=namespace.html_root, + user_root=namespace.user_root, + no_prompt=namespace.no_prompt, + no_backup=namespace.no_backup, + dry_run=namespace.dry_run) + except weewx.ViolatedPrecondition as e: + sys.exit(str(e)) + + +def upgrade_station(config_dict, namespace): + weectllib.station_actions.station_upgrade(config_dict=config_dict, + dist_config_path=namespace.dist_config, + examples_root=namespace.examples_root, + skin_root=namespace.skin_root, + what=namespace.what, + no_confirm=namespace.yes, + no_backup=namespace.no_backup, + dry_run=namespace.dry_run) diff --git a/dist/weewx-5.0.2/src/weectllib/tests/test_station_actions.py b/dist/weewx-5.0.2/src/weectllib/tests/test_station_actions.py new file mode 100644 index 0000000..1bad37e --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/tests/test_station_actions.py @@ -0,0 +1,361 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the configuration utilities.""" +import contextlib +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +import configobj + +import weecfg +import weectllib.station_actions +import weeutil.config +import weeutil.weeutil +import weewx +import weewxd + +weewxd_path = weewxd.__file__ + +# For the tests, use the version of weewx.conf that comes with WeeWX. +with weeutil.weeutil.get_resource_fd('weewx_data', 'weewx.conf') as fd: + CONFIG_DICT = configobj.ConfigObj(fd, encoding='utf-8', file_error=True) + +STATION_URL = 'https://weewx.com' + + +def suppress_stdout(func): + def wrapper(*args, **kwargs): + with open(os.devnull, 'w') as devnull: + with contextlib.redirect_stdout(devnull): + return func(*args, **kwargs) + + return wrapper + + +class CommonConfigTest(unittest.TestCase): + + def setUp(self): + self.config_dict = weeutil.config.deep_copy(CONFIG_DICT) + + +class LocationConfigTest(CommonConfigTest): + + def test_default_config_location(self): + weectllib.station_actions.config_location(self.config_dict, no_prompt=True) + self.assertEqual(self.config_dict['Station']['location'], "WeeWX station") + del self.config_dict['Station']['location'] + weectllib.station_actions.config_location(self.config_dict, no_prompt=True) + self.assertEqual(self.config_dict['Station']['location'], "WeeWX station") + + def test_arg_config_location(self): + weectllib.station_actions.config_location(self.config_dict, location='foo', no_prompt=True) + self.assertEqual(self.config_dict['Station']['location'], "foo") + + @suppress_stdout + def test_prompt_config_location(self): + with patch('weectllib.station_actions.input', side_effect=['']): + weectllib.station_actions.config_location(self.config_dict) + self.assertEqual(self.config_dict['Station']['location'], "WeeWX station") + with patch('weectllib.station_actions.input', side_effect=['bar']): + weectllib.station_actions.config_location(self.config_dict) + self.assertEqual(self.config_dict['Station']['location'], "bar") + + +class AltitudeConfigTest(CommonConfigTest): + + def test_default_config_altitude(self): + weectllib.station_actions.config_altitude(self.config_dict, no_prompt=True) + self.assertEqual(self.config_dict['Station']['altitude'], ["0", "foot"]) + # Delete the value in the configuration dictionary + del self.config_dict['Station']['altitude'] + # Now we should get the hardwired default + weectllib.station_actions.config_altitude(self.config_dict, no_prompt=True) + self.assertEqual(self.config_dict['Station']['altitude'], ["0", "foot"]) + + def test_arg_config_altitude(self): + weectllib.station_actions.config_altitude(self.config_dict, altitude="500, meter") + self.assertEqual(self.config_dict['Station']['altitude'], ["500", "meter"]) + + def test_badarg_config_altitude(self): + with self.assertRaises(ValueError): + # Bad unit + weectllib.station_actions.config_altitude(self.config_dict, altitude="500, foo") + with self.assertRaises(ValueError): + # Bad value + weectllib.station_actions.config_altitude(self.config_dict, altitude="500f, foot") + + @suppress_stdout + def test_prompt_config_altitude(self): + with patch('weectllib.station_actions.input', side_effect=['']): + weectllib.station_actions.config_altitude(self.config_dict) + self.assertEqual(self.config_dict['Station']['altitude'], ["0", "foot"]) + with patch('weectllib.station_actions.input', side_effect=['110, meter']): + weectllib.station_actions.config_altitude(self.config_dict) + self.assertEqual(self.config_dict['Station']['altitude'], ["110", "meter"]) + # Try 'feet' instead of 'foot' + with patch('weectllib.station_actions.input', side_effect=['700, feet']): + weectllib.station_actions.config_altitude(self.config_dict) + self.assertEqual(self.config_dict['Station']['altitude'], ["700", "foot"]) + # Try 'meters' instead of 'meter': + with patch('weectllib.station_actions.input', side_effect=['110, meters']): + weectllib.station_actions.config_altitude(self.config_dict) + self.assertEqual(self.config_dict['Station']['altitude'], ["110", "meter"]) + + @suppress_stdout + def test_badprompt_config_altitude(self): + # Include a bad unit. It should prompt again + with patch('weectllib.station_actions.input', side_effect=['100, foo', '110, meter']): + weectllib.station_actions.config_altitude(self.config_dict) + self.assertEqual(self.config_dict['Station']['altitude'], ["110", "meter"]) + # Include a bad value. It should prompt again + with patch('weectllib.station_actions.input', side_effect=['100f, foot', '110, meter']): + weectllib.station_actions.config_altitude(self.config_dict) + self.assertEqual(self.config_dict['Station']['altitude'], ["110", "meter"]) + + +class LatLonConfigTest(CommonConfigTest): + + def test_default_config_latlon(self): + # Use the default as supplied by CONFIG_DICT + weectllib.station_actions.config_latlon(self.config_dict, no_prompt=True) + self.assertEqual(float(self.config_dict['Station']['latitude']), 0.0) + self.assertEqual(float(self.config_dict['Station']['longitude']), 0.0) + # Delete the values in the configuration dictionary + del self.config_dict['Station']['latitude'] + del self.config_dict['Station']['longitude'] + # Now the defaults should be the hardwired defaults + weectllib.station_actions.config_latlon(self.config_dict, no_prompt=True) + self.assertEqual(float(self.config_dict['Station']['latitude']), 0.0) + self.assertEqual(float(self.config_dict['Station']['longitude']), 0.0) + + def test_arg_config_latlon(self): + weectllib.station_actions.config_latlon(self.config_dict, latitude='-20', longitude='-40') + self.assertEqual(float(self.config_dict['Station']['latitude']), -20.0) + self.assertEqual(float(self.config_dict['Station']['longitude']), -40.0) + + def test_badarg_config_latlon(self): + with self.assertRaises(ValueError): + weectllib.station_actions.config_latlon(self.config_dict, latitude="-20f", + longitude='-40') + + @suppress_stdout + def test_prompt_config_latlong(self): + with patch('weecfg.input', side_effect=['-21', '-41']): + weectllib.station_actions.config_latlon(self.config_dict) + self.assertEqual(float(self.config_dict['Station']['latitude']), -21.0) + self.assertEqual(float(self.config_dict['Station']['longitude']), -41.0) + + +class RegistryConfigTest(CommonConfigTest): + + def test_default_register(self): + weectllib.station_actions.config_registry(self.config_dict, no_prompt=True) + self.assertFalse( + self.config_dict['StdRESTful']['StationRegistry']['register_this_station']) + + def test_args_register(self): + # Missing station_url: + with self.assertRaises(weewx.ViolatedPrecondition): + weectllib.station_actions.config_registry(self.config_dict, register='True', + no_prompt=True) + # This time we supply a station_url. Should be OK. + weectllib.station_actions.config_registry(self.config_dict, register='True', + station_url=STATION_URL, no_prompt=True) + self.assertTrue(self.config_dict['StdRESTful']['StationRegistry']['register_this_station']) + self.assertEqual(self.config_dict['Station']['station_url'], STATION_URL) + # Alternatively, the config file already had a station_url: + self.config_dict['Station']['station_url'] = STATION_URL + weectllib.station_actions.config_registry(self.config_dict, register='True', + no_prompt=True) + self.assertTrue(self.config_dict['StdRESTful']['StationRegistry']['register_this_station']) + self.assertEqual(self.config_dict['Station']['station_url'], STATION_URL) + + @suppress_stdout + def test_prompt_register(self): + with patch('weeutil.weeutil.input', side_effect=['y']): + with patch('weecfg.input', side_effect=[STATION_URL]): + weectllib.station_actions.config_registry(self.config_dict) + self.assertTrue(self.config_dict['StdRESTful']['StationRegistry']['register_this_station']) + self.assertEqual(self.config_dict['Station']['station_url'], STATION_URL) + + # Try again, but without specifying an URL. Should ask twice. + with patch('weeutil.weeutil.input', side_effect=['y']): + with patch('weecfg.input', side_effect=["", STATION_URL]): + weectllib.station_actions.config_registry(self.config_dict) + self.assertTrue(self.config_dict['StdRESTful']['StationRegistry']['register_this_station']) + self.assertEqual(self.config_dict['Station']['station_url'], STATION_URL) + + # Now with a bogus URL + with patch('weeutil.weeutil.input', side_effect=['y']): + with patch('weecfg.input', side_effect=['https://www.example.com', STATION_URL]): + weectllib.station_actions.config_registry(self.config_dict) + self.assertTrue(self.config_dict['StdRESTful']['StationRegistry']['register_this_station']) + self.assertEqual(self.config_dict['Station']['station_url'], STATION_URL) + + +class UnitsConfigTest(CommonConfigTest): + + def test_default_units(self): + weectllib.station_actions.config_units(self.config_dict, no_prompt=True) + self.assertEqual(self.config_dict['StdReport']['Defaults']['unit_system'], 'us') + + def test_custom_units(self): + del self.config_dict['StdReport']['Defaults']['unit_system'] + weectllib.station_actions.config_units(self.config_dict, no_prompt=True) + self.assertNotIn('unit_system', self.config_dict['StdReport']['Defaults']) + + def test_args_units(self): + weectllib.station_actions.config_units(self.config_dict, unit_system='metricwx', + no_prompt=True) + self.assertEqual(self.config_dict['StdReport']['Defaults']['unit_system'], 'metricwx') + + @suppress_stdout + def test_prompt_units(self): + with patch('weecfg.input', side_effect=['metricwx']): + weectllib.station_actions.config_units(self.config_dict) + self.assertEqual(self.config_dict['StdReport']['Defaults']['unit_system'], 'metricwx') + # Do it again, but with a wrong unit system name. It should ask again. + with patch('weecfg.input', side_effect=['metricwz', 'metricwx']): + weectllib.station_actions.config_units(self.config_dict) + self.assertEqual(self.config_dict['StdReport']['Defaults']['unit_system'], 'metricwx') + + +class DriverConfigTest(CommonConfigTest): + + def test_default_config_driver(self): + weectllib.station_actions.config_driver(self.config_dict, no_prompt=True) + self.assertEqual(self.config_dict['Station']['station_type'], 'Simulator') + self.assertEqual(self.config_dict['Simulator']['driver'], 'weewx.drivers.simulator') + + def test_arg_config_driver(self): + weectllib.station_actions.config_driver(self.config_dict, driver='weewx.drivers.vantage', + no_prompt=True) + self.assertEqual(self.config_dict['Station']['station_type'], 'Vantage') + self.assertEqual(self.config_dict['Vantage']['driver'], 'weewx.drivers.vantage') + + def test_arg_noeditor_config_driver(self): + # Test a driver that does not have a configurator editor. Because all WeeWX drivers do, we + # have to disable one of them. + import weewx.drivers.vantage + weewx.drivers.vantage.hold = weewx.drivers.vantage.confeditor_loader + del weewx.drivers.vantage.confeditor_loader + # At this point, there is no configuration loader, so a minimal version of [Vantage] + # should be supplied. + weectllib.station_actions.config_driver(self.config_dict, + driver='weewx.drivers.vantage', + no_prompt=True) + self.assertEqual(self.config_dict['Station']['station_type'], 'Vantage') + self.assertEqual(self.config_dict['Vantage']['driver'], 'weewx.drivers.vantage') + # The rest of the [Vantage] stanza should be missing. Try a key. + self.assertNotIn('port', self.config_dict['Vantage']) + # Restore the editor: + weewx.drivers.vantage.confeditor_loader = weewx.drivers.vantage.hold + + @suppress_stdout + def test_prompt_config_driver(self): + with patch('weecfg.input', side_effect=['6', '', '/dev/ttyS0']): + weectllib.station_actions.config_driver(self.config_dict) + self.assertEqual(self.config_dict['Station']['station_type'], 'Vantage') + self.assertEqual(self.config_dict['Vantage']['port'], '/dev/ttyS0') + + # Do it again. This time, the stanza ['Vantage'] will exist, and we'll just modify it + with patch('weecfg.input', side_effect=['', '', '/dev/ttyS1']): + weectllib.station_actions.config_driver(self.config_dict) + self.assertEqual(self.config_dict['Station']['station_type'], 'Vantage') + self.assertEqual(self.config_dict['Vantage']['port'], '/dev/ttyS1') + + +class TestConfigRoots(CommonConfigTest): + + def test_args_config_roots(self): + weectllib.station_actions.config_roots(self.config_dict, skin_root='foo', + html_root='bar', sqlite_root='baz') + self.assertEqual(self.config_dict['StdReport']['SKIN_ROOT'], 'foo') + self.assertEqual(self.config_dict['StdReport']['HTML_ROOT'], 'bar') + self.assertEqual(self.config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'], 'baz') + + # Delete the options, then try again. They should be replaced with defaults + del self.config_dict['StdReport']['SKIN_ROOT'] + del self.config_dict['StdReport']['HTML_ROOT'] + del self.config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'] + weectllib.station_actions.config_roots(self.config_dict) + self.assertEqual(self.config_dict['StdReport']['SKIN_ROOT'], 'skins') + self.assertEqual(self.config_dict['StdReport']['HTML_ROOT'], 'public_html') + self.assertEqual(self.config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'], + 'archive') + + +class TestCreateStation(unittest.TestCase): + + def test_create_default(self): + "Test creating a new station" + # Get a temporary directory to create it in + with tempfile.TemporaryDirectory(dir='/var/tmp') as weewx_root: + # We have not run 'pip', so the only copy of weewxd.py is the one in the repository. + # Create a station using the defaults + weectllib.station_actions.station_create(weewx_root=weewx_root, no_prompt=True) + config_path = os.path.join(weewx_root, 'weewx.conf') + + # Retrieve the config file that was created and check it: + config_dict = configobj.ConfigObj(config_path, encoding='utf-8') + self.assertNotIn('WEEWX_ROOT', config_dict) + self.assertNotIn('WEEWX_ROOT_CONFIG', config_dict) + self.assertEqual(config_dict['Station']['station_type'], 'Simulator') + self.assertEqual(config_dict['Simulator']['driver'], 'weewx.drivers.simulator') + self.assertEqual(config_dict['StdReport']['SKIN_ROOT'], 'skins') + self.assertEqual(config_dict['StdReport']['HTML_ROOT'], 'public_html') + self.assertEqual(config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'], 'archive') + + # Make sure all the skins are there + for skin in ['Seasons', 'Smartphone', 'Mobile', 'Standard', + 'Ftp', 'Rsync']: + p = os.path.join(weewx_root, config_dict['StdReport']['SKIN_ROOT'], skin) + self.assertTrue(os.path.isdir(p)) + + # Retrieve the systemd utility file and check it + path = os.path.join(weewx_root, 'util/systemd/weewx.service') + with open(path, 'rt') as fd: + for line in fd: + if line.startswith('ExecStart'): + self.assertEqual(line.strip(), + f"ExecStart={sys.executable} {weewxd_path} {config_path}") + + +class TestReconfigureStation(unittest.TestCase): + + def test_reconfigure(self): + "Test reconfiguring a station" + # Get a temporary directory to create a station in + with tempfile.TemporaryDirectory(dir='/var/tmp') as weewx_root: + # Create a station using the defaults + weectllib.station_actions.station_create(weewx_root=weewx_root, no_prompt=True) + + # Now retrieve the config file that was created. The retrieval must use "read_config()" + # because that's what station_reconfigure() is expecting. + config_path = os.path.join(weewx_root, 'weewx.conf') + config_path, config_dict = weecfg.read_config(config_path) + # Now reconfigure it: + weectllib.station_actions.station_reconfigure(config_dict, + weewx_root='/etc/weewx', + driver='weewx.drivers.vantage', + no_prompt=True) + # Re-read it: + config_dict = configobj.ConfigObj(config_path, encoding='utf-8') + # Check it out. + self.assertEqual(config_dict['WEEWX_ROOT'], '/etc/weewx') + self.assertNotIn('WEEWX_ROOT_CONFIG', config_dict) + self.assertEqual(config_dict['Station']['station_type'], 'Vantage') + self.assertEqual(config_dict['Vantage']['driver'], 'weewx.drivers.vantage') + self.assertEqual(config_dict['StdReport']['SKIN_ROOT'], 'skins') + self.assertEqual(config_dict['StdReport']['HTML_ROOT'], 'public_html') + self.assertEqual(config_dict['DatabaseTypes']['SQLite']['SQLITE_ROOT'], 'archive') + + +if __name__ == "__main__": + unittest.main() diff --git a/dist/weewx-5.0.2/src/weectllib/tests/test_weectllib.py b/dist/weewx-5.0.2/src/weectllib/tests/test_weectllib.py new file mode 100644 index 0000000..df18551 --- /dev/null +++ b/dist/weewx-5.0.2/src/weectllib/tests/test_weectllib.py @@ -0,0 +1,60 @@ +# +# Copyright (c) 2023-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +import datetime +import unittest + +from weectllib import parse_dates + + +class ParseTest(unittest.TestCase): + + def test_parse_default(self): + from_val, to_val = parse_dates() + self.assertIsNone(from_val) + self.assertIsNone(to_val) + + def test_parse_date(self): + from_val, to_val = parse_dates(date="2021-05-13") + self.assertIsInstance(from_val, datetime.date) + self.assertEqual(from_val, datetime.date(2021, 5, 13)) + self.assertEqual(from_val, to_val) + + def test_parse_datetime(self): + from_valt, to_valt = parse_dates(date="2021-05-13", as_datetime=True) + self.assertIsInstance(from_valt, datetime.datetime) + self.assertEqual(from_valt, datetime.datetime(2021, 5, 13)) + self.assertEqual(from_valt, to_valt) + + from_valt, to_valt = parse_dates(date="2021-05-13T15:47:20", as_datetime=True) + self.assertIsInstance(from_valt, datetime.datetime) + self.assertEqual(from_valt, datetime.datetime(2021, 5, 13, 15, 47, 20)) + + def test_parse_from(self): + from_val, to_val = parse_dates(from_date="2021-05-13") + self.assertIsInstance(from_val, datetime.date) + self.assertEqual(from_val, datetime.date(2021, 5, 13)) + self.assertIsNone(to_val) + + def test_parse_to(self): + from_val, to_val = parse_dates(to_date="2021-06-02") + self.assertIsInstance(to_val, datetime.date) + self.assertEqual(to_val, datetime.date(2021, 6, 2)) + self.assertIsNone(from_val) + + def test_parse_from_to(self): + from_val, to_val = parse_dates(from_date="2021-05-13", to_date="2021-06-02") + self.assertIsInstance(from_val, datetime.date) + self.assertEqual(from_val, datetime.date(2021, 5, 13)) + self.assertEqual(to_val, datetime.date(2021, 6, 2)) + + def test_parse_from_to_time(self): + from_val, to_val = parse_dates(from_date="2021-05-13T08:30:05", + to_date="2021-06-02T21:11:55", + as_datetime=True) + self.assertIsInstance(from_val, datetime.datetime) + self.assertEqual(from_val, datetime.datetime(2021, 5, 13, 8, 30, 5)) + self.assertEqual(to_val, datetime.datetime(2021, 6, 2, 21, 11, 55)) diff --git a/dist/weewx-5.0.2/src/weedb/NOTES.md b/dist/weewx-5.0.2/src/weedb/NOTES.md new file mode 100644 index 0000000..2acc90d --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/NOTES.md @@ -0,0 +1,70 @@ +This table shows how the various MySQLdb and sqlite exceptions are mapped to a weedb exception. + +# weewx Version 3.6 or earlier: + +| weedb class | Sqlite class | MySQLdb class | MySQLdb error number | Description | +|--------------------|--------------------|--------------------|:--------------------:|---------------------------------| +| `OperationalError` | *N/A* | `OperationalError` | 2002 | Server down | +| `OperationalError` | *N/A* | `OperationalError` | 2005 | Unknown host | +| `OperationalError` | *N/A* | `OperationalError` | 1045 | Bad or non-existent password | +| `NoDatabase` | *N/A* | `OperationalError` | 1008 | Drop non-existent database | +| `NoDatabase` | `OperationalError` | `OperationalError` | 1044 | No permission | +| `OperationalError` | *N/A* | `OperationalError` | 1049 | Open non-existent database | +| `DatabaseExists` | *N/A* | `ProgrammingError` | 1007 | Database already exists | +| `OperationalError` | `OperationalError` | `OperationalError` | 1050 | Table already exists | +| `ProgrammingError` | *N/A* | `ProgrammingError` | 1146 | SELECT on non-existing database | +| `ProgrammingError` | `OperationalError` | `ProgrammingError` | 1146 | SELECT non-existing table | +| `OperationalError` | `OperationalError` | `OperationalError` | 1054 | SELECT non-existing column | +| `IntegrityError` | `IntegrityError` | `IntegrityError` | 1062 | Duplicate key | + +### V3.6 Exception hierarchy + +~~~ +StandardError +|__DatabaseError + |__IntegrityError + |__ProgrammingError + |__OperationalError + |__DatabaseExists + |__NoDatabase +~~~ + + +# weewx Version 3.7 and later: + +| weedb class | Sqlite class | MySQLdb class | MySQLdb error number | Description | +|-----------------------|--------------------|--------------------|:--------------------:|---------------------------------| +| `CannotConnectError` | *N/A* | `OperationalError` | 2002 | Server down | +| `CannotConnectError` | *N/A* | `OperationalError` | 2003 | Host error | +| `CannotConnectError` | *N/A* | `OperationalError` | 2005 | Unknown host | +| `DisconnectError` | *N/A* | `OperationalError` | 2006 | Server gone | +| `DisconnectError` | *N/A* | `OperationalError` | 2013 | Lost connection during query | +| `BadPasswordError` | *N/A* | `OperationalError` | 1045 | Bad or non-existent password | +| `NoDatabaseError` | *N/A* | `OperationalError` | 1008 | Drop non-existent database | +| `PermissionError` | `OperationalError` | `OperationalError` | 1044 | No permission | +| `NoDatabaseError` | *N/A* | `OperationalError` | 1049 | Open non-existent database | +| `DatabaseExistsError` | *N/A* | `ProgrammingError` | 1007 | Database already exists | +| `TableExistsError` | `OperationalError` | `OperationalError` | 1050 | Table already exists | +| `NoTableError` | *N/A* | `ProgrammingError` | 1146 | SELECT on non-existing database | +| `NoTableError` | `OperationalError` | `ProgrammingError` | 1146 | SELECT non-existing table | +| `NoColumnError` | `OperationalError` | `OperationalError` | 1054 | SELECT non-existing column | +| `IntegrityError` | `IntegrityError` | `IntegrityError` | 1062 | Duplicate key | + +### V3.7 Exception hierarchy + +~~~ +StandardError +|__DatabaseError + |__IntegrityError + |__ProgrammingError + |__DatabaseExistsError + |__TableExistsError + |__NoTableError + |__OperationalError + |__NoDatabaseError + |__CannotConnectError + |__DisconnectError + |__NoColumnError + |__BadPasswordError + |__PermissionError +~~~ diff --git a/dist/weewx-5.0.2/src/weedb/__init__.py b/dist/weewx-5.0.2/src/weedb/__init__.py new file mode 100644 index 0000000..edbf913 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/__init__.py @@ -0,0 +1,227 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Middleware that sits above DBAPI and makes it a little more database independent. + +Weedb generally follows the MySQL exception model. Specifically: + - Operations on a non-existent database result in a weedb.OperationalError exception + being raised. + - Operations on a non-existent table result in a weedb.ProgrammingError exception + being raised. + - Select statements requesting non-existing columns result in a weedb.OperationalError + exception being raised. + - Attempt to add a duplicate key results in a weedb.IntegrityError exception + being raised. +""" + +import importlib + + +# The exceptions that the weedb package can raise: +class DatabaseError(Exception): + """Base class of all weedb exceptions.""" + + +class IntegrityError(DatabaseError): + """Operation attempted involving the relational integrity of the database.""" + + +class ProgrammingError(DatabaseError): + """SQL or other programming error.""" + + +class DatabaseExistsError(ProgrammingError): + """Attempt to create a database that already exists""" + + +class TableExistsError(ProgrammingError): + """Attempt to create a table that already exists.""" + + +class NoTableError(ProgrammingError): + """Attempt to operate on a non-existing table.""" + + +class OperationalError(DatabaseError): + """Runtime database errors.""" + + +class NoDatabaseError(OperationalError): + """Operation attempted on a database that does not exist.""" + + +class CannotConnectError(OperationalError): + """Unable to connect to the database server.""" + + +class DisconnectError(OperationalError): + """Database went away.""" + + +class NoColumnError(OperationalError): + """Attempt to operate on a column that does not exist.""" + + +class BadPasswordError(OperationalError): + """Bad or missing password.""" + + +class PermissionError(OperationalError): + """Lacking necessary permissions.""" + + +# For backwards compatibility: +DatabaseExists = DatabaseExistsError +NoDatabase = NoDatabaseError +CannotConnect = CannotConnectError + + +# In what follows, the test whether a database dictionary has function "dict" is +# to get around a bug in ConfigObj. It seems to be unable to unpack (using the +# '**' notation) a ConfigObj dictionary into a function. By calling .dict() a +# regular dictionary is returned, which can be unpacked. + +def create(db_dict): + """Create a database. If it already exists, an exception of type + weedb.DatabaseExistsError will be raised.""" + driver_mod = importlib.import_module(db_dict['driver']) + # See note above + if hasattr(db_dict, "dict"): + return driver_mod.create(**db_dict.dict()) + else: + return driver_mod.create(**db_dict) + + +def connect(db_dict): + """Return a connection to a database. If the database does not + exist, an exception of type weedb.NoDatabaseError will be raised.""" + driver_mod = importlib.import_module(db_dict['driver']) + # See note above + if hasattr(db_dict, "dict"): + return driver_mod.connect(**db_dict.dict()) + else: + return driver_mod.connect(**db_dict) + + +def drop(db_dict): + """Drop (delete) a database. If the database does not exist, + the exception weedb.NoDatabaseError will be raised.""" + driver_mod = importlib.import_module(db_dict['driver']) + # See note above + if hasattr(db_dict, "dict"): + return driver_mod.drop(**db_dict.dict()) + else: + return driver_mod.drop(**db_dict) + + +class Connection(object): + """Abstract base class, representing a connection to a database.""" + + def __init__(self, connection, database_name, dbtype): + """Superclass should raise exception of type weedb.OperationalError + if the database does not exist.""" + self.connection = connection + self.database_name = database_name + self.dbtype = dbtype + + def cursor(self): + """Returns an appropriate database cursor.""" + raise NotImplementedError + + def execute(self, sql_string, sql_tuple=()): + """Execute a sql statement. This version does not return a cursor, + so it can only be used for statements that do not return a result set.""" + + cursor = self.cursor() + try: + cursor.execute(sql_string, sql_tuple) + finally: + cursor.close() + + def tables(self): + """Returns a list of the tables in the database. + Returns an empty list if the database has no tables in it.""" + raise NotImplementedError + + def genSchemaOf(self, table): + """Generator function that returns a summary of the table's schema. + It returns a 6-way tuple: + (number, column_name, column_type, can_be_null, default_value, is_primary) + + Example: + (2, 'mintime', 'INTEGER', True, None, False)""" + raise NotImplementedError + + def columnsOf(self, table): + """Returns a list of the column names in the specified table. Implementers + should raise an exception of type weedb.ProgrammingError if the table does not exist.""" + raise NotImplementedError + + def get_variable(self, var_name): + """Return a database specific operational variable. Generally, things like + pragmas, or optimization-related variables. + + It returns a 2-way tuple: + (variable-name, variable-value) + If the variable does not exist, it returns None. + """ + raise NotImplementedError + + @property + def has_math(self): + """Returns True if the database supports math functions such as cos() and sin(). + False otherwise.""" + return True + + def begin(self): + raise NotImplementedError + + def commit(self): + raise NotImplementedError + + def rollback(self): + raise NotImplementedError + + def close(self): + try: + self.connection.close() + except DatabaseError: + pass + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + try: + self.close() + except DatabaseError: + pass + + +class Cursor(object): + pass + + +class Transaction(object): + """Class to be used to wrap transactions in a 'with' clause.""" + + def __init__(self, connection): + self.connection = connection + self.cursor = self.connection.cursor() + + def __enter__(self): + self.connection.begin() + return self.cursor + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + if etyp is None: + self.connection.commit() + else: + self.connection.rollback() + try: + self.cursor.close() + except DatabaseError: + pass + diff --git a/dist/weewx-5.0.2/src/weedb/mysql.py b/dist/weewx-5.0.2/src/weedb/mysql.py new file mode 100644 index 0000000..2bb3397 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/mysql.py @@ -0,0 +1,316 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""weedb driver for the MySQL database""" + +import decimal + +try: + import MySQLdb +except ImportError: + # Maybe the user has "pymysql", a pure-Python version? + import pymysql as MySQLdb + from pymysql import DatabaseError as MySQLDatabaseError +else: + try: + from MySQLdb import DatabaseError as MySQLDatabaseError + except ImportError: + from _mysql_exceptions import DatabaseError as MySQLDatabaseError + +from weeutil.weeutil import to_bool +import weedb + +DEFAULT_ENGINE = 'INNODB' + +exception_map = { + 1007: weedb.DatabaseExistsError, + 1008: weedb.NoDatabaseError, + 1044: weedb.PermissionError, + 1045: weedb.BadPasswordError, + 1049: weedb.NoDatabaseError, + 1050: weedb.TableExistsError, + 1054: weedb.NoColumnError, + 1091: weedb.NoColumnError, + 1062: weedb.IntegrityError, + 1146: weedb.NoTableError, + 1927: weedb.CannotConnectError, + 2002: weedb.CannotConnectError, + 2003: weedb.CannotConnectError, + 2005: weedb.CannotConnectError, + 2006: weedb.DisconnectError, + 2013: weedb.DisconnectError, + None: weedb.DatabaseError +} + + +def guard(fn): + """Decorator function that converts MySQL exceptions into weedb exceptions.""" + + def guarded_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except MySQLDatabaseError as e: + # Get the MySQL exception number out of e: + try: + errno = e.args[0] + except (IndexError, AttributeError): + errno = None + # Default exception is weedb.DatabaseError + klass = exception_map.get(errno, weedb.DatabaseError) + raise klass(e) + + return guarded_fn + + +def connect(host='localhost', user='', password='', database_name='', + driver='', port=3306, engine=DEFAULT_ENGINE, autocommit=True, **kwargs): + """Connect to the specified database""" + return Connection(host=host, port=int(port), user=user, password=password, + database_name=database_name, engine=engine, autocommit=autocommit, **kwargs) + + +def create(host='localhost', user='', password='', database_name='', + driver='', port=3306, engine=DEFAULT_ENGINE, autocommit=True, **kwargs): + """Create the specified database. If it already exists, + an exception of type weedb.DatabaseExistsError will be raised.""" + + # Open up a connection w/o specifying the database. + with Connection(host=host, + port=int(port), + user=user, + password=password, + autocommit=autocommit, + **kwargs) as connect: + with connect.cursor() as cursor: + # Now create the database. + cursor.execute("CREATE DATABASE %s" % (database_name,)) + + +def drop(host='localhost', user='', password='', database_name='', + driver='', port=3306, engine=DEFAULT_ENGINE, autocommit=True, + **kwargs): + """Drop (delete) the specified database.""" + + with Connection(host=host, + port=int(port), + user=user, + password=password, + autocommit=autocommit, + **kwargs) as connect: + with connect.cursor() as cursor: + cursor.execute("DROP DATABASE %s" % database_name) + + +class Connection(weedb.Connection): + """A wrapper around a MySQL connection object.""" + + @guard + def __init__(self, host='localhost', user='', password='', database_name='', + port=3306, engine=DEFAULT_ENGINE, autocommit=True, **kwargs): + """Initialize an instance of Connection. + + Args: + host (str): IP or hostname hosting the mysql database. + Alternatively, the path to the socket mount. (required) + user (str): The username (required) + password (str): The password for the username (required) + database_name (str): The database to be used. (required) + port (int): Its port number (optional; default is 3306) + engine (str): The MySQL database engine to use (optional; default is 'INNODB') + autocommit (bool): If True, autocommit is enabled (default is True) + kwargs (dict): Any extra arguments you may wish to pass on to MySQL + connect statement. See the file MySQLdb/connections.py for a list (optional). + """ + connection = MySQLdb.connect(host=host, port=int(port), user=user, password=password, + database=database_name, **kwargs) + + weedb.Connection.__init__(self, connection, database_name, 'mysql') + + # Set the storage engine to be used + set_engine(self.connection, engine) + + # Set the transaction isolation level. + self.connection.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") + self.connection.autocommit(to_bool(autocommit)) + + def cursor(self): + """Return a cursor object.""" + # The implementation of the MySQLdb cursor is lame enough that we are + # obliged to include a wrapper around it: + return Cursor(self) + + @guard + def tables(self): + """Returns a list of tables in the database.""" + + table_list = list() + # Get a cursor directly from MySQL + with self.connection.cursor() as cursor: + cursor.execute("""SHOW TABLES;""") + while True: + row = cursor.fetchone() + if row is None: break + # Extract the table name. In case it's in unicode, convert to a regular string. + table_list.append(str(row[0])) + return table_list + + @guard + def genSchemaOf(self, table): + """Return a summary of the schema of the specified table. + + If the table does not exist, an exception of type weedb.OperationalError is raised.""" + + with self.connection.cursor() as cursor: + # If the table does not exist, this will raise a MySQL ProgrammingError exception, + # which gets converted to a weedb.OperationalError exception by the guard decorator + cursor.execute("""SHOW COLUMNS IN %s;""" % table) + irow = 0 + while True: + row = cursor.fetchone() + if row is None: + break + # Append this column to the list of columns. + colname = str(row[0]) + if row[1].upper() == 'DOUBLE': + coltype = 'REAL' + elif row[1].upper().startswith('INT'): + coltype = 'INTEGER' + elif row[1].upper().startswith('CHAR'): + coltype = 'STR' + else: + coltype = str(row[1]).upper() + is_primary = True if row[3] == 'PRI' else False + can_be_null = False if row[2] == '' else to_bool(row[2]) + yield (irow, colname, coltype, can_be_null, row[4], is_primary) + irow += 1 + + @guard + def columnsOf(self, table): + """Return a list of columns in the specified table. + + If the table does not exist, an exception of type weedb.OperationalError is raised.""" + column_list = [row[1] for row in self.genSchemaOf(table)] + return column_list + + @guard + def get_variable(self, var_name): + with self.connection.cursor() as cursor: + cursor.execute("SHOW VARIABLES LIKE '%s';" % var_name) + row = cursor.fetchone() + # This is actually a 2-way tuple (variable-name, variable-value), + # or None, if the variable does not exist. + return row + + @guard + def begin(self): + """Begin a transaction.""" + self.connection.query("START TRANSACTION") + + @guard + def commit(self): + self.connection.commit() + + @guard + def rollback(self): + self.connection.rollback() + + +class Cursor(weedb.Cursor): + """A wrapper around the MySQLdb cursor object""" + + @guard + def __init__(self, connection): + """Initialize a Cursor from a connection. + + connection: An instance of db.mysql.Connection""" + + # Get the MySQLdb cursor and store it internally: + self.cursor = connection.connection.cursor() + + @guard + def execute(self, sql_string, sql_tuple=()): + """Execute a SQL statement on the MySQL server. + + sql_string: A SQL statement to be executed. It should use ? as + a placeholder. + + sql_tuple: A tuple with the values to be used in the placeholders.""" + + # MySQL uses '%s' as placeholders, so replace the ?'s with %s + mysql_string = sql_string.replace('?', '%s') + + # Convert sql_tuple to a plain old tuple, just in case it actually + # derives from tuple, but overrides the string conversion (as is the + # case with a TimeSpan object): + self.cursor.execute(mysql_string, tuple(sql_tuple)) + + return self + + def fetchone(self): + # Get a result from the MySQL cursor, then run it through the _massage + # filter below + return _massage(self.cursor.fetchone()) + + def drop_columns(self, table, column_names): + """Drop the set of 'column_names' from table 'table'. + + table: The name of the table from which the column(s) are to be dropped. + + column_names: A set (or list) of column names to be dropped. It is not an error to try to drop + a non-existent column. + """ + for column_name in column_names: + self.execute("ALTER TABLE %s DROP COLUMN %s;" % (table, column_name)) + + def close(self): + try: + self.cursor.close() + del self.cursor + except AttributeError: + pass + + # + # Supplying functions __iter__ and next allows the cursor to be used as an iterator. + # + def __iter__(self): + return self + + def __next__(self): + result = self.fetchone() + if result is None: + raise StopIteration + return result + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): + self.close() + + +# +# This is a utility function for converting a result set that might contain +# longs or decimal.Decimals (which MySQLdb uses) to something containing just ints. +# +def _massage(seq): + # Return the _massaged sequence if it exists, otherwise, return None + if seq is not None: + return [int(i) if isinstance(i, (int, decimal.Decimal)) else i for i in seq] + + +def set_engine(connect, engine): + """Set the default MySQL storage engine.""" + try: + server_version = connect._server_version + except AttributeError: + server_version = connect.server_version + # Some servers return lists of ints, some lists of strings, some a string. + # Try to normalize: + if isinstance(server_version, (tuple, list)): + server_version = '%s.%s' % server_version[:2] + if server_version >= '5.5': + connect.query("SET default_storage_engine=%s" % engine) + else: + connect.query("SET storage_engine=%s;" % engine) diff --git a/dist/weewx-5.0.2/src/weedb/sqlite.py b/dist/weewx-5.0.2/src/weedb/sqlite.py new file mode 100644 index 0000000..8118ec5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/sqlite.py @@ -0,0 +1,297 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""weedb driver for sqlite""" + +import os.path + +# Import sqlite3. If it does not support the 'with' statement, then +# import pysqlite2, which might... +import sqlite3 + +if not hasattr(sqlite3.Connection, "__exit__"): # @UndefinedVariable + del sqlite3 + from pysqlite2 import dbapi2 as sqlite3 # @Reimport @UnresolvedImport + +# Test to see whether this version of SQLite has math functions. An explicit test is required +# (rather than just check version numbers) because the SQLite library may or may not have been +# compiled with the DSQLITE_ENABLE_MATH_FUNCTIONS option. +try: + with sqlite3.connect(":memory:") as conn: + conn.execute("SELECT RADIANS(0.0), SIN(0.0), COS(0.0);") +except sqlite3.OperationalError: + has_math = False +else: + has_math = True + +import weedb +from weeutil.weeutil import to_int, to_bool + + +def guard(fn): + """Decorator function that converts sqlite exceptions into weedb exceptions.""" + + def guarded_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except sqlite3.IntegrityError as e: + raise weedb.IntegrityError(e) + except sqlite3.OperationalError as e: + msg = str(e).lower() + if msg.startswith("unable to open"): + raise weedb.PermissionError(e) + elif msg.startswith("no such table"): + raise weedb.NoTableError(e) + elif msg.endswith("already exists"): + raise weedb.TableExistsError(e) + elif msg.startswith("no such column"): + raise weedb.NoColumnError(e) + else: + raise weedb.OperationalError(e) + except sqlite3.ProgrammingError as e: + raise weedb.ProgrammingError(e) + + return guarded_fn + + +def connect(database_name='', SQLITE_ROOT='', driver='', **argv): # @UnusedVariable + """Factory function, to keep things compatible with DBAPI. """ + return Connection(database_name=database_name, SQLITE_ROOT=SQLITE_ROOT, **argv) + + +@guard +def create(database_name='', SQLITE_ROOT='', driver='', **argv): # @UnusedVariable + """Create the database specified by the db_dict. If it already exists, + an exception of type DatabaseExistsError will be thrown.""" + file_path = _get_filepath(SQLITE_ROOT, database_name, **argv) + # Check whether the database file exists: + if os.path.exists(file_path): + raise weedb.DatabaseExistsError("Database %s already exists" % (file_path,)) + else: + if file_path != ':memory:': + # If it doesn't exist, create the parent directories + fileDirectory = os.path.dirname(file_path) + if not os.path.exists(fileDirectory): + try: + os.makedirs(fileDirectory) + except OSError: + raise weedb.PermissionError("No permission to create %s" % fileDirectory) + timeout = to_int(argv.get('timeout', 5)) + isolation_level = argv.get('isolation_level') + # Open, then immediately close the database. + connection = sqlite3.connect(file_path, timeout=timeout, isolation_level=isolation_level) + connection.close() + + +def drop(database_name='', SQLITE_ROOT='', driver='', **argv): # @UnusedVariable + file_path = _get_filepath(SQLITE_ROOT, database_name, **argv) + try: + os.remove(file_path) + except OSError as e: + errno = getattr(e, 'errno', 2) + if errno == 13: + raise weedb.PermissionError("No permission to drop database %s" % file_path) + else: + raise weedb.NoDatabaseError("Attempt to drop non-existent database %s" % file_path) + + +def _get_filepath(SQLITE_ROOT, database_name, **argv): + """Utility function to calculate the path to the sqlite database file.""" + if database_name == ':memory:': + return database_name + # For backwards compatibility, allow the keyword 'root', if 'SQLITE_ROOT' is + # not defined: + root_dir = SQLITE_ROOT or argv.get('root', '') + return os.path.join(root_dir, database_name) + + +class Connection(weedb.Connection): + """A wrapper around a sqlite3 connection object.""" + + @guard + def __init__(self, database_name='', SQLITE_ROOT='', pragmas=None, **argv): + """Initialize an instance of Connection. + + Args: + + database_name: The name of the Sqlite database. This is generally the file name + SQLITE_ROOT: The path to the directory holding the database. Joining "SQLITE_ROOT" with + "database_name" results in the full path to the sqlite file. + pragmas: Any pragma statements, in the form of a dictionary. + timeout: The amount of time, in seconds, to wait for a lock to be released. + Optional. Default is 5. + isolation_level(str): The type of isolation level to use. One of None, + DEFERRED, IMMEDIATE, or EXCLUSIVE. Default is None (autocommit mode). + + Raises: + NoDatabaseError: If the database file does not exist. + """ + + self.file_path = _get_filepath(SQLITE_ROOT, database_name, **argv) + if self.file_path != ':memory:' and not os.path.exists(self.file_path): + raise weedb.NoDatabaseError("Attempt to open a non-existent database %s" + % self.file_path) + timeout = to_int(argv.get('timeout', 5)) + isolation_level = argv.get('isolation_level') + connection = sqlite3.connect(self.file_path, timeout=timeout, + isolation_level=isolation_level) + + if pragmas: + for pragma in pragmas: + connection.execute("PRAGMA %s=%s;" % (pragma, pragmas[pragma])) + weedb.Connection.__init__(self, connection, database_name, 'sqlite') + + @guard + def cursor(self): + """Return a cursor object.""" + return self.connection.cursor(Cursor) + + @guard + def execute(self, sql_string, sql_tuple=()): + """Execute a sql statement. This specialized version takes advantage + of sqlite's ability to do an execute without a cursor.""" + + with self.connection: + self.connection.execute(sql_string, sql_tuple) + + @guard + def tables(self): + """Returns a list of tables in the database.""" + + table_list = list() + for row in self.connection.execute("SELECT tbl_name FROM sqlite_master " + "WHERE type='table';"): + # Extract the table name. Sqlite returns unicode, so always + # convert to a regular string: + table_list.append(str(row[0])) + return table_list + + @guard + def genSchemaOf(self, table): + """Return a summary of the schema of the specified table. + + If the table does not exist, an exception of type weedb.OperationalError is raised.""" + for row in self.connection.execute("""PRAGMA table_info(%s);""" % table): + if row[2].upper().startswith('CHAR'): + coltype = 'STR' + else: + coltype = str(row[2]).upper() + yield (row[0], str(row[1]), coltype, not to_bool(row[3]), row[4], to_bool(row[5])) + + def columnsOf(self, table): + """Return a list of columns in the specified table. If the table does not exist, + None is returned.""" + + column_list = [row[1] for row in self.genSchemaOf(table)] + + # If there are no columns (which means the table did not exist) raise an exceptional + if not column_list: + raise weedb.ProgrammingError("No such table %s" % table) + return column_list + + @guard + def get_variable(self, var_name): + cursor = self.connection.cursor() + try: + cursor.execute("PRAGMA %s;" % var_name) + row = cursor.fetchone() + return None if row is None else (var_name, row[0]) + finally: + cursor.close() + + @property + def has_math(self): + global has_math + return has_math + + @guard + def begin(self): + self.connection.execute("BEGIN TRANSACTION") + + @guard + def commit(self): + self.connection.commit() + + @guard + def rollback(self): + self.connection.rollback() + + @guard + def close(self): + self.connection.close() + + +class Cursor(sqlite3.Cursor, weedb.Cursor): + """A wrapper around the sqlite cursor object""" + + # The sqlite3 cursor object is very full-featured. We need only turn + # the sqlite exceptions into weedb exceptions. + @guard + def execute(self, *args, **kwargs): + return sqlite3.Cursor.execute(self, *args, **kwargs) + + @guard + def fetchone(self): + return sqlite3.Cursor.fetchone(self) + + @guard + def fetchall(self): + return sqlite3.Cursor.fetchall(self) + + @guard + def fetchmany(self, size=None): + if size is None: size = self.arraysize + return sqlite3.Cursor.fetchmany(self, size) + + def drop_columns(self, table, column_names): + """Drop the set of 'column_names' from table 'table'. + + table: The name of the table from which the column(s) are to be dropped. + + column_names: A set (or list) of column names to be dropped. It is not an error to try to + drop a non-existent column. + """ + + existing_column_set = set() + create_list = [] + insert_list = [] + + self.execute("""PRAGMA table_info(%s);""" % table) + + for row in self.fetchall(): + # Unpack the row + row_no, obs_name, obs_type, no_null, default, pk = row + existing_column_set.add(obs_name) + # Search through the target columns. + if obs_name in column_names: + continue + no_null_str = " NOT NULL" if no_null else "" + pk_str = " UNIQUE PRIMARY KEY" if pk else "" + default_str = " DEFAULT %s" % default if default is not None else "" + create_list.append("`%s` %s%s%s%s" % (obs_name, obs_type, no_null_str, + pk_str, default_str)) + insert_list.append(obs_name) + + for column in column_names: + if column not in existing_column_set: + raise weedb.NoColumnError("Cannot DROP '%s'; column does not exist." % column) + + create_str = ", ".join(create_list) + insert_str = ", ".join(insert_list) + + self.execute("CREATE TEMPORARY TABLE %s_temp (%s);" % (table, create_str)) + self.execute("INSERT INTO %s_temp SELECT %s FROM %s;" % (table, insert_str, table)) + self.execute("DROP TABLE %s;" % table) + self.execute("CREATE TABLE %s (%s);" % (table, create_str)) + self.execute("INSERT INTO %s SELECT %s FROM %s_temp;" % (table, insert_str, table)) + self.execute("DROP TABLE %s_temp;" % table) + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + # It is not an error to close a sqlite3 cursor multiple times, + # so there's no reason to guard it with a "try" clause: + self.close() diff --git a/dist/weewx-5.0.2/src/weedb/tests/__init__.py b/dist/weewx-5.0.2/src/weedb/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weedb/tests/check_mysql.py b/dist/weewx-5.0.2/src/weedb/tests/check_mysql.py new file mode 100644 index 0000000..806ef18 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/tests/check_mysql.py @@ -0,0 +1,163 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the MySQLdb interface.""" + +# This module does not test anything in weewx. Instead, it checks that +# the MySQLdb interface acts the way we think it should. +# +# It uses two MySQL users, weewx1 and weewx2. The companion +# script "setup_mysql.sh" will set them up with the necessary permissions. +# +import unittest + +try: + import MySQLdb + has_MySQLdb = True +except ImportError: + # Some installs use 'pymysql' instead of 'MySQLdb' + import pymysql as MySQLdb + from pymysql import IntegrityError, ProgrammingError, OperationalError + has_MySQLdb = False +else: + try: + from MySQLdb import IntegrityError, ProgrammingError, OperationalError + except ImportError: + from _mysql_exceptions import IntegrityError, ProgrammingError, OperationalError + + +def get_error(e): + return e.exception.args[0] + + +class Cursor(object): + """Class to be used to wrap a cursor in a 'with' clause.""" + + def __init__(self, host='localhost', user='', password='', database=''): + self.connection = MySQLdb.connect(host=host, user=user, + password=password, database=database) + self.cursor = self.connection.cursor() + + def __enter__(self): + return self.cursor + + def __exit__(self, etyp, einst, etb): + self.cursor.close() + try: + self.connection.close() + except ProgrammingError: + pass + + +class TestMySQL(unittest.TestCase): + + def setUp(self): + self.tearDown() + + def tearDown(self): + """Remove any databases we created.""" + with Cursor(user='weewx1', password='weewx1') as cursor: + try: + cursor.execute("DROP DATABASE test_weewx1") + except OperationalError: + pass + try: + cursor.execute("DROP DATABASE test_weewx2") + except OperationalError: + pass + + def test_bad_host(self): + with self.assertRaises(OperationalError) as e: + with Cursor(host='foohost', user='weewx1', password='weewx1') as e: + pass + if has_MySQLdb: + self.assertEqual(get_error(e), 2005) + else: + self.assertEqual(get_error(e), 2003) + + def test_bad_password(self): + with self.assertRaises(OperationalError) as e: + with Cursor(user='weewx1', password='badpw') as e: + pass + self.assertEqual(get_error(e), 1045) + + def test_drop_nonexistent_database(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + with self.assertRaises(OperationalError) as e: + cursor.execute("DROP DATABASE test_weewx1") + self.assertEqual(get_error(e), 1008) + + def test_drop_nopermission(self): + with Cursor(user='weewx1', password='weewx1') as cursor1: + cursor1.execute("CREATE DATABASE test_weewx1") + with Cursor(user='weewx2', password='weewx2') as cursor2: + with self.assertRaises(OperationalError) as e: + cursor2.execute("DROP DATABASE test_weewx1") + self.assertEqual(get_error(e), 1044) + + def test_create_nopermission(self): + with Cursor(user='weewx2', password='weewx2') as cursor: + with self.assertRaises(OperationalError) as e: + cursor.execute("CREATE DATABASE test_weewx1") + self.assertEqual(get_error(e), 1044) + + def test_double_db_create(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + cursor.execute("CREATE DATABASE test_weewx1") + with self.assertRaises(ProgrammingError) as e: + cursor.execute("CREATE DATABASE test_weewx1") + self.assertEqual(get_error(e), 1007) + + def test_open_nonexistent_database(self): + with self.assertRaises(OperationalError) as e: + with Cursor(user='weewx1', password='weewx1', database='test_weewx1') as cursor: + pass + self.assertEqual(get_error(e), 1049) + + def test_select_nonexistent_database(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + with self.assertRaises(OperationalError) as e: + cursor.execute("SELECT foo from test_weewx1.bar") + self.assertEqual(get_error(e), 1049) + + def test_select_nonexistent_table(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + cursor.execute("CREATE DATABASE test_weewx1") + cursor.execute("CREATE TABLE test_weewx1.bar (col1 int, col2 int)") + with self.assertRaises(ProgrammingError) as e: + cursor.execute("SELECT foo from test_weewx1.fubar") + self.assertEqual(get_error(e), 1146) + + def test_double_table_create(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + cursor.execute("CREATE DATABASE test_weewx1") + cursor.execute("CREATE TABLE test_weewx1.bar (col1 int, col2 int)") + with self.assertRaises(OperationalError) as e: + cursor.execute("CREATE TABLE test_weewx1.bar (col1 int, col2 int)") + self.assertEqual(get_error(e), 1050) + + def test_select_nonexistent_column(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + cursor.execute("CREATE DATABASE test_weewx1") + cursor.execute("CREATE TABLE test_weewx1.bar (col1 int, col2 int)") + with self.assertRaises(OperationalError) as e: + cursor.execute("SELECT foo from test_weewx1.bar") + self.assertEqual(get_error(e), 1054) + + def test_duplicate_key(self): + with Cursor(user='weewx1', password='weewx1') as cursor: + cursor.execute("CREATE DATABASE test_weewx1") + cursor.execute("CREATE TABLE test_weewx1.test1 " + "( dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY, col1 int, col2 int)") + cursor.execute("INSERT INTO test_weewx1.test1 " + "(dateTime, col1, col2) VALUES (1, 10, 20)") + with self.assertRaises(IntegrityError) as e: + cursor.execute("INSERT INTO test_weewx1.test1 (dateTime, col1, col2) " + "VALUES (1, 30, 40)") + self.assertEqual(get_error(e), 1062) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weedb/tests/check_sqlite.py b/dist/weewx-5.0.2/src/weedb/tests/check_sqlite.py new file mode 100644 index 0000000..3b55e82 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/tests/check_sqlite.py @@ -0,0 +1,111 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the sqlite3 interface.""" + +# +# This module does not test anything in weewx. Instead, it checks that +# the sqlite interface acts the way we think it should. +# + +import os +import sqlite3 +import sys +import unittest +from sqlite3 import IntegrityError, OperationalError + +# This database should be somewhere where you have write permissions: +sqdb1 = '/var/tmp/sqdb1.sdb' +# This database should be somewhere where you do NOT have write permissions: +sqdb2 = '/usr/local/sqdb2.sdb' + +# Double check that we have the necessary permissions (or lack thereof): +try: + fd = open(sqdb1, 'w') + fd.close() +except: + print("For tests to work properly, you must have write permission to '%s'." % sqdb1, file=sys.stderr) + print("Change the permissions and try again.", file=sys.stderr) + sys.exit("Must have write permissions to '%s'" % sqdb1) +try: + fd = open(sqdb2, 'w') + fd.close() +except IOError: + pass +else: + print("For tests to work properly, you must NOT have write permission to '%s'." % sqdb2, file=sys.stderr) + print("Change the permissions and try again.", file=sys.stderr) + sys.exit("Must not have write permissions to '%s'" % sqdb2) + + +class Cursor(object): + """Class to be used to wrap a cursor in a 'with' clause.""" + + def __init__(self, file_path): + self.connection = sqlite3.connect(file_path) + self.cursor = self.connection.cursor() + + def __enter__(self): + return self.cursor + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.cursor.close() + self.connection.close() + + +class TestSqlite3(unittest.TestCase): + + def setUp(self): + """Make sure sqdb1 and sqdb2 are gone.""" + self.tearDown() + + def tearDown(self): + """Remove any databases we created.""" + try: + os.remove(sqdb1) + except OSError: + pass + try: + os.remove(sqdb2) + except OSError: + pass + + def test_create_nopermission(self): + with self.assertRaises(OperationalError) as e: + with Cursor(sqdb2) as cursor: + pass + self.assertEqual(str(e.exception), 'unable to open database file') + + def test_select_nonexistent_table(self): + with Cursor(sqdb1) as cursor: + with self.assertRaises(OperationalError) as e: + cursor.execute("SELECT foo from bar") + self.assertEqual(str(e.exception), "no such table: bar") + + def test_double_table_create(self): + with Cursor(sqdb1) as cursor: + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + with self.assertRaises(OperationalError) as e: + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + self.assertEqual(str(e.exception), 'table bar already exists') + + def test_select_nonexistent_column(self): + with Cursor(sqdb1) as cursor: + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + with self.assertRaises(OperationalError) as e: + cursor.execute("SELECT foo from bar") + self.assertEqual(str(e.exception), 'no such column: foo') + + def test_duplicate_key(self): + with Cursor(sqdb1) as cursor: + cursor.execute("CREATE TABLE test1 ( dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY, col1 int, col2 int)") + cursor.execute("INSERT INTO test1 (dateTime, col1, col2) VALUES (1, 10, 20)") + with self.assertRaises(IntegrityError) as e: + cursor.execute("INSERT INTO test1 (dateTime, col1, col2) VALUES (1, 30, 40)") + self.assertEqual(str(e.exception), "UNIQUE constraint failed: test1.dateTime") + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weedb/tests/setup_mysql.sh b/dist/weewx-5.0.2/src/weedb/tests/setup_mysql.sh new file mode 100755 index 0000000..84fdf23 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/tests/setup_mysql.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Shell script to set up the MySQL database for testing +# +# It creates three users: +# +# 1. User: weewx +# Password: weewx +# 2. User: weewx1 +# Password: weewx1 +# 3. User: weewx2 +# Password: weewx2 +# +# NB: user weewx2 has more restrictive permissions than user weewx1 +# +if [ "${MYSQL_NO_OPTS:-0}" = "1" ]; then + CMD=mysql +else + echo "Give the root password when prompted->" + # Use the TCP protocol so we can connect to a Docker container running MySQL. + CMD="mysql --force --protocol=tcp -u root -p" +fi +$CMD << EOF +drop user if exists 'weewx'; +drop user if exists 'weewx1'; +drop user if exists 'weewx2'; +create user 'weewx' identified by 'weewx'; +create user 'weewx1' identified by 'weewx1'; +create user 'weewx2' identified by 'weewx2'; +grant select, update, create, delete, drop, insert on test.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_alt_weewx.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_alt_weewx.* to 'weewx1'; +grant select, update, create, delete, drop, insert on test_scratch.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_sim.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_sim.* to 'weewx1'; +grant select, update, create, delete, drop, insert on test_weedb.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_weedb.* to 'weewx1'; +grant select, update, create, delete, drop, insert on test_weewx.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_weewx.* to 'weewx1'; +grant select, update, create, delete, drop, insert on test_weewx1.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_weewx2.* to 'weewx'; +grant select, update, create, delete, drop, insert on test_weewx1.* to 'weewx1'; +grant select, update, create, delete, drop, insert on test_weewx2.* to 'weewx1'; +grant select, update, create, delete, drop, insert on test_weewx2.* to 'weewx2'; +grant select, update, create, delete, drop, insert on weewx.* to 'weewx'; +EOF +if [ $? -eq 0 ]; then + echo "Finished setting up MySQL." +else + echo "Problems setting up MySQL" + exit 1 +fi diff --git a/dist/weewx-5.0.2/src/weedb/tests/test_errors.py b/dist/weewx-5.0.2/src/weedb/tests/test_errors.py new file mode 100644 index 0000000..78dad0f --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/tests/test_errors.py @@ -0,0 +1,195 @@ +"""Test the weedb exception hierarchy""" +import os.path +import sys +import unittest + +import weedb + +# +# For these tests to work, the database for sqdb1 must in a place where you have write permissions, +# and the database for sqdb2 must be in a place where you do NOT have write permissions +sqdb1_dict = {'database_name': '/var/tmp/weewx_test/sqdb1.sdb', 'driver': 'weedb.sqlite', 'timeout': '2'} +sqdb2_dict = {'database_name': '/usr/local/sqdb2.sdb', 'driver': 'weedb.sqlite', 'timeout': '2'} +mysql1_dict = {'database_name': 'test_weewx1', 'user': 'weewx1', 'password': 'weewx1', 'driver': 'weedb.mysql'} +mysql2_dict = {'database_name': 'test_weewx1', 'user': 'weewx2', 'password': 'weewx2', 'driver': 'weedb.mysql'} + +# Double check that we have the necessary permissions (or lack thereof): +try: + file_directory = os.path.dirname(sqdb1_dict['database_name']) + if not os.path.exists(file_directory): + os.makedirs(file_directory) + fd = open(sqdb1_dict['database_name'], 'w') + fd.close() +# Python 2 raises IOError; Python 3 raises PermissionError, a subclass of OSError: +except (IOError, OSError): + sys.exit("For tests to work properly, you must have permission to write to '%s'.\n" + "Change the permissions and try again." % sqdb1_dict['database_name']) + +try: + fd = open(sqdb2_dict['database_name'], 'w') + fd.close() +# Python 2 raises IOError; Python 3 raises PermissionError, a subclass of OSError: +except (IOError, OSError): + pass +else: + sys.exit("For tests to work properly, you must NOT have permission to write to '%s'.\n" + "Change the permissions and try again." % sqdb2_dict['database_name']) + + +class Cursor(object): + """Class to be used to wrap a cursor in a 'with' clause.""" + + def __init__(self, db_dict): + self.connection = weedb.connect(db_dict) + self.cursor = self.connection.cursor() + + def __enter__(self): + return self.cursor + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.cursor.close() + self.connection.close() + + +class Common(unittest.TestCase): + + def setUp(self): + """Drop the old databases, in preparation for running a test.""" + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + try: + weedb.drop(mysql1_dict) + except weedb.NoDatabase: + pass + try: + weedb.drop(sqdb1_dict) + except weedb.NoDatabase: + pass + + def test_bad_host(self): + mysql_dict = dict(mysql1_dict) + mysql_dict['host'] = 'foohost' + with self.assertRaises(weedb.CannotConnectError): + weedb.connect(mysql_dict) + + def test_bad_password(self): + mysql_dict = dict(mysql1_dict) + mysql_dict['password'] = 'badpw' + with self.assertRaises(weedb.BadPasswordError): + weedb.connect(mysql_dict) + + def test_drop_nonexistent_database(self): + with self.assertRaises(weedb.NoDatabase): + weedb.drop(mysql1_dict) + with self.assertRaises(weedb.NoDatabase): + weedb.drop(sqdb1_dict) + + def test_drop_nopermission(self): + weedb.create(mysql1_dict) + with self.assertRaises(weedb.PermissionError): + weedb.drop(mysql2_dict) + weedb.create(sqdb1_dict) + # Can't really test this one without setting up a file where + # we have no write permission + with self.assertRaises(weedb.NoDatabaseError): + weedb.drop(sqdb2_dict) + + def test_create_nopermission(self): + with self.assertRaises(weedb.PermissionError): + weedb.create(mysql2_dict) + with self.assertRaises(weedb.PermissionError): + weedb.create(sqdb2_dict) + + def test_double_db_create(self): + weedb.create(mysql1_dict) + with self.assertRaises(weedb.DatabaseExists): + weedb.create(mysql1_dict) + weedb.create(sqdb1_dict) + with self.assertRaises(weedb.DatabaseExists): + weedb.create(sqdb1_dict) + + def test_open_nonexistent_database(self): + with self.assertRaises(weedb.OperationalError): + connect = weedb.connect(mysql1_dict) + with self.assertRaises(weedb.OperationalError): + connect = weedb.connect(sqdb1_dict) + + def test_select_nonexistent_database(self): + mysql_dict = dict(mysql1_dict) + mysql_dict.pop('database_name') + connect = weedb.connect(mysql_dict) + cursor = connect.cursor() + # Earlier versions of MySQL would raise error number 1146, "Table doesn't exist". + with self.assertRaises((weedb.NoDatabaseError, weedb.NoTableError)): + cursor.execute("SELECT foo from test_weewx1.bar") + cursor.close() + connect.close() + + # There's no analogous operation with sqlite. You + # must create the database in order to open it. + + def test_select_nonexistent_table(self): + def test(db_dict): + weedb.create(db_dict) + connect = weedb.connect(db_dict) + cursor = connect.cursor() + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + with self.assertRaises(weedb.NoTableError) as e: + cursor.execute("SELECT foo from fubar") + cursor.close() + connect.close() + + test(mysql1_dict) + test(sqdb1_dict) + + def test_double_table_create(self): + def test(db_dict): + weedb.create(db_dict) + connect = weedb.connect(db_dict) + cursor = connect.cursor() + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + with self.assertRaises(weedb.TableExistsError) as e: + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + cursor.close() + connect.close() + + test(mysql1_dict) + test(sqdb1_dict) + + def test_select_nonexistent_column(self): + def test(db_dict): + weedb.create(db_dict) + connect = weedb.connect(db_dict) + cursor = connect.cursor() + cursor.execute("CREATE TABLE bar (col1 int, col2 int)") + with self.assertRaises(weedb.NoColumnError) as e: + cursor.execute("SELECT foo from bar") + cursor.close() + connect.close() + + test(mysql1_dict) + test(sqdb1_dict) + + def test_duplicate_key(self): + def test(db_dict): + weedb.create(db_dict) + connect = weedb.connect(db_dict) + cursor = connect.cursor() + cursor.execute("CREATE TABLE test1 ( dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY, col1 int, col2 int)") + cursor.execute("INSERT INTO test1 (dateTime, col1, col2) VALUES (1, 10, 20)") + with self.assertRaises(weedb.IntegrityError) as e: + cursor.execute("INSERT INTO test1 (dateTime, col1, col2) VALUES (1, 30, 40)") + cursor.close() + connect.close() + + test(mysql1_dict) + test(sqdb1_dict) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weedb/tests/test_weedb.py b/dist/weewx-5.0.2/src/weedb/tests/test_weedb.py new file mode 100644 index 0000000..2981149 --- /dev/null +++ b/dist/weewx-5.0.2/src/weedb/tests/test_weedb.py @@ -0,0 +1,255 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the weedb package. + +For this test to work, MySQL user 'weewx' must have full access to database 'test': + mysql> grant select, update, create, delete, drop, insert on test.* to weewx@localhost; +""" + +import unittest + +import weedb +import weedb.sqlite +from weeutil.weeutil import version_compare + +sqlite_db_dict = {'database_name': '/var/tmp/test.sdb', 'driver': 'weedb.sqlite', 'timeout': '2'} +mysql_db_dict = {'database_name': 'test_weewx1', 'user': 'weewx1', 'password': 'weewx1', + 'driver': 'weedb.mysql'} + +# Schema summary: +# (col_number, col_name, col_type, can_be_null, default_value, part_of_primary) +schema = [(0, 'dateTime', 'INTEGER', False, None, True), + (1, 'min', 'REAL', True, None, False), + (2, 'mintime', 'INTEGER', True, None, False), + (3, 'max', 'REAL', True, None, False), + (4, 'maxtime', 'INTEGER', True, None, False), + (5, 'sum', 'REAL', True, None, False), + (6, 'count', 'INTEGER', True, None, False), + (7, 'descript', 'STR', True, None, False)] + + +class Common(unittest.TestCase): + + def setUp(self): + self.tearDown() + + def tearDown(self): + try: + weedb.drop(self.db_dict) + except: + pass + + def populate_db(self): + weedb.create(self.db_dict) + with self.assertRaises(weedb.DatabaseExists): + weedb.create(self.db_dict) + with weedb.connect(self.db_dict) as _connect: + with weedb.Transaction(_connect) as _cursor: + _cursor.execute("CREATE TABLE test1 (dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY," + " min REAL, mintime INTEGER, max REAL, maxtime INTEGER, sum REAL," + " count INTEGER, descript CHAR(20));") + _cursor.execute("CREATE TABLE test2 (dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY," + " min REAL, mintime INTEGER, max REAL, maxtime INTEGER, sum REAL, " + "count INTEGER, descript CHAR(20));") + for irec in range(20): + _cursor.execute("INSERT INTO test1 (dateTime, min, mintime) VALUES (?, ?, ?)", + (irec, 10 * irec, irec)) + + def test_drop(self): + with self.assertRaises(weedb.NoDatabase): + weedb.drop(self.db_dict) + + def test_double_create(self): + weedb.create(self.db_dict) + with self.assertRaises(weedb.DatabaseExists): + weedb.create(self.db_dict) + + def test_no_db(self): + with self.assertRaises(weedb.NoDatabaseError): + weedb.connect(self.db_dict) + + def test_no_tables(self): + weedb.create(self.db_dict) + with weedb.connect(self.db_dict) as _connect: + self.assertEqual(_connect.tables(), []) + with self.assertRaises(weedb.ProgrammingError): + _connect.columnsOf('test1') + with self.assertRaises(weedb.ProgrammingError): + _connect.columnsOf('foo') + + def test_create(self): + self.populate_db() + with weedb.connect(self.db_dict) as _connect: + self.assertEqual(sorted(_connect.tables()), ['test1', 'test2']) + self.assertEqual(_connect.columnsOf('test1'), ['dateTime', 'min', 'mintime', 'max', + 'maxtime', 'sum', 'count', 'descript']) + self.assertEqual(_connect.columnsOf('test2'), ['dateTime', 'min', 'mintime', 'max', + 'maxtime', 'sum', 'count', 'descript']) + for icol, col in enumerate(_connect.genSchemaOf('test1')): + self.assertEqual(schema[icol], col) + for icol, col in enumerate(_connect.genSchemaOf('test2')): + self.assertEqual(schema[icol], col) + # Make sure an IntegrityError gets raised in the case of a duplicate key: + with weedb.Transaction(_connect) as _cursor: + with self.assertRaises(weedb.IntegrityError): + _cursor.execute("INSERT INTO test1 (dateTime, min, mintime) VALUES (0, 10, 0)") + + def test_bad_table(self): + self.populate_db() + with weedb.connect(self.db_dict) as _connect: + with self.assertRaises(weedb.ProgrammingError): + _connect.columnsOf('foo') + + def test_select(self): + self.populate_db() + with weedb.connect(self.db_dict) as _connect: + with _connect.cursor() as _cursor: + _cursor.execute("SELECT dateTime, min FROM test1") + for i, _row in enumerate(_cursor): + self.assertEqual(_row[0], i) + + # SELECT with wild card, using a result set + _result = _cursor.execute("SELECT * from test1") + for i, _row in enumerate(_result): + self.assertEqual(_row[0], i) + + # Find a matching result set + _cursor.execute("SELECT dateTime, min FROM test1 WHERE dateTime = 5") + _row = _cursor.fetchone() + self.assertEqual(_row[0], 5) + self.assertEqual(_row[1], 50) + + # Now test where there is no matching result: + _cursor.execute("SELECT dateTime, min FROM test1 WHERE dateTime = -1") + _row = _cursor.fetchone() + self.assertIsNone(_row) + + # Same, but using an aggregate: + _cursor.execute("SELECT MAX(min) FROM test1 WHERE dateTime = -1") + _row = _cursor.fetchone() + self.assertIsNotNone(_row) + self.assertIsNone(_row[0]) + + def test_bad_select(self): + self.populate_db() + with weedb.connect(self.db_dict) as _connect: + with _connect.cursor() as _cursor: + # Test SELECT on a bad table name + with self.assertRaises(weedb.ProgrammingError): + _cursor.execute("SELECT dateTime, min FROM foo") + + # Test SELECT on a bad column name + with self.assertRaises(weedb.OperationalError): + _cursor.execute("SELECT dateTime, foo FROM test1") + + def test_rollback(self): + # Create the database and schema + weedb.create(self.db_dict) + with weedb.connect(self.db_dict) as _connect: + with _connect.cursor() as _cursor: + _cursor.execute( + "CREATE TABLE test1 (dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY, x REAL )") + + # Now start the transaction + _connect.begin() + for i in range(10): + _cursor.execute("INSERT INTO test1 (dateTime, x) VALUES (?, ?)", (i, i + 1)) + # Roll it back + _connect.rollback() + + # Make sure nothing is in the database + with weedb.connect(self.db_dict) as _connect: + with _connect.cursor() as _cursor: + _cursor.execute("SELECT dateTime, x from test1") + _row = _cursor.fetchone() + self.assertIsNone(_row) + + def test_transaction(self): + # Create the database and schema + weedb.create(self.db_dict) + with weedb.connect(self.db_dict) as _connect: + + # With sqlite, a rollback can roll back a table creation. With MySQL, it does not. So, + # create the table outside the transaction. We're not as concerned about a + # transaction failing when creating a table, because it only happens the first time + # weewx starts up. + _connect.execute("CREATE TABLE test1 (dateTime INTEGER NOT NULL UNIQUE PRIMARY KEY, " + "x REAL );") + + # We're going to trigger the rollback by raising a bogus exception. + # Be prepared to catch it. + try: + with weedb.Transaction(_connect) as _cursor: + for i in range(10): + _cursor.execute("INSERT INTO test1 (dateTime, x) VALUES (?, ?)", + (i, i + 1)) + # Raise an exception: + raise Exception("Bogus exception") + except Exception: + pass + + # Now make sure nothing is in the database + with weedb.connect(self.db_dict) as _connect: + with _connect.cursor() as _cursor: + _cursor.execute("SELECT dateTime, x from test1") + _row = _cursor.fetchone() + self.assertIsNone(_row) + + +class TestSqlite(Common): + + def __init__(self, *args, **kwargs): + self.db_dict = sqlite_db_dict + super().__init__(*args, **kwargs) + + def test_variable(self): + import sqlite3 + weedb.create(self.db_dict) + with weedb.connect(self.db_dict) as _connect: + # Early versions of sqlite did not support journal modes. + # Not sure exactly when it started, but I know that v3.4.2 did not have it. + if version_compare(sqlite3.sqlite_version, '3.4.2') > 0: + _v = _connect.get_variable('journal_mode') + self.assertEqual(_v[1].lower(), 'delete') + _v = _connect.get_variable('foo') + self.assertIsNone(_v) + _connect.close() + + +class TestMySQL(Common): + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + def __init__(self, *args, **kwargs): + self.db_dict = mysql_db_dict + super().__init__(*args, **kwargs) + + def test_variable(self): + weedb.create(self.db_dict) + with weedb.connect(self.db_dict) as _connect: + _v = _connect.get_variable('lower_case_table_names') + self.assertTrue(_v[1] in ['0', '1', '2'], "Unknown lower_case_table_names value") + _v = _connect.get_variable('foo') + self.assertEqual(_v, None) + + +def suite(): + tests = ['test_drop', 'test_double_create', 'test_no_db', 'test_no_tables', + 'test_create', 'test_bad_table', 'test_select', 'test_bad_select', + 'test_rollback', 'test_transaction', 'test_variable'] + return unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/dist/weewx-5.0.2/src/weeimport/__init__.py b/dist/weewx-5.0.2/src/weeimport/__init__.py new file mode 100644 index 0000000..d333804 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/__init__.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2009-2016 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +""" +Package weeimport. A set of modules for importing observational data into WeeWX. + +""" diff --git a/dist/weewx-5.0.2/src/weeimport/csvimport.py b/dist/weewx-5.0.2/src/weeimport/csvimport.py new file mode 100644 index 0000000..dd3e07b --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/csvimport.py @@ -0,0 +1,289 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# + +"""Module to process a CSV data file and for import by wee_import.""" + +# Python imports +import csv +import io +import logging +import os + + +# WeeWX imports +from . import weeimport +import weewx + +from weeutil.weeutil import timestamp_to_string, option_as_list +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class CSVSource +# ============================================================================ + +class CSVSource(weeimport.Source): + """Class to interact with a CSV format text file. + + Handles the import of data from a CSV format data file with known field + names. + """ + + # Define a dict to map CSV fields to WeeWX archive fields. For a CSV import + # the default map is empty as the field map is specified by the user in the + # wee_import config file. + default_map = {} + # define a dict to map cardinal, intercardinal and secondary intercardinal + # directions to degrees + wind_dir_map = {'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5, + 'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5, + 'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5, + 'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5, + 'NORTH': 0.0, 'NORTHNORTHEAST': 22.5, + 'NORTHEAST': 45.0, 'EASTNORTHEAST': 67.5, + 'EAST': 90.0, 'EASTSOUTHEAST': 112.5, + 'SOUTHEAST': 135.0, 'SOUTHSOUTHEAST': 157.5, + 'SOUTH': 180.0, 'SOUTHSOUTHWEST': 202.5, + 'SOUTHWEST': 225.0, 'WESTSOUTHWEST': 247.5, + 'WEST': 270.0, 'WESTNORTHWEST': 292.5, + 'NORTHWEST': 315.0, 'NORTHNORTHWEST': 337.5 + } + + def __init__(self, config_path, config_dict, import_config_path, + csv_config_dict, **kwargs): + + # call our parents __init__ + super().__init__(config_dict, csv_config_dict, **kwargs) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.csv_config_dict = csv_config_dict + + # get a few config settings from our CSV config dict + # csv field delimiter + self.delimiter = str(self.csv_config_dict.get('delimiter', ',')) + # string format used to decode the imported field holding our dateTime + self.raw_datetime_format = self.csv_config_dict.get('raw_datetime_format', + '%Y-%m-%d %H:%M:%S') + # Is our rain discrete or cumulative. Legacy import config files used + # the 'rain' config option to determine whether the imported rainfall + # value was a discrete per period value or a cumulative value. This is + # now handled on a per-field basis through the field map; however, we + # need to be able to support old import config files that use the + # legacy rain config option. + _rain = self.csv_config_dict.get('rain') + # set our rain property only if the rain config option was explicitly + # set + if _rain is not None: + self.rain = _rain + # determine valid range for imported wind direction + _wind_direction = option_as_list(self.csv_config_dict.get('wind_direction', + '0,360')) + try: + if float(_wind_direction[0]) <= float(_wind_direction[1]): + self.wind_dir = [float(_wind_direction[0]), + float(_wind_direction[1])] + else: + self.wind_dir = [-360, 360] + except (KeyError, ValueError): + self.wind_dir = [-360, 360] + # get our source file path + try: + self.source = csv_config_dict['file'] + except KeyError: + raise weewx.ViolatedPrecondition("CSV source file not specified " + "in '%s'." % import_config_path) + # get the source file encoding, default to utf-8-sig + self.source_encoding = self.csv_config_dict.get('source_encoding', + 'utf-8-sig') + # initialise our import field-to-WeeWX archive field map + _map = dict(CSVSource.default_map) + # create the final field map based on the default field map and any + # field map options provided by the user + self.map = self.parse_map(_map, + self.csv_config_dict.get('FieldMap', {}), + self.csv_config_dict.get('FieldMapExtensions', {})) + # initialise some other properties we will need + self.start = 1 + self.end = 1 + self.increment = 1 + + # property holding dict of last seen values for cumulative observations + self.last_values = {} + + # tell the user/log what we intend to do + _msg = "A CSV import from source file '%s' has been requested." % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if kwargs['date']: + _msg = " source=%s, date=%s" % (self.source, kwargs['date']) + else: + # we must have --from and --to + _msg = " source=%s, from=%s, to=%s" % (self.source, + kwargs['from_datetime'], + kwargs['to_datetime']) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc_missing=%s, " \ + "ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s, " \ + "date/time_string_format=%s" % (self.tranche, + self.interval, + self.raw_datetime_format) + if self.verbose: + print(_msg) + log.debug(_msg) + if hasattr(self, 'rain'): + _msg = " delimiter='%s', rain=%s, wind_direction=%s" % (self.delimiter, + self.rain, + self.wind_dir) + else: + _msg = " delimiter='%s', wind_direction=%s" % (self.delimiter, + self.wind_dir) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s" % (self.UV_sensor, self.solar_sensor) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound to " \ + "database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + self.print_map() + if self.calc_missing: + _msg = "Missing derived observations will be calculated." + print(_msg) + log.info(_msg) + + if not self.UV_sensor: + _msg = "All WeeWX UV fields will be set to None." + print(_msg) + log.info(_msg) + if not self.solar_sensor: + _msg = "All WeeWX radiation fields will be set to None." + print(_msg) + log.info(_msg) + if kwargs['date'] or kwargs['from_datetime']: + _msg = "Observations timestamped after %s and " \ + "up to and" % timestamp_to_string(self.first_ts) + print(_msg) + log.info(_msg) + _msg = "including %s will be imported." % timestamp_to_string(self.last_ts) + print(_msg) + log.info(_msg) + if self.dry_run: + _msg = "This is a dry run, imported data will not be saved to archive." + print(_msg) + log.info(_msg) + + def get_raw_data(self, period): + """Obtain an iterable containing the raw data to be imported. + + Raw data is read and any clean-up/pre-processing carried out before the + iterable is returned. In this case we will use csv.Dictreader(). The + iterable should be of a form where the field names in the field map can + be used to map the data to the WeeWX archive record format. + + Input parameters: + + period: a simple counter that is unused but retained to keep the + getRawData() signature the same across all classes. + """ + + # does our source exist? + if os.path.isfile(self.source): + # It exists. The source file may use some encoding, if we can't + # decode it raise a WeeImportDecodeError. + try: + with io.open(self.source, mode='r', encoding=self.source_encoding) as f: + _raw_data = f.readlines() + except UnicodeDecodeError as e: + # not a utf-8 based encoding, so raise a WeeImportDecodeError + raise weeimport.WeeImportDecodeError(e) + else: + # if it doesn't we can't go on so raise it + raise weeimport.WeeImportIOError("CSV source file '%s' could " \ + "not be found." % self.source) + + # just in case the data has been sourced from the web we will remove + # any HTML tags and blank lines that may exist + _clean_data = [] + for _row in _raw_data: + # check for and remove any null bytes + clean_row = _row + if "\x00" in _row: + clean_row = clean_row.replace("\x00", "") + _msg = "One or more null bytes found in and removed " \ + "from file '%s'" % self.source + print(_msg) + log.info(_msg) + # get rid of any HTML tags + _line = ''.join(CSVSource._tags.split(clean_row)) + if _line != "\n": + # save anything that is not a blank line + _clean_data.append(_line) + + # create a dictionary CSV reader, using the first line as the set of keys + _csv_reader = csv.DictReader(_clean_data, delimiter=self.delimiter) + + # return our CSV dict reader + return _csv_reader + + @staticmethod + def period_generator(): + """Generator function to control CSV import processing loop. + + Since CSV imports from a single file this generator need only + return a single value before it is exhausted. + """ + + yield 1 + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + For CSV imports there is only one period, so it is always the first. + """ + + return True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + For CSV imports there is only one period, so it is always the last. + """ + + return True diff --git a/dist/weewx-5.0.2/src/weeimport/cumulusimport.py b/dist/weewx-5.0.2/src/weeimport/cumulusimport.py new file mode 100644 index 0000000..f9a819e --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/cumulusimport.py @@ -0,0 +1,385 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Module to interact with Cumulus monthly log files and import raw +observational data for use with weeimport. +""" + +# Python imports +import csv +import glob +import io +import logging +import os +import time + +# WeeWX imports +from . import weeimport +import weewx + +from weeutil.weeutil import timestamp_to_string +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class CumulusSource +# ============================================================================ + +class CumulusSource(weeimport.Source): + """Class to interact with a Cumulus generated monthly log files. + + Handles the import of data from Cumulus monthly log files.Cumulus stores + observation data in monthly log files. Each log file contains a month of + data in CSV format. The format of the CSV data (e.g., date separator, field + delimiter, decimal point character) depends upon the settings used in + Cumulus. + + Data is imported from all month log files found in the source directory one + log file at a time. Units of measure are not specified in the monthly log + files so the units of measure must be specified in the wee_import config + file. Whilst the Cumulus monthly log file format is well-defined, some + pre-processing of the data is required to provide data in a format the + suitable for use in the wee_import mapping methods. + """ + + # List of field names used during import of Cumulus log files. These field + # names are for internal wee_import use only as Cumulus monthly log files + # do not have a header line with defined field names. Cumulus monthly log + # field 0 and field 1 are date and time fields respectively. getRawData() + # combines these fields to return a formatted date-time string that is later + # converted into a unix epoch timestamp. + _field_list = ['datetime', 'cur_out_temp', 'cur_out_hum', + 'cur_dewpoint', 'avg_wind_speed', 'gust_wind_speed', + 'avg_wind_bearing', 'cur_rain_rate', 'day_rain', 'cur_slp', + 'rain_counter', 'curr_in_temp', 'cur_in_hum', + 'latest_wind_gust', 'cur_windchill', 'cur_heatindex', + 'cur_uv', 'cur_solar', 'cur_et', 'annual_et', + 'cur_app_temp', 'cur_tmax_solar', 'day_sunshine_hours', + 'cur_wind_bearing', 'day_rain_rg11', 'midnight_rain'] + # tuple of fields using 'temperature' units + _temperature_fields = ('cur_out_temp', 'cur_dewpoint', 'curr_in_temp', + 'cur_windchill', 'cur_heatindex','cur_app_temp') + # tuple of fields using 'pressure' units + _pressure_fields = ('cur_slp', ) + # tuple of fields using 'rain' units + _rain_fields = ('day_rain', 'cur_et', 'annual_et', + 'day_rain_rg11', 'midnight_rain') + # tuple of fields using 'rain rate' units + _rain_rate_fields = ('cur_rain_rate', ) + # tuple of fields using 'speed' units + _speed_fields = ('avg_wind_speed', 'gust_wind_speed', 'latest_wind_gust') + # dict to lookup rain rate units given rain units + rain_units_dict = {'inch': 'inch_per_hour', 'mm': 'mm_per_hour'} + # Dict containing default mapping of Cumulus fields (refer to _field_list) + # to WeeWX archive fields. The user may modify this mapping by including a + # [[FieldMap]] and/or a [[FieldMapExtensions]] stanza in the import config + # file. + default_map = { + 'dateTime': { + 'source_field': 'datetime', + 'unit': 'unix_epoch'}, + 'outTemp': { + 'source_field': 'cur_out_temp'}, + 'inTemp': { + 'source_field': 'cur_in_temp'}, + 'outHumidity': { + 'source_field': 'cur_out_hum', + 'unit': 'percent'}, + 'inHumidity': { + 'source_field': 'cur_in_hum', + 'unit': 'percent'}, + 'dewpoint': { + 'source_field': 'cur_dewpoint'}, + 'heatindex': { + 'source_field': 'cur_heatindex'}, + 'windchill': { + 'source_field': 'cur_windchill'}, + 'appTemp': { + 'source_field': 'cur_app_temp'}, + 'barometer': { + 'source_field': 'cur_slp'}, + 'rain': { + 'source_field': 'midnight_rain', + 'is_cumulative': True}, + 'rainRate': { + 'source_field': 'cur_rain_rate'}, + 'windSpeed': { + 'source_field': 'avg_wind_speed'}, + 'windDir': { + 'source_field': 'avg_wind_bearing', + 'unit': 'degree_compass'}, + 'windGust': { + 'source_field': 'gust_wind_speed'}, + 'radiation': { + 'source_field': 'cur_solar', + 'unit': 'watt_per_meter_squared'}, + 'UV': { + 'source_field': 'cur_uv', + 'unit': 'uv_index'} + } + + def __init__(self, config_path, config_dict, import_config_path, + cumulus_config_dict, **kwargs): + + # call our parents __init__ + super().__init__(config_dict, cumulus_config_dict, **kwargs) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.cumulus_config_dict = cumulus_config_dict + + # wind dir bounds + self.wind_dir = [0, 360] + + # field delimiter used in monthly log files, default to comma + self.delimiter = str(cumulus_config_dict.get('delimiter', ',')) + + # date separator used in monthly log files, default to solidus + separator = cumulus_config_dict.get('separator', '/') + # we combine Cumulus date and time fields to give a fixed format + # date-time string + self.raw_datetime_format = separator.join(('%d', '%m', '%y %H:%M')) + + # initialise our import field-to-WeeWX archive field map + _map = dict(CumulusSource.default_map) + # create the final field map based on the default field map and any + # field map options provided by the user + self.map = self.parse_map(_map, + self.cumulus_config_dict.get('FieldMap', {}), + self.cumulus_config_dict.get('FieldMapExtensions', {})) + + # Cumulus log files have a number of 'rain' fields that can be used to + # derive the WeeWX rain field. Which one is available depends on the + # Cumulus version that created the logs. The preferred field is field + # 26(AA) - total rainfall since midnight but it is only available in + # Cumulus v1.9.4 or later. If that field is not available then the + # preferred field in field 09(J) - total rainfall today then field + # 11(L) - total rainfall counter. Initialise the rain_source_confirmed + # property now and we will deal with it later when we have some source + # data. + self.rain_source_confirmed = None + + # get our source file path + try: + self.source = cumulus_config_dict['directory'] + except KeyError: + _msg = "Cumulus monthly logs directory not specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # get the source file encoding, default to utf-8-sig + self.source_encoding = self.cumulus_config_dict.get('source_encoding', + 'utf-8-sig') + + # property holding the current log file name being processed + self.file_name = None + + # Now get a list on monthly log files sorted from oldest to newest + month_log_list = glob.glob(self.source + '/?????log.txt') + _temp = [(fn, fn[-9:-7], time.strptime(fn[-12:-9], '%b').tm_mon) for fn in month_log_list] + self.log_list = [a[0] for a in sorted(_temp, + key=lambda el: (el[1], el[2]))] + if len(self.log_list) == 0: + raise weeimport.WeeImportIOError( + "No Cumulus monthly logs found in directory '%s'." % self.source) + + # property holding dict of last seen values for cumulative observations + self.last_values = {} + + # tell the user/log what we intend to do + _msg = "Cumulus monthly log files in the '%s' directory will be imported" % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if kwargs['date']: + _msg = " date=%s" % kwargs['date'] + else: + # we must have --from and --to + _msg = " from=%s, to=%s" % (kwargs['from_datetime'], kwargs['to_datetime']) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc_missing=%s, " \ + "ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s" % (self.tranche, + self.interval) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s" % (self.UV_sensor, self.solar_sensor) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound " \ + "to database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + self.print_map() + if self.calc_missing: + print("Missing derived observations will be calculated.") + if not self.UV_sensor: + print("All WeeWX UV fields will be set to None.") + if not self.solar_sensor: + print("All WeeWX radiation fields will be set to None.") + if kwargs['date'] or kwargs['from_datetime']: + print("Observations timestamped after %s and " + "up to and" % timestamp_to_string(self.first_ts)) + print("including %s will be imported." % timestamp_to_string(self.last_ts)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def get_raw_data(self, period): + """Get raw observation data and construct a map from Cumulus monthly + log fields to WeeWX archive fields. + + Obtain raw observational data from Cumulus monthly logs. This raw data + needs to be cleaned of unnecessary characters/codes, a date-time field + generated for each row and an iterable returned. + + Input parameters: + + period: the file name, including path, of the Cumulus monthly log + file from which raw obs data will be read. + """ + + # period holds the filename of the monthly log file that contains our + # data. Does our source exist? + if os.path.isfile(period): + # It exists. The source file may use some encoding, if we can't + # decode it raise a WeeImportDecodeError. + try: + with io.open(period, mode='r', encoding=self.source_encoding) as f: + _raw_data = f.readlines() + except UnicodeDecodeError as e: + # not a utf-8 based encoding, so raise a WeeImportDecodeError + raise weeimport.WeeImportDecodeError(e) + else: + # If it doesn't we can't go on so raise it + raise weeimport.WeeImportIOError( + "Cumulus monthly log file '%s' could not be found." % period) + + # Our raw data needs a bit of cleaning up before we can parse/map it. + _clean_data = [] + for _row in _raw_data: + # check for and remove any null bytes + clean_row = _row + if "\x00" in _row: + clean_row = clean_row.replace("\x00", "") + _msg = "One or more null bytes found in and removed " \ + "from monthly log file '%s'" % (period, ) + print(_msg) + log.info(_msg) + # make sure we have full stops as decimal points + _line = clean_row.replace(self.decimal_sep, '.') + # ignore any blank lines + if _line != "\n": + # Cumulus has separate date and time fields as the first 2 + # fields of a row. It is easier to combine them now into a + # single date-time field that we can parse later when we map the + # raw data. + _datetime_line = _line.replace(self.delimiter, ' ', 1) + # Save what's left + _clean_data.append(_datetime_line) + + # if we haven't confirmed our source for the WeeWX rain field we need + # to do so now + if self.rain_source_confirmed is None: + # The Cumulus source field depends on the Cumulus version that + # created the log files. Unfortunately, we can only determine + # which field to use by looking at the mapped Cumulus data. If we + # look at our DictReader we have no way to reset it, so we create + # a one off DictReader to use instead. + _rain_reader = csv.DictReader(_clean_data, fieldnames=self._field_list, + delimiter=self.delimiter) + # now that we know what Cumulus fields are available we can set our + # rain source appropriately + self.set_rain_source(_rain_reader) + + # Now create a dictionary CSV reader + _reader = csv.DictReader(_clean_data, fieldnames=self._field_list, + delimiter=self.delimiter) + # Return our dict reader + return _reader + + def period_generator(self): + """Generator function yielding a sequence of monthly log file names. + + This generator controls the FOR statement in the parents run() method + that loops over the monthly log files to be imported. The generator + yields a monthly log file name from the list of monthly log files to + be imported until the list is exhausted. + """ + + # step through each of our file names + for self.file_name in self.log_list: + # yield the file name + yield self.file_name + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list, or if it is None (the initialisation value). + """ + + return self.file_name == self.log_list[0] if self.file_name is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current file name being processed is the last in + the list. + """ + + return self.file_name == self.log_list[-1] + + def set_rain_source(self, _data): + """Set the Cumulus field to be used as the WeeWX rain field source. + """ + + _row = next(_data) + if _row['midnight_rain'] is not None: + # we have data in midnight_rain, our default source, so leave + # things as they are and return + pass + elif _row['day_rain'] is not None: + # we have data in day_rain so use that as our rain source + self.map['rain']['source'] = 'day_rain' + elif _row['rain_counter'] is not None: + # we have data in rain_counter so use that as our rain source + self.map['rain']['source'] = 'rain_counter' + else: + # We should never end up in this state but.... + # We have no suitable rain source so we can't import so remove the + # rain field entry from the header map. + del self.map['rain'] + # we only need to do this once so set our flag to True + self.rain_source_confirmed = True + return diff --git a/dist/weewx-5.0.2/src/weeimport/wdimport.py b/dist/weewx-5.0.2/src/weeimport/wdimport.py new file mode 100644 index 0000000..ce7ba3b --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/wdimport.py @@ -0,0 +1,571 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and +# Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Module for use with weeimport to import observational data from Weather +Display monthly log files. +""" + +# Python imports +import collections +import csv +import datetime +import glob +import io +import logging +import operator +import os +import time + +# WeeWX imports +from . import weeimport +import weeutil.weeutil +import weewx + +from weeutil.weeutil import timestamp_to_string +from weewx.units import unit_nicknames + +log = logging.getLogger(__name__) + +# ============================================================================ +# class WDSource +# ============================================================================ + + +class WDSource(weeimport.Source): + """Class to interact with a Weather Display generated monthly log files. + + Handles the import of data from WD monthly log files. WD stores observation + data across a number of monthly log files. Each log file contains one month + of minute separated data in structured text or csv format. + + Data is imported from all monthly log files found in the source directory + one set of monthly log files at a time. Units of measure are not specified + in the monthly log files so the units of measure must be specified in the + wee_import config file. Whilst the WD monthly log file formats are well + defined, some pre-processing of the data is required to provide data in a + format suitable for use in the wee_import mapping methods. + + WD log file units are set to either Metric or US in WD via the 'Log File' + setting under 'Units' on the 'Units/Wind Chill' tab of the universal setup. + The units used in each log file in each case are: + + Log File Field Metric US + Units Units + MMYYYYlg.txt day + MMYYYYlg.txt month + MMYYYYlg.txt year + MMYYYYlg.txt hour + MMYYYYlg.txt minute + MMYYYYlg.txt temperature C F + MMYYYYlg.txt humidity % % + MMYYYYlg.txt dewpoint C F + MMYYYYlg.txt barometer hPa inHg + MMYYYYlg.txt windspeed knots mph + MMYYYYlg.txt gustspeed knots mph + MMYYYYlg.txt direction degrees degrees + MMYYYYlg.txt rainlastmin mm inch + MMYYYYlg.txt dailyrain mm inch + MMYYYYlg.txt monthlyrain mm inch + MMYYYYlg.txt yearlyrain mm inch + MMYYYYlg.txt heatindex C F + MMYYYYvantagelog.txt Solar radiation W/sqm W/sqm + MMYYYYvantagelog.txt UV index index + MMYYYYvantagelog.txt Daily ET mm inch + MMYYYYvantagelog.txt soil moist cb cb + MMYYYYvantagelog.txt soil temp C F + MMYYYYvantageextrasensrslog.txt temp1-temp7 C F + MMYYYYvantageextrasensrslog.txt hum1-hum7 % % + """ + + # Dict of log files and field names that we know how to process. Field + # names would normally be derived from the first line of log file but + # inconsistencies in field naming in the log files make this overly + # complicated and difficult. 'fields' entry can be overridden from import + # config if required. + logs = {'lg.txt': {'fields': ['day', 'month', 'year', 'hour', 'minute', + 'temperature', 'humidity', 'dewpoint', + 'barometer', 'windspeed', 'gustspeed', + 'direction', 'rainlastmin', 'dailyrain', + 'monthlyrain', 'yearlyrain', 'heatindex'] + }, + 'lgcsv.csv': {'fields': ['day', 'month', 'year', 'hour', 'minute', + 'temperature', 'humidity', 'dewpoint', + 'barometer', 'windspeed', 'gustspeed', + 'direction', 'rainlastmin', 'dailyrain', + 'monthlyrain', 'yearlyrain', 'heatindex'] + }, + 'vantageextrasensorslog.csv': {'fields': ['day', 'month', 'year', + 'hour', 'minute', 'temp1', + 'temp2', 'temp3', 'temp4', + 'temp5', 'temp6', 'temp7', + 'hum1', 'hum2', 'hum3', + 'hum4', 'hum5', 'hum6', + 'hum7'] + }, + 'vantagelog.txt': {'fields': ['day', 'month', 'year', 'hour', + 'minute', 'radiation', 'UV', + 'dailyet', 'soilmoist', + 'soiltemp'] + }, + 'vantagelogcsv.csv': {'fields': ['day', 'month', 'year', 'hour', + 'minute', 'radiation', 'UV', + 'dailyet', 'soilmoist', + 'soiltemp'] + } + } + + # dict to lookup WD log field units based on the WD logging Metric or US + # unit system + wd_unit_sys = {'temperature': {'METRIC': 'degree_C', 'US': 'degree_F'}, + 'pressure': {'METRIC': 'hPa', 'US': 'inHg'}, + 'rain': {'METRIC': 'mm', 'US': 'inch'}, + 'speed': {'METRIC': 'knot', 'US': 'mile_per_hour'} + } + + # tuple of fields using 'temperature' units + _temperature_fields = ('temperature', 'dewpoint', 'heatindex', 'soiltemp', + 'temp1', 'temp2', 'temp3', 'temp4', 'temp5', + 'temp6', 'temp7') + # tuple of fields using 'pressure' units + _pressure_fields = ('barometer', ) + # tuple of fields using 'rain' units + _rain_fields = ('rainlastmin', 'daily_rain', 'monthlyrain', 'yearlyrain', 'dailyet') + # tuple of fields using 'speed' units + _speed_fields = ('windspeed', 'gustspeed') + + # dict to map all possible WD field names (refer _field_list) to WeeWX + # archive field names and units + default_map = { + 'dateTime': { + 'source_field': 'datetime', + 'unit': 'unix_epoch'}, + 'outTemp': { + 'source_field': 'temperature'}, + 'outHumidity': { + 'source_field': 'humidity', + 'unit': 'percent'}, + 'dewpoint': { + 'source_field': 'dewpoint'}, + 'heatindex': { + 'source_field': 'heatindex'}, + 'barometer': { + 'source_field': 'barometer'}, + 'rain': { + 'source_field': 'rainlastmin'}, + 'windSpeed': { + 'source_field': 'windspeed'}, + 'windDir': { + 'source_field': 'direction', + 'unit': 'degree_compass'}, + 'windGust': { + 'source_field': 'gustspeed'}, + 'radiation': { + 'source_field': 'radiation', + 'unit': 'watt_per_meter_squared'}, + 'UV': { + 'source_field': 'uv', + 'unit': 'uv_index'}, + 'ET': { + 'source_field': 'dailyet', + 'is_cumulative': True}, + 'soilMoist1': { + 'source_field': 'soilmoist', + 'unit': 'centibar'}, + 'soilTemp1': { + 'source_field': 'soiltemp'}, + 'extraTemp1': { + 'source_field': 'temp1'}, + 'extraHumid1': { + 'source_field': 'humid1'}, + 'extraTemp2': { + 'source_field': 'temp2'}, + 'extraHumid2': { + 'source_field': 'humid2'}, + 'extraTemp3': { + 'source_field': 'temp3'}, + 'extraHumid3': { + 'source_field': 'humid3'}, + 'extraTemp4': { + 'source_field': 'temp4'}, + 'extraHumid4': { + 'source_field': 'humid4'}, + 'extraTemp5': { + 'source_field': 'temp5'}, + 'extraHumid5': { + 'source_field': 'humid5'}, + 'extraTemp6': { + 'source_field': 'temp6'}, + 'extraHumid6': { + 'source_field': 'humid6'}, + 'extraTemp7': { + 'source_field': 'temp7'}, + 'extraHumid7': { + 'source_field': 'humid7'} + } + + def __init__(self, config_path, config_dict, import_config_path, + wd_config_dict, **kwargs): + + # call our parents __init__ + super().__init__(config_dict, wd_config_dict, **kwargs) + + # save the import config path + self.import_config_path = import_config_path + # save the import config dict + self.wd_config_dict = wd_config_dict + + # our parent uses 'derive' as the default interval setting, for WD the + # default should be 1 (minute) so redo the interval setting with our + # default + self.interval = wd_config_dict.get('interval', 1) + + # wind dir bounds + self.wind_dir = [0, 360] + + # field delimiter used in text format monthly log files, default to + # space + self.txt_delimiter = str(wd_config_dict.get('txt_delimiter', ' ')) + # field delimiter used in csv format monthly log files, default to + # comma + self.csv_delimiter = str(wd_config_dict.get('csv_delimiter', ',')) + + # ignore extreme > 255.0 values for temperature and humidity fields + self.ignore_extr_th = weeutil.weeutil.tobool(wd_config_dict.get('ignore_extreme_temp_hum', + True)) + + # initialise the import field-to-WeeWX archive field map + _map = dict(WDSource.default_map) + # create the final field map based on the default field map and any + # field map options provided by the user + self.map = self.parse_map(_map, + self.wd_config_dict.get('FieldMap', {}), + self.wd_config_dict.get('FieldMapExtensions', {})) + + # property holding the current log file name being processed + self.file_name = None + + # obtain a list of logs files to be processed + _to_process = wd_config_dict.get('logs_to_process', list(self.logs.keys())) + self.logs_to_process = weeutil.weeutil.option_as_list(_to_process) + + # can missing log files be ignored + self.ignore_missing_log = weeutil.weeutil.to_bool(wd_config_dict.get('ignore_missing_log', + True)) + + # get our source file path + try: + self.source = wd_config_dict['directory'] + except KeyError: + _msg = "Weather Display monthly logs directory not " \ + "specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # get the source file encoding, default to utf-8-sig + self.source_encoding = self.wd_config_dict.get('source_encoding', + 'utf-8-sig') + + # Now get a list on monthly log files sorted from oldest to newest. + # This is complicated by the log file naming convention used by WD. + # first the 1 digit months + _lg_5_list = glob.glob(self.source + '/' + '[0-9]' * 5 + 'lg.txt') + # and the 2 digit months + _lg_6_list = glob.glob(self.source + '/' + '[0-9]' * 6 + 'lg.txt') + # concatenate the two lists to get the complete list + month_lg_list = _lg_5_list + _lg_6_list + # create a list of log files in chronological order (month, year) + _temp = [] + # create a list of log files, adding year and month fields for sorting + for p in month_lg_list: + # obtain the file name + fn = os.path.split(p)[1] + # obtain the numeric part of the file name + _digits = ''.join(c for c in fn if c.isdigit()) + # append a list of format [path+file name, month, year] + _temp.append([p, int(_digits[:-4]), int(_digits[-4:])]) + # now sort the list keeping just the log file path and name + self.log_list = [a[0] for a in sorted(_temp, key=lambda el: (el[2], el[1]))] + # if there are no log files then there is nothing to be done + if len(self.log_list) == 0: + raise weeimport.WeeImportIOError("No Weather Display monthly logs " + "found in directory '%s'." % self.source) + # Some log files have entries that belong in a different month. + # Initialise a list to hold these extra records for processing during + # the appropriate month + self.extras = {} + for log_to_process in self.logs_to_process: + self.extras[log_to_process] = [] + + # property holding dict of last seen values for cumulative observations + self.last_values = {} + + # tell the user/log what we intend to do + _msg = "Weather Display monthly log files in the '%s' " \ + "directory will be imported" % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if kwargs['date']: + _msg = " date=%s" % kwargs['date'] + else: + # we must have --from and --to + _msg = " from=%s, to=%s" % (kwargs['from_datetime'], kwargs['to_datetime']) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc_missing=%s, " \ + "ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s" % (self.tranche, + self.interval) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s ignore extreme temperature " \ + "and humidity=%s" % (self.UV_sensor, + self.solar_sensor, + self.ignore_extr_th) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound to " \ + "database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + self.print_map() + if self.calc_missing: + print("Missing derived observations will be calculated.") + if not self.UV_sensor: + print("All WeeWX UV fields will be set to None.") + if not self.solar_sensor: + print("All WeeWX radiation fields will be set to None.") + if kwargs['date'] or kwargs['from_datetime']: + print("Observations timestamped after %s and " + "up to and" % timestamp_to_string(self.first_ts)) + print("including %s will be imported." % timestamp_to_string(self.last_ts)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def get_raw_data(self, period): + """ Obtain raw observation data from a log file. + + The getRawData() method must return a single iterable containing the + raw observational data for the period. Since Weather Display uses more + than one log file per month the data from all relevant log files needs + to be combined into a single dict. A date-time field must be generated + for each row and the resulting raw data dict returned. + + Input parameters: + + period: the file name, including path, of the Weather Display monthly + log file from which raw obs data will be read. + """ + + # since we may have multiple log files to parse for a given period + # strip out the month-year portion of the log file + _path, _file = os.path.split(period) + _prefix = _file[:-6] + _month = _prefix[:-4] + _year = _prefix[-4:] + + # initialise a list to hold the list of dicts read from each log file + _data_list = [] + # iterate over the log files to processed + for lg in self.logs_to_process: + # obtain the path and file name of the log we are to process + _fn = '%s%s' % (_prefix, lg) + _path_file_name = os.path.join(_path, _fn) + + # check that the log file exists + if os.path.isfile(_path_file_name): + # It exists. The source file may use some encoding, if we can't + # decode it raise a WeeImportDecodeError. + try: + with io.open(_path_file_name, mode='r', encoding=self.source_encoding) as f: + _raw_data = f.readlines() + except UnicodeDecodeError as e: + # not a utf-8 based encoding, so raise a WeeImportDecodeError + raise weeimport.WeeImportDecodeError(e) + else: + # log file does not exist ignore it if we are allowed else + # raise it + if self.ignore_missing_log: + continue + else: + _msg = "Weather Display monthly log file '%s' could " \ + "not be found." % _path_file_name + raise weeimport.WeeImportIOError(_msg) + + # Determine delimiter to use. This is a simple check, if 'csv' + # exists anywhere in the file name then assume a csv and use the + # csv delimiter otherwise use the txt delimiter + _del = self.csv_delimiter if 'csv' in lg.lower() else self.txt_delimiter + + # the raw data needs a bit of cleaning up before we can parse/map it + _clean_data = [] + for i, _row in enumerate(_raw_data): + # do a crude check of the expected v actual number of columns + # since we are not aware whether the WD log files structure may + # have changed over time + + # ignore the first line, it will likely be header info + if i == 2 and \ + len(" ".join(_row.split()).split(_del)) != len(self.logs[lg]['fields']): + _msg = "Unexpected number of columns found in '%s': " \ + "%s v %s" % (_fn, + len(_row.split(_del)), + len(self.logs[lg]['fields'])) + print(_msg) + log.info(_msg) + # check for and remove any null bytes + clean_row = _row + if "\x00" in _row: + clean_row = clean_row.replace("\x00", "") + _msg = "One or more null bytes found in and removed " \ + "from row %d in file '%s'" % (i, _fn) + print(_msg) + log.info(_msg) + # make sure we have full stops as decimal points + _clean_data.append(clean_row.replace(self.decimal_sep, '.')) + + # initialise a list to hold our processed data for this log file + _data = [] + # obtain the field names to be used for this log file + _field_names = self.logs[lg].get('fields') + # create a CSV dictionary reader, use skipinitialspace=True to skip + # any extra whitespace between columns and fieldnames to specify + # the field names to be used + _reader = csv.DictReader(_clean_data, + delimiter=_del, + skipinitialspace=True, + fieldnames=_field_names) + # skip the header line since we are using our own field names + next(_reader) + # iterate over the records and calculate a unix timestamp for each + # record + for rec in _reader: + # first get a datetime object from the individual date-time + # fields + _dt = datetime.datetime(int(rec['year']), int(rec['month']), + int(rec['day']), int(rec['hour']), + int(rec['minute'])) + # now as a timetuple + _tt = _dt.timetuple() + # and finally a timestamp but as a string like the rest of our + # data + _ts = "%s" % int(time.mktime(_tt)) + # add the timestamp to our record + _ts_rec = dict(rec, **{'datetime': _ts}) + # some WD log files contain records from another month so check + # year and month and if the record belongs to another month + # store it for use later otherwise add it to this month's data + if _ts_rec['year'] == _year and _ts_rec['month'] == _month: + # add the timestamped record to our data list + _data.append(_ts_rec) + else: + # add the record to the list for later processing + self.extras[lg].append(_ts_rec) + # now add any extras that may belong in this month + for e_rec in self.extras[lg]: + if e_rec['year'] == _year and e_rec['month'] == _month: + # add the record + _data.append(e_rec) + # now update our extras and remove any records we added + self.extras[lg][:] = [x for x in self.extras[lg] if + not (x['year'] == _year and x['month'] == _month)] + + # There may be duplicate timestamped records in the data. We will + # keep the first encountered duplicate and discard the latter ones, + # but we also need to keep track of the duplicate timestamps for + # later reporting. + + # initialise a set to hold the timestamps we have seen + _timestamps = set() + # initialise a list to hold the unique timestamped records + unique_data = [] + # iterate over each record in the list of records + for item in _data: + # has this timestamp been seen before + if item['datetime'] not in _timestamps: + # no it hasn't, so keep the record and add the timestamp + # to the list of timestamps seen + unique_data.append(item) + _timestamps.add(item['datetime']) + else: + # yes it has been seen, so add the timestamp to the list of + # duplicates for later reporting + self.period_duplicates.add(int(item['datetime'])) + + # add the data (list of dicts) to the list of processed log file + # data + _data_list.append(unique_data) + + # we have all our data so now combine the data for each timestamp into + # a common record, this gives us a single list of dicts + d = collections.defaultdict(dict) + for _list in _data_list: + for elm in _list: + d[elm['datetime']].update(elm) + + # The combined data will likely not be in dateTime order, WD logs can + # be imported in a dateTime unordered state but the user will be + # presented with a more legible display as the import progresses if + # the data is in ascending dateTime order. + _sorted = sorted(list(d.values()), key=operator.itemgetter('datetime')) + # return our sorted data + return _sorted + + def period_generator(self): + """Generator function yielding a sequence of monthly log file names. + + This generator controls the FOR statement in the parents run() method + that loops over the monthly log files to be imported. The generator + yields a monthly log file name from the list of monthly log files to + be imported until the list is exhausted. + """ + + # Step through each of our file names + for self.file_name in self.log_list: + # yield the file name + yield self.file_name + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list, or if it is None (the initialisation value). + """ + + return self.file_name == self.log_list[0] if self.file_name is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current file name being processed is the last in + the list. + """ + + return self.file_name == self.log_list[-1] diff --git a/dist/weewx-5.0.2/src/weeimport/weathercatimport.py b/dist/weewx-5.0.2/src/weeimport/weathercatimport.py new file mode 100644 index 0000000..adfae02 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/weathercatimport.py @@ -0,0 +1,401 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# +"""Module for use with wee_import to import observational data from WeatherCat +monthly .cat files. +""" + +# Python imports +import glob +import logging +import os +import shlex +import time + +# WeeWX imports +import weewx +from weeutil.weeutil import timestamp_to_string +from weewx.units import unit_nicknames +from . import weeimport + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class WeatherCatSource +# ============================================================================ + +class WeatherCatSource(weeimport.Source): + """Class to interact with a WeatherCat monthly data (.cat) files. + + Handles the import of data from WeatherCat monthly data files. WeatherCat + stores formatted observation data in text files using the .cat extension. + Each file contains a month of data with one record per line. Fields in each + record are space delimited with field names and field data separated by a + colon (:). + + Data files are named 'x_WeatherCatData.cat' where x is the month + number (1..12). Each year's monthly data files are located in a directory + named 'YYYY' where YYYY is the year number. wee_import relies on this + directory structure for successful import of WeatherCat data. + + Data is imported from all monthly data files found in the source directory + one file at a time. Units of measure are not specified in the monthly data + files so the units of measure must be specified in the import config file + being used. + + WeatherCat supports the following units: + - temperature: °C and °F + - dewpoint: °C and °F but set independently of temperature + - pressure: inHg, hPa and mBar + - rain: inch and mm + - wind speed: km/hr, mph, knots and m/s + """ + + # dict to map all possible WeatherCat .cat file field names to WeeWX + # archive field names and units + default_map = { + 'dateTime': { + 'source_field': 'datetime', + 'unit': 'unix_epoch'}, + 'outTemp': { + 'source_field': 'T', + 'unit': 'degree_C'}, + 'inTemp': { + 'source_field': 'Ti', + 'unit': 'degree_C'}, + 'extraTemp1': { + 'source_field': 'T1', + 'unit': 'degree_C'}, + 'extraTemp2': { + 'source_field': 'T2', + 'unit': 'degree_C'}, + 'extraTemp3': { + 'source_field': 'T3', + 'unit': 'degree_C'}, + 'dewpoint': { + 'source_field': 'D', + 'unit': 'degree_C'}, + 'barometer': { + 'source_field': 'Pr', + 'unit': 'mbar'}, + 'windSpeed': { + 'source_field': 'W', + 'unit': 'km_per_hour'}, + 'windDir': { + 'source_field': 'Wd', + 'unit': 'degree_compass'}, + 'windchill': { + 'source_field': 'Wc', + 'unit': 'degree_C'}, + 'windGust': { + 'source_field': 'Wg', + 'unit': 'km_per_hour'}, + 'rainRate': { + 'source_field': 'Ph', + 'unit': 'mm_per_hour'}, + 'rain': { + 'source_field': 'P', + 'unit': 'mm'}, + 'outHumidity': { + 'source_field': 'H', + 'unit': 'percent'}, + 'inHumidity': { + 'source_field': 'Hi', + 'unit': 'percent'}, + 'extraHumid1': { + 'source_field': 'H1', + 'unit': 'percent'}, + 'extraHumid2': { + 'source_field': 'H2', + 'unit': 'percent'}, + 'radiation': { + 'source_field': 'S', + 'unit': 'watt_per_meter_squared'}, + 'soilMoist1': { + 'source_field': 'Sm1', + 'unit': 'centibar'}, + 'soilMoist2': { + 'source_field': 'Sm2', + 'unit': 'centibar'}, + 'soilMoist3': { + 'source_field': 'Sm3', + 'unit': 'centibar'}, + 'soilMoist4': { + 'source_field': 'Sm4', + 'unit': 'centibar'}, + 'leafWet1': { + 'source_field': 'Lw1', + 'unit': 'count'}, + 'leafWet2': { + 'source_field': 'Lw2', + 'unit': 'count'}, + 'soilTemp1': { + 'source_field': 'St1', + 'unit': 'degree_C'}, + 'soilTemp2': { + 'source_field': 'St2', + 'unit': 'degree_C'}, + 'soilTemp3': { + 'source_field': 'St3', + 'unit': 'degree_C'}, + 'soilTemp4': { + 'source_field': 'St4', + 'unit': 'degree_C'}, + 'leafTemp1': { + 'source_field': 'Lt1', + 'unit': 'degree_C'}, + 'leafTemp2': { + 'source_field': 'Lt2', + 'unit': 'degree_C'}, + 'UV': { + 'source_field': 'U', + 'unit': 'uv_index'} + } + # unit groups used to specify units used by WeatherCat field + weathercat_unit_groups = ('temperature', 'dewpoint', 'pressure', + 'windspeed', 'precipitation') + # tuple of fields using 'temperature' units + _temperature_fields = ('T', 'Ti', 'T1', 'T2', 'T3', 'Wc', 'St1', 'St2', + 'St3', 'St4', 'Lt1', 'Lt2') + # tuple of fields using 'dewpoint' units + _dewpoint_fields = ('D', ) + # tuple of fields using 'pressure' units + _pressure_fields = ('Pr', ) + # tuple of fields using 'precipitation' units + _precipitation_fields = ('P', ) + # tuple of fields using 'precipitation rate' units + _precipitation_rate_fields = ('Ph', ) + # tuple of fields using 'windspeed' units + _windspeed_fields = ('W', 'Wg') + + def __init__(self, config_path, config_dict, import_config_path, + weathercat_config_dict, **kwargs): + + # call our parents __init__ + super().__init__(config_dict, weathercat_config_dict, **kwargs) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.weathercat_config_dict = weathercat_config_dict + + # wind dir bounds + self.wind_dir = [0, 360] + + # The WeatherCatData.cat file structure is well-defined, so we can + # construct our import field-to-WeeWX archive field map now. The user + # can specify the units used in the monthly data files, so first + # construct a default field map then go through and adjust the units + # where necessary. + # construct our import field-to-WeeWX archive field map + _map = dict(WeatherCatSource.default_map) + # create the final field map based on the default field map and any + # field map options provided by the user + self.map = self.parse_map(_map, + self.weathercat_config_dict.get('FieldMap', {}), + self.weathercat_config_dict.get('FieldMapExtensions', {})) + + # property holding the current log file name being processed + self.file_name = None + + # get our source file path + try: + self.source = weathercat_config_dict['directory'] + except KeyError: + raise weewx.ViolatedPrecondition( + "WeatherCat directory not specified in '%s'." % import_config_path) + + # Get a list of monthly data files sorted from oldest to newest. + # Remember the files are in 'year' folders. + # first get a list of all the 'year' folders including path + _y_list = [os.path.join(self.source, d) for d in os.listdir(self.source) + if os.path.isdir(os.path.join(self.source, d))] + # initialise our list of monthly data files + f_list = [] + # iterate over the 'year' directories + for _dir in _y_list: + # find any monthly data files in the 'year' directory and add them + # to the file list + f_list += glob.glob(''.join([_dir, '/*[0-9]_WeatherCatData.cat'])) + # now get an intermediate list that we can use to sort the file list + # from oldest to newest + _temp = [(fn, + os.path.basename(os.path.dirname(fn)), + os.path.basename(fn).split('_')[0].zfill(2)) for fn in f_list] + # now do the sorting + self.cat_list = [a[0] for a in sorted(_temp, + key=lambda el: (el[1], el[2]))] + + if len(self.cat_list) == 0: + raise weeimport.WeeImportIOError( + "No WeatherCat monthly .cat files found in directory '%s'." % self.source) + + # property holding dict of last seen values for cumulative observations + self.last_values = {} + + # tell the user/log what we intend to do + _msg = "WeatherCat monthly .cat files in the '%s' directory " \ + "will be imported" % self.source + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if kwargs['date']: + _msg = " date=%s" % kwargs['date'] + else: + # we must have --from and --to + _msg = " from=%s, to=%s" % (kwargs['from_datetime'], kwargs['to_datetime']) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " dry-run=%s, calc-missing=%s" % (self.dry_run, + self.calc_missing) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s" % (self.tranche, + self.interval) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " UV=%s, radiation=%s" % (self.UV_sensor, self.solar_sensor) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is " \ + "bound to database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system " \ + "is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + self.print_map() + if self.calc_missing: + print("Missing derived observations will be calculated.") + if not self.UV_sensor: + print("All WeeWX UV fields will be set to None.") + if not self.solar_sensor: + print("All WeeWX radiation fields will be set to None.") + if kwargs['date'] or kwargs['from_datetime']: + print("Observations timestamped after %s and " + "up to and" % (timestamp_to_string(self.first_ts),)) + print("including %s will be imported." % (timestamp_to_string(self.last_ts),)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def get_raw_data(self, period): + """Get the raw data and create WeatherCat to WeeWX archive field map. + + Create a WeatherCat to WeeWX archive field map and instantiate a + generator to yield one row of raw observation data at a time from the + monthly data file. + + Field names and units are fixed for a WeatherCat monthly data file, so + we can use a fixed field map. + + Calculating a date-time for each row requires obtaining the year from + the monthly data file directory, month from the monthly data file name + and day, hour and minute from the individual rows in the monthly data + file. This calculation could be performed in the mapRawData() method, + but it is convenient to do it here as all the source data is readily + available plus it maintains simplicity in the mapRawData() method. + + Input parameter: + + period: The file name, including path, of the WeatherCat monthly + data file from which raw obs data will be read. + """ + + # confirm the source exists + if os.path.isfile(period): + # obtain year from the directory containing the monthly data file + _year = os.path.basename(os.path.dirname(period)) + # obtain the month number from the monthly data filename, we need + # to zero pad to ensure we get a two character month + _month = os.path.basename(period).split('_')[0].zfill(2) + # read the monthly data file line by line + with open(period, 'r') as f: + for _raw_line in f: + # the line is a data line if it has a t and V key + if 't:' in _raw_line and 'V:' in _raw_line: + # we have a data line + _row = {} + # check for and remove any null bytes and strip any + # whitespace + if "\x00" in _raw_line: + _line = _raw_line.replace("\x00", "").strip() + _msg = "One or more null bytes found in and removed " \ + "from month '%d' year '%d'" % (int(_month), _year) + print(_msg) + log.info(_msg) + else: + # strip any whitespace + _line = _raw_line.strip() + # iterate over the key-value pairs on the line + for pair in shlex.split(_line): + _split_pair = pair.split(":", 1) + # if we have a key-value pair save the data in the + # row dict + if len(_split_pair) > 1: + _row[_split_pair[0]] = _split_pair[1] + # calculate an epoch timestamp for the row + if 't' in _row: + _ymt = ''.join([_year, _month, _row['t']]) + try: + _datetm = time.strptime(_ymt, "%Y%m%d%H%M") + _row['datetime'] = str(int(time.mktime(_datetm))) + except ValueError: + raise ValueError("Cannot convert '%s' to timestamp." % _ymt) + yield _row + else: + # if it doesn't we can't go on so raise it + _msg = "WeatherCat monthly .cat file '%s' could not be found." % period + raise weeimport.WeeImportIOError(_msg) + + def period_generator(self): + """Generator yielding a sequence of WeatherCat monthly data file names. + + This generator controls the FOR statement in the parent's run() method + that iterates over the monthly data files to be imported. The generator + yields a monthly data file name from the sorted list of monthly data + files to be imported until the list is exhausted. The generator also + sets the first_period and last_period properties.""" + + # step through each of our file names + for self.file_name in self.cat_list: + # yield the file name + yield self.file_name + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list or if the current period is None (the initialisation value). + """ + + return self.file_name == self.cat_list[0] if self.file_name is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current file name being processed is the last in + the list. + """ + + return self.file_name == self.cat_list[-1] diff --git a/dist/weewx-5.0.2/src/weeimport/weeimport.py b/dist/weewx-5.0.2/src/weeimport/weeimport.py new file mode 100644 index 0000000..99a9a9f --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/weeimport.py @@ -0,0 +1,1358 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# + +"""Module providing the base classes and API for importing observational data +into WeeWX. +""" + +# Python imports +import datetime +import logging +import numbers +import re +import sys +import time +from datetime import datetime as dt + +# WeeWX imports +import weecfg +import weecfg.database +import weewx +import weewx.accum +import weewx.qc +import weewx.wxservices +from weeutil.weeutil import timestamp_to_string, option_as_list, to_int, tobool, get_object, \ + max_with_none +from weewx.manager import open_manager_with_config +from weewx.units import unit_constants, unit_nicknames, convertStd, to_std_system, ValueTuple + +log = logging.getLogger(__name__) + +# List of sources we support +SUPPORTED_SOURCES = ['CSV', 'WU', 'Cumulus', 'WD', 'WeatherCat', 'Ecowitt'] + + +# ============================================================================ +# Error Classes +# ============================================================================ + +class WeeImportOptionError(Exception): + """Base class of exceptions thrown when encountering an error with a + command line option. + """ + + +class WeeImportMapError(Exception): + """Base class of exceptions thrown when encountering an error with an + external source-to-WeeWX field map. + """ + + +class WeeImportIOError(Exception): + """Base class of exceptions thrown when encountering an I/O error with an + external source. + """ + + +class WeeImportFieldError(Exception): + """Base class of exceptions thrown when encountering an error with a field + from an external source. + """ + + +class WeeImportDecodeError(Exception): + """Base class of exceptions thrown when encountering a decode error with an + external source. + """ + +# ============================================================================ +# class Source +# ============================================================================ + +class Source(object): + """Base class used for interacting with an external data source to import + records into the WeeWX archive. + + __init__() must define the following properties: + dry_run - Is this a dry run (ie do not save imported records + to archive). [True|False]. + calc_missing - Calculate any missing derived observations. + [True|False]. + ignore_invalid_data - Ignore any invalid data found in a source field. + [True|False]. + tranche - Number of records to be written to archive in a + single transaction. Integer. + interval - Method of determining interval value if interval + field not included in data source. + ['config'|'derive'|x] where x is an integer. + + Child classes are used to interact with a specific source (eg CSV file, + WU). Any such child classes must define a get_raw_data() method which: + - gets the raw observation data and returns an iterable yielding data + dicts whose fields can be mapped to a WeeWX archive field + - defines an import data field-to-WeeWX archive field map (self.map) + + self.raw_datetime_format - Format of date time data field from which + observation timestamp is to be derived. A + string in Python datetime string format such + as '%Y-%m-%d %H:%M:%S'. If the date time + data field cannot be interpreted as a string + wee_import attempts to interpret the field + as a unix timestamp. If the field is not a + valid unix timestamp an error is raised. + """ + + # reg expression to match any HTML tag of the form <...> + _tags = re.compile(r'\<.*\>') + + special_processing_fields = ('dateTime', 'usUnits', 'interval') + + def __init__(self, config_dict, import_config_dict, **kwargs): + """A generic initialisation. + + Set some realistic default values for options read from the import + config file. Obtain objects to handle missing derived obs (if required) + and QC on imported data. Parse any --date command line option, so we + know what records to import. + """ + + # save our WeeWX config dict + self.config_dict = config_dict + + # get our import config dict settings + # interval, default to 'derive' + self.interval = import_config_dict.get('interval', 'derive') + # do we ignore invalid data, default to True + self.ignore_invalid_data = tobool(import_config_dict.get('ignore_invalid_data', + True)) + # tranche, default to 250 + self.tranche = to_int(import_config_dict.get('tranche', 250)) + # apply QC, default to True + self.apply_qc = tobool(import_config_dict.get('qc', True)) + # calc-missing, default to True + self.calc_missing = tobool(import_config_dict.get('calc_missing', True)) + # decimal separator, default to period '.' + self.decimal_sep = import_config_dict.get('decimal', '.') + + # Some sources include UV index and solar radiation values even if no + # sensor was present. The WeeWX convention is to store the None value + # when a sensor or observation does not exist. Record whether UV and/or + # solar radiation sensor was present. + # UV, default to True + self.UV_sensor = tobool(import_config_dict.get('UV_sensor', True)) + # solar, default to True + self.solar_sensor = tobool(import_config_dict.get('solar_sensor', True)) + + # initialise ignore extreme > 255.0 values for temperature and + # humidity fields for WD imports + self.ignore_extr_th = False + + self.db_binding_wx = get_binding(config_dict) + self.dbm = open_manager_with_config(config_dict, self.db_binding_wx, + initialize=True, + default_binding_dict={'table_name': 'archive', + 'manager': 'weewx.wxmanager.DaySummaryManager', + 'schema': 'schemas.wview_extended.schema'}) + # get the unit system used in our db + if self.dbm.std_unit_system is None: + # we have a fresh archive (ie no records) so cannot deduce + # the unit system in use, so go to our config_dict + self.archive_unit_sys = unit_constants[self.config_dict['StdConvert'].get('target_unit', + 'US')] + else: + # get our unit system from the archive db + self.archive_unit_sys = self.dbm.std_unit_system + + # initialise the accum dict with any Accumulator config in the config + # dict + weewx.accum.initialize(self.config_dict) + + # get ourselves a QC object to do QC on imported records + try: + mm_dict = config_dict['StdQC']['MinMax'] + except KeyError: + mm_dict = {} + self.import_QC = weewx.qc.QC(mm_dict) + + # process our command line options + self.dry_run = kwargs['dry_run'] + self.verbose = kwargs['verbose'] + self.no_prompt = kwargs['no_prompt'] + self.suppress_warning = kwargs['suppress_warning'] + + # By processing any --date, --from and --to options we need to derive + # self.first_ts and self.last_ts; the earliest (exclusive) and latest + # (inclusive) timestamps of data to be imported. If we have no --date, + # --from or --to then set both to None (we then get the default action + # for each import type). + # First we see if we have a valid --date, if not then we look for + # --from and --to. + if kwargs['date'] or kwargs['date'] == "": + # there is a --date but is it valid + try: + _first_dt = dt.strptime(kwargs['date'], "%Y-%m-%d") + except ValueError: + # Could not convert --date. If we have a --date it must be + # valid otherwise we can't continue so raise it. + _msg = "Invalid --date option specified." + raise WeeImportOptionError(_msg) + else: + # we have a valid date so do some date arithmetic + _last_dt = _first_dt + datetime.timedelta(days=1) + self.first_ts = time.mktime(_first_dt.timetuple()) + self.last_ts = time.mktime(_last_dt.timetuple()) + elif kwargs['from_datetime'] or kwargs['to_datetime'] or kwargs['from_datetime'] == '' or kwargs['to_datetime'] == '': + # There is a --from and/or a --to, but do we have both and are + # they valid. + # try --from first + try: + if 'T' in kwargs['from_datetime']: + _from_dt = dt.strptime(kwargs['from_datetime'], "%Y-%m-%dT%H:%M") + else: + _from_dt = dt.strptime(kwargs['from_datetime'], "%Y-%m-%d") + _from_ts = time.mktime(_from_dt.timetuple()) + except TypeError: + # --from not specified we can't continue so raise it + _msg = "Missing --from option. Both --from and --to must be specified." + raise WeeImportOptionError(_msg) + except ValueError: + # could not convert --from, we can't continue so raise it + _msg = "Invalid --from option." + raise WeeImportOptionError(_msg) + # try --to + try: + if 'T' in kwargs['to_datetime']: + _to_dt = dt.strptime(kwargs['to_datetime'], "%Y-%m-%dT%H:%M") + else: + _to_dt = dt.strptime(kwargs['to_datetime'], "%Y-%m-%d") + # since it is just a date we want the end of the day + _to_dt += datetime.timedelta(days=1) + _to_ts = time.mktime(_to_dt.timetuple()) + except TypeError: + # --to not specified , we can't continue so raise it + _msg = "Missing --to option. Both --from and --to must be specified." + raise WeeImportOptionError(_msg) + except ValueError: + # could not convert --to, we can't continue so raise it + _msg = "Invalid --to option." + raise WeeImportOptionError(_msg) + # If we made it here we have a _from_ts and _to_ts. Do a simple + # error check first. + if _from_ts > _to_ts: + # from is later than to, raise it + _msg = "--from value is later than --to value." + raise WeeImportOptionError(_msg) + self.first_ts = _from_ts + self.last_ts = _to_ts + else: + # no --date or --from/--to so we take the default, set all to None + self.first_ts = None + self.last_ts = None + + # initialise a few properties we will need during the import + # answer flags + self.ans = None + self.interval_ans = None + # properties to help with processing multi-period imports + self.period_no = None + # total records processed + self.total_rec_proc = 0 + # total unique records identified + self.total_unique_rec = 0 + # total duplicate records identified + self.total_duplicate_rec = 0 + # time we started to first save + self.t1 = None + # time taken to process + self.tdiff = None + # earliest timestamp imported + self.earliest_ts = None + # latest timestamp imported + self.latest_ts = None + + # initialise two sets to hold timestamps of records for which we + # encountered duplicates + + # duplicates seen over all periods + self.duplicates = set() + # duplicates seen over the current period + self.period_duplicates = set() + + @staticmethod + def source_factory(config_path, config_dict, import_config, **kwargs): + """Factory to produce a Source object. + + Returns an appropriate object depending on the source type. Raises a + weewx.UnsupportedFeature error if an object could not be created. + """ + + # get wee_import config dict if it exists + import_config_path, import_config_dict = weecfg.read_config(None, + None, + file_name=import_config) + # we should have a source parameter at the root of out import config + # file, try to get it but be prepared to catch the error. + try: + source = import_config_dict['source'] + except KeyError: + # we have no source parameter so check if we have a single source + # config stanza, if we do then proceed using that + _source_keys = [s for s in SUPPORTED_SOURCES if s in import_config_dict.keys] + if len(_source_keys) == 1: + # we have a single source config stanza so use that + source = _source_keys[0] + else: + # there is no source parameter and we do not have a single + # source config stanza so raise an error + _msg = "Invalid 'source' parameter or no 'source' parameter specified in %s" % import_config_path + raise weewx.UnsupportedFeature(_msg) + # if we made it this far we have all we need to create an object + module_class = '.'.join(['weeimport', + source.lower() + 'import', + source + 'Source']) + return get_object(module_class)(config_path, + config_dict, + import_config_path, + import_config_dict.get(source, {}), + **kwargs) + + def run(self): + """Main entry point for importing from an external source. + + Source data may be provided as a group of records over a single period + (eg a single CSV file) or as a number of groups of records covering + multiple periods(eg a WU multi-day import). Step through each group of + records, getting the raw data, mapping the data and saving the data for + each period. + """ + + # setup a counter to count the periods of records + self.period_no = 1 + # obtain the lastUpdate metadata value before we import anything + last_update = to_int(self.dbm._read_metadata('lastUpdate')) + with self.dbm as archive: + if self.first_period: + # collect the time for some stats reporting later + self.t1 = time.time() + # it's convenient to give this message now + if self.dry_run: + print('Starting dry run import ...') + else: + print('Starting import ...') + + if self.first_period and not self.last_period: + # there are more periods so say so + print("Records covering multiple periods have been identified for import.") + + # step through our periods of records until we reach the end. A + # 'period' of records may comprise the contents of a file, a day + # of WU obs or a month of Cumulus obs + for period in self.period_generator(): + + # if we are importing multiple periods of data then tell the + # user what period we are up to + if not (self.first_period and self.last_period): + print("Period %d ..." % self.period_no) + + # get the raw data + _msg = 'Obtaining raw import data for period %d ...' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + try: + _raw_data = self.get_raw_data(period) + except WeeImportIOError as e: + print("**** Unable to load source data for period %d." % self.period_no) + log.info("**** Unable to load source data for period %d." % self.period_no) + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + log.info("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + # increment our period counter + self.period_no += 1 + continue + except WeeImportDecodeError as e: + print("**** Unable to decode source data for period %d." % self.period_no) + log.info("**** Unable to decode source data for period %d." % self.period_no) + print("**** %s" % e) + log.info("**** %s" % e) + print("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + log.info("**** Period %d will be skipped. " + "Proceeding to next period." % self.period_no) + print("**** Consider specifying the source file encoding " + "using the 'source_encoding' config option.") + log.info("**** Consider specifying the source file encoding " + "using the 'source_encoding' config option.") + # increment our period counter + self.period_no += 1 + continue + _msg = 'Raw import data read successfully for period %d.' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + + # map the raw data to a WeeWX archive compatible dictionary + _msg = 'Mapping raw import data for period %d ...' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + _mapped_data = self.map_raw_data(_raw_data, self.archive_unit_sys) + _msg = 'Raw import data mapped successfully for period %d.' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + + # save the mapped data to archive + # first advise the user and log, but only if it's not a dry run + if not self.dry_run: + _msg = 'Saving mapped data to archive for period %d ...' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + self.save_to_archive(archive, _mapped_data) + # advise the user and log, but only if it's not a dry run + if not self.dry_run: + _msg = 'Mapped data saved to archive successfully ' \ + 'for period %d.' % self.period_no + if self.verbose: + print(_msg) + log.info(_msg) + # increment our period counter + self.period_no += 1 + # The source data has been processed and any records saved to + # archive (except if it was a dry run). + + # calculate the time taken for the import for our summary + self.tdiff = time.time() - self.t1 + + # now update the lastUpdate metadata field, set it to the max of + # the timestamp of the youngest record imported and the value of + # lastUpdate from before we started + new_last_update = max_with_none((last_update, self.latest_ts)) + if new_last_update is not None: + self.dbm._write_metadata('lastUpdate', str(int(new_last_update))) + # If necessary, calculate any missing derived fields and provide + # the user with suitable summary output. + if self.total_rec_proc == 0: + # nothing was imported so no need to calculate any missing + # fields just inform the user what was done + _msg = 'No records were identified for import. Exiting. Nothing done.' + print(_msg) + log.info(_msg) + else: + # We imported something, but was it a dry run or not? + total_rec = self.total_rec_proc + self.total_duplicate_rec + if self.dry_run: + # It was a dry run. Skip calculation of missing derived + # fields (since there are no archive records to process), + # just provide the user with a summary of what we did. + _msg = "Finished dry run import" + print(_msg) + log.info(_msg) + _msg = "%d records were processed and %d unique records would "\ + "have been imported." % (total_rec, + self.total_rec_proc) + print(_msg) + log.info(_msg) + if self.total_duplicate_rec > 1: + _msg = "%d duplicate records were ignored." % self.total_duplicate_rec + print(_msg) + log.info(_msg) + elif self.total_duplicate_rec == 1: + _msg = "1 duplicate record was ignored." + print(_msg) + log.info(_msg) + else: + # It was not a dry run so calculate any missing derived + # fields and provide the user with a summary of what we did. + if self.calc_missing: + # We were asked to calculate missing derived fields, so + # get a CalcMissing object. + # First construct a CalcMissing config dict + # (self.dry_run will never be true). Subtract 0.5 + # seconds from the earliest timestamp as calc_missing + # only calculates missing derived obs for records + # timestamped after start_ts. + calc_missing_config_dict = {'name': 'Calculate Missing Derived Observations', + 'binding': self.db_binding_wx, + 'start_ts': self.earliest_ts-0.5, + 'stop_ts': self.latest_ts, + 'trans_days': 1, + 'dry_run': self.dry_run is True} + # now obtain a CalcMissing object + self.calc_missing_obj = weecfg.database.CalcMissing(self.config_dict, + calc_missing_config_dict) + _msg = "Calculating missing derived observations ..." + print(_msg) + log.info(_msg) + # do the calculations + self.calc_missing_obj.run() + _msg = "Finished calculating missing derived observations" + print(_msg) + log.info(_msg) + # now provide the summary report + _msg = "Finished import" + print(_msg) + log.info(_msg) + _msg = "%d records were processed and %d unique records " \ + "imported in %.2f seconds." % (total_rec, + self.total_rec_proc, + self.tdiff) + print(_msg) + log.info(_msg) + if self.total_duplicate_rec > 1: + _msg = "%d duplicate records were ignored." % self.total_duplicate_rec + print(_msg) + log.info(_msg) + elif self.total_duplicate_rec == 1: + _msg = "1 duplicate record was ignored." + print(_msg) + log.info(_msg) + print("Those records with a timestamp already " + "in the archive will not have been") + print("imported. Confirm successful import in the WeeWX log file.") + + def parse_map(self, map, field_map, field_map_extensions): + """Update a field map with a field map and/or field map extension. + + The user may alter the default field map in two ways: through use of a + new field map (defined in the [[FieldMap]] stanza) and/or a by altering an + existing field map with one or more field map extensions (defined in the + [[FieldMapExtensions]] stanza). If specified, the [[FieldMap]] stanza is + used as the base-line field map for the import (if it is not specified the + default field map is used as the base-line field map). The base-line field + map can be further altered using the [[FieldMapExtensions]] stanza. + """ + + # first of all get our baseline field map, it will be as defined in + # field_map (the [[FieldMap]] stanza) or if field_map has no entries it + # will be the default field map + if field_map is not None and len(field_map) > 0: + # We have a field_map, but is it a legacy CSV field map or a new common + # source field map. A legacy field map will consist of scalars only, + # whereas a new common source field map will be None (ie no field map) + # or it will have one or more sections. + if len(field_map.scalars) > 0: + # we likely have a legacy field map + _map = self.parse_legacy_field_map(field_map) + else: + # so use it + _map = dict(field_map) + else: + # we have no field map so use the default field map + _map = dict(map) + # obtain a list of source fields that will be mapped + _mapped_source_fields = [config['source_field'] for field, config in _map.items()] + # we may need to modify the baseline field map so make a working copy + _ext_map = dict(_map) + # iterate over any field map extension entries + for field, config in field_map_extensions.items(): + # we can only map a given source field to a single WeeWX field, if a + # field map extension maps a source field that is already mapped then + # we need to remove the pre-existing mapping + if 'source_field' in config and config['source_field'] in _mapped_source_fields: + # we have a source field that is already mapped, so look through + # our existing field map to find it + for w, c in dict(_ext_map).items(): + if c['source_field'] == config['source_field']: + # found it, so pop the map entry from our baseline map + _map.pop(w) + # add ur field map extension entry to our baseline map + _map[field] = config + # return the finished field map + return _map + + def parse_legacy_field_map(self, field_map): + + _map = dict() + for _key, _item in field_map.items(): + _entry = option_as_list(_item) + # expect 2 parameters for each option: source field, units + if len(_entry) == 2: + # we have 2 parameter so that's field and units, but units + # could be 'text' indicating a text field + if _entry[1] != 'text': + _map[_key] = {'source_field': _entry[0], + 'unit': _entry[1]} + else: + _map[_key] = {'source_field': _entry[0], + 'is_text': True} + # if the entry is not empty then it might be valid ie just a + # field name (eg if usUnits is specified) + elif _entry != [''] and len(_entry) == 1: + # we have 1 parameter so it must be just name + _map[_key] = {'source_field': _entry[0]} + else: + # otherwise it's invalid so ignore it + pass + + # now do some crude error checking + + # dateTime. We must have a dateTime mapping. Check for a + # 'field_name' field under 'dateTime' and be prepared to catch the + # error if it does not exist. + try: + if _map['dateTime']['source_field']: + # we have a 'source_field' entry so continue + pass + else: + # something is wrong; we have a 'source_field' entry, but it is not + # valid so raise an error + _msg = "Invalid legacy mapping specified in '%s' " \ + "for field 'dateTime'." % self.import_config_path + raise WeeImportMapError(_msg) + except KeyError: + _msg = "No legacy mapping specified in '%s' for " \ + "field 'dateTime'." % self.import_config_path + raise WeeImportMapError(_msg) + + # usUnits. We don't have to have a mapping for usUnits but if we + # don't then we must have 'units' specified for each field mapping. + if 'usUnits' not in _map or _map['usUnits'].get('source_field') is None: + # no unit system mapping do we have units specified for + # each individual field + for _key, _val in _map.items(): + # we don't need to check dateTime and usUnits or fields that + # are marked as text + if _key not in ['dateTime', 'usUnits'] or not _val.get('is_text', False): + if 'unit' in _val: + # we have a unit field, do we know about it + if _val['unit'] not in weewx.units.conversionDict \ + and _val['unit'] not in weewx.units.USUnits.values(): + # we have an invalid unit string so tell the + # user and exit + _msg = "Unknown units '%s' specified for " \ + "field '%s' in %s." % (_val['unit'], + _key, + self.import_config_path) + raise weewx.UnitError(_msg) + else: + # we don't have a unit field, that's not allowed + # so raise an error + _msg = "No units specified for source field " \ + "'%s' in %s." % (_val['source_field'], + self.import_config_path) + raise WeeImportMapError(_msg) + # if we got this far we have a usable map to return + return _map + + def print_map(self): + """Display/log the field map. + + Display and/or log the field map in use. The field map is only + displayed on the console if --verbose was used. the field map is always + logged. + """ + + _msg = "The following imported field-to-WeeWX field map will be used:" + if self.verbose: + print(_msg) + log.info(_msg) + # iterate over the field map entries + for weewx_field, source_field_config in self.map.items(): + _unit_msg = "" + if 'unit' in source_field_config: + _unit_msg = " in units '%s'" % source_field_config['unit'] + if source_field_config.get('text', False): + _unit_msg = " as text" + _msg = " source field '%s'%s --> WeeWX field '%s'" % (source_field_config['source_field'], + _unit_msg, + weewx_field) + if self.verbose: + print(_msg) + log.info(_msg) + # display a message if the source field is marked as cumulative + if 'is_cumulative' in source_field_config and source_field_config['is_cumulative']: + _msg = (" (source field '%s' will be treated as a cumulative " + "value)" % source_field_config['source_field']) + if self.verbose: + print(_msg) + log.info(_msg) + # we could have a legacy rain = cumulative option + elif weewx_field == 'rain' and hasattr(self, 'rain') and self.rain == 'cumulative': + _msg = (" (WeeWX field '%s' will be calculated from " + "a cumulative value)" % weewx_field) + if self.verbose: + print(_msg) + log.info(_msg) + + def map_raw_data(self, data, unit_sys=weewx.US): + """Maps raw data to WeeWX archive record compatible dictionaries. + + Takes an iterable source of raw data observations, maps the fields of + each row to a WeeWX field based on the field map and performs any + necessary unit conversion. + + Input parameters: + + data: iterable that yields the data records to be processed. + + unit_sys: WeeWX unit system in which the generated records will be + provided. Omission will result in US customary (weewx.US) + being used. + + Returns a list of dicts of WeeWX compatible archive records. + """ + + # initialise our list of mapped records + _records = [] + # initialise some rain variables + _last_ts = None + _last_rain = None + # list of fields we have given the user a warning over, prevents us + # giving multiple warnings for the same field. + _warned = [] + # step through each row in our data + for _row in data: + _rec = {} + # first off process the fields that require special processing + # dateTime + if 'source_field' in self.map['dateTime']: + # we have a map for dateTime + try: + _raw_dateTime = _row[self.map['dateTime']['source_field']] + except KeyError: + _msg = "Field '%s' not found in source "\ + "data." % self.map['dateTime']['source_field'] + raise WeeImportFieldError(_msg) + # now process the raw date time data + if isinstance(_raw_dateTime, numbers.Number) or _raw_dateTime.isdigit(): + # Our dateTime is a number, is it a timestamp already? + # Try to use it and catch the error if there is one and + # raise it higher. + try: + _rec_dateTime = int(_raw_dateTime) + except ValueError: + _msg = "Invalid '%s' field. Cannot convert '%s' to " \ + "timestamp." % (self.map['dateTime']['source_field'], + _raw_dateTime) + raise ValueError(_msg) + else: + # it's a non-numeric string so try to parse it and catch + # the error if there is one and raise it higher + try: + _datetm = time.strptime(_raw_dateTime, + self.raw_datetime_format) + _rec_dateTime = int(time.mktime(_datetm)) + except ValueError: + _msg = "Invalid '%s' field. Cannot convert '%s' to " \ + "timestamp." % (self.map['dateTime']['source_field'], + _raw_dateTime) + raise ValueError(_msg) + # if we have a timeframe of concern does our record fall within + # it + if (self.first_ts is None and self.last_ts is None) or \ + self.first_ts < _rec_dateTime <= self.last_ts: + # we have no timeframe or if we do it falls within it so + # save the dateTime + _rec['dateTime'] = _rec_dateTime + # update earliest and latest record timestamps + if self.earliest_ts is None or _rec_dateTime < self.earliest_ts: + self.earliest_ts = _rec_dateTime + if self.latest_ts is None or _rec_dateTime > self.earliest_ts: + self.latest_ts = _rec_dateTime + else: + # it is not so skip to the next record + continue + else: + # there is no mapped field for dateTime so raise an error + raise ValueError("No mapping for WeeWX field 'dateTime'.") + # usUnits + _units = None + if 'usUnits' in self.map.keys() and 'source_field' in self.map['usUnits']: + # we have a field map for a unit system + try: + # The mapped field is in _row so try to get the raw data. + # If it's not there then raise an error. + _raw_units = int(_row[self.map['usUnits']['source_field']]) + except KeyError: + _msg = "Field '%s' not found in "\ + "source data." % self.map['usUnits']['source_field'] + raise WeeImportFieldError(_msg) + # we have a value but is it valid + if _raw_units in unit_nicknames: + # it is valid so use it + _units = _raw_units + else: + # the units value is not valid so raise an error + _msg = "Invalid unit system '%s'(0x%02x) mapped from data source. " \ + "Check data source or field mapping." % (_raw_units, + _raw_units) + raise weewx.UnitError(_msg) + # interval + if 'interval' in self.map.keys() and 'source_field' in self.map['interval']: + # We have a map for interval so try to get the raw data. If + # it's not there raise an error. + try: + _tfield = _row[self.map['interval']['source_field']] + except KeyError: + _msg = "Field '%s' not found in "\ + "source data." % self.map['interval']['source_field'] + raise WeeImportFieldError(_msg) + # now process the raw interval data + if _tfield is not None and _tfield != '': + try: + _rec['interval'] = int(_tfield) + except ValueError: + _msg = "Invalid '%s' field. Cannot convert '%s' to " \ + "an integer." % (self.map['interval']['source_field'], + _tfield) + raise ValueError(_msg) + else: + # if it happens to be None then raise an error + _msg = "Invalid value '%s' for mapped field '%s' at " \ + "timestamp '%s'." % (_tfield, + self.map['interval']['source_field'], + timestamp_to_string(_rec['dateTime'])) + raise ValueError(_msg) + else: + # we have no mapping so calculate it, wrap in a try..except in + # case it cannot be calculated + try: + _rec['interval'] = self.get_interval(_last_ts, _rec['dateTime']) + except WeeImportFieldError as e: + # We encountered a WeeImportFieldError, which means we + # cannot calculate the interval value, possibly because + # this record is out of date-time order. We cannot use this + # record so skip it, advise the user (via console and log) + # and move to the next record. + _msg = "Record discarded: %s" % e + print(_msg) + log.info(_msg) + continue + # now step through the rest of the fields in our map and process + # the fields that don't require special processing + for _field in self.map: + # skip those that have had special processing + if _field in self.special_processing_fields: + continue + # process everything else + else: + # is our mapped field in the record + if self.map[_field]['source_field'] in _row: + # yes it is + # first check to see if this is a text field + if self.map[_field].get('text', False): + # we have a text field, so accept the field + # contents as is + _rec[_field] = _row[self.map[_field]['source_field']] + else: + # we have a non-text field so try to get a value + # for the obs but if we can't, catch the error + try: + _value = float(_row[self.map[_field]['source_field']].strip()) + except AttributeError: + # the data has no strip() attribute so chances + # are it's a number already, or it could + # (somehow ?) be None + if _row[self.map[_field]['source_field']] is None: + _value = None + else: + try: + _value = float(_row[self.map[_field]['source_field']]) + except TypeError: + # somehow we have data that is not a + # number or a string + _msg = "%s: cannot convert '%s' to float at " \ + "timestamp '%s'." % (_field, + _row[self.map[_field]['source_field']], + timestamp_to_string(_rec['dateTime'])) + raise TypeError(_msg) + except ValueError: + # A ValueError means that float() could not + # convert the string or number to a float, most + # likely because we have non-numeric, non-None + # data. We have some other possibilities to + # work through before we give up. + + # start by setting our result to None. + _value = None + + # perhaps it is numeric data but with something + # other that a period as decimal separator, try + # using float() again after replacing the + # decimal seperator + if self.decimal_sep is not None: + _data = _row[self.map[_field]['source_field']].replace(self.decimal_sep, + '.') + try: + _value = float(_data) + except ValueError: + # still could not convert it so pass + pass + + # If this is a csv import and we are mapping to + # a direction field, perhaps we have a string + # representation of a cardinal, inter-cardinal + # or secondary inter-cardinal direction that we + # can convert to degrees + + if _value is None and hasattr(self, 'wind_dir_map') and \ + self.map[_field]['unit'] == 'degree_compass': + # we have a csv import and we are mapping + # to a direction field, so try a cardinal + # conversion + + # first strip any whitespace and hyphens + # from the data + _stripped = re.sub(r'[\s-]+', '', + _row[self.map[_field]['source_field']]) + # try to use the data as the key in a dict + # mapping directions to degrees, if there + # is no match we will have None returned + try: + _value = self.wind_dir_map[_stripped.upper()] + except KeyError: + # we did not find a match so pass + pass + # we have exhausted all possibilities, so if we + # have a non-None result use it, otherwise we + # either ignore it or raise an error + if _value is None and not self.ignore_invalid_data: + _msg = "%s: cannot convert '%s' to float at " \ + "timestamp '%s'." % (_field, + _row[self.map[_field]['source_field']], + timestamp_to_string(_rec['dateTime'])) + raise ValueError(_msg) + + # some fields need some special processing + + # data from cumulative fields needs special processing, + # also required for the WeeWX 'rain' field where the + # legacy 'rain = cumulative' option is used in the + # import config file + if ('is_cumulative' in self.map[_field] and self.map[_field]['is_cumulative']) \ + or (_field == "rain" and getattr(self, 'rain', 'discrete') == "cumulative"): + # we have a cumulative field, so process as such + _value = self.process_cumulative(self.map[_field]['source_field'], + _value) + + # wind - check any wind direction fields are within our + # bounds and convert to 0 to 360 range + elif _field == "windDir" or _field == "windGustDir": + if _value is not None and (self.wind_dir[0] <= _value <= self.wind_dir[1]): + # normalise to 0 to 360 + _value %= 360 + else: + # outside our bounds so set to None + _value = None + # UV - if there was no UV sensor used to create the + # imported data then we need to set the imported value + # to None + elif _field == 'UV': + if not self.UV_sensor: + _value = None + # solar radiation - if there was no solar radiation + # sensor used to create the imported data then we need + # to set the imported value to None + elif _field == 'radiation': + if not self.solar_sensor: + _value = None + + # check and ignore if required temperature and humidity + # values of 255.0 and greater + if self.ignore_extr_th \ + and self.map[_field]['unit'] in ['degree_C', 'degree_F', 'percent'] \ + and _value >= 255.0: + _value = None + # if there is no mapped field for a unit system we + # have to do field by field unit conversions + if _units is None: + _vt = ValueTuple(_value, + self.map[_field]['unit'], + weewx.units.obs_group_dict[_field]) + _conv_vt = convertStd(_vt, unit_sys) + _rec[_field] = _conv_vt.value + else: + # we do have a mapped field for a unit system so + # save the field in our record and continue, any + # unit conversion will be done in bulk later + _rec[_field] = _value + else: + # no it's not in our record, so set the field in our + # output to None + _rec[_field] = None + # now warn the user about this field if we have not + # already done so + if self.map[_field]['source_field'] not in _warned: + _msg = "Warning: Import field '%s' is mapped to WeeWX " \ + "field '%s' but the" % (self.map[_field]['source_field'], + _field) + if not self.suppress_warning: + print(_msg) + log.info(_msg) + _msg = " import field '%s' could not be found " \ + "in one or more records." % self.map[_field]['source_field'] + if not self.suppress_warning: + print(_msg) + log.info(_msg) + _msg = " WeeWX field '%s' will be "\ + "set to 'None' in these records." % _field + if not self.suppress_warning: + print(_msg) + log.info(_msg) + # make sure we do this warning once only + _warned.append(self.map[_field]['source_field']) + # if we have a mapped field for a unit system with a valid value, + # then all we need do is set 'usUnits', bulk conversion is taken + # care of by saveToArchive() + if _units is not None: + # we have a mapped field for a unit system with a valid value + _rec['usUnits'] = _units + else: + # no mapped field for unit system, but we have already + # converted any necessary fields on a field by field basis so + # all we need do is set 'usUnits', any bulk conversion will be + # taken care of by saveToArchive() + _rec['usUnits'] = unit_sys + # If interval is being derived from record timestamps our first + # record will have an interval of None. In this case we wait until + # we have the second record, and then we use the interval between + # records 1 and 2 as the interval for record 1. + if len(_records) == 1 and _records[0]['interval'] is None: + _records[0]['interval'] = _rec['interval'] + _last_ts = _rec['dateTime'] + # this record is done, add it to our list of records to return + _records.append(_rec) + # If we have more than 1 unique value for interval in our records it + # could be a sign of missing data and impact the integrity of our data, + # so do the check and see if the user wants to continue + if len(_records) > 0: + # if we have any records to return do the unique interval check + # before we return the records + _start_interval = _records[0]['interval'] + _diff_interval = False + for _rec in _records: + if _rec['interval'] != _start_interval: + _diff_interval = True + break + if _diff_interval and self.interval_ans != 'y': + # we had more than one unique value for interval, warn the user + _msg = "Warning: Records to be imported contain multiple " \ + "different 'interval' values." + print(_msg) + log.info(_msg) + print(" This may mean the imported data is missing " + "some records and it may lead") + print(" to data integrity issues. If the raw data has " + "a known, fixed interval") + print(" value setting the relevant 'interval' setting " + "in wee_import config to") + print(" this value may give a better result.") + while self.interval_ans not in ['y', 'n']: + if self.no_prompt: + self.interval_ans = 'y' + else: + self.interval_ans = input('Are you sure you want to proceed (y/n)? ') + if self.interval_ans == 'n': + # the user chose to abort, but we may have already + # processed some records. So log it then raise a SystemExit() + if self.dry_run: + print("Dry run import aborted by user. %d records were processed." % self.total_rec_proc) + else: + if self.total_rec_proc > 0: + print("Those records with a timestamp already in the " + "archive will not have been") + print("imported. As the import was aborted before completion " + "refer to the WeeWX log") + print("file to confirm which records were imported.") + raise SystemExit('Exiting.') + else: + print("Import aborted by user. No records saved to archive.") + _msg = "User chose to abort import. %d records were processed. " \ + "Exiting." % self.total_rec_proc + log.info(_msg) + raise SystemExit('Exiting. Nothing done.') + _msg = "Mapped %d records." % len(_records) + if self.verbose: + print(_msg) + log.info(_msg) + # the user wants to continue, or we have only one unique value for + # interval so return the records + return _records + else: + _msg = "Mapped 0 records." + if self.verbose: + print(_msg) + log.info(_msg) + # we have no records to return so return None + return None + + def get_interval(self, last_ts, current_ts): + """Determine an interval value for a record. + + The interval field can be determined in one of the following ways: + + - Derived from the raw data. The interval is calculated as the + difference between the timestamps of consecutive records rounded to + the nearest minute. In this case interval can change between + records if the records are not evenly spaced in time or if there + are missing records. This method is the default and is used when + the interval parameter in wee_import.conf is 'derive'. + + - Read from weewx.conf. The interval value is read from the + archive_interval parameter in [StdArchive] in weewx.conf. In this + case interval may or may not be the same as the difference in time + between consecutive records. This method may be of use when the + import source has a known interval but may be missing a number of + records which makes deriving the interval from the imported data + problematic. This method is used when the interval parameter in + wee_import.conf is 'conf'. + + Input parameters: + + last_ts. timestamp of the previous record. + current_rain. timestamp of the current record. + + Returns the interval (in minutes) for the current record. + """ + + # did we have a number specified in wee_import.conf, if so use that + try: + return float(self.interval) + except ValueError: + pass + # how are we getting interval + if self.interval.lower() == 'conf': + # get interval from weewx.conf + return to_int(float(self.config_dict['StdArchive'].get('archive_interval')) / 60.0) + elif self.interval.lower() == 'derive': + # get interval from the timestamps of consecutive records + try: + _interval = int((current_ts - last_ts) / 60.0) + # but if _interval < 0 our records are not in date-time order + if _interval < 0: + # so raise a WeeImportFieldError exception + _msg = "Cannot derive 'interval' for record "\ + "timestamp: %s. " % timestamp_to_string(current_ts) + raise WeeImportFieldError(_msg) + except TypeError: + _interval = None + return _interval + else: + # we don't know what to do so raise an error + _msg = "Cannot derive 'interval'. Unknown 'interval' "\ + "setting in %s." % self.import_config_path + raise ValueError(_msg) + + def process_cumulative(self, source_field, current_value): + """Determine a per-period obs value for a cumulative field. + + If the data source provides the obs value as a cumulative value then + the per-period value is the simple difference between the two values. + But we need to take into account some special cases: + + No last value. Will occur for very first record or maybe in an error + condition. Need to return 0.0. + last value > current value. Occurs when the cumulative value was reset + (maybe daily or some other period). Need to + return the current value. + current value is None. Could occur if the imported value could not be + converted to a numeric and config option + ignore_invalid_data is set. + + Input parameters: + + source_field. The source field containing the cumulative data + current_value. Current cumulative value. + + Returns the per-period value. + """ + + if source_field in self.last_values and self.last_values[source_field] is not None: + # we have a value for the previous period + if current_value is not None and current_value >= self.last_values[source_field]: + # we just want the difference + result = current_value - self.last_values[source_field] + else: + # we are at a cumulative reset point or current_value is None, + # either way we just want the current_value + result = current_value + else: + # we have not seen this source field before or if we have it's last + # value was None, so save the current value as the last value and + # return 0.0 + result = 0.0 + # set our last value to the current value + self.last_values[source_field] = current_value + # return the result + return result + + def qc(self, data_dict, data_type): + """ Apply weewx.conf QC to a record. + + If qc option is set in the import config file then apply any StdQC + min/max checks specified in weewx.conf. + + Input parameters: + + data_dict: A WeeWX compatible archive record. + + Returns nothing. data_dict is modified directly with obs outside of QC + limits set to None. + """ + + if self.apply_qc: + self.import_QC.apply_qc(data_dict, data_type=data_type) + + def save_to_archive(self, archive, records): + """ Save records to the WeeWX archive. + + Supports saving one or more records to archive. Each collection of + records is processed and saved to archive in transactions of + self.tranche records at a time. + + if the import config file qc option was set quality checks on the + imported record are performed using the WeeWX StdQC configuration from + weewx.conf. Any missing derived observations are then added to the + archive record using the WeeWX WXCalculate class if the import config + file calc_missing option was set. WeeWX API addRecord() method is used + to add archive records. + + If --dry-run was set then every aspect of the import is carried out but + nothing is saved to archive. If --dry-run was not set then the user is + requested to confirm the import before any records are saved to archive. + + Input parameters: + + archive: database manager object for the WeeWX archive. + + records: iterable that provides WeeWX compatible archive records + (in dict form) to be written to archive + """ + + # do we have any records? + if records and len(records) > 0: + # if this is the first period then give a little summary about what + # records we have + # TODO. Check that a single period shows correct and consistent console output + if self.first_period and self.last_period: + # there is only 1 period, so we can count them + print("%s records identified for import." % len(records)) + # we do, confirm the user actually wants to save them + while self.ans not in ['y', 'n'] and not self.dry_run: + if self.no_prompt: + self.ans = 'y' + else: + print("Proceeding will save all imported records in the WeeWX archive.") + self.ans = input("Are you sure you want to proceed (y/n)? ") + if self.ans == 'y' or self.dry_run: + # we are going to save them + # reset record counter + nrecs = 0 + # initialise our list of records for this tranche + _tranche = [] + # initialise a set for use in our dry run, this lets us + # give some better stats on records imported + unique_set = set() + # step through each record in this period + for _rec in records: + # convert our record + _conv_rec = to_std_system(_rec, self.archive_unit_sys) + # perform any required QC checks + self.qc(_conv_rec, 'Archive') + # add the record to our tranche and increment our count + _tranche.append(_conv_rec) + nrecs += 1 + # if we have a full tranche then save to archive and reset + # the tranche + if len(_tranche) >= self.tranche: + # add the record only if it is not a dry run + if not self.dry_run: + # add the record only if it is not a dry run + archive.addRecord(_tranche) + # add our the dateTime for each record in our tranche + # to the dry run set + for _trec in _tranche: + unique_set.add(_trec['dateTime']) + # tell the user what we have done + _msg = "Unique records processed: %d; "\ + "Last timestamp: %s\r" % (nrecs, + timestamp_to_string(_rec['dateTime'])) + print(_msg, end='', file=sys.stdout) + sys.stdout.flush() + _tranche = [] + # we have processed all records but do we have any records left + # in the tranche? + if len(_tranche) > 0: + # we do so process them + if not self.dry_run: + # add the record only if it is not a dry run + archive.addRecord(_tranche) + # add our the dateTime for each record in our tranche to + # the dry run set + for _trec in _tranche: + unique_set.add(_trec['dateTime']) + # tell the user what we have done + _msg = "Unique records processed: %d; "\ + "Last timestamp: %s\r" % (nrecs, + timestamp_to_string(_rec['dateTime'])) + print(_msg, end='', file=sys.stdout) + print() + sys.stdout.flush() + # update our counts + self.total_rec_proc += nrecs + self.total_unique_rec += len(unique_set) + # mention any duplicates we encountered + num_duplicates = len(self.period_duplicates) + self.total_duplicate_rec += num_duplicates + if num_duplicates > 0: + if num_duplicates == 1: + _msg = " 1 duplicate record was identified "\ + "in period %d:" % self.period_no + else: + _msg = " %d duplicate records were identified "\ + "in period %d:" % (num_duplicates, + self.period_no) + if not self.suppress_warning: + print(_msg) + log.info(_msg) + for ts in sorted(self.period_duplicates): + _msg = " %s" % timestamp_to_string(ts) + if not self.suppress_warning: + print(_msg) + log.info(_msg) + # add the period duplicates to the overall duplicates + self.duplicates |= self.period_duplicates + # reset the period duplicates + self.period_duplicates = set() + elif self.ans == 'n': + # user does not want to import so display a message and then + # ask to exit + _msg = "User chose not to import records. Exiting. Nothing done." + print(_msg) + log.info(_msg) + raise SystemExit('Exiting. Nothing done.') + else: + # we have no records to import, advise the user but what we say + # will depend on if there are any more periods to import + if self.first_period and self.last_period: + # there was only 1 period + _msg = 'No records identified for import.' + else: + # multiple periods + _msg = 'Period %d - no records identified for import.' % self.period_no + print(_msg) + + +# ============================================================================ +# Utility functions +# ============================================================================ + +def get_binding(config_dict): + """Get the binding for the WeeWX database.""" + + # Extract our binding from the StdArchive section of the config file. If + # it's missing, return None. + if 'StdArchive' in config_dict: + db_binding_wx = config_dict['StdArchive'].get('data_binding', + 'wx_binding') + else: + db_binding_wx = None + return db_binding_wx diff --git a/dist/weewx-5.0.2/src/weeimport/wuimport.py b/dist/weewx-5.0.2/src/weeimport/wuimport.py new file mode 100644 index 0000000..560695c --- /dev/null +++ b/dist/weewx-5.0.2/src/weeimport/wuimport.py @@ -0,0 +1,464 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick +# +# See the file LICENSE.txt for your full rights. +# + +"""Module to interact with the Weather Underground API and obtain raw +observational data for use with wee_import. +""" + +# Python imports +import datetime +import gzip +import io +import json +import logging +import numbers +import socket +import sys +import urllib.error +import urllib.request + +# WeeWX imports +import weewx +from weeutil.weeutil import timestamp_to_string, option_as_list, startOfDay +from weewx.units import unit_nicknames +from . import weeimport + +log = logging.getLogger(__name__) + + +# ============================================================================ +# class WUSource +# ============================================================================ + +class WUSource(weeimport.Source): + """Class to interact with the Weather Underground API. + + Uses the WU PWS history API call via http to obtain historical weather + observations for a given PWS. Unlike the previous WU import module that + was based on an earlier API, the use of the v2 API requires an API key. + + The Weather Company PWS historical data API v2 documentation: + https://docs.google.com/document/d/1w8jbqfAk0tfZS5P7hYnar1JiitM0gQZB-clxDfG3aD0/edit + """ + + # Dict containing default mapping of WU fields to WeeWX archive fields. + # This mapping may be replaced or modified by including a [[FieldMap]] + # and/or [[FieldMapExtensions]] stanza in the import config file. Note that + # Any unit settings in the [[FieldMap]] and/or [[FieldMapExtensions]] + # stanza in the WU import config file are ignored as the WU API returns + # data using a specified set of units. These units are set by weectl import + # and cannot be set by the user (nor do they need to be set by the user). + # Any necessary unit conversions are performed by weectl import before data + # is saved to database. + default_map = { + 'dateTime': { + 'source_field': 'epoch', + 'unit': 'unix_epoch'}, + 'outTemp': { + 'source_field': 'tempAvg', + 'unit': 'degree_F'}, + 'outHumidity': { + 'source_field': 'humidityAvg', + 'unit': 'percent'}, + 'dewpoint': { + 'source_field': 'dewptAvg', + 'unit': 'degree_F'}, + 'heatindex': { + 'source_field': 'heatindexAvg', + 'unit': 'degree_F'}, + 'windchill': { + 'source_field': 'windchillAvg', + 'unit': 'degree_F'}, + 'barometer': { + 'source_field': 'pressureAvg', + 'unit': 'inHg'}, + 'rain': { + 'source_field': 'precipTotal', + 'unit': 'inch', + 'is_cumulative': True}, + 'rainRate': { + 'source_field': 'precipRate', + 'unit': 'inch_per_hour'}, + 'windSpeed': { + 'source_field': 'windspeedAvg', + 'unit': 'mile_per_hour'}, + 'windDir': { + 'source_field': 'winddirAvg', + 'unit': 'degree_compass'}, + 'windGust': { + 'source_field': 'windgustHigh', + 'unit': 'mile_per_hour'}, + 'radiation': { + 'source_field': 'solarRadiationHigh', + 'unit': 'watt_per_meter_squared'}, + 'UV': { + 'source_field': 'uvHigh', + 'unit': 'uv_index'} + } + # additional fields required for (in this case) calculation of barometer + _extra_fields = ['pressureMin', 'pressureMax'] + + def __init__(self, config_path, config_dict, import_config_path, + wu_config_dict, **kwargs): + + # call our parents __init__ + super().__init__(config_dict, wu_config_dict, **kwargs) + + # save our import config path + self.import_config_path = import_config_path + # save our import config dict + self.wu_config_dict = wu_config_dict + + # get the WU station ID + try: + self.station_id = wu_config_dict['station_id'] + except KeyError: + _msg = "Weather Underground station ID not specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # get the WU API key + try: + self.api_key = wu_config_dict['api_key'] + except KeyError: + _msg = "Weather Underground API key not specified in '%s'." % import_config_path + raise weewx.ViolatedPrecondition(_msg) + + # Is our rain discrete or cumulative. Legacy import config files used + # the 'rain' config option to determine whether the imported rainfall + # value was a discrete per period value or a cumulative value. This is + # now handled on a per-field basis through the field map; however, we + # need ot be able to support old import config files that use the + # legacy rain config option. + _rain = self.wu_config_dict.get('rain') + # set our rain property only if the rain config option was explicitly + # set + if _rain is not None: + self.rain = _rain + + # wind direction bounds + _wind_direction = option_as_list(wu_config_dict.get('wind_direction', + '0,360')) + try: + if float(_wind_direction[0]) <= float(_wind_direction[1]): + self.wind_dir = [float(_wind_direction[0]), + float(_wind_direction[1])] + else: + self.wind_dir = [0, 360] + except (IndexError, TypeError): + self.wind_dir = [0, 360] + + # some properties we know because of the format of the returned WU data + # WU returns a fixed format date-time string + self.raw_datetime_format = '%Y-%m-%d %H:%M:%S' + + # construct our import field-to-WeeWX archive field map + _default_map = dict(WUSource.default_map) + # create the final field map based on the default field map and any + # field map options provided by the user + _map = self.parse_map(_default_map, + self.wu_config_dict.get('FieldMap', {}), + self.wu_config_dict.get('FieldMapExtensions', {})) + # The field map we have now may either include no source field unit + # settings (the import config file instructions ask that a simplified + # field map (ie containing source_field only) be specified) or the user + # may have specified their own possibly inappropriate source field unit + # settings. The WU data obtained by weectl import is provided using + # what WU calls 'imperial (english)' units. Accordingly, we need to + # either add or update the unit setting for each source field to ensure + # the correct units are specified. To do this we iterate over each + # field map entry and set/update the unit setting to the unit setting + # for the corresponding source field in the default field map. + + # first make a copy of the field map as we will likely be changing it + _map_copy = dict(_map) + # iterate over the current field map entries + for w_field, s_config in _map_copy.items(): + # obtain the source field for the current entry + source_field = s_config['source_field'] + # find source_field in the default field map and obtain the + # corresponding unit setting + # first set _unit to None so we know if we found an entry + _unit = None + # now iterate over the entries in the default field map looking for + # the source field we are currently using + for _field, _config in WUSource.default_map.items(): + # do we have a match + if _config['source_field'] == source_field: + # we have a match, obtain the unit setting and exit the + # loop + _unit = _config['unit'] + break + # we have finished iterating over the default field map, did we + # find a match + if _unit is not None: + # we found a match so update our current map + _map[w_field]['unit'] = _unit + else: + # We did not find a match so we don't know what unit this + # source field uses, most likely a [[FieldMap]] error but it + # could be something else. Either way we cannot continue, raise + # a suitable exception. + msg = f"Invalid field map. Could not find 'unit' "\ + f"for source field '{source_field}'" + raise weeimport.WeeImportMapError(msg) + # save the updated map + self.map = _map + + # For a WU import we might have to import multiple days but we can only + # get one day at a time from WU. So our start and end properties + # (counters) are datetime objects and our increment is a timedelta. + # Get datetime objects for any date or date range specified on the + # command line, if there wasn't one then default to today. + self.start = datetime.datetime.fromtimestamp(startOfDay(self.first_ts)) + self.end = datetime.datetime.fromtimestamp(startOfDay(self.last_ts)) + # set our increment + self.increment = datetime.timedelta(days=1) + + # property holding the current period being processed + self.period = None + + # property holding dict of last seen values for cumulative observations + self.last_values = {} + + # tell the user/log what we intend to do + _msg = "Observation history for Weather Underground station '%s' will be imported." % self.station_id + print(_msg) + log.info(_msg) + _msg = "The following options will be used:" + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " config=%s, import-config=%s" % (config_path, + self.import_config_path) + if self.verbose: + print(_msg) + log.debug(_msg) + if kwargs['date']: + _msg = " station=%s, date=%s" % (self.station_id, kwargs['date']) + else: + # we must have --from and --to + _msg = " station=%s, from=%s, to=%s" % (self.station_id, + kwargs['from_datetime'], + kwargs['to_datetime']) + if self.verbose: + print(_msg) + log.debug(_msg) + _obf_api_key_msg = '='.join([' apiKey', + '*'*(len(self.api_key) - 4) + self.api_key[-4:]]) + if self.verbose: + print(_obf_api_key_msg) + log.debug(_obf_api_key_msg) + _msg = " dry-run=%s, calc_missing=%s, ignore_invalid_data=%s" % (self.dry_run, + self.calc_missing, + self.ignore_invalid_data) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = " tranche=%s, interval=%s, wind_direction=%s" % (self.tranche, + self.interval, + self.wind_dir) + if self.verbose: + print(_msg) + log.debug(_msg) + _msg = "Using database binding '%s', which is bound to database '%s'" % (self.db_binding_wx, + self.dbm.database_name) + print(_msg) + log.info(_msg) + _msg = "Destination table '%s' unit system is '%#04x' (%s)." % (self.dbm.table_name, + self.archive_unit_sys, + unit_nicknames[self.archive_unit_sys]) + print(_msg) + log.info(_msg) + self.print_map() + if self.calc_missing: + print("Missing derived observations will be calculated.") + if kwargs['date'] or kwargs['from_datetime']: + print("Observations timestamped after %s and up to and" % timestamp_to_string(self.first_ts)) + print("including %s will be imported." % timestamp_to_string(self.last_ts)) + if self.dry_run: + print("This is a dry run, imported data will not be saved to archive.") + + def get_raw_data(self, period): + """Get raw observation data for a WU PWS for a given period. + + Obtain raw observational data from WU via the WU API. This raw data + needs some basic processing to place it in a format suitable for + wee_import to ingest. + + Input parameters: + + period: a datetime object representing the day of WU data from + which raw obs data will be read. + """ + + # the date for which we want the WU data is held in a datetime object, + # we need to convert it to a timetuple + day_tt = period.timetuple() + # and then format the date suitable for use in the WU API URL + day = "%4d%02d%02d" % (day_tt.tm_year, + day_tt.tm_mon, + day_tt.tm_mday) + + # construct the URL to be used + url = "https://api.weather.com/v2/pws/history/all?" \ + "stationId=%s&format=json&units=e&numericPrecision=decimal&date=%s&apiKey=%s" \ + % (self.station_id, day, self.api_key) + # create a Request object using the constructed URL + request_obj = urllib.request.Request(url) + # add necessary headers + request_obj.add_header('Cache-Control', 'no-cache') + request_obj.add_header('Accept-Encoding', 'gzip') + # hit the API wrapping in a try..except to catch any errors + try: + response = urllib.request.urlopen(request_obj) + except urllib.error.URLError as e: + print("Unable to open Weather Underground station " + self.station_id, " or ", e, file=sys.stderr) + log.error("Unable to open Weather Underground station %s or %s" % (self.station_id, e)) + raise + except socket.timeout as e: + print("Socket timeout for Weather Underground station " + self.station_id, file=sys.stderr) + log.error("Socket timeout for Weather Underground station %s" % self.station_id) + print(" **** %s" % e, file=sys.stderr) + log.error(" **** %s" % e) + raise + # check the response code and raise an exception if there was an error + if hasattr(response, 'code') and response.code != 200: + if response.code == 204: + _msg = "Possibly a bad station ID, an invalid date or data does not exist for this period." + else: + _msg = "Bad response code returned: %d." % response.code + raise weeimport.WeeImportIOError(_msg) + + # The WU API says that compression is required, but let's be prepared + # if compression is not used + if response.info().get('Content-Encoding') == 'gzip': + buf = io.BytesIO(response.read()) + f = gzip.GzipFile(fileobj=buf) + # but what charset is in use + char_set = response.headers.get_content_charset() + # get the raw data making sure we decode the charset if required + if char_set is not None: + _raw_data = f.read().decode(char_set) + else: + _raw_data = f.read() + # decode the json data + _raw_decoded_data = json.loads(_raw_data) + else: + _raw_data = response + # decode the json data + _raw_decoded_data = json.load(_raw_data) + + # The raw WU response is not suitable to return as is, we need to + # return an iterable that provides a dict of observational data for each + # available timestamp. In this case a list of dicts is appropriate. + + # initialise a list of dicts + wu_data = [] + # first check we have some observational data + if 'observations' in _raw_decoded_data: + # iterate over each record in the WU data + for record in _raw_decoded_data['observations']: + # initialise a dict to hold the resulting data for this record + _flat_record = {} + # iterate over each WU API response field that we can use + _fields = [c['source_field'] for c in self.map.values()] + self._extra_fields + for obs in _fields: + # The field may appear as a top level field in the WU data + # or it may be embedded in the dict in the WU data that + # contains variable unit data. Look in the top level record + # first. If its there uses it, otherwise look in the + # variable units dict. If it can't be fond then skip it. + if obs in record: + # it's in the top level record + _flat_record[obs] = record[obs] + else: + # it's not in the top level so look in the variable + # units dict + try: + _flat_record[obs] = record['imperial'][obs] + except KeyError: + # it's not there so skip it + pass + if obs == 'epoch': + # An epoch timestamp could be in seconds or + # milliseconds, WeeWX uses seconds. We can check by + # trying to convert the epoch value into a datetime + # object, if the epoch value is in milliseconds it will + # fail. In that case divide the epoch value by 1000. + # Note we would normally expect to see a ValueError but + # on armhf platforms we might see an OverflowError. + try: + _date = datetime.date.fromtimestamp(_flat_record['epoch']) + except (ValueError, OverflowError): + _flat_record['epoch'] = _flat_record['epoch'] // 1000 + # WU in its wisdom provides min and max pressure but no average + # pressure (unlike other obs) so we need to calculate it. If + # both min and max are numeric use a simple average of the two + # (they will likely be the same anyway for non-RF stations). + # Otherwise use max if numeric, then use min if numeric + # otherwise skip. + self.calc_pressure(_flat_record) + # append the data dict for the current record to the list of + # dicts for this period + wu_data.append(_flat_record) + # return our dict + return wu_data + + @staticmethod + def calc_pressure(record): + """Calculate pressureAvg field. + + The WU API provides min and max pressure but no average pressure. + Calculate an average pressure to be used in the import using one of the + following (in order): + + 1. simple average of min and max pressure + 2. max pressure + 3. min pressure + 4. None + """ + + if 'pressureMin' in record and 'pressureMax' in record and isinstance(record['pressureMin'], numbers.Number) and isinstance(record['pressureMax'], numbers.Number): + record['pressureAvg'] = (record['pressureMin'] + record['pressureMax'])/2.0 + elif 'pressureMax' in record and isinstance(record['pressureMax'], numbers.Number): + record['pressureAvg'] = record['pressureMax'] + elif 'pressureMin' in record and isinstance(record['pressureMin'], numbers.Number): + record['pressureAvg'] = record['pressureMin'] + elif 'pressureMin' in record or 'pressureMax' in record: + record['pressureAvg'] = None + + def period_generator(self): + """Generator function yielding a sequence of datetime objects. + + This generator controls the FOR statement in the parents run() method + that loops over the WU days to be imported. The generator yields a + datetime object from the range of dates to be imported.""" + + self.period = self.start + while self.period <= self.end: + yield self.period + self.period += self.increment + + @property + def first_period(self): + """True if current period is the first period otherwise False. + + Return True if the current file name being processed is the first in + the list or it is None (the initialisation value). + """ + + return self.period == self.start if self.period is not None else True + + @property + def last_period(self): + """True if current period is the last period otherwise False. + + Return True if the current period being processed is >= the end of the + WU import period. Return False if the current period is None (the + initialisation value). + """ + + return self.period >= self.end if self.period is not None else False diff --git a/dist/weewx-5.0.2/src/weeplot/__init__.py b/dist/weewx-5.0.2/src/weeplot/__init__.py new file mode 100644 index 0000000..fb2c6ac --- /dev/null +++ b/dist/weewx-5.0.2/src/weeplot/__init__.py @@ -0,0 +1,14 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Package weeplot. A set of modules for doing simple plots + +""" +# Define possible exceptions that could get thrown. + +class ViolatedPrecondition(Exception): + """Exception thrown when a function is called with violated preconditions. + + """ diff --git a/dist/weewx-5.0.2/src/weeplot/genplot.py b/dist/weewx-5.0.2/src/weeplot/genplot.py new file mode 100644 index 0000000..c84df2e --- /dev/null +++ b/dist/weewx-5.0.2/src/weeplot/genplot.py @@ -0,0 +1,737 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Routines for generating image plots.""" + +import colorsys +import locale +import os +import time + +from PIL import Image, ImageDraw, ImageFont + +import weeplot.utilities +import weeutil.weeutil +from weeplot.utilities import tobgr +from weeutil.weeutil import max_with_none, min_with_none, to_bool, option_as_list + +# Test if this version of Pillow has ImageFont.getbbox. If not, we will activate a workaround. +try: + ImageFont.ImageFont.getbbox +except AttributeError: + PIL_HAS_BBOX = False +else: + PIL_HAS_BBOX = True + + +class GeneralPlot(object): + """Holds various parameters necessary for a plot. It should be specialized by the type of plot. + """ + def __init__(self, plot_dict): + """Initialize an instance of GeneralPlot. + + plot_dict: an instance of ConfigObj, or something that looks like it. + """ + + self.line_list = [] + + self.xscale = (None, None, None) + self.yscale = (None, None, None) + + self.anti_alias = int(plot_dict.get('anti_alias', 1)) + + self.image_width = int(plot_dict.get('image_width', 300)) * self.anti_alias + self.image_height = int(plot_dict.get('image_height', 180)) * self.anti_alias + self.image_background_color = tobgr(plot_dict.get('image_background_color', '0xf5f5f5')) + + self.chart_background_color = tobgr(plot_dict.get('chart_background_color', '0xd8d8d8')) + self.chart_gridline_color = tobgr(plot_dict.get('chart_gridline_color', '0xa0a0a0')) + color_list = option_as_list(plot_dict.get('chart_line_colors', ['0xff0000', '0x00ff00', '0x0000ff'])) + fill_color_list = option_as_list(plot_dict.get('chart_fill_colors', color_list)) + width_list = option_as_list(plot_dict.get('chart_line_width', [1, 1, 1])) + self.chart_line_colors = [tobgr(v) for v in color_list] + self.chart_fill_colors = [tobgr(v) for v in fill_color_list] + self.chart_line_widths = [int(v) for v in width_list] + + + self.top_label_font_path = plot_dict.get('top_label_font_path') + self.top_label_font_size = int(plot_dict.get('top_label_font_size', 10)) * self.anti_alias + + self.unit_label = None + self.unit_label_font_path = plot_dict.get('unit_label_font_path') + self.unit_label_font_color = tobgr(plot_dict.get('unit_label_font_color', '0x000000')) + self.unit_label_font_size = int(plot_dict.get('unit_label_font_size', 10)) * self.anti_alias + self.unit_label_position = (10 * self.anti_alias, 0) + + self.bottom_label = u"" + self.bottom_label_font_path = plot_dict.get('bottom_label_font_path') + self.bottom_label_font_color= tobgr(plot_dict.get('bottom_label_font_color', '0x000000')) + self.bottom_label_font_size = int(plot_dict.get('bottom_label_font_size', 10)) * self.anti_alias + self.bottom_label_offset = int(plot_dict.get('bottom_label_offset', 3)) + + self.axis_label_font_path = plot_dict.get('axis_label_font_path') + self.axis_label_font_color = tobgr(plot_dict.get('axis_label_font_color', '0x000000')) + self.axis_label_font_size = int(plot_dict.get('axis_label_font_size', 10)) * self.anti_alias + + # Make sure the formats used for the x- and y-axes are in unicode. + self.x_label_format = plot_dict.get('x_label_format') + self.y_label_format = plot_dict.get('y_label_format') + + self.x_nticks = int(plot_dict.get('x_nticks', 10)) + self.y_nticks = int(plot_dict.get('y_nticks', 10)) + + self.x_label_spacing = int(plot_dict.get('x_label_spacing', 2)) + self.y_label_spacing = int(plot_dict.get('y_label_spacing', 2)) + + # Calculate sensible margins for the given image and font sizes. + self.y_label_side = plot_dict.get('y_label_side', 'left') + if self.y_label_side == 'left' or self.y_label_side == 'both': + self.lmargin = int(4.0 * self.axis_label_font_size) + else: + self.lmargin = 20 * self.anti_alias + if self.y_label_side == 'right' or self.y_label_side == 'both': + self.rmargin = int(4.0 * self.axis_label_font_size) + else: + self.rmargin = 20 * self.anti_alias + self.bmargin = int(1.5 * (self.bottom_label_font_size + self.axis_label_font_size) + 0.5) + self.tmargin = int(1.5 * self.top_label_font_size + 0.5) + self.tbandht = int(1.2 * self.top_label_font_size + 0.5) + self.padding = 3 * self.anti_alias + + self.render_rose = False + self.rose_width = int(plot_dict.get('rose_width', 21)) + self.rose_height = int(plot_dict.get('rose_height', 21)) + self.rose_diameter = int(plot_dict.get('rose_diameter', 10)) + self.rose_position = (self.lmargin + self.padding + 5, self.image_height - self.bmargin - self.padding - self.rose_height) + self.rose_rotation = None + self.rose_label = plot_dict.get('rose_label', 'N') + self.rose_label_font_path = plot_dict.get('rose_label_font_path', self.bottom_label_font_path) + self.rose_label_font_size = int(plot_dict.get('rose_label_font_size', 10)) + self.rose_label_font_color = tobgr(plot_dict.get('rose_label_font_color', '0x000000')) + self.rose_line_width = int(plot_dict.get('rose_line_width', 1)) + self.rose_color = plot_dict.get('rose_color') + if self.rose_color is not None: + self.rose_color = tobgr(self.rose_color) + + # Show day/night transitions + self.show_daynight = to_bool(plot_dict.get('show_daynight', False)) + self.daynight_day_color = tobgr(plot_dict.get('daynight_day_color', '0xffffff')) + self.daynight_night_color = tobgr(plot_dict.get('daynight_night_color', '0xf0f0f0')) + self.daynight_edge_color = tobgr(plot_dict.get('daynight_edge_color', '0xefefef')) + self.daynight_gradient = int(plot_dict.get('daynight_gradient', 20)) + + # initialize the location + self.latitude = None + self.longitude = None + + # normalize the font paths relative to the skin directory + skin_dir = plot_dict.get('skin_dir', '') + self.top_label_font_path = self.normalize_path(skin_dir, self.top_label_font_path) + self.bottom_label_font_path = self.normalize_path(skin_dir, self.bottom_label_font_path) + self.unit_label_font_path = self.normalize_path(skin_dir, self.unit_label_font_path) + self.axis_label_font_path = self.normalize_path(skin_dir, self.axis_label_font_path) + self.rose_label_font_path = self.normalize_path(skin_dir, self.rose_label_font_path) + + @staticmethod + def normalize_path(skin_dir, path): + if path is None: + return None + return os.path.join(skin_dir, path) + + def setBottomLabel(self, bottom_label): + """Set the label to be put at the bottom of the plot. """ + self.bottom_label = bottom_label + + def setUnitLabel(self, unit_label): + """Set the label to be used to show the units of the plot. """ + self.unit_label = unit_label + + def setXScaling(self, xscale): + """Set the X scaling. + + xscale: A 3-way tuple (xmin, xmax, xinc) + """ + self.xscale = xscale + + def setYScaling(self, yscale): + """Set the Y scaling. + + yscale: A 3-way tuple (ymin, ymax, yinc) + """ + self.yscale = yscale + + def addLine(self, line): + """Add a line to be plotted. + + line: an instance of PlotLine + """ + if None in line.x: + raise weeplot.ViolatedPrecondition("X vector cannot have any values 'None' ") + self.line_list.append(line) + + def setLocation(self, lat, lon): + self.latitude = lat + self.longitude = lon + + def setDayNight(self, showdaynight, daycolor, nightcolor, edgecolor): + """Configure day/night bands. + + showdaynight: Boolean flag indicating whether to draw day/night bands + + daycolor: color for day bands + + nightcolor: color for night bands + + edgecolor: color for transition between day and night + """ + self.show_daynight = showdaynight + self.daynight_day_color = daycolor + self.daynight_night_color = nightcolor + self.daynight_edge_color = edgecolor + + def render(self): + """Traverses the universe of things that have to be plotted in this image, rendering + them and returning the results as a new Image object. + """ + + # NB: In what follows the variable 'draw' is an instance of an ImageDraw object and is in pixel units. + # The variable 'sdraw' is an instance of ScaledDraw and its units are in the "scaled" units of the plot + # (e.g., the horizontal scaling might be for seconds, the vertical for degrees Fahrenheit.) + image = Image.new("RGB", (self.image_width, self.image_height), self.image_background_color) + draw = ImageDraw.ImageDraw(image) + draw.rectangle(((self.lmargin,self.tmargin), + (self.image_width - self.rmargin, self.image_height - self.bmargin)), + fill=self.chart_background_color) + + self._renderBottom(draw) + self._renderTopBand(draw) + + self._calcXScaling() + self._calcYScaling() + self._calcXLabelFormat() + self._calcYLabelFormat() + + sdraw = self._getScaledDraw(draw) + if self.show_daynight: + self._renderDayNight(sdraw) + self._renderXAxes(sdraw) + self._renderYAxes(sdraw) + self._renderPlotLines(sdraw) + if self.render_rose: + self._renderRose(image, draw) + + if self.anti_alias != 1: + image.thumbnail((self.image_width / self.anti_alias, + self.image_height / self.anti_alias), + Image.LANCZOS) + + return image + + def _getScaledDraw(self, draw): + """Returns an instance of ScaledDraw, with the appropriate scaling. + + draw: An instance of ImageDraw + """ + sdraw = weeplot.utilities.ScaledDraw( + draw, + ( + (self.lmargin + self.padding, self.tmargin + self.padding), + (self.image_width - self.rmargin - self.padding, self.image_height - self.bmargin - self.padding) + ), + ( + (self.xscale[0], self.yscale[0]), + (self.xscale[1], self.yscale[1]) + ) + ) + return sdraw + + def _renderDayNight(self, sdraw): + """Draw vertical bands for day/night.""" + (first, transitions) = weeutil.weeutil.getDayNightTransitions( + self.xscale[0], self.xscale[1], self.latitude, self.longitude) + color = self.daynight_day_color \ + if first == 'day' else self.daynight_night_color + xleft = self.xscale[0] + for x in transitions: + sdraw.rectangle(((xleft,self.yscale[0]), + (x,self.yscale[1])), fill=color) + xleft = x + color = self.daynight_night_color \ + if color == self.daynight_day_color else self.daynight_day_color + sdraw.rectangle(((xleft,self.yscale[0]), + (self.xscale[1],self.yscale[1])), fill=color) + if self.daynight_gradient: + if first == 'day': + color1 = self.daynight_day_color + color2 = self.daynight_night_color + else: + color1 = self.daynight_night_color + color2 = self.daynight_day_color + nfade = self.daynight_gradient + # gradient is longer at the poles than the equator + d = 120 + 300 * (1 - (90.0 - abs(self.latitude)) / 90.0) + for i in range(len(transitions)): + last_ = self.xscale[0] if i == 0 else transitions[i-1] + next_ = transitions[i+1] if i < len(transitions)-1 else self.xscale[1] + for z in range(1,nfade): + c = blend_hls(color2, color1, float(z)/float(nfade)) + rgbc = int2rgbstr(c) + x1 = transitions[i]-d*(nfade+1)/2+d*z + if last_ < x1 < next_: + sdraw.rectangle(((x1, self.yscale[0]), + (x1+d, self.yscale[1])), + fill=rgbc) + if color1 == self.daynight_day_color: + color1 = self.daynight_night_color + color2 = self.daynight_day_color + else: + color1 = self.daynight_day_color + color2 = self.daynight_night_color + # draw a line at the actual sunrise/sunset + for x in transitions: + sdraw.line((x,x),(self.yscale[0],self.yscale[1]), + fill=self.daynight_edge_color) + + def _renderXAxes(self, sdraw): + """Draws the x-axis and vertical constant-x lines, as well as the labels. """ + + axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path, + self.axis_label_font_size) + + drawlabelcount = 0 + for x in weeutil.weeutil.stampgen(self.xscale[0], self.xscale[1], self.xscale[2]) : + sdraw.line((x, x), + (self.yscale[0], self.yscale[1]), + fill=self.chart_gridline_color, + width=self.anti_alias) + if drawlabelcount % self.x_label_spacing == 0 : + xlabel = self._genXLabel(x) + if PIL_HAS_BBOX: + axis_label_width = sdraw.draw.textlength(xlabel, font=axis_label_font) + else: + axis_label_width, _ = sdraw.draw.textsize(xlabel, font=axis_label_font) + xpos = sdraw.xtranslate(x) + sdraw.draw.text((xpos - axis_label_width/2, self.image_height - self.bmargin + 2), + xlabel, fill=self.axis_label_font_color, font=axis_label_font) + drawlabelcount += 1 + + def _renderYAxes(self, sdraw): + """Draws the y-axis and horizontal constant-y lines, as well as the labels. + Should be sufficient for most purposes. + """ + nygridlines = int((self.yscale[1] - self.yscale[0]) / self.yscale[2] + 1.5) + axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path, + self.axis_label_font_size) + + # Draw the (constant y) grid lines + for i in range(nygridlines) : + y = self.yscale[0] + i * self.yscale[2] + sdraw.line((self.xscale[0], self.xscale[1]), (y, y), fill=self.chart_gridline_color, + width=self.anti_alias) + # Draw a label on every other line: + if i % self.y_label_spacing == 0 : + ylabel = self._genYLabel(y) + if PIL_HAS_BBOX: + left, top, right, bottom = axis_label_font.getbbox(ylabel) + axis_label_width, axis_label_height = right - left, bottom - top + else: + axis_label_width, axis_label_height = sdraw.draw.textsize(ylabel, + font=axis_label_font) + ypos = sdraw.ytranslate(y) + # We want to treat Truetype and bitmapped fonts the same. By default, Truetype + # measures the top of the bounding box at the top of the ascender, while it's + # the top of the text for bitmapped. Specify an anchor of "lt" (left, top) for + # both. NB: argument "anchor" has been around at least as early as + # Pillow V5.0 (2018), but was not implemented until V8.0.0. + if self.y_label_side == 'left' or self.y_label_side == 'both': + sdraw.draw.text((self.lmargin - axis_label_width - 2, ypos - axis_label_height/2), + ylabel, fill=self.axis_label_font_color, font=axis_label_font, + anchor="lt") + if self.y_label_side == 'right' or self.y_label_side == 'both': + sdraw.draw.text((self.image_width - self.rmargin + 4, ypos - axis_label_height/2), + ylabel, fill=self.axis_label_font_color, font=axis_label_font, + anchor="lt") + + def _renderPlotLines(self, sdraw): + """Draw the collection of lines, using a different color for each one. Because there is + a limited set of colors, they need to be recycled if there are very many lines. + """ + nlines = len(self.line_list) + ncolors = len(self.chart_line_colors) + nfcolors = len(self.chart_fill_colors) + nwidths = len(self.chart_line_widths) + + # Draw them in reverse order, so the first line comes out on top of the image + for j, this_line in enumerate(self.line_list[::-1]): + + iline=nlines-j-1 + color = self.chart_line_colors[iline%ncolors] if this_line.color is None else this_line.color + fill_color = self.chart_fill_colors[iline%nfcolors] if this_line.fill_color is None else this_line.fill_color + width = (self.chart_line_widths[iline%nwidths] if this_line.width is None else this_line.width) * self.anti_alias + + # Calculate the size of a gap in data + maxdx = None + if this_line.line_gap_fraction is not None: + maxdx = this_line.line_gap_fraction * (self.xscale[1] - self.xscale[0]) + + if this_line.plot_type == 'line': + ms = this_line.marker_size + if ms is not None: + ms *= self.anti_alias + sdraw.line(this_line.x, + this_line.y, + line_type=this_line.line_type, + marker_type=this_line.marker_type, + marker_size=ms, + fill = color, + width = width, + maxdx = maxdx) + elif this_line.plot_type == 'bar' : + for x, y, bar_width in zip(this_line.x, this_line.y, this_line.bar_width): + if y is None: + continue + sdraw.rectangle(((x - bar_width, self.yscale[0]), (x, y)), fill=fill_color, outline=color) + elif this_line.plot_type == 'vector' : + for (x, vec) in zip(this_line.x, this_line.y): + sdraw.vector(x, vec, + vector_rotate = this_line.vector_rotate, + fill = color, + width = width) + self.render_rose = True + self.rose_rotation = this_line.vector_rotate + if self.rose_color is None: + self.rose_color = color + + def _renderBottom(self, draw): + """Draw anything at the bottom (just some text right now). """ + bottom_label_font = weeplot.utilities.get_font_handle(self.bottom_label_font_path, + self.bottom_label_font_size) + if PIL_HAS_BBOX: + left, top, right, bottom = bottom_label_font.getbbox(self.bottom_label) + bottom_label_width, bottom_label_height = right - left, bottom - top + else: + bottom_label_width, bottom_label_height = draw.textsize(self.bottom_label, + font=bottom_label_font) + draw.text(((self.image_width - bottom_label_width)/2, + self.image_height - bottom_label_height - self.bottom_label_offset), + self.bottom_label, + fill=self.bottom_label_font_color, + font=bottom_label_font, + anchor="lt") + + def _renderTopBand(self, draw): + """Draw the top band and any text in it. """ + # Draw the top band rectangle + draw.rectangle(((0,0), + (self.image_width, self.tbandht)), + fill = self.chart_background_color) + + # Put the units in the upper left corner + unit_label_font = weeplot.utilities.get_font_handle(self.unit_label_font_path, + self.unit_label_font_size) + if self.unit_label: + if self.y_label_side == 'left' or self.y_label_side == 'both': + draw.text(self.unit_label_position, + self.unit_label, + fill=self.unit_label_font_color, + font=unit_label_font) + if self.y_label_side == 'right' or self.y_label_side == 'both': + unit_label_position_right = (self.image_width - self.rmargin + 4, 0) + draw.text(unit_label_position_right, + self.unit_label, + fill=self.unit_label_font_color, + font=unit_label_font) + + top_label_font = weeplot.utilities.get_font_handle(self.top_label_font_path, + self.top_label_font_size) + + # The top label is the appended label_list. However, it has to be drawn in segments + # because each label may be in a different color. For now, append them together to get + # the total width + top_label = u' '.join([line.label for line in self.line_list]) + if PIL_HAS_BBOX: + top_label_width= draw.textlength(top_label, font=top_label_font) + else: + top_label_width, _ = draw.textsize(top_label, font=top_label_font) + + x = (self.image_width - top_label_width)/2 + y = 0 + + ncolors = len(self.chart_line_colors) + for i, this_line in enumerate(self.line_list): + color = self.chart_line_colors[i%ncolors] if this_line.color is None else this_line.color + # Draw a label + draw.text( (x,y), this_line.label, fill = color, font = top_label_font) + # Now advance the width of the label we just drew, plus a space: + if PIL_HAS_BBOX: + label_width = draw.textlength(this_line.label + u' ', font= top_label_font) + else: + label_width, _ = draw.textsize(this_line.label + u' ', font= top_label_font) + x += label_width + + def _renderRose(self, image, draw): + """Draw a compass rose.""" + + rose_center_x = self.rose_width/2 + 1 + rose_center_y = self.rose_height/2 + 1 + barb_width = 3 + barb_height = 3 + # The background is all white with a zero alpha (totally transparent) + rose_image = Image.new("RGBA", (self.rose_width, self.rose_height), (0x00, 0x00, 0x00, 0x00)) + rose_draw = ImageDraw.Draw(rose_image) + + fill_color = add_alpha(self.rose_color) + # Draw the arrow straight up (North). First the shaft: + rose_draw.line( ((rose_center_x, 0), (rose_center_x, self.rose_height)), + width = self.rose_line_width, + fill = fill_color) + # Now the left barb: + rose_draw.line( ((rose_center_x - barb_width, barb_height), (rose_center_x, 0)), + width = self.rose_line_width, + fill = fill_color) + # And the right barb: + rose_draw.line( ((rose_center_x, 0), (rose_center_x + barb_width, barb_height)), + width = self.rose_line_width, + fill = fill_color) + + rose_draw.ellipse(((rose_center_x - self.rose_diameter/2, + rose_center_y - self.rose_diameter/2), + (rose_center_x + self.rose_diameter/2, + rose_center_y + self.rose_diameter/2)), + outline = fill_color) + + # Rotate if necessary: + if self.rose_rotation: + rose_image = rose_image.rotate(self.rose_rotation) + rose_draw = ImageDraw.Draw(rose_image) + + # Calculate the position of the "N" label: + rose_label_font = weeplot.utilities.get_font_handle(self.rose_label_font_path, + self.rose_label_font_size) + if PIL_HAS_BBOX: + left, top, right, bottom = rose_label_font.getbbox(self.rose_label) + rose_label_width, rose_label_height = right - left, bottom - top + else: + rose_label_width, rose_label_height = draw.textsize(self.rose_label, + font=rose_label_font) + + # Draw the label in the middle of the (possibly) rotated arrow + rose_draw.text((rose_center_x - rose_label_width/2 - 1, + rose_center_y - rose_label_height/2 - 1), + self.rose_label, + fill=add_alpha(self.rose_label_font_color), + font=rose_label_font, + anchor="lt") + + # Paste the image of the arrow on to the main plot. The alpha + # channel of the image will be used as the mask. + # This will cause the arrow to overlay the background plot + image.paste(rose_image, self.rose_position, rose_image) + + def _calcXScaling(self): + """Calculates the x scaling. It will probably be specialized by + plots where the x-axis represents time. + """ + (xmin, xmax) = self._calcXMinMax() + + self.xscale = weeplot.utilities.scale(xmin, xmax, self.xscale, nsteps=self.x_nticks) + + def _calcYScaling(self): + """Calculates y scaling. Can be used 'as-is' for most purposes.""" + # The filter is necessary because unfortunately the value 'None' is not + # excluded from min and max (i.e., min(None, x) is not necessarily x). + # The try block is necessary because min of an empty list throws a + # ValueError exception. + ymin = ymax = None + for line in self.line_list: + if line.plot_type == 'vector': + try: + # For progressive vector plots, we want the magnitude of the complex vector + yline_max = max(abs(c) for c in [v for v in line.y if v is not None]) + except ValueError: + yline_max = None + yline_min = - yline_max if yline_max is not None else None + else: + yline_min = min_with_none(line.y) + yline_max = max_with_none(line.y) + ymin = min_with_none([ymin, yline_min]) + ymax = max_with_none([ymax, yline_max]) + + if ymin is None and ymax is None : + # No valid data. Pick an arbitrary scaling + self.yscale=(0.0, 1.0, 0.2) + else: + self.yscale = weeplot.utilities.scale(ymin, ymax, self.yscale, nsteps=self.y_nticks) + + def _calcXLabelFormat(self): + if self.x_label_format is None: + self.x_label_format = weeplot.utilities.pickLabelFormat(self.xscale[2]) + + def _calcYLabelFormat(self): + if self.y_label_format is None: + self.y_label_format = weeplot.utilities.pickLabelFormat(self.yscale[2]) + + def _genXLabel(self, x): + xlabel = locale.format_string(self.x_label_format, x) + return xlabel + + def _genYLabel(self, y): + ylabel = locale.format_string(self.y_label_format, y) + return ylabel + + def _calcXMinMax(self): + xmin = xmax = None + for line in self.line_list: + xline_min = min_with_none(line.x) + xline_max = max_with_none(line.x) + # If the line represents a bar chart, then the actual minimum has to + # be adjusted for the bar width of the first point + if line.plot_type == 'bar': + xline_min = xline_min - line.bar_width[0] + xmin = min_with_none([xmin, xline_min]) + xmax = max_with_none([xmax, xline_max]) + return xmin, xmax + + +class TimePlot(GeneralPlot) : + """Class that specializes GeneralPlot for plots where the x-axis is time.""" + + def _calcXScaling(self): + """Specialized version for time plots.""" + if None in self.xscale: + (xmin, xmax) = self._calcXMinMax() + self.xscale = weeplot.utilities.scaletime(xmin, xmax) + + def _calcXLabelFormat(self): + """Specialized version for time plots. Assumes that time is in unix epoch time.""" + if self.x_label_format is None: + (xmin, xmax) = self._calcXMinMax() + if xmin is not None and xmax is not None: + delta = xmax - xmin + if delta > 30*24*3600: + self.x_label_format = u"%x" + elif delta > 24*3600: + self.x_label_format = u"%x %X" + else: + self.x_label_format = u"%X" + + def _genXLabel(self, x): + """Specialized version for time plots. Assumes that time is in unix epoch time.""" + if self.x_label_format is None: + return u'' + time_tuple = time.localtime(x) + # There are still some strftimes out there that don't support Unicode. + try: + xlabel = time.strftime(self.x_label_format, time_tuple) + except UnicodeEncodeError: + # Convert it to UTF8, then back again: + xlabel = time.strftime(self.x_label_format.encode('utf-8'), time_tuple).decode('utf-8') + return xlabel + + +class PlotLine(object): + """Represents a single line (or bar) in a plot. """ + def __init__(self, x, y, label='', color=None, fill_color=None, width=None, plot_type='line', + line_type='solid', marker_type=None, marker_size=10, + bar_width=None, vector_rotate = None, line_gap_fraction=None): + self.x = x + self.y = y + self.label = label + self.plot_type = plot_type + self.line_type = line_type + self.marker_type = marker_type + self.marker_size = marker_size + self.color = color + self.fill_color = fill_color + self.width = width + self.bar_width = bar_width + self.vector_rotate = vector_rotate + self.line_gap_fraction = line_gap_fraction + + +def blend_hls(c, bg, alpha): + """Fade from c to bg using alpha channel where 1 is solid and 0 is + transparent. This fades across the hue, saturation, and lightness.""" + return blend(c, bg, alpha, alpha, alpha) + + +def blend_ls(c, bg, alpha): + """Fade from c to bg where 1 is solid and 0 is transparent. + Change only the lightness and saturation, not hue.""" + return blend(c, bg, 1.0, alpha, alpha) + + +def blend(c, bg, alpha_h, alpha_l, alpha_s): + """Fade from c to bg in the hue, lightness, saturation colorspace. + Added hue directionality to choose the shortest circular hue path e.g. + https://stackoverflow.com/questions/1416560/hsl-interpolation + Also, grey detection to minimize colour wheel travel. Interesting resource: + http://davidjohnstone.net/pages/lch-lab-colour-gradient-picker + """ + + r1,g1,b1 = int2rgb(c) + h1,l1,s1 = colorsys.rgb_to_hls(r1/255.0, g1/255.0, b1/255.0) + + r2,g2,b2 = int2rgb(bg) + h2,l2,s2 = colorsys.rgb_to_hls(r2/255.0, g2/255.0, b2/255.0) + + # Check if either of the values is grey (saturation 0), + # in which case don't needlessly reset hue to '0', reducing travel around colour wheel + if s1 == 0: h1 = h2 + if s2 == 0: h2 = h1 + + h_delta = h2 - h1 + + if abs(h_delta) > 0.5: + # If interpolating over more than half-circle (0.5 radians) take shorter, opposite direction... + h_range = 1.0 - abs(h_delta) + h_dir = +1.0 if h_delta < 0.0 else -1.0 + + # Calculte h based on line back from h2 as proportion of h_range and alpha + h = h2 - ( h_dir * h_range * alpha_h ) + + # Clamp h within 0.0 to 1.0 range + h = h + 1.0 if h < 0.0 else h + h = h - 1.0 if h > 1.0 else h + else: + # Interpolating over less than a half-circle, so use normal interpolation as before + h = alpha_h * h1 + (1 - alpha_h) * h2 + + l = alpha_l * l1 + (1 - alpha_l) * l2 + s = alpha_s * s1 + (1 - alpha_s) * s2 + + r,g,b = colorsys.hls_to_rgb(h, l, s) + + r = round(r * 255.0) + g = round(g * 255.0) + b = round(b * 255.0) + + t = rgb2int(int(r),int(g),int(b)) + + return int(t) + + +def int2rgb(x): + b = (x >> 16) & 0xff + g = (x >> 8) & 0xff + r = x & 0xff + return r,g,b + + +def int2rgbstr(x): + return '#%02x%02x%02x' % int2rgb(x) + + +def rgb2int(r,g,b): + return r + g*256 + b*256*256 + + +def add_alpha(i): + """Add an opaque alpha channel to an integer RGB value""" + r = i & 0xff + g = (i >> 8) & 0xff + b = (i >> 16) & 0xff + a = 0xff # Opaque alpha + return r,g,b,a diff --git a/dist/weewx-5.0.2/src/weeplot/tests/__init__.py b/dist/weewx-5.0.2/src/weeplot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weeplot/tests/test_utilities.py b/dist/weewx-5.0.2/src/weeplot/tests/test_utilities.py new file mode 100644 index 0000000..a3e8c0d --- /dev/null +++ b/dist/weewx-5.0.2/src/weeplot/tests/test_utilities.py @@ -0,0 +1,203 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test functions in weeplot.utilities""" + +import os +import unittest + +from weeplot.utilities import * +from weeplot.utilities import _rel_approx_equal +from weeutil.weeutil import timestamp_to_string as to_string + + +class WeePlotUtilTest(unittest.TestCase): + """Test the functions in weeplot.utilities""" + + def test_scale(self): + """Test function scale()""" + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(1.1, 12.3, (0, 14, 2)), + "(0.00000, 14.00000, 2.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(1.1, 12.3), + "(0.00000, 14.00000, 2.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(-1.1, 12.3), + "(-2.00000, 14.00000, 2.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(-12.1, -5.3), + "(-13.00000, -5.00000, 1.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(10.0, 10.0), + "(10.00000, 10.10000, 0.01000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(10.0, 10.001), + "(10.00000, 10.00100, 0.00010)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(10.0, 10.0 + 1e-8), + "(10.00000, 10.10000, 0.01000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(0.0, 0.05, (None, None, .1), 10), + "(0.00000, 1.00000, 0.10000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(16.8, 21.5, (None, None, 2), 10), + "(16.00000, 36.00000, 2.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(16.8, 21.5, (None, None, 2), 4), + "(16.00000, 22.00000, 2.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(0.0, 0.21, (None, None, .02)), + "(0.00000, 0.22000, 0.02000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(100.0, 100.0, (None, 100, None)), + "(99.00000, 100.00000, 0.20000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(100.0, 100.0, (100, None, None)), + "(100.00000, 101.00000, 0.20000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(100.0, 100.0, (0, None, None)), + "(0.00000, 120.00000, 20.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(0.0, 0.2, (None, 100, None)), + "(0.00000, 100.00000, 20.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(0.0, 0.0, (0, None, 1), 10), + "(0.00000, 10.00000, 2.00000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(-17.0, -5.0, + (0, None, .1), 10), + "(0.00000, 1.00000, 0.20000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(5.0, 17.0, + (None, 1, .1), 10), + "(0.00000, 1.00000, 0.20000)") + + self.assertEqual("(%.5f, %.5f, %.5f)" % scale(5.0, 17.0, + (0, 1, None)), + "(0.00000, 1.00000, 0.20000)") + + def test_scaletime(self): + """test function scaletime()""" + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # 24 hours on an hour boundary + time_ts = time.mktime(time.strptime("2013-05-17 08:00", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 24 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-16 09:00:00 PDT (1368720000), " + "2013-05-17 09:00:00 PDT (1368806400), 10800") + + # 24 hours on a 3-hour boundary + time_ts = time.mktime(time.strptime("2013-05-17 09:00", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 24 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-16 09:00:00 PDT (1368720000), " + "2013-05-17 09:00:00 PDT (1368806400), 10800") + + # 24 hours on a non-hour boundary + time_ts = time.mktime(time.strptime("2013-05-17 09:01", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 24 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-16 12:00:00 PDT (1368730800), " + "2013-05-17 12:00:00 PDT (1368817200), 10800") + + # Example 4: 27 hours + time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 27 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-16 06:00:00 PDT (1368709200), " + "2013-05-17 09:00:00 PDT (1368806400), 10800") + + # 3 hours on a 15 minute boundary + time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 3 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-17 05:00:00 PDT (1368792000), " + "2013-05-17 08:00:00 PDT (1368802800), 900") + + # 3 hours on a non-15 minute boundary + time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 3 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-17 05:00:00 PDT (1368792000), " + "2013-05-17 08:00:00 PDT (1368802800), 900") + + # 12 hours + time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 12 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-16 20:00:00 PDT (1368759600), " + "2013-05-17 08:00:00 PDT (1368802800), 3600") + + # 15 hours + time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + xmin, xmax, xinc = scaletime(time_ts - 15 * 3600, time_ts) + self.assertEqual("%s, %s, %s" % (to_string(xmin), to_string(xmax), xinc), + "2013-05-16 17:00:00 PDT (1368748800), " + "2013-05-17 08:00:00 PDT (1368802800), 7200") + + def test_xy_seq_line(self): + """Test function xy_seq_line()""" + x = [1, 2, 3] + y = [10, 20, 30] + self.assertEqual([xy_seq for xy_seq + in xy_seq_line(x, y)], [[(1, 10), (2, 20), (3, 30)]]) + + x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + y = [0, 10, None, 30, None, None, 60, 70, 80, None] + self.assertEqual([xy_seq for xy_seq + in xy_seq_line(x, y)], [[(0, 0), (1, 10)], + [(3, 30)], + [(6, 60), (7, 70), (8, 80)]]) + + x = [0] + y = [None] + self.assertEqual([xy_seq for xy_seq in xy_seq_line(x, y)], []) + + x = [0, 1, 2] + y = [None, None, None] + self.assertEqual([xy_seq for xy_seq in xy_seq_line(x, y)], []) + + # Using maxdx of 2: + x = [0, 1, 2, 3, 5.1, 6, 7, 8, 9] + y = [0, 10, 20, 30, 50, 60, 70, 80, 90] + self.assertEqual([xy_seq for xy_seq + in xy_seq_line(x, y, 2)], [[(0, 0), (1, 10), (2, 20), (3, 30)], + [(5.1, 50), (6, 60), (7, 70), + (8, 80), (9, 90)]]) + + def test_pickLabelFormat(self): + """Test function pickLabelFormat""" + + self.assertEqual(pickLabelFormat(1), "%.0f") + self.assertEqual(pickLabelFormat(20), "%.0f") + self.assertEqual(pickLabelFormat(.2), "%.1f") + self.assertEqual(pickLabelFormat(.01), "%.2f") + + def test__rel_approx_equal(self): + """Test function test__rel_approx_equal""" + + self.assertFalse(_rel_approx_equal(1.23456, 1.23457)) + self.assertTrue(_rel_approx_equal(1.2345678, 1.2345679)) + self.assertTrue(_rel_approx_equal(0.0, 0.0)) + self.assertFalse(_rel_approx_equal(0.0, 0.1)) + self.assertFalse(_rel_approx_equal(0.0, 1e-9)) + self.assertTrue(_rel_approx_equal(1.0, 1.0 + 1e-9)) + self.assertTrue(_rel_approx_equal(1e8, 1e8 + 1e-3)) + + def test_tobgr(self): + """Test the function tobgr()""" + self.assertEqual(tobgr("red"), 0x0000ff, "Test color name") + self.assertEqual(tobgr("#f1f2f3"), 0xf3f2f1, "Test RGB string") + self.assertEqual(tobgr("0xf1f2f3"), 0xf1f2f3, "Test BGR string") + self.assertEqual(tobgr(0xf1f2f3), 0xf1f2f3, "Test BGR int") + with self.assertRaises(ValueError): + tobgr("#f1f2fk") + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weeplot/utilities.py b/dist/weewx-5.0.2/src/weeplot/utilities.py new file mode 100644 index 0000000..4615e16 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeplot/utilities.py @@ -0,0 +1,657 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Various utilities used by the plot package.""" + +import datetime +import math +import time + +from PIL import ImageFont, ImageColor + +import weeplot + + +def scale(data_min, data_max, prescale=(None, None, None), nsteps=10): + """Calculates an appropriate min, max, and step size for scaling axes on a plot. + + The origin (zero) is guaranteed to be on an interval boundary. + + Args: + + data_min(float): The minimum data value + + data_max(float): The maximum data value. Must be greater than or equal to data_min. + + prescale(tuple): A 3-way tuple. A non-None min or max value (positions 0 and 1, + respectively) will be fixed to that value. A non-None interval (position 2) + be at least as big as that value. Default = (None, None, None) + + nsteps(int): The nominal number of desired steps. Default = 10 + + Returns: + tuple: A three-way tuple. First value is the lowest scale value, second the highest. + The third value is the step (increment) between them. + + Examples: + >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3, (0, 14, 2))) + (0.0, 14.0, 2.0) + >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3)) + (0.0, 14.0, 2.0) + >>> print("(%.1f, %.1f, %.1f)" % scale(-1.1, 12.3)) + (-2.0, 14.0, 2.0) + >>> print("(%.1f, %.1f, %.1f)" % scale(-12.1, -5.3)) + (-13.0, -5.0, 1.0) + >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0)) + (10.00, 10.10, 0.01) + >>> print("(%.2f, %.4f, %.4f)" % scale(10.0, 10.001)) + (10.00, 10.0010, 0.0001) + >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0+1e-8)) + (10.00, 10.10, 0.01) + >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.05, (None, None, .1), 10)) + (0.00, 1.00, 0.10) + >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 10)) + (16.00, 36.00, 2.00) + >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 4)) + (16.00, 22.00, 2.00) + >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.21, (None, None, .02))) + (0.00, 0.22, 0.02) + >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (None, 100, None))) + (99.00, 100.00, 0.20) + >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (100, None, None))) + (100.00, 101.00, 0.20) + >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (0, None, None))) + (0.00, 120.00, 20.00) + >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.2, (None, 100, None))) + (0.00, 100.00, 20.00) + + """ + + # If all the values are hard-wired in, then there's nothing to do: + if None not in prescale: + return prescale + + # Unpack + minscale, maxscale, min_interval = prescale + + # Make sure data_min and data_max are float values, in case a user passed + # in integers: + data_min = float(data_min) + data_max = float(data_max) + + if data_max < data_min: + raise weeplot.ViolatedPrecondition("scale() called with max value less than min value") + + # In case minscale and/or maxscale was specified, clip data_min and data_max to make sure they + # stay within bounds + if maxscale is not None: + data_max = min(data_max, maxscale) + if data_max < data_min: + data_min = data_max + if minscale is not None: + data_min = max(data_min, minscale) + if data_max < data_min: + data_max = data_min + + # Check the special case where the min and max values are equal. + if _rel_approx_equal(data_min, data_max): + # They are equal. We need to move one or the other to create a range, while + # being careful that the resultant min/max stay within the interval [minscale, maxscale] + # Pick a step out value based on min_interval if the user has supplied one. Otherwise, + # arbitrarily pick 0.1 + if min_interval is not None: + step_out = min_interval * nsteps + else: + step_out = 0.01 * round(abs(data_max), 2) if data_max else 0.1 + if maxscale is not None: + # maxscale if fixed. Move data_min. + data_min = data_max - step_out + elif minscale is not None: + # minscale if fixed. Move data_max. + data_max = data_min + step_out + else: + # Both can float. Check special case where data_min and data_max are zero + if data_min == 0.0: + data_max = 1.0 + else: + # Just arbitrarily move one. Say, data_max. + data_max = data_min + step_out + + if minscale is not None and maxscale is not None: + if maxscale < minscale: + raise weeplot.ViolatedPrecondition("scale() called with prescale max less than min") + frange = maxscale - minscale + elif minscale is not None: + frange = data_max - minscale + elif maxscale is not None: + frange = maxscale - data_min + else: + frange = data_max - data_min + steps = frange / float(nsteps) + + mag = math.floor(math.log10(steps)) + magPow = math.pow(10.0, mag) + magMsd = math.floor(steps / magPow + 0.5) + + if magMsd > 5.0: + magMsd = 10.0 + elif magMsd > 2.0: + magMsd = 5.0 + else: # magMsd > 1.0 + magMsd = 2 + + # This will be the nominal interval size + interval = magMsd * magPow + + # Test it against the desired minimum, if any + if min_interval is None or interval >= min_interval: + # Either no min interval was specified, or it's safely + # less than the chosen interval. + if minscale is None: + minscale = interval * math.floor(data_min / interval) + + if maxscale is None: + maxscale = interval * math.ceil(data_max / interval) + + else: + + # The request for a minimum interval has kicked in. + # Sometimes this can make for a plot with just one or + # two intervals in it. Adjust the min and max values + # to get a nice plot + interval = float(min_interval) + + if minscale is None: + if maxscale is None: + # Both can float. Pick values so the range is near the bottom + # of the scale: + minscale = interval * math.floor(data_min / interval) + maxscale = minscale + interval * nsteps + else: + # Only minscale can float + minscale = maxscale - interval * nsteps + else: + if maxscale is None: + # Only maxscale can float + maxscale = minscale + interval * nsteps + else: + # Both are fixed --- nothing to be done + pass + + return minscale, maxscale, interval + + +def scaletime(tmin_ts, tmax_ts): + """Picks a time scaling suitable for a time plot. + + Args: + tmin_ts(float): The minimum time that must be included. + tmax_ts(float): The maximum time that must be included + + Returns: + tuple: A scaling 3-tuple. First element is the start time, second the stop + time, third the increment. All are in seconds (epoch time in the case of the + first two). + + Example 1: 24 hours on an hour boundary + >>> from weeutil.weeutil import timestamp_to_string as to_string + >>> time_ts = time.mktime(time.strptime("2013-05-17 08:00", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800 + + Example 2: 24 hours on a 3-hour boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 09:00", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800 + + Example 3: 24 hours on a non-hour boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 09:01", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 12:00:00 PDT (1368730800) 2013-05-17 12:00:00 PDT (1368817200) 10800 + + Example 4: 27 hours + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 27*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 06:00:00 PDT (1368709200) 2013-05-17 09:00:00 PDT (1368806400) 10800 + + Example 5: 3 hours on a 15-minute boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900 + + Example 6: 3 hours on a non 15-minute boundary + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900 + + Example 7: 12 hours + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 12*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 20:00:00 PDT (1368759600) 2013-05-17 08:00:00 PDT (1368802800) 3600 + + Example 8: 15 hours + >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) + >>> xmin, xmax, xinc = scaletime(time_ts - 15*3600, time_ts) + >>> print(to_string(xmin), to_string(xmax), xinc) + 2013-05-16 17:00:00 PDT (1368748800) 2013-05-17 08:00:00 PDT (1368802800) 7200 + """ + if tmax_ts <= tmin_ts: + raise weeplot.ViolatedPrecondition("scaletime called with tmax <= tmin") + + tdelta = tmax_ts - tmin_ts + + tmin_dt = datetime.datetime.fromtimestamp(tmin_ts) + tmax_dt = datetime.datetime.fromtimestamp(tmax_ts) + + if tdelta <= 16 * 3600: + if tdelta <= 3 * 3600: + # For time intervals less than 3 hours, use an increment of 15 minutes + interval = 900 + elif tdelta <= 12 * 3600: + # For intervals from 3 hours up through 12 hours, use one hour + interval = 3600 + else: + # For intervals from 12 through 16 hours, use two hours. + interval = 7200 + # Get to the one-hour boundary below tmax: + stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) + # if tmax happens to be on a one-hour boundary we're done. Otherwise, round + # up to the next one-hour boundary: + if tmax_dt > stop_dt: + stop_dt += datetime.timedelta(hours=1) + n_hours = int((tdelta + 3599) / 3600) + start_dt = stop_dt - datetime.timedelta(hours=n_hours) + + elif tdelta <= 27 * 3600: + # A day plot is wanted. A time increment of 3 hours is appropriate + interval = 3 * 3600 + # h is the hour of tmax_dt + h = tmax_dt.timetuple()[3] + # Subtract off enough to get to the lower 3-hour boundary from tmax: + stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) \ + - datetime.timedelta(hours=h % 3) + # If tmax happens to lie on a 3-hour boundary we don't need to do anything. If not, we need + # to round up to the next 3-hour boundary: + if tmax_dt > stop_dt: + stop_dt += datetime.timedelta(hours=3) + # The stop time is one day earlier + start_dt = stop_dt - datetime.timedelta(days=1) + + if tdelta == 27 * 3600: + # A "slightly more than a day plot" is wanted. Start 3 hours earlier: + start_dt -= datetime.timedelta(hours=3) + + elif 27 * 3600 < tdelta <= 31 * 24 * 3600: + # The timescale is between a day and a month. A time increment of one day is appropriate + start_dt = tmin_dt.replace(hour=0, minute=0, second=0, microsecond=0) + stop_dt = tmax_dt.replace(hour=0, minute=0, second=0, microsecond=0) + + tmax_tt = tmax_dt.timetuple() + if tmax_tt[3] != 0 or tmax_tt[4] != 0: + stop_dt += datetime.timedelta(days=1) + + interval = 24 * 3600 + elif tdelta <= 2 * 365.25 * 24 * 3600: + # The timescale is between a month and 2 years, inclusive. A time increment of a month + # is appropriate + start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + year, mon, day = tmax_dt.timetuple()[0:3] + if day != 1: + mon += 1 + if mon == 13: + mon = 1 + year += 1 + stop_dt = datetime.datetime(year, mon, 1) + # Average month length: + interval = 365.25 / 12 * 24 * 3600 + else: + # The time-scale is over 2 years. A time increment of six months is appropriate + start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + year, mon, day = tmax_dt.timetuple()[0:3] + if day != 1 or mon != 1: + day = 1 + mon = 1 + year += 1 + stop_dt = datetime.datetime(year, mon, 1) + # Average length of six months + interval = 365.25 * 24 * 3600 / 2.0 + + # Convert to epoch time stamps + start_ts = int(time.mktime(start_dt.timetuple())) + stop_ts = int(time.mktime(stop_dt.timetuple())) + + return start_ts, stop_ts, interval + + +class ScaledDraw(object): + """Like an ImageDraw object, but lines are scaled. """ + + def __init__(self, draw, imagebox, scaledbox): + """Initialize a ScaledDraw object. + + Example: + scaledraw = ScaledDraw(draw, ((10, 10), (118, 246)), ((0.0, 0.0), (10.0, 1.0))) + + would create a scaled drawing where the upper-left image coordinate (10, 10) would + correspond to the scaled coordinate( 0.0, 1.0). The lower-right image coordinate + would correspond to the scaled coordinate (10.0, 0.0). + + draw: an instance of ImageDraw + + imagebox: a 2-tuple of the box coordinates on the image ((ulx, uly), (lrx, lry)) + + scaledbox: a 2-tuple of the box coordinates of the scaled plot ((llx, lly), (urx, ury)) + + """ + uli = imagebox[0] + lri = imagebox[1] + lls = scaledbox[0] + urs = scaledbox[1] + if urs[1] == lls[1]: + pass + self.xscale = float(lri[0] - uli[0]) / float(urs[0] - lls[0]) + self.yscale = -float(lri[1] - uli[1]) / float(urs[1] - lls[1]) + self.xoffset = int(lri[0] - urs[0] * self.xscale + 0.5) + self.yoffset = int(uli[1] - urs[1] * self.yscale + 0.5) + + self.draw = draw + + def line(self, x, y, line_type='solid', marker_type=None, marker_size=8, maxdx=None, + **options): + """Draw a scaled line on the instance's ImageDraw object. + + Args: + x(list[float]): sequence of x coordinates + y(list[float|None]): sequence of y coordinates, some of which are possibly null + (value of None) + line_type(str|None): 'solid' for line that connect the coordinates + None for no line + marker_type(str|None): None or 'none' for no marker. + 'cross' for a cross + 'circle' for a circle + 'box' for a box + 'x' for an X + marker_size(int): Size of the marker in pixels + maxdx(float): defines what constitutes a gap in samples. if two data points + are more than maxdx apart they are treated as separate segments. + + For a scatter plot, set line_type to None and marker_type to something other than None. + """ + # Break the line around any nulls or gaps between samples + for xy_seq in xy_seq_line(x, y, maxdx): + # Create a list with the scaled coordinates... + xy_seq_scaled = [(self.xtranslate(xc), self.ytranslate(yc)) for (xc, yc) in xy_seq] + if line_type == 'solid': + # Now pick the appropriate drawing function, depending on the length of the line: + if len(xy_seq) == 1: + self.draw.point(xy_seq_scaled, fill=options['fill']) + else: + self.draw.line(xy_seq_scaled, **options) + if marker_type and marker_type.lower().strip() not in ['none', '']: + self.marker(xy_seq_scaled, marker_type, marker_size=marker_size, **options) + + def marker(self, xy_seq, marker_type, marker_size=10, **options): + half_size = marker_size / 2 + marker = marker_type.lower() + for x, y in xy_seq: + if marker == 'cross': + self.draw.line([(x - half_size, y), (x + half_size, y)], **options) + self.draw.line([(x, y - half_size), (x, y + half_size)], **options) + elif marker == 'x': + self.draw.line([(x - half_size, y - half_size), (x + half_size, y + half_size)], + **options) + self.draw.line([(x - half_size, y + half_size), (x + half_size, y - half_size)], + **options) + elif marker == 'circle': + self.draw.ellipse([(x - half_size, y - half_size), + (x + half_size, y + half_size)], outline=options['fill']) + elif marker == 'box': + self.draw.line([(x - half_size, y - half_size), + (x + half_size, y - half_size), + (x + half_size, y + half_size), + (x - half_size, y + half_size), + (x - half_size, y - half_size)], **options) + + def rectangle(self, box, **options): + """Draw a scaled rectangle. + + box: A pair of 2-way tuples for the lower-left, then upper-right corners of + the box [(llx, lly), (urx, ury)] + + options: passed on to draw.rectangle. Usually contains 'fill' (the color) + """ + # Unpack the box + (llsx, llsy), (ursx, ursy) = box + + ulix = int(llsx * self.xscale + self.xoffset + 0.5) + uliy = int(ursy * self.yscale + self.yoffset + 0.5) + lrix = int(ursx * self.xscale + self.xoffset + 0.5) + lriy = int(llsy * self.yscale + self.yoffset + 0.5) + box_scaled = ((ulix, uliy), (lrix, lriy)) + self.draw.rectangle(box_scaled, **options) + + def vector(self, x, vec, vector_rotate, **options): + + if vec is None: + return + xstart_scaled = self.xtranslate(x) + ystart_scaled = self.ytranslate(0) + + vecinc_scaled = vec * self.yscale + + if vector_rotate: + vecinc_scaled *= complex(math.cos(math.radians(vector_rotate)), + math.sin(math.radians(vector_rotate))) + + # Subtract off the x increment because the x-axis + # *increases* to the right, unlike y, which increases + # downwards + xend_scaled = xstart_scaled - vecinc_scaled.real + yend_scaled = ystart_scaled + vecinc_scaled.imag + + self.draw.line(((xstart_scaled, ystart_scaled), (xend_scaled, yend_scaled)), **options) + + def xtranslate(self, x): + return int(x * self.xscale + self.xoffset + 0.5) + + def ytranslate(self, y): + return int(y * self.yscale + self.yoffset + 0.5) + + +def xy_seq_line(x, y, maxdx=None): + """Generator function that breaks a line into individual segments around + any nulls held in y or any gaps in x greater than maxdx. + + x: iterable sequence of x coordinates. All values must be non-null + + y: iterable sequence of y coordinates, possibly with some embedded + nulls (that is, their value==None) + + yields: Lists of (x,y) coordinates + + Example 1 + >>> x=[ 1, 2, 3] + >>> y=[10, 20, 30] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + [(1, 10), (2, 20), (3, 30)] + + Example 2 + >>> x=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + >>> y=[0, 10, None, 30, None, None, 60, 70, 80, None] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + [(0, 0), (1, 10)] + [(3, 30)] + [(6, 60), (7, 70), (8, 80)] + + Example 3 + >>> x=[ 0 ] + >>> y=[None] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + + Example 4 + >>> x=[ 0, 1, 2] + >>> y=[None, None, None] + >>> for xy_seq in xy_seq_line(x,y): + ... print(xy_seq) + + Example 5 (using gap) + >>> x=[0, 1, 2, 3, 5.1, 6, 7, 8, 9] + >>> y=[0, 10, 20, 30, 50, 60, 70, 80, 90] + >>> for xy_seq in xy_seq_line(x,y,2): + ... print(xy_seq) + [(0, 0), (1, 10), (2, 20), (3, 30)] + [(5.1, 50), (6, 60), (7, 70), (8, 80), (9, 90)] + """ + + line = [] + last_x = None + for xy in zip(x, y): + dx = xy[0] - last_x if last_x is not None else 0 + last_x = xy[0] + # If the y coordinate is None or dx > maxdx, that marks a break + if xy[1] is None or (maxdx is not None and dx > maxdx): + # If the length of the line is non-zero, yield it + if len(line): + yield line + line = [] if xy[1] is None else [xy] + else: + line.append(xy) + if len(line): + yield line + + +def pickLabelFormat(increment): + """Pick an appropriate label format for the given increment. + + Examples: + >>> print(pickLabelFormat(1)) + %.0f + >>> print(pickLabelFormat(20)) + %.0f + >>> print(pickLabelFormat(.2)) + %.1f + >>> print(pickLabelFormat(.01)) + %.2f + """ + + i_log = math.log10(increment) + if i_log < 0: + i_log = abs(i_log) + decimal_places = int(i_log) + if i_log != decimal_places: + decimal_places += 1 + else: + decimal_places = 0 + + return u"%%.%df" % decimal_places + + +def get_font_handle(fontpath_str, *args): + """Get a handle for a font path, caching the results""" + + # Look for the font in the cache + font_key = (fontpath_str, args) + if font_key in get_font_handle.fontCache: + return get_font_handle.fontCache[font_key] + + font = None + if fontpath_str is not None: + try: + if fontpath_str.endswith('.ttf'): + # 1. Nice feature of Pillow: if fontpath_str is an absolute path, and it cannot be + # found, then Pillow will search for the font in system resources as well. + # 2. Specifying the basic layout engine is necessary to avoid a segmentation + # fault in Pillow versions earlier than 8.2. + # See https://github.com/python-pillow/Pillow/issues/3066 + # 3. But, unfortunately, the means for specifying the Layout engine changed + # with Pillow V9.1. The old way was deprecated in Pillow 10.0, + # so if we want to support versions older than 9.1, we have to try it both ways + try: + # First, try it the modern way (see note 3 above) + font = ImageFont.truetype(fontpath_str, # See note 1 + layout_engine=ImageFont.Layout.BASIC, # See note 2 + *args) + except AttributeError: + # That didn't work. Try it the old way (see note 3) + font = ImageFont.truetype(fontpath_str, # See note 1 + layout_engine=ImageFont.LAYOUT_BASIC, # See note 2 + *args) + else: + font = ImageFont.load_path(fontpath_str) + except IOError: + pass + + if font is None: + font = ImageFont.load_default() + if font is not None: + get_font_handle.fontCache[font_key] = font + return font + + +get_font_handle.fontCache = {} + + +def _rel_approx_equal(x, y, rel=1e-7): + """Relative test for equality. + + Example + >>> _rel_approx_equal(1.23456, 1.23457) + False + >>> _rel_approx_equal(1.2345678, 1.2345679) + True + >>> _rel_approx_equal(0.0, 0.0) + True + >>> _rel_approx_equal(0.0, 0.1) + False + >>> _rel_approx_equal(0.0, 1e-9) + False + >>> _rel_approx_equal(1.0, 1.0+1e-9) + True + >>> _rel_approx_equal(1e8, 1e8+1e-3) + True + """ + return abs(x - y) <= rel * max(abs(x), abs(y)) + + +def tobgr(x): + """Convert a color to little-endian integer. The PIL wants either + a little-endian integer (0xBBGGRR) or a string (#RRGGBB). weewx expects + little-endian integer. Accept any standard color format that is known + by ImageColor for example #RGB, #RRGGBB, hslHSL as well as standard color + names from X11 and CSS3. See ImageColor for complete set of colors. + """ + if isinstance(x, str): + if x.startswith('0x'): + return int(x, 0) + try: + r, g, b = ImageColor.getrgb(x) + return r + g * 256 + b * 256 * 256 + except ValueError: + try: + return int(x) + except ValueError as exc: + raise ValueError("Unknown color specifier: '%s'. " + "Colors must be specified as 0xBBGGRR, #RRGGBB, " + "or standard color names." % x) from exc + return x + + +if __name__ == "__main__": + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weeutil/Moon.py b/dist/weewx-5.0.2/src/weeutil/Moon.py new file mode 100644 index 0000000..e9fc901 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/Moon.py @@ -0,0 +1,50 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Given a date, determine the phase of the moon.""" + +import time +import math + +moon_phases = ["new (totally dark)", + "waxing crescent (increasing to full)", + "in its first quarter (increasing to full)", + "waxing gibbous (increasing to full)", + "full (full light)", + "waning gibbous (decreasing from full)", + "in its last quarter (decreasing from full)", + "waning crescent (decreasing from full)"] + +# First new moon of 2018: 17-Jan-2018 at 02:17 UTC +new_moon_2018 = 1516155420 + + +def moon_phase(year, month, day, hour=12): + """Calculates the phase of the moon, given a year, month, day. + + returns: a tuple. First value is an index into an array + of moon phases, such as Moon.moon_phases above. Second + value is the percent fullness of the moon. + """ + + # Convert to UTC + time_ts = time.mktime((year, month, day, hour, 0, 0, 0, 0, -1)) + + return moon_phase_ts(time_ts) + + +def moon_phase_ts(time_ts): + # How many days since the first moon of 2018 + delta_days = (time_ts - new_moon_2018) / 86400.0 + # Number of lunations + lunations = delta_days / 29.530588 + + # The fraction of the lunar cycle + position = float(lunations) % 1.0 + # The percent illumination, rounded to the nearest integer + fullness = int(100.0 * (1.0 - math.cos(2.0 * math.pi * position)) / 2.0 + 0.5) + index = int((position * 8) + 0.5) & 7 + + return index, fullness diff --git a/dist/weewx-5.0.2/src/weeutil/Sun.py b/dist/weewx-5.0.2/src/weeutil/Sun.py new file mode 100644 index 0000000..8daa458 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/Sun.py @@ -0,0 +1,541 @@ +# -*- coding: iso-8859-1 -*- +""" +SUNRISET.C - computes Sun rise/set times, start/end of twilight, and + the length of the day at any date and latitude + +Written as DAYLEN.C, 1989-08-16 + +Modified to SUNRISET.C, 1992-12-01 + +(c) Paul Schlyter, 1989, 1992 + +Released to the public domain by Paul Schlyter, December 1992 + +Direct conversion to Java +Sean Russell + +Conversion to Python Class, 2002-03-21 +Henrik Hrknen + +Solar Altitude added by Miguel Tremblay 2005-01-16 +Solar flux, equation of time and import of python library + added by Miguel Tremblay 2007-11-22 + + +2007-12-12 - v1.5 by Miguel Tremblay: bug fix to solar flux calculation + +2009-03-27 - v1.6 by Tom Keffer; Got rid of the unnecessary (and stateless) + class Sun. Cleaned up. + + +""" +SUN_PY_VERSION = "1.6.0" + +import math +from math import pi + +import calendar + +# Some conversion factors between radians and degrees +RADEG= 180.0 / pi +DEGRAD = pi / 180.0 +INV360 = 1.0 / 360.0 + +#Convenience functions for working in degrees: +# The trigonometric functions in degrees +def sind(x): + """Returns the sin in degrees""" + return math.sin(x * DEGRAD) + +def cosd(x): + """Returns the cos in degrees""" + return math.cos(x * DEGRAD) + +def tand(x): + """Returns the tan in degrees""" + return math.tan(x * DEGRAD) + +def atand(x): + """Returns the arc tan in degrees""" + return math.atan(x) * RADEG + +def asind(x): + """Returns the arc sin in degrees""" + return math.asin(x) * RADEG + +def acosd(x): + """Returns the arc cos in degrees""" + return math.acos(x) * RADEG + +def atan2d(y, x): + """Returns the atan2 in degrees""" + return math.atan2(y, x) * RADEG + + +def daysSince2000Jan0(y, m, d): + """A macro to compute the number of days elapsed since 2000 Jan 0.0 + (which is equal to 1999 Dec 31, 0h UT)""" + return 367.0 * y - ((7.0 * (y + ((m + 9.0) / 12.0))) / 4.0) + (275.0 * m / 9.0) + d - 730530.0 + +# Following are some macros around the "workhorse" function __daylen__ +# They mainly fill in the desired values for the reference altitude +# below the horizon, and also selects whether this altitude should +# refer to the Sun's center or its upper limb. + +def dayLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, from sunrise to sunset. + Sunrise/set is considered to occur when the Sun's upper limb is + 35 arc minutes below the horizon (this accounts for the refraction + of the Earth's atmosphere). + """ + return __daylen__(year, month, day, lon, lat, -35.0/60.0, 1) + + +def dayCivilTwilightLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, including civil twilight. + Civil twilight starts/ends when the Sun's center is 6 degrees below + the horizon. + """ + return __daylen__(year, month, day, lon, lat, -6.0, 0) + + +def dayNauticalTwilightLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, incl. nautical twilight. + Nautical twilight starts/ends when the Sun's center is 12 degrees + below the horizon. + """ + return __daylen__(year, month, day, lon, lat, -12.0, 0) + + +def dayAstronomicalTwilightLength(year, month, day, lon, lat): + """ + This macro computes the length of the day, incl. astronomical twilight. + Astronomical twilight starts/ends when the Sun's center is 18 degrees + below the horizon. + """ + return __daylen__(year, month, day, lon, lat, -18.0, 0) + + +def sunRiseSet(year, month, day, lon, lat): + """ + This macro computes times for sunrise/sunset. + Sunrise/set is considered to occur when the Sun's upper limb is + 35 arc minutes below the horizon (this accounts for the refraction + of the Earth's atmosphere). + """ + return __sunriset__(year, month, day, lon, lat, -35.0/60.0, 1) + + +def civilTwilight(year, month, day, lon, lat): + """ + This macro computes the start and end times of civil twilight. + Civil twilight starts/ends when the Sun's center is 6 degrees below + the horizon. + """ + return __sunriset__(year, month, day, lon, lat, -6.0, 0) + + +def nauticalTwilight(year, month, day, lon, lat): + """ + This macro computes the start and end times of nautical twilight. + Nautical twilight starts/ends when the Sun's center is 12 degrees + below the horizon. + """ + return __sunriset__(year, month, day, lon, lat, -12.0, 0) + + +def astronomicalTwilight(year, month, day, lon, lat): + """ + This macro computes the start and end times of astronomical twilight. + Astronomical twilight starts/ends when the Sun's center is 18 degrees + below the horizon. + """ + return __sunriset__(year, month, day, lon, lat, -18.0, 0) + + +# The "workhorse" function for sun rise/set times +def __sunriset__(year, month, day, lon, lat, altit, upper_limb): + """ + Note: year,month,date = calendar date, 1801-2099 only. + Eastern longitude positive, Western longitude negative + Northern latitude positive, Southern latitude negative + The longitude value IS critical in this function! + altit = the altitude which the Sun should cross + Set to -35/60 degrees for rise/set, -6 degrees + for civil, -12 degrees for nautical and -18 + degrees for astronomical twilight. + upper_limb: non-zero -> upper limb, zero -> center + Set to non-zero (e.g. 1) when computing rise/set + times, and to zero when computing start/end of + twilight. + *rise = where to store the rise time + *set = where to store the set time + Both times are relative to the specified altitude, + and thus this function can be used to compute + various twilight times, as well as rise/set times + Return value: 0 = sun rises/sets this day, times stored at + *trise and *tset. + +1 = sun above the specified 'horizon' 24 hours. + *trise set to time when the sun is at south, + minus 12 hours while *tset is set to the south + time plus 12 hours. 'Day' length = 24 hours + -1 = sun is below the specified 'horizon' 24 hours + 'Day' length = 0 hours, *trise and *tset are + both set to the time when the sun is at south. + """ + # Compute d of 12h local mean solar time + d = daysSince2000Jan0(year,month,day) + 0.5 - (lon/360.0) + + # Compute local sidereal time of this moment + sidtime = revolution(GMST0(d) + 180.0 + lon) + + # Compute Sun's RA + Decl at this moment + res = sunRADec(d) + sRA = res[0] + sdec = res[1] + sr = res[2] + + # Compute time when Sun is at south - in hours UT + tsouth = 12.0 - rev180(sidtime - sRA)/15.0; + + # Compute the Sun's apparent radius, degrees + sradius = 0.2666 / sr; + + # Do correction to upper limb, if necessary + if upper_limb: + altit = altit - sradius + + # Compute the diurnal arc that the Sun traverses to reach + # the specified altitude altit: + + cost = (sind(altit) - sind(lat) * sind(sdec))/\ + (cosd(lat) * cosd(sdec)) + + if cost >= 1.0: + t = 0.0 # Sun always below altit + + elif cost <= -1.0: + t = 12.0; # Sun always above altit + + else: + t = acosd(cost)/15.0 # The diurnal arc, hours + + + # Store rise and set times - in hours UT + return (tsouth-t, tsouth+t) + + +def __daylen__(year, month, day, lon, lat, altit, upper_limb): + """ + Note: year,month,date = calendar date, 1801-2099 only. + Eastern longitude positive, Western longitude negative + Northern latitude positive, Southern latitude negative + The longitude value is not critical. Set it to the correct + longitude if you're picky, otherwise set to, say, 0.0 + The latitude however IS critical - be sure to get it correct + altit = the altitude which the Sun should cross + Set to -35/60 degrees for rise/set, -6 degrees + for civil, -12 degrees for nautical and -18 + degrees for astronomical twilight. + upper_limb: non-zero -> upper limb, zero -> center + Set to non-zero (e.g. 1) when computing day length + and to zero when computing day+twilight length. + + """ + + # Compute d of 12h local mean solar time + d = daysSince2000Jan0(year,month,day) + 0.5 - (lon/360.0) + + # Compute obliquity of ecliptic (inclination of Earth's axis) + obl_ecl = 23.4393 - 3.563E-7 * d + + # Compute Sun's position + res = sunpos(d) + slon = res[0] + sr = res[1] + + # Compute sine and cosine of Sun's declination + sin_sdecl = sind(obl_ecl) * sind(slon) + cos_sdecl = math.sqrt(1.0 - sin_sdecl * sin_sdecl) + + # Compute the Sun's apparent radius, degrees + sradius = 0.2666 / sr + + # Do correction to upper limb, if necessary + if upper_limb: + altit = altit - sradius + + + cost = (sind(altit) - sind(lat) * sin_sdecl) / \ + (cosd(lat) * cos_sdecl) + if cost >= 1.0: + t = 0.0 # Sun always below altit + + elif cost <= -1.0: + t = 24.0 # Sun always above altit + + else: + t = (2.0/15.0) * acosd(cost); # The diurnal arc, hours + + return t + + +def sunpos(d): + """ + Computes the Sun's ecliptic longitude and distance + at an instant given in d, number of days since + 2000 Jan 0.0. The Sun's ecliptic latitude is not + computed, since it's always very near 0. + """ + + # Compute mean elements + M = revolution(356.0470 + 0.9856002585 * d) + w = 282.9404 + 4.70935E-5 * d + e = 0.016709 - 1.151E-9 * d + + # Compute true longitude and radius vector + E = M + e * RADEG * sind(M) * (1.0 + e * cosd(M)) + x = cosd(E) - e + y = math.sqrt(1.0 - e*e) * sind(E) + r = math.sqrt(x*x + y*y) #Solar distance + v = atan2d(y, x) # True anomaly + lon = v + w # True solar longitude + if lon >= 360.0: + lon = lon - 360.0 # Make it 0..360 degrees + + return (lon,r) + + +def sunRADec(d): + """ + Returns the angle of the Sun (RA) + the declination (dec) and the distance of the Sun (r) + for a given day d. + """ + + # Compute Sun's ecliptical coordinates + res = sunpos(d) + lon = res[0] # True solar longitude + r = res[1] # Solar distance + + # Compute ecliptic rectangular coordinates (z=0) + x = r * cosd(lon) + y = r * sind(lon) + + # Compute obliquity of ecliptic (inclination of Earth's axis) + obl_ecl = 23.4393 - 3.563E-7 * d + + # Convert to equatorial rectangular coordinates - x is unchanged + z = y * sind(obl_ecl) + y = y * cosd(obl_ecl) + + # Convert to spherical coordinates + RA = atan2d(y, x) + dec = atan2d(z, math.sqrt(x*x + y*y)) + + return (RA, dec, r) + + + +def GMST0(d): + """ + This function computes GMST0, the Greenwich Mean Sidereal Time + at 0h UT (i.e. the sidereal time at the Greenwhich meridian at + 0h UT). GMST is then the sidereal time at Greenwich at any + time of the day. I've generalized GMST0 as well, and define it + as: GMST0 = GMST - UT -- this allows GMST0 to be computed at + other times than 0h UT as well. While this sounds somewhat + contradictory, it is very practical: instead of computing + GMST like: + + GMST = (GMST0) + UT * (366.2422/365.2422) + + where (GMST0) is the GMST last time UT was 0 hours, one simply + computes: + + GMST = GMST0 + UT + + where GMST0 is the GMST "at 0h UT" but at the current moment! + Defined in this way, GMST0 will increase with about 4 min a + day. It also happens that GMST0 (in degrees, 1 hr = 15 degr) + is equal to the Sun's mean longitude plus/minus 180 degrees! + (if we neglect aberration, which amounts to 20 seconds of arc + or 1.33 seconds of time) + """ + # Sidtime at 0h UT = L (Sun's mean longitude) + 180.0 degr + # L = M + w, as defined in sunpos(). Since I'm too lazy to + # add these numbers, I'll let the C compiler do it for me. + # Any decent C compiler will add the constants at compile + # time, imposing no runtime or code overhead. + + sidtim0 = revolution((180.0 + 356.0470 + 282.9404) + + (0.9856002585 + 4.70935E-5) * d) + return sidtim0; + + +def solar_altitude(latitude, year, month, day): + """ + Compute the altitude of the sun. No atmospherical refraction taken + in account. + Altitude of the southern hemisphere are given relative to + true north. + Altitude of the northern hemisphere are given relative to + true south. + Declination is between 23.5 North and 23.5 South depending + on the period of the year. + Source of formula for altitude is PhysicalGeography.net + http://www.physicalgeography.net/fundamentals/6h.html + """ + # Compute declination + N = daysSince2000Jan0(year, month, day) + res = sunRADec(N) + declination = res[1] + + # Compute the altitude + altitude = 90.0 - latitude + declination + + # In the tropical and in extreme latitude, values over 90 may occur. + if altitude > 90: + altitude = 90 - (altitude-90) + + if altitude < 0: + altitude = 0 + + return altitude + + +def get_max_solar_flux(latitude, year, month, day): + """ + Compute the maximal solar flux to reach the ground for this date and + latitude. + Originaly comes from Environment Canada weather forecast model. + Information was of the public domain before release by Environment Canada + Output is in W/M^2. + """ + + (unused_fEot, fR0r, tDeclsc) = equation_of_time(year, month, day, latitude) + fSF = (tDeclsc[0]+tDeclsc[1])*fR0r + + # In the case of a negative declinaison, solar flux is null + if fSF < 0: + fCoeff = 0 + else: + fCoeff = -1.56e-12*fSF**4 + 5.972e-9*fSF**3 -\ + 8.364e-6*fSF**2 + 5.183e-3*fSF - 0.435 + + fSFT = fSF * fCoeff + + if fSFT < 0: + fSFT=0 + + return fSFT + + +def equation_of_time(year, month, day, latitude): + """ + Description: Subroutine computing the part of the equation of time + needed in the computing of the theoritical solar flux + Correction originating of the CMC GEM model. + + Parameters: int nTime : cTime for the correction of the time. + + Returns: tuple (double fEot, double fR0r, tuple tDeclsc) + dEot: Correction for the equation of time + dR0r: Corrected solar constant for the equation of time + tDeclsc: Declinaison + """ + # Julian date + nJulianDate = Julian(year, month, day) + # Check if it is a leap year + if(calendar.isleap(year)): + fDivide = 366.0 + else: + fDivide = 365.0 + # Correction for "equation of time" + fA = nJulianDate/fDivide*2*pi + fR0r = __Solcons(fA)*0.1367e4 + fRdecl = 0.412*math.cos((nJulianDate+10.0)*2.0*pi/fDivide-pi) + fDeclsc1 = sind(latitude)*math.sin(fRdecl) + fDeclsc2 = cosd(latitude)*math.cos(fRdecl) + tDeclsc = (fDeclsc1, fDeclsc2) + # in minutes + fEot = 0.002733 -7.343*math.sin(fA)+ .5519*math.cos(fA) \ + - 9.47*math.sin(2.0*fA) - 3.02*math.cos(2.0*fA) \ + - 0.3289*math.sin(3.*fA) -0.07581*math.cos(3.0*fA) \ + -0.1935*math.sin(4.0*fA) -0.1245*math.cos(4.0*fA) + # Express in fraction of hour + fEot = fEot/60.0 + # Express in radians + fEot = fEot*15*pi/180.0 + + return (fEot, fR0r, tDeclsc) + + +def __Solcons(dAlf): + """ + Name: __Solcons + + Parameters: [I] double dAlf : Solar constant to correct the excentricity + + Returns: double dVar : Variation of the solar constant + + Functions Called: cos, sin + + Description: Statement function that calculates the variation of the + solar constant as a function of the julian day. (dAlf, in radians) + + Notes: Comes from the + + Revision History: + Author Date Reason + Miguel Tremblay June 30th 2004 + """ + + dVar = 1.0/(1.0-9.464e-4*math.sin(dAlf)-0.01671*math.cos(dAlf)- \ + + 1.489e-4*math.cos(2.0*dAlf)-2.917e-5*math.sin(3.0*dAlf)- \ + + 3.438e-4*math.cos(4.0*dAlf))**2 + return dVar + + +def Julian(year, month, day): + """ + Return julian day. + """ + if calendar.isleap(year): # Bissextil year, 366 days + lMonth = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366] + else: # Normal year, 365 days + lMonth = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365] + + nJulian = lMonth[month-1] + day + return nJulian + +def revolution(x): + """ + This function reduces any angle to within the first revolution + by subtracting or adding even multiples of 360.0 until the + result is >= 0.0 and < 360.0 + + Reduce angle to within 0..360 degrees + """ + return (x - 360.0 * math.floor(x * INV360)) + + +def rev180(x): + """ + Reduce angle to within +180...+180 degrees + """ + return (x - 360.0 * math.floor(x * INV360 + 0.5)) + + + +if __name__ == "__main__": + (sunrise_utc, sunset_utc) = sunRiseSet(2009, 3, 27, -122.65, 45.517) + print(sunrise_utc, sunset_utc) + + #Assert that the results are within 1 minute of NOAA's + # calculator (see http://www.srrb.noaa.gov/highlights/sunrise/sunrise.html) + assert((sunrise_utc - 14.00) < 1.0/60.0) + assert((sunset_utc - 26.55) < 1.0/60.0) diff --git a/dist/weewx-5.0.2/src/weeutil/__init__.py b/dist/weewx-5.0.2/src/weeutil/__init__.py new file mode 100644 index 0000000..3c5ab5c --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/__init__.py @@ -0,0 +1,6 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""General utilities""" diff --git a/dist/weewx-5.0.2/src/weeutil/config.py b/dist/weewx-5.0.2/src/weeutil/config.py new file mode 100644 index 0000000..93e79e2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/config.py @@ -0,0 +1,265 @@ +# +# Copyright (c) 2018-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Convenience functions for ConfigObj""" + +import configobj +from configobj import Section + + +def search_up(d, k, *default): + """Search a ConfigObj dictionary for a key. If it's not found, try my parent, and so on + to the root. + + d: An instance of configobj.Section + + k: A key to be searched for. If not found in d, it's parent will be searched + + default: If the key is not found, then the default is returned. If no default is given, + then an AttributeError exception is raised. + + Example: + + >>> c = configobj.ConfigObj({"color":"blue", "size":10, "robin":{"color":"red", "sound": {"volume": "loud"}}}) + >>> print(search_up(c['robin'], 'size')) + 10 + >>> print(search_up(c, 'color')) + blue + >>> print(search_up(c['robin'], 'color')) + red + >>> print(search_up(c['robin'], 'flavor', 'salty')) + salty + >>> try: + ... print(search_up(c['robin'], 'flavor')) + ... except AttributeError: + ... print('not found') + not found + >>> print(search_up(c['robin'], 'sound')) + {'volume': 'loud'} + >>> print(search_up(c['robin'], 'smell', {})) + {} + """ + if k in d: + return d[k] + if d.parent is d: + if len(default): + return default[0] + else: + raise AttributeError(k) + else: + return search_up(d.parent, k, *default) + + +def accumulateLeaves(d, max_level=99): + """Merges leaf options above a ConfigObj section with itself, accumulating the results. + + This routine is useful for specifying defaults near the root node, + then having them overridden in the leaf nodes of a ConfigObj. + + d: instance of a configobj.Section (i.e., a section of a ConfigObj) + + Returns: a dictionary with all the accumulated scalars, up to max_level deep, + going upwards + + Example: Supply a default color=blue, size=10. The section "dayimage" overrides the former: + + >>> c = configobj.ConfigObj({"color":"blue", "size":10, "dayimage":{"color":"red", "position":{"x":20, "y":30}}}) + >>> accumulateLeaves(c["dayimage"]) == {"color":"red", "size": 10} + True + >>> accumulateLeaves(c["dayimage"], max_level=0) == {'color': 'red'} + True + >>> accumulateLeaves(c["dayimage"]["position"]) == {'color': 'red', 'size': 10, 'y': 30, 'x': 20} + True + >>> accumulateLeaves(c["dayimage"]["position"], max_level=1) == {'color': 'red', 'y': 30, 'x': 20} + True + """ + + # Use recursion. If I am the root object, then there is nothing above + # me to accumulate. Start with a virgin ConfigObj + if d.parent is d: + cum_dict = configobj.ConfigObj() + else: + if max_level: + # Otherwise, recursively accumulate scalars above me + cum_dict = accumulateLeaves(d.parent, max_level - 1) + else: + cum_dict = configobj.ConfigObj() + + # Now merge my scalars into the results: + merge_dict = {k: d[k] for k in d.scalars} + cum_dict.merge(merge_dict) + return cum_dict + + +def merge_config(self_config, indict): + """Merge and patch a config file""" + + self_config.merge(indict) + patch_config(self_config, indict) + + +def patch_config(self_config, indict): + """The ConfigObj merge does not transfer over parentage, nor comments. This function + fixes these limitations. + + Example: + >>> import sys + >>> from io import StringIO + >>> c = configobj.ConfigObj(StringIO('''[Section1] + ... option1 = bar''')) + >>> d = configobj.ConfigObj(StringIO('''[Section1] + ... # This is a Section2 comment + ... [[Section2]] + ... option2 = foo + ... ''')) + >>> c.merge(d) + >>> # First do accumulateLeaves without a patch + >>> print(accumulateLeaves(c['Section1']['Section2'])) + {'option2': 'foo'} + >>> # Now patch and try again + >>> patch_config(c, d) + >>> print(accumulateLeaves(c['Section1']['Section2'])) + {'option1': 'bar', 'option2': 'foo'} + >>> c.write() + ['[Section1]', 'option1 = bar', '# This is a Section2 comment', '[[Section2]]', 'option2 = foo'] + """ + for key in self_config: + if isinstance(self_config[key], Section) \ + and key in indict and isinstance(indict[key], Section): + self_config[key].parent = self_config + self_config[key].main = self_config.main + self_config.comments[key] = indict.comments[key] + self_config.inline_comments[key] = indict.inline_comments[key] + patch_config(self_config[key], indict[key]) + + +def comment_scalar(a_dict, key): + """Comment out a scalar in a ConfigObj object. + + Convert an entry into a comment, sticking it at the beginning of the section. + + Returns: 0 if nothing was done. + 1 if the ConfigObj object was changed. + """ + + # If the key is not in the list of scalars there is no need to do anything. + if key not in a_dict.scalars: + return 0 + + # Save the old comments + comment = a_dict.comments[key] + inline_comment = a_dict.inline_comments[key] + if inline_comment is None: + inline_comment = '' + # Build a new inline comment holding the key and value, as well as the old inline comment + new_inline_comment = "%s = %s %s" % (key, a_dict[key], inline_comment) + + # Delete the old key + del a_dict[key] + + # If that was the only key, there's no place to put the comments. Do nothing. + if len(a_dict.scalars): + # Otherwise, put the comments before the first entry + first_key = a_dict.scalars[0] + a_dict.comments[first_key] += comment + a_dict.comments[first_key].append(new_inline_comment) + + return 1 + + +def delete_scalar(a_dict, key): + """Delete a scalar in a ConfigObj object. + + Returns: 0 if nothing was done. + 1 if the scalar was deleted + """ + + if key not in a_dict.scalars: + return 0 + + del a_dict[key] + return 1 + + +def conditional_merge(a_dict, b_dict): + """Merge fields from b_dict into a_dict, but only if they do not yet + exist in a_dict""" + # Go through each key in b_dict + for k in b_dict: + if isinstance(b_dict[k], dict): + if k not in a_dict: + # It's a new section. Initialize it... + a_dict[k] = {} + # ... and transfer over the section comments, if available + try: + a_dict.comments[k] = b_dict.comments[k] + except AttributeError: + pass + conditional_merge(a_dict[k], b_dict[k]) + elif k not in a_dict: + # It's a scalar. Transfer over the value... + a_dict[k] = b_dict[k] + # ... then its comments, if available: + try: + a_dict.comments[k] = b_dict.comments[k] + except AttributeError: + pass + + +def config_from_str(input_str): + """Return a ConfigObj from a string. Values will be in Unicode.""" + from io import StringIO + config = configobj.ConfigObj(StringIO(input_str), encoding='utf-8', default_encoding='utf-8') + return config + + +def deep_copy(old_dict, parent=None, depth=None, main=None): + """Return a deep copy of a ConfigObj""" + + # Is this a copy starting from the top level? + if isinstance(old_dict, configobj.ConfigObj): + new_dict = configobj.ConfigObj('', + encoding=old_dict.encoding, + default_encoding=old_dict.default_encoding, + interpolation=old_dict.interpolation, + indent_type=old_dict.indent_type) + new_dict.initial_comment = list(old_dict.initial_comment) + else: + # No. It's a copy of something deeper down. If no parent or main is given, then + # adopt the parent and main of the incoming dictionary. + new_dict = configobj.Section(parent if parent is not None else old_dict.parent, + depth if depth is not None else old_dict.depth, + main if main is not None else old_dict.main) + for entry in old_dict: + # Avoid interpolation by using the version of __getitem__ from dict + old_value = dict.__getitem__(old_dict, entry) + if isinstance(old_value, configobj.Section): + new_value = deep_copy(old_value, new_dict, new_dict.depth + 1, new_dict.main) + elif isinstance(old_value, list): + # Make a copy + new_value = list(old_value) + elif isinstance(old_value, tuple): + # Make a copy + new_value = tuple(old_value) + else: + # It's a scalar, possibly a string + new_value = old_value + new_dict[entry] = new_value + # A comment is a list of strings. We need to make a copy of the list, but the strings + # themselves are immutable, so we don't need to copy them. That means a simple shallow + # copy will do: + new_dict.comments[entry] = list(old_dict.comments[entry]) + # An inline comment is either None, or a string. Either way, they are immutable, so + # a simple assignment will work: + new_dict.inline_comments[entry] = old_dict.inline_comments[entry] + return new_dict + + +if __name__ == "__main__": + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weeutil/ftpupload.py b/dist/weewx-5.0.2/src/weeutil/ftpupload.py new file mode 100644 index 0000000..0e61018 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/ftpupload.py @@ -0,0 +1,325 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""For uploading files to a remove server via FTP""" + +import ftplib +import hashlib +import logging +import os +import pickle +import sys +import time + + +log = logging.getLogger(__name__) + + +class FtpUpload(object): + """Uploads a directory and all its descendants to a remote server. + + Keeps track of when a file was last uploaded, so it is uploaded only + if its modification time is newer.""" + + def __init__(self, server, + user, password, + local_root, remote_root, + port=21, + name="FTP", + passive=True, + secure=False, + debug=0, + secure_data=True, + reuse_ssl=False, + encoding='utf-8', + ciphers=None): + """Initialize an instance of FtpUpload. + + After initializing, call method run() to perform the upload. + + server: The remote server to which the files are to be uploaded. + + user, + password : The username and password that are to be used. + + name: A unique name to be given for this FTP session. This allows more + than one session to be uploading from the same local directory. [Optional. + Default is 'FTP'.] + + passive: True to use passive mode; False to use active mode. [Optional. + Default is True (passive mode)] + + secure: Set to True to attempt an FTP over TLS (FTPS) session. + + debug: Set to 1 for extra debug information, 0 otherwise. + + secure_data: If a secure session is requested (option secure=True), + should we attempt a secure data connection as well? This option is useful + due to a bug in the Python FTP client library. See Issue #284. + [Optional. Default is True] + + reuse_ssl: Work around a bug in the Python library that closes ssl sockets that should + be reused. See https://bit.ly/3dKq4JY [Optional. Default is False] + + encoding: The vast majority of FTP servers chat using UTF-8. However, there are a few + oddballs that use Latin-1. + + ciphers: Explicitly set the cipher(s) to be used by the ssl sockets. + """ + self.server = server + self.user = user + self.password = password + self.local_root = os.path.normpath(local_root) + self.remote_root = os.path.normpath(remote_root) + self.port = port + self.name = name + self.passive = passive + self.secure = secure + self.debug = debug + self.secure_data = secure_data + self.reuse_ssl = reuse_ssl + self.encoding = encoding + self.ciphers = ciphers + + if self.reuse_ssl and (sys.version_info.major < 3 or sys.version_info.minor < 6): + raise ValueError("Reusing an SSL connection requires Python version 3.6 or greater") + + def run(self): + """Perform the actual upload. + + returns: the number of files uploaded.""" + + # Get the timestamp and members of the last upload: + timestamp, fileset, hashdict = self.get_last_upload() + + n_uploaded = 0 + + try: + if self.secure: + log.debug("Attempting secure connection to %s", self.server) + if self.reuse_ssl: + # Activate the workaround for the Python ftplib library. + from ssl import SSLSocket + + class ReusedSslSocket(SSLSocket): + def unwrap(self): + pass + + class WeeFTPTLS(ftplib.FTP_TLS): + """Explicit FTPS, with shared TLS session""" + + def ntransfercmd(self, cmd, rest=None): + conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) + if self._prot_p: + conn = self.context.wrap_socket(conn, + server_hostname=self.host, + session=self.sock.session) + conn.__class__ = ReusedSslSocket + return conn, size + log.debug("Reusing SSL connections.") + # Python 3.8 and earlier do not support the encoding + # parameter. Be prepared to catch the TypeError that may + # occur with python 3.8 and earlier. + try: + ftp_server = WeeFTPTLS(encoding=self.encoding) + except TypeError: + # we likely have python 3.8 or earlier, so try again + # without encoding + ftp_server = WeeFTPTLS() + log.debug("FTP encoding not supported, ignoring.") + else: + # Python 3.8 and earlier do not support the encoding + # parameter. Be prepared to catch the TypeError that may + # occur with python 3.8 and earlier. + try: + ftp_server = ftplib.FTP_TLS(encoding=self.encoding) + except TypeError: + # we likely have python 3.8 or earlier, so try again + # without encoding + ftp_server = ftplib.FTP_TLS() + log.debug("FTP encoding not supported, ignoring.") + + # If the user has specified one, set a customized cipher: + if self.ciphers: + ftp_server.context.set_ciphers(self.ciphers) + log.debug("Set ciphers to %s", self.ciphers) + + else: + log.debug("Attempting connection to %s", self.server) + # Python 3.8 and earlier do not support the encoding parameter. + # Be prepared to catch the TypeError that may occur with + # python 3.8 and earlier. + try: + ftp_server = ftplib.FTP(encoding=self.encoding) + except TypeError: + # we likely have python 3.8 or earlier, so try again + # without encoding + ftp_server = ftplib.FTP() + log.debug("FTP encoding not supported, ignoring.") + + if self.debug >= 2: + ftp_server.set_debuglevel(self.debug) + + ftp_server.set_pasv(self.passive) + ftp_server.connect(self.server, self.port) + ftp_server.login(self.user, self.password) + if self.secure and self.secure_data: + ftp_server.prot_p() + log.debug("Secure data connection to %s", self.server) + else: + log.debug("Connected to %s", self.server) + + # Walk the local directory structure + for (dirpath, unused_dirnames, filenames) in os.walk(self.local_root): + + # Strip out the common local root directory. What is left + # will be the relative directory both locally and remotely. + local_rel_dir_path = dirpath.replace(self.local_root, '.') + if _skip_this_dir(local_rel_dir_path): + continue + # This is the absolute path to the remote directory: + remote_dir_path = os.path.normpath(os.path.join(self.remote_root, + local_rel_dir_path)) + + # Make the remote directory if necessary: + _make_remote_dir(ftp_server, remote_dir_path) + + # Now iterate over all members of the local directory: + for filename in filenames: + + full_local_path = os.path.join(dirpath, filename) + + # calculate hash + filehash=sha256sum(full_local_path) + + # See if this file can be skipped: + if _skip_this_file(timestamp, fileset, hashdict, full_local_path, filehash): + continue + + full_remote_path = os.path.join(remote_dir_path, filename) + stor_cmd = "STOR %s" % full_remote_path + + log.debug("%s %s/%s %s" % (n_uploaded,local_rel_dir_path,filename,filehash)) + + with open(full_local_path, 'rb') as fd: + try: + ftp_server.storbinary(stor_cmd, fd) + except ftplib.all_errors as e: + # Unsuccessful. Log it, then reraise the exception + log.error("Failed uploading %s to server %s. Reason: '%s'", + full_local_path, self.server, e) + raise + # Success. + n_uploaded += 1 + fileset.add(full_local_path) + hashdict[full_local_path]=filehash + log.debug("Uploaded file %s to %s", full_local_path, full_remote_path) + finally: + try: + ftp_server.quit() + except Exception: + pass + + timestamp = time.time() + self.save_last_upload(timestamp, fileset, hashdict) + return n_uploaded + + def get_last_upload(self): + """Reads the time and members of the last upload from the local root""" + + timestamp_file_path = os.path.join(self.local_root, "#%s.last" % self.name) + + # If the file does not exist, an IOError exception will be raised. + # If the file exists, but is truncated, an EOFError will be raised. + # Either way, be prepared to catch it. + try: + with open(timestamp_file_path, "rb") as f: + timestamp = pickle.load(f) + fileset = pickle.load(f) + hashdict = pickle.load(f) + except (IOError, EOFError, pickle.PickleError, AttributeError): + timestamp = 0 + fileset = set() + hashdict = {} + # Either the file does not exist, or it is garbled. + # Either way, it's safe to remove it. + try: + os.remove(timestamp_file_path) + except OSError: + pass + + return timestamp, fileset, hashdict + + def save_last_upload(self, timestamp, fileset, hashdict): + """Saves the time and members of the last upload in the local root.""" + timestamp_file_path = os.path.join(self.local_root, "#%s.last" % self.name) + with open(timestamp_file_path, "wb") as f: + pickle.dump(timestamp, f) + pickle.dump(fileset, f) + pickle.dump(hashdict, f) + + +def _skip_this_file(timestamp, fileset, hashdict, full_local_path, filehash): + """Determine whether to skip a specific file.""" + + filename = os.path.basename(full_local_path) + if filename[-1] == '~' or filename[0] == '#': + return True + + if full_local_path not in fileset: + return False + + if filehash is not None: + # use hash if available + if full_local_path not in hashdict: + return False + if hashdict[full_local_path] != filehash: + return False + else: + # otherwise use file time + if os.stat(full_local_path).st_mtime > timestamp: + return False + + # Filename is in the set, and is up-to-date. + return True + + +def _skip_this_dir(local_dir): + """Determine whether to skip a directory.""" + + return os.path.basename(local_dir) in {'.svn', 'CVS', '__pycache__', '.idea', '.git'} + + +def _make_remote_dir(ftp_server, remote_dir_path): + """Make a remote directory if necessary.""" + + try: + ftp_server.mkd(remote_dir_path) + except ftplib.all_errors as e: + # Got an exception. It might be because the remote directory already exists: + if sys.exc_info()[0] is ftplib.error_perm: + msg = str(e).strip() + # If a directory already exists, some servers respond with a '550' ("Requested + # action not taken") code, others with a '521' ("Access denied" or "Pathname + # already exists") code. + if msg.startswith('550') or msg.startswith('521'): + # Directory already exists + return + # It's a real error. Log it, then re-raise the exception. + log.error("Error creating directory %s", remote_dir_path) + raise + + log.debug("Made directory %s", remote_dir_path) + +# from https://stackoverflow.com/questions/22058048/hashing-a-file-in-python + +def sha256sum(filename): + h = hashlib.sha256() + b = bytearray(128*1024) + mv = memoryview(b) + with open(filename, 'rb', buffering=0) as f: + for n in iter(lambda : f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() diff --git a/dist/weewx-5.0.2/src/weeutil/log.py b/dist/weewx-5.0.2/src/weeutil/log.py new file mode 100644 index 0000000..c00f437 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/log.py @@ -0,0 +1,116 @@ +# +# Copyright (c) 2019-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""WeeWX logging facility + +OBSOLETE: Use weeutil.logging instead +""" + +import os +import syslog +import traceback + +from io import StringIO + +log_levels = { + 'debug': syslog.LOG_DEBUG, + 'info': syslog.LOG_INFO, + 'warning': syslog.LOG_WARNING, + 'critical': syslog.LOG_CRIT, + 'error': syslog.LOG_ERR +} + + +def log_open(log_label='weewx'): + syslog.openlog(log_label, syslog.LOG_PID | syslog.LOG_CONS) + + +def log_upto(log_level=None): + """Set what level of logging we want.""" + if log_level is None: + log_level = syslog.LOG_INFO + elif isinstance(log_level, str): + log_level = log_levels.get(log_level, syslog.LOG_INFO) + syslog.setlogmask(syslog.LOG_UPTO(log_level)) + + +def logdbg(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_DEBUG, "%s: %s" % (prefix, msg)) + + +def loginf(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_INFO, "%s: %s" % (prefix, msg)) + + +def lognote(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_NOTICE, "%s: %s" % (prefix, msg)) + + +# In case anyone is really wedded to the idea of 6 letter log functions: +lognot = lognote + + +def logwar(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_WARNING, "%s: %s" % (prefix, msg)) + + +def logerr(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_ERR, "%s: %s" % (prefix, msg)) + + +def logalt(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_ALERT, "%s: %s" % (prefix, msg)) + + +def logcrt(msg, prefix=None): + if prefix is None: + prefix = _get_file_root() + syslog.syslog(syslog.LOG_CRIT, "%s: %s" % (prefix, msg)) + + +def log_traceback(prefix='', log_level=None): + """Log the stack traceback into syslog. + + prefix: A string, which will be put in front of each log entry. Default is no string. + + log_level: Either a syslog level (e.g., syslog.LOG_INFO), or a string. Valid strings + are given by the keys of log_levels. + """ + if log_level is None: + log_level = syslog.LOG_INFO + elif isinstance(log_level, str): + log_level = log_levels.get(log_level, syslog.LOG_INFO) + sfd = StringIO() + traceback.print_exc(file=sfd) + sfd.seek(0) + for line in sfd: + syslog.syslog(log_level, "%s: %s" % (prefix, line)) + + +def _get_file_root(): + """Figure out who is the caller of the logging function""" + + # Get the stack: + tb = traceback.extract_stack() + # Go back 3 frames. First frame is get_file_root(), 2nd frame is the logging function, 3rd frame + # is what we want: what called the logging function + calling_frame = tb[-3] + # Get the file name of what called the logging function + calling_file = os.path.basename(calling_frame[0]) + # Get rid of any suffix (e.g., ".py"): + file_root = calling_file.split('.')[0] + return file_root diff --git a/dist/weewx-5.0.2/src/weeutil/logger.py b/dist/weewx-5.0.2/src/weeutil/logger.py new file mode 100644 index 0000000..64b91c3 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/logger.py @@ -0,0 +1,197 @@ +# +# Copyright (c) 2020-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""WeeWX logging facility""" + +import logging.config +import os.path +import sys +from io import StringIO + +import configobj + +import weewx + +# The logging defaults. Note that two kinds of placeholders are used: +# +# {value}: these are plugged in by the function setup(). +# %(value)s: these are plugged in by the Python logging module. +# +LOGGING_STR = """[Logging] + version = 1 + disable_existing_loggers = False + + # Root logger + [[root]] + level = {log_level} + handlers = syslog, + + # Additional loggers would go in the following section. This is useful for + # tailoring logging for individual modules. + [[loggers]] + + # Definitions of possible logging destinations + [[handlers]] + + # System logger + [[[syslog]]] + level = DEBUG + formatter = standard + class = logging.handlers.SysLogHandler + address = {address} + facility = {facility} + + # Log to console + [[[console]]] + level = DEBUG + formatter = verbose + class = logging.StreamHandler + # Alternate choice is 'ext://sys.stderr' + stream = ext://sys.stdout + + # How to format log messages + [[formatters]] + [[[simple]]] + format = "%(levelname)s %(message)s" + [[[standard]]] + format = "{process_name}[%(process)d]: %(levelname)s %(name)s: %(message)s" + [[[verbose]]] + format = "%(asctime)s {process_name}[%(process)d]: %(levelname)s %(name)s: %(message)s" + # Format to use for dates and times: + datefmt = %Y-%m-%d %H:%M:%S +""" + +# These values are known only at runtime +if sys.platform == "darwin": + address = '/var/run/syslog' + facility = 'local1' +elif sys.platform.startswith('linux'): + address = '/dev/log' + facility = 'user' +elif sys.platform.startswith('freebsd'): + address = '/var/run/log' + facility = 'user' +elif sys.platform.startswith('netbsd'): + address = '/var/run/log' + facility = 'user' +elif sys.platform.startswith('openbsd'): + address = '/dev/log' + facility = 'user' +else: + address = ('localhost', 514) + facility = 'user' + + +def setup(process_name, config_dict=None): + """Set up the weewx logging facility""" + + global address, facility + + # Create a ConfigObj from the default string. No interpolation (it interferes with the + # interpolation directives embedded in the string). + log_config = configobj.ConfigObj(StringIO(LOGGING_STR), interpolation=False, encoding='utf-8') + + # Turn off interpolation in the incoming dictionary. First save the old + # value, then restore later. However, the incoming dictionary may be a simple + # Python dictionary and not have interpolation. Hence, the try block. + try: + old_interpolation = config_dict.interpolation + config_dict.interpolation = False + except AttributeError: + old_interpolation = None + + # Merge in the user additions / changes: + if config_dict: + log_config.merge(config_dict) + weewx_root = config_dict.get("WEEWX_ROOT") + # Set weewx.debug as necessary: + weewx.debug = int(config_dict.get('debug', 0)) + else: + weewx_root = None + + # Get (and remove) the LOG_ROOT, which we use to set the directory where any rotating files + # will be located. Python logging does not use it. + log_root = log_config['Logging'].pop('LOG_ROOT', '') + if weewx_root: + log_root = os.path.join(weewx_root, log_root) + + # Adjust the logging level in accordance to whether the 'debug' flag is on + log_level = 'DEBUG' if weewx.debug else 'INFO' + + # Now we need to walk the structure, plugging in the values we know. + # First, we need a function to do this: + def _fix(section, key): + if isinstance(section[key], (list, tuple)): + # The value is a list or tuple + section[key] = [item.format(log_level=log_level, + address=address, + facility=facility, + process_name=process_name) for item in section[key]] + else: + # The value is a string + section[key] = section[key].format(log_level=log_level, + address=address, + facility=facility, + process_name=process_name) + if key == 'filename' and log_root: + # Allow relative file paths, in which case they are relative to LOG_ROOT: + section[key] = os.path.join(log_root, section[key]) + # Create any intervening directories: + os.makedirs(os.path.dirname(section[key]), exist_ok=True) + + # Using the function, walk the 'Logging' part of the structure + log_config['Logging'].walk(_fix) + + # Now walk the structure again, this time converting any strings to an appropriate type: + log_config['Logging'].walk(_convert_from_string) + + # Extract just the part used by Python's logging facility + log_dict = log_config.dict().get('Logging', {}) + + # Finally! The dictionary is ready. Set the defaults. + logging.config.dictConfig(log_dict) + + # Restore the old interpolation value + if old_interpolation is not None: + config_dict.interpolation = old_interpolation + + +def log_traceback(log_fn, prefix=''): + """Log the stack traceback into a logger. + + log_fn: One of the logging.Logger logging functions, such as logging.Logger.warning. + + prefix: A string, which will be put in front of each log entry. Default is no string. + """ + import traceback + sfd = StringIO() + traceback.print_exc(file=sfd) + sfd.seek(0) + for line in sfd: + log_fn("%s%s", prefix, line) + + +def _convert_from_string(section, key): + """If possible, convert any strings to an appropriate type.""" + # Check to make sure it is a string + if isinstance(section[key], str): + if section[key].lower() == 'false': + # It's boolean False + section[key] = False + elif section[key].lower() == 'true': + # It's boolean True + section[key] = True + elif section[key].count('.') == 1: + # Contains a decimal point. Could be float + try: + section[key] = float(section[key]) + except ValueError: + pass + else: + # Try integer? + try: + section[key] = int(section[key]) + except ValueError: + pass diff --git a/dist/weewx-5.0.2/src/weeutil/printer.py b/dist/weewx-5.0.2/src/weeutil/printer.py new file mode 100644 index 0000000..06d4a5d --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/printer.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2009-2024 Tom Keffer and Matthew Wall +# +# See the file LICENSE.txt for your rights. +# +class Printer(object): + def __init__(self, verbosity=0, fd=None): + self.verbosity = verbosity + if fd is None: + import sys + fd = sys.stdout + self.fd = fd + + def out(self, msg, level=0): + if self.verbosity >= level: + print("%s%s" % (' ' * (level - 1), msg), file=self.fd) diff --git a/dist/weewx-5.0.2/src/weeutil/rsyncupload.py b/dist/weewx-5.0.2/src/weeutil/rsyncupload.py new file mode 100644 index 0000000..a45f344 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/rsyncupload.py @@ -0,0 +1,174 @@ +# +# Copyright (c) 2012 Will Page +# Derivative of ftpupload.py, credit to Tom Keffer +# +# Refactored by tk 3-Jan-2021 +# +# See the file LICENSE.txt for your full rights. +# +"""For uploading files to a remove server via Rsync""" + +import errno +import logging +import os +import subprocess +import sys +import time + +log = logging.getLogger(__name__) + + +class RsyncUpload(object): + """Uploads a directory and all its descendants to a remote server. + + Keeps track of what files have changed, and only updates changed files.""" + + def __init__(self, local_root, remote_root, + server, user=None, delete=False, port=None, + ssh_options=None, compress=False, + log_success=True, log_failure=True, + timeout=None): + """Initialize an instance of RsyncUpload. + + After initializing, call method run() to perform the upload. + + server: The remote server to which the files are to be uploaded. + + user: The username that is to be used. [Optional, maybe] + + delete: delete remote files that don't match with local files. Use + with caution. [Optional. Default is False.] + """ + self.local_root = os.path.normpath(local_root) + self.remote_root = os.path.normpath(remote_root) + self.server = server + self.user = user + self.delete = delete + self.port = port + self.ssh_options = ssh_options + self.compress = compress + self.log_success = log_success + self.log_failure = log_failure + self.timeout = timeout + + def run(self): + """Perform the actual upload.""" + + t1 = time.time() + + # If the source path ends with a slash, rsync interprets + # that as a request to copy all the directory's *contents*, + # whereas if it doesn't, it copies the entire directory. + # We want the former, so make it end with a slash. + # Note: Don't add the slash if local_root isn't a directory + if self.local_root.endswith(os.sep) or not os.path.isdir(self.local_root): + rsynclocalspec = self.local_root + else: + rsynclocalspec = self.local_root + os.sep + + if self.user: + rsyncremotespec = "%s@%s:%s" % (self.user, self.server, self.remote_root) + else: + rsyncremotespec = "%s:%s" % (self.server, self.remote_root) + + if self.port: + rsyncsshstring = "ssh -p %d" % self.port + else: + rsyncsshstring = "ssh" + + if self.ssh_options: + rsyncsshstring = rsyncsshstring + " " + self.ssh_options + + cmd = ['rsync'] + # archive means: + # recursive, copy symlinks as symlinks, preserve permissions, + # preserve modification times, preserve group and owner, + # preserve device files and special files, but not ACLs, + # no hardlinks, and no extended attributes + cmd.extend(["--archive"]) + # provide some stats on the transfer + cmd.extend(["--stats"]) + # Remove files remotely when they're removed locally + if self.delete: + cmd.extend(["--delete"]) + if self.compress: + cmd.extend(["--compress"]) + if self.timeout is not None: + cmd.extend(["--timeout=%s" % self.timeout]) + cmd.extend(["-e"]) + cmd.extend([rsyncsshstring]) + cmd.extend([rsynclocalspec]) + cmd.extend([rsyncremotespec]) + + try: + log.debug("rsyncupload: cmd: [%s]" % cmd) + rsynccmd = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + stdout = rsynccmd.communicate()[0] + stroutput = stdout.decode("utf-8").strip() + except OSError as e: + if e.errno == errno.ENOENT: + log.error("rsync does not appear to be installed on " + "this system. (errno %d, '%s')" % (e.errno, e.strerror)) + raise + + t2 = time.time() + + # we have some output from rsync so generate an appropriate message + if 'rsync error' not in stroutput: + # No rsync error message. Parse the status message for useful information. + if self.log_success: + # Create a dictionary of message and their values. kv_list is a list of + # (key, value) tuples. + kv_list = [line.split(':', 1) for line in stroutput.splitlines() if ':' in line] + # Now convert to dictionary, while stripping the keys and values + rsyncinfo = {k.strip(): v.strip() for k, v in kv_list} + # Get number of files and bytes transferred, and produce an appropriate message + N = rsyncinfo.get('Number of regular files transferred', + rsyncinfo.get('Number of files transferred')) + Nbytes = rsyncinfo.get('Total transferred file size') + if N is not None and Nbytes is not None: + log.info("rsync'd %s files (%s) in %0.2f seconds", N.strip(), + Nbytes.strip(), t2 - t1) + else: + log.info("rsync executed in %0.2f seconds", t2 - t1) + else: + # rsync error message found. If requested, log it + if self.log_failure: + log.error("rsync reported errors. Original command: %s", cmd) + for line in stroutput.splitlines(): + log.error("**** %s", line) + + +if __name__ == '__main__': + import configobj + + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('wee_rsyncupload') + + if len(sys.argv) < 2: + print("""Usage: rsyncupload.py path-to-configuration-file [path-to-be-rsync'd]""") + sys.exit(weewx.CMD_ERROR) + + try: + config_dict = configobj.ConfigObj(sys.argv[1], file_error=True, encoding='utf-8') + except IOError: + print("Unable to open configuration file %s" % sys.argv[1]) + raise + + if len(sys.argv) == 2: + try: + rsync_dir = os.path.join(config_dict['WEEWX_ROOT'], + config_dict['StdReport']['HTML_ROOT']) + except KeyError: + print("No HTML_ROOT in configuration dictionary.") + sys.exit(1) + else: + rsync_dir = sys.argv[2] + + rsync_upload = RsyncUpload(rsync_dir, **config_dict['StdReport']['RSYNC']) + rsync_upload.run() diff --git a/dist/weewx-5.0.2/src/weeutil/startup.py b/dist/weewx-5.0.2/src/weeutil/startup.py new file mode 100644 index 0000000..b8ac513 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/startup.py @@ -0,0 +1,71 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Utilities used when starting up a WeeWX application""" +import importlib +import logging +import os.path +import sys + +log = logging.getLogger(__name__) + + +def extract_roots(config_dict): + """Get the location of the various root directories used by weewx. + The extracted paths are *absolute* paths. That is, they are no longer relative to WEEWX_ROOT. + + Args: + config_dict(dict): The configuration dictionary. It must contain a value for WEEWX_ROOT. + Returns: + dict[str, str]: Key is the type of root, value is its absolute location. + """ + # Check if this dictionary is from a pre-V5 package install. If so, we have to patch + # USER_ROOT to its new location. + if 'USER_ROOT' not in config_dict and config_dict['WEEWX_ROOT'] == '/': + user_root = '/etc/weewx/bin/user' + else: + user_root = config_dict.get('USER_ROOT', 'bin/user') + + root_dict = { + 'WEEWX_ROOT': config_dict['WEEWX_ROOT'], + 'USER_DIR': os.path.abspath(os.path.join(config_dict['WEEWX_ROOT'], user_root)), + 'BIN_DIR': os.path.abspath(os.path.join(os.path.dirname(__file__), '..')), + 'EXT_DIR': os.path.abspath(os.path.join(config_dict['WEEWX_ROOT'], user_root, 'installer')) + } + + # Add SKIN_ROOT if it can be found: + try: + root_dict['SKIN_DIR'] = os.path.abspath( + os.path.join(root_dict['WEEWX_ROOT'], config_dict['StdReport']['SKIN_ROOT']) + ) + except KeyError: + pass + + return root_dict + + +def initialize(config_dict): + """Set debug, set up the logger, and add the user path + + Args: + config_dict(dict): The configuration dictionary + + Returns: + tuple[str,str]: A tuple containing (WEEWX_ROOT, USER_ROOT) + """ + + root_dict = extract_roots(config_dict) + + # Add the 'user' package to PYTHONPATH + parent_of_user_dir = os.path.abspath(os.path.join(root_dict['USER_DIR'], '..')) + sys.path.append(parent_of_user_dir) + + # Now we can import user.extensions + try: + importlib.import_module('user.extensions') + except ModuleNotFoundError as e: + log.error("Cannot load user extensions: %s", e) + + return config_dict['WEEWX_ROOT'], root_dict['USER_DIR'] diff --git a/dist/weewx-5.0.2/src/weeutil/tests/__init__.py b/dist/weewx-5.0.2/src/weeutil/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weeutil/tests/test_config.py b/dist/weewx-5.0.2/src/weeutil/tests/test_config.py new file mode 100644 index 0000000..8b94205 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/tests/test_config.py @@ -0,0 +1,97 @@ +# coding: utf-8 +# +# Copyright (c) 2020-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test module weeutil.config""" +import logging +import unittest +from io import BytesIO, StringIO + +import configobj + +import weeutil.config +import weeutil.logger +import weewx + +weewx.debug = 1 + +log = logging.getLogger(__name__) + +# Set up logging using the defaults. +weeutil.logger.setup('weetest_config') + + +class TestConfigString(unittest.TestCase): + + def test_config_from_str(self): + test_str = """degree_C = °C""" + c = weeutil.config.config_from_str(test_str) + # Make sure the values are Unicode + self.assertEqual(type(c['degree_C']), str) + + +class TestConfig(unittest.TestCase): + test_dict_str = u"""[Logging] + [[formatters]] + [[[simple]]] + # -1.33 à 1.72?? -2.3 à 990mb (mes 1068) + format = %(levelname)s %(message)s # Inline comment æ ø å + [[[standard]]] + format = {process_name}[%(process)d] + [[[verbose]]] + format = {process_name}[%(process)d] %(levelname)s + datefmt = %Y-%m-%d %H:%M:%S +""" + + def setUp(self): + test_dict = StringIO(TestConfig.test_dict_str) + self.c_in = configobj.ConfigObj(test_dict, encoding='utf-8', default_encoding='utf-8') + + def test_deep_copy_ConfigObj(self): + """Test copying a full ConfigObj""" + + c_out = weeutil.config.deep_copy(self.c_in) + self.assertIsInstance(c_out, configobj.ConfigObj) + self.assertEqual(c_out, self.c_in) + + # Make sure the parentage is correct + self.assertIs(c_out['Logging']['formatters'].parent, c_out['Logging']) + self.assertIs(c_out['Logging']['formatters'].parent.parent, c_out) + self.assertIs(c_out['Logging']['formatters'].main, c_out) + self.assertIsNot(c_out['Logging']['formatters'].main, self.c_in) + self.assertIs(c_out.main, c_out) + self.assertIsNot(c_out.main, self.c_in) + + # Try changing something and see if it's still equal: + c_out['Logging']['formatters']['verbose']['datefmt'] = 'foo' + self.assertNotEqual(c_out, self.c_in) + # The original ConfigObj entry should still be the same + self.assertEqual(self.c_in['Logging']['formatters']['verbose']['datefmt'], + '%Y-%m-%d %H:%M:%S') + + def test_deep_copy_Section(self): + """Test copying just a section""" + c_out = weeutil.config.deep_copy(self.c_in['Logging']['formatters']) + self.assertNotIsInstance(c_out, configobj.ConfigObj) + self.assertIsInstance(c_out, configobj.Section) + self.assertEqual(c_out, self.c_in['Logging']['formatters']) + + # Check parentage + self.assertIs(c_out.main, self.c_in) + self.assertIs(c_out.parent, self.c_in['Logging']) + self.assertIs(c_out['verbose'].parent, c_out) + self.assertIs(c_out['verbose'].parent.parent, self.c_in['Logging']) + + def test_deep_copy_write(self): + c_out = weeutil.config.deep_copy(self.c_in) + bio = BytesIO() + c_out.write(bio) + bio.seek(0) + out_str = bio.read().decode('utf-8') + self.assertEqual(out_str, TestConfig.test_dict_str) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weeutil/tests/test_sun.py b/dist/weewx-5.0.2/src/weeutil/tests/test_sun.py new file mode 100644 index 0000000..16a5155 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/tests/test_sun.py @@ -0,0 +1,32 @@ +# +# Copyright (c) 2018 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +import os +import time +import unittest + +from weeutil import Sun + + +class SunTest(unittest.TestCase): + + def test_sunRiseSet(self): + os.environ['TZ'] = 'Australia/Sydney' + time.tzset() + # Sydney, Australia + result = Sun.sunRiseSet(2012, 1, 1, 151.21, -33.86) + self.assertAlmostEqual(result[0], -5.223949864965772, 6) + self.assertAlmostEqual(result[1], 9.152208948206106, 6) + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + # Hood River, USA + result = Sun.sunRiseSet(2012, 1, 1, -121.566, 45.686) + self.assertAlmostEqual(result[0], 15.781521580780003, 6) + self.assertAlmostEqual(result[1], 24.528947667456983, 6) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weeutil/tests/test_weeutil.py b/dist/weewx-5.0.2/src/weeutil/tests/test_weeutil.py new file mode 100644 index 0000000..502ee0e --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/tests/test_weeutil.py @@ -0,0 +1,1078 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test routines for weeutil.weeutil.""" + +import unittest + +from weeutil.weeutil import * # @UnusedWildImport +from weewx.tags import TimespanBinder + +# Check for backwards compatiblity shim: +from weeutil.weeutil import accumulateLeaves, search_up + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + + +def timestamp_to_local(ts): + """Return a string in local time""" + return timestamp_to_string(ts, "%Y-%m-%d %H:%M:%S") + + +class WeeutilTest(unittest.TestCase): + + def test_convertToFloat(self): + + self.assertEqual(convertToFloat(['1.0', '2.0', 'None', 'none', '5.0', '6.0']), + [1.0, 2.0, None, None, 5.0, 6.0]) + self.assertIsNone(convertToFloat(None)) + + def test_rounder(self): + self.assertEqual(rounder(1.2345, 2), 1.23) + self.assertEqual(rounder(1.2345, 0), 1) + self.assertIsInstance(rounder(1.2345, 0), int) + self.assertEqual(rounder([1.2345, 6.73848, 4.2901], 2), [1.23, 6.74, 4.29]) + self.assertEqual(rounder(complex(1.2345, -2.1191), 2), complex(1.23, -2.12)) + self.assertEqual(rounder([complex(1.2345, -2.1191), complex(5.1921, 11.2092)], 2), + [complex(1.23, -2.12), complex(5.19, 11.21)]) + self.assertIsNone(rounder(None, 2)) + self.assertEqual(rounder(1.2345, None), 1.2345) + self.assertEqual(rounder(Polar(1.2345, 6.7890), 2), Polar(1.23, 6.79)) + self.assertEqual(rounder('abc', 2), 'abc') + + def test_option_as_list(self): + + self.assertEqual(option_as_list("abc"), ['abc']) + self.assertEqual(option_as_list(u"abc"), [u'abc']) + self.assertEqual(option_as_list(['a', 'b']), ['a', 'b']) + self.assertEqual(option_as_list(None), None) + self.assertEqual(option_as_list(''), ['']) + + def test_list_as_string(self): + self.assertEqual(list_as_string('a string'), "a string") + self.assertEqual(list_as_string(['a', 'string']), "a, string") + self.assertEqual(list_as_string('Reno, NV'), "Reno, NV") + + def test_stampgen(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Test the start of DST using a 30-minute increment: + start = time.mktime((2013, 3, 10, 0, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 3, 10, 6, 0, 0, 0, 0, -1)) + result = list(stampgen(start, stop, 1800)) + self.assertEqual(result, [1362902400, 1362904200, 1362906000, 1362907800, + 1362909600, 1362911400, 1362913200, 1362915000, + 1362916800, 1362918600, 1362920400]) + + # Test the ending of DST using a 30-minute increment: + start = time.mktime((2013, 11, 3, 0, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 11, 3, 6, 0, 0, 0, 0, -1)) + result = list(stampgen(start, stop, 1800)) + self.assertEqual(result, [1383462000, 1383463800, 1383465600, 1383467400, + 1383472800, 1383474600, 1383476400, 1383478200, + 1383480000, 1383481800, 1383483600, 1383485400, + 1383487200]) + + # Test the start of DST using a 3-hour increment + start = time.mktime((2013, 3, 9, 12, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 3, 10, 11, 0, 0, 0, 0, -1)) + result = list(stampgen(start, stop, 10800)) + self.assertEqual(result, [1362859200, 1362870000, 1362880800, 1362891600, + 1362902400, 1362909600, 1362920400, 1362931200]) + + # Test the end of DST using a 3-hour increment + start = time.mktime((2013, 11, 2, 12, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 11, 3, 12, 0, 0, 0, 0, -1)) + result = list(stampgen(start, stop, 10800)) + self.assertEqual(result, [1383418800, 1383429600, 1383440400, 1383451200, + 1383462000, 1383476400, 1383487200, 1383498000, 1383508800]) + + # Test for month increment + start = time.mktime((2013, 1, 1, 0, 0, 0, 0, 0, -1)) + stop = time.mktime((2014, 1, 1, 0, 0, 0, 0, 0, -1)) + result = list(stampgen(start, stop, 365.25 / 12 * 24 * 3600)) + self.assertEqual(result, [1357027200, 1359705600, 1362124800, 1364799600, 1367391600, + 1370070000, 1372662000, 1375340400, 1378018800, 1380610800, + 1383289200, 1385884800, 1388563200]) + + def test_nominal_spans(self): + + self.assertEqual(nominal_spans(1800), 1800) + self.assertEqual(nominal_spans('1800'), 1800) + self.assertEqual(nominal_spans('hour'), 3600) + self.assertEqual(nominal_spans('HOUR'), 3600) + self.assertEqual(nominal_spans('60M'), 3600) + self.assertEqual(nominal_spans('3h'), 3 * 3600) + self.assertEqual(nominal_spans('3d'), 3 * 3600 * 24) + self.assertEqual(nominal_spans('2w'), 14 * 3600 * 24) + self.assertEqual(nominal_spans('12m'), 365.25 * 24 * 3600) + self.assertEqual(nominal_spans('1y'), 365.25 * 24 * 3600) + self.assertIsNone(nominal_spans(None)) + with self.assertRaises(ValueError): + nominal_spans('foo') + with self.assertRaises(ValueError): + nominal_spans('1800.0') + + def test_intervalgen(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Test the start of DST using a 30-minute increment: + start = time.mktime((2013, 3, 10, 0, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 3, 10, 5, 0, 0, 0, 0, -1)) + result = list(intervalgen(start, stop, 1800)) + self.assertEqual(result, + list(map(lambda t: TimeSpan(t[0], t[1]), [(1362902400, 1362904200), + (1362904200, 1362906000), + (1362906000, 1362907800), + (1362907800, 1362909600), + (1362909600, 1362911400), + (1362911400, 1362913200), + (1362913200, 1362915000), + (1362915000, 1362916800)]))) + + # Test the ending of DST using a 30-minute increment: + start = time.mktime((2013, 11, 3, 0, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 11, 3, 6, 0, 0, 0, 0, -1)) + result = list(intervalgen(start, stop, 1800)) + self.assertEqual(result, + list(map(lambda t: TimeSpan(t[0], t[1]), [(1383462000, 1383463800), + (1383463800, 1383465600), + (1383465600, 1383467400), + (1383467400, 1383472800), + (1383472800, 1383474600), + (1383474600, 1383476400), + (1383476400, 1383478200), + (1383478200, 1383480000), + (1383480000, 1383481800), + (1383481800, 1383483600), + (1383483600, 1383485400), + (1383485400, 1383487200)]))) + + # Test the start of DST using a 3-hour increment: + start = time.mktime((2013, 3, 9, 12, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 3, 10, 11, 0, 0, 0, 0, -1)) + result = list(intervalgen(start, stop, 10800)) + self.assertEqual(result, + list(map(lambda t: TimeSpan(t[0], t[1]), [(1362859200, 1362870000), + (1362870000, 1362880800), + (1362880800, 1362891600), + (1362891600, 1362902400), + (1362902400, 1362909600), + (1362909600, 1362920400), + (1362920400, 1362931200), + (1362931200, 1362938400)]))) + + # Test the ending of DST using a 3-hour increment: + start = time.mktime((2013, 11, 2, 12, 0, 0, 0, 0, -1)) + stop = time.mktime((2013, 11, 3, 12, 0, 0, 0, 0, -1)) + result = list(intervalgen(start, stop, 10800)) + self.assertEqual(result, + list(map(lambda t: TimeSpan(t[0], t[1]), [(1383418800, 1383429600), + (1383429600, 1383440400), + (1383440400, 1383451200), + (1383451200, 1383462000), + (1383462000, 1383476400), + (1383476400, 1383487200), + (1383487200, 1383498000), + (1383498000, 1383508800)]))) + + # Test a monthly increment: + start = time.mktime((2013, 1, 1, 0, 0, 0, 0, 0, -1)) + stop = time.mktime((2014, 1, 1, 0, 0, 0, 0, 0, -1)) + result = list(intervalgen(start, stop, 365.25 / 12 * 24 * 3600)) + expected = list(map(lambda t: TimeSpan(t[0], t[1]), [(1357027200, 1359705600), + (1359705600, 1362124800), + (1362124800, 1364799600), + (1364799600, 1367391600), + (1367391600, 1370070000), + (1370070000, 1372662000), + (1372662000, 1375340400), + (1375340400, 1378018800), + (1378018800, 1380610800), + (1380610800, 1383289200), + (1383289200, 1385884800), + (1385884800, 1388563200)])) + self.assertEqual(result, expected) + + def test_archiveHoursAgoSpan(self): + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + self.assertEqual(str(archiveHoursAgoSpan(time_ts, hours_ago=0)), + "[2013-07-04 01:00:00 PDT (1372924800) -> " + "2013-07-04 02:00:00 PDT (1372928400)]") + self.assertEqual(str(archiveHoursAgoSpan(time_ts, hours_ago=2)), + "[2013-07-03 23:00:00 PDT (1372917600) -> " + "2013-07-04 00:00:00 PDT (1372921200)]") + time_ts = time.mktime(datetime.date(2013, 7, 4).timetuple()) + self.assertEqual(str(archiveHoursAgoSpan(time_ts, hours_ago=0)), + "[2013-07-03 23:00:00 PDT (1372917600) -> " + "2013-07-04 00:00:00 PDT (1372921200)]") + self.assertEqual(str(archiveHoursAgoSpan(time_ts, hours_ago=24)), + "[2013-07-02 23:00:00 PDT (1372831200) -> " + "2013-07-03 00:00:00 PDT (1372834800)]") + self.assertIsNone(archiveHoursAgoSpan(None, hours_ago=24)) + + def test_archiveSpanSpan(self): + """Test archiveSpanSpan() using Brisbane time""" + os.environ['TZ'] = 'Australia/Brisbane' + time.tzset() + time_ts = int(time.mktime(time.strptime("2015-07-21 09:05:35", "%Y-%m-%d %H:%M:%S"))) + self.assertEqual(time_ts, 1437433535) + self.assertEqual(archiveSpanSpan(time_ts, time_delta=3600), + TimeSpan(1437429935, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, hour_delta=6), TimeSpan(1437411935, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, day_delta=1), TimeSpan(1437347135, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, time_delta=3600, day_delta=1), + TimeSpan(1437343535, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, week_delta=4), TimeSpan(1435014335, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, month_delta=1), TimeSpan(1434841535, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, year_delta=1), TimeSpan(1405897535, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts), TimeSpan(1437433534, 1437433535)) + + # Test forcing to midnight boundary: + self.assertEqual(archiveSpanSpan(time_ts, hour_delta=6, boundary='midnight'), + TimeSpan(1437400800, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, day_delta=1, boundary='midnight'), + TimeSpan(1437314400, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, time_delta=3600, day_delta=1, + boundary='midnight'), + TimeSpan(1437314400, 1437433535)) + self.assertEqual(archiveSpanSpan(time_ts, week_delta=4, boundary='midnight'), + TimeSpan(1434981600, 1437433535)) + with self.assertRaises(ValueError): + archiveSpanSpan(time_ts, hour_delta=6, boundary='foo') + + # Test over a DST boundary. Because Brisbane does not observe DST, we need to + # switch timezones. + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + time_ts = time.mktime(time.strptime("2016-03-13 10:00:00", "%Y-%m-%d %H:%M:%S")) + self.assertEqual(time_ts, 1457888400) + span = archiveSpanSpan(time_ts, day_delta=1) + self.assertEqual(span, TimeSpan(1457805600, 1457888400)) + # Note that there is not 24 hours of time over this span: + self.assertEqual(span.stop - span.start, 23 * 3600) + self.assertIsNone(archiveSpanSpan(None, day_delta=1)) + + def test_isMidnight(self): + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + self.assertFalse(isMidnight(time.mktime(time.strptime("2013-07-04 01:57:35", + "%Y-%m-%d %H:%M:%S")))) + self.assertTrue(isMidnight(time.mktime(time.strptime("2013-07-04 00:00:00", + "%Y-%m-%d %H:%M:%S")))) + + def test_isStartOfDay(self): + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + self.assertFalse(isStartOfDay(time.mktime(time.strptime("2013-07-04 01:57:35", + "%Y-%m-%d %H:%M:%S")))) + self.assertTrue(isStartOfDay(time.mktime(time.strptime("2013-07-04 00:00:00", + "%Y-%m-%d %H:%M:%S")))) + + # Brazilian DST starts at midnight + os.environ['TZ'] = 'America/Sao_Paulo' + time.tzset() + # This time is the start of DST and considered the start of the day: 4-11-2018 0100 + self.assertTrue(isStartOfDay(1541300400)) + self.assertFalse(isStartOfDay(1541300400 - 10)) + + def test_startOfInterval(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + t_length = 1 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 57, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 5 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 55, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 1 * 60 + t_test = time.mktime((2009, 3, 4, 1, 0, 0, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 0, 59, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 5 * 60 + t_test = time.mktime((2009, 3, 4, 1, 0, 0, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 0, 55, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 10 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 50, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 15 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 45, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 20 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 40, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 30 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 30, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 60 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 0, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + t_length = 120 * 60 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 0, 0, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Do a test over the spring DST boundary + # This is 03:22:05 DST, just after the change over. + # The correct answer is 03:00:00 DST. + t_length = 120 * 60 + t_test = time.mktime((2009, 3, 8, 3, 22, 5, 0, 0, 1)) + t_ans = int(time.mktime((2009, 3, 8, 3, 0, 0, 0, 0, 1))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Do a test over the spring DST boundary, but this time + # on an archive interval boundary, 01:00:00 ST, the + # instant of the change over. + # Correct answer is 00:59:00 ST. + t_length = 60 + t_test = time.mktime((2009, 3, 8, 1, 0, 0, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 8, 0, 59, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Do a test over the fall DST boundary. + # This is 01:22:05 DST, just before the change over. + # The correct answer is 01:00:00 DST. + t_length = 120 * 60 + t_test = time.mktime((2009, 11, 1, 1, 22, 5, 0, 0, 1)) + t_ans = int(time.mktime((2009, 11, 1, 1, 0, 0, 0, 0, 1))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Do it again, except after the change over + # This is 01:22:05 ST, just after the change over. + # The correct answer is 00:00:00 ST (which is 01:00:00 DST). + t_length = 120 * 60 + t_test = time.mktime((2009, 11, 1, 1, 22, 5, 0, 0, 0)) + t_ans = int(time.mktime((2009, 11, 1, 0, 0, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Once again at 01:22:05 ST, just before the change over, but w/shorter interval + t_length = 5 * 60 + t_test = time.mktime((2009, 11, 1, 1, 22, 5, 0, 0, 1)) + t_ans = int(time.mktime((2009, 11, 1, 1, 20, 0, 0, 0, 1))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Once again at 01:22:05 ST, just after the change over, but w/shorter interval + t_length = 5 * 60 + t_test = time.mktime((2009, 11, 1, 1, 22, 5, 0, 0, 0)) + t_ans = int(time.mktime((2009, 11, 1, 1, 20, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Once again at 01:22:05 ST, just after the change over, but with 1 hour interval + t_length = 60 * 60 + t_test = time.mktime((2009, 11, 1, 1, 22, 5, 0, 0, 0)) + t_ans = int(time.mktime((2009, 11, 1, 1, 0, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Once again, but an archive interval boundary + # This is 01:00:00 DST, the instant of the changeover + # The correct answer is 00:59:00 DST. + t_length = 1 * 60 + t_test = time.mktime((2009, 11, 1, 1, 0, 0, 0, 0, 1)) + t_ans = int(time.mktime((2009, 11, 1, 0, 59, 0, 0, 0, 1))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + # Oddball archive interval + t_length = 480 + t_test = time.mktime((2009, 3, 4, 1, 57, 17, 0, 0, 0)) + t_ans = int(time.mktime((2009, 3, 4, 1, 52, 0, 0, 0, 0))) + t_start = startOfInterval(t_test, t_length) + self.assertEqual(t_start, t_ans) + + def test_TimeSpans(self): + + t = TimeSpan(1230000000, 1231000000) + # Reflexive test: + self.assertEqual(t, t) + tsub = TimeSpan(1230500000, 1230600000) + self.assertTrue(t.includes(tsub)) + self.assertFalse(tsub.includes(t)) + tleft = TimeSpan(1229000000, 1229100000) + self.assertFalse(t.includes(tleft)) + tright = TimeSpan(1232000000, 1233000000) + self.assertFalse(t.includes(tright)) + + # Test dictionary lookups. This will test hash and equality. + dic = {t: 't', tsub: 'tsub', tleft: 'tleft', tright: 'tright'} + self.assertEqual(dic[t], 't') + + self.assertTrue(t.includesArchiveTime(1230000001)) + self.assertFalse(t.includesArchiveTime(1230000000)) + + self.assertEqual(t.length, 1231000000 - 1230000000) + + with self.assertRaises(ValueError): + _ = TimeSpan(1231000000, 1230000000) + + def test_genYearSpans(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Should generate years 2007 through 2008:" + start_ts = time.mktime((2007, 12, 3, 10, 15, 0, 0, 0, -1)) + stop_ts = time.mktime((2008, 3, 1, 0, 0, 0, 0, 0, -1)) + + yearlist = [span for span in genYearSpans(start_ts, stop_ts)] + + expected = [ + "[2007-01-01 00:00:00 PST (1167638400) -> 2008-01-01 00:00:00 PST (1199174400)]", + "[2008-01-01 00:00:00 PST (1199174400) -> 2009-01-01 00:00:00 PST (1230796800)]"] + + for got, expect in zip(yearlist, expected): + self.assertEqual(str(got), expect) + + def test_genMonthSpans(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Should generate months 2007-12 through 2008-02: + start_ts = time.mktime((2007, 12, 3, 10, 15, 0, 0, 0, -1)) + stop_ts = time.mktime((2008, 3, 1, 0, 0, 0, 0, 0, -1)) + + monthlist = [span for span in genMonthSpans(start_ts, stop_ts)] + + expected = [ + "[2007-12-01 00:00:00 PST (1196496000) -> 2008-01-01 00:00:00 PST (1199174400)]", + "[2008-01-01 00:00:00 PST (1199174400) -> 2008-02-01 00:00:00 PST (1201852800)]", + "[2008-02-01 00:00:00 PST (1201852800) -> 2008-03-01 00:00:00 PST (1204358400)]"] + + for got, expect in zip(monthlist, expected): + self.assertEqual(str(got), expect) + + # Add a second to the stop time. This should generate months 2007-12 through 2008-03:" + start_ts = time.mktime((2007, 12, 3, 10, 15, 0, 0, 0, -1)) + stop_ts = time.mktime((2008, 3, 1, 0, 0, 1, 0, 0, -1)) + + monthlist = [span for span in genMonthSpans(start_ts, stop_ts)] + + expected = [ + "[2007-12-01 00:00:00 PST (1196496000) -> 2008-01-01 00:00:00 PST (1199174400)]", + "[2008-01-01 00:00:00 PST (1199174400) -> 2008-02-01 00:00:00 PST (1201852800)]", + "[2008-02-01 00:00:00 PST (1201852800) -> 2008-03-01 00:00:00 PST (1204358400)]", + "[2008-03-01 00:00:00 PST (1204358400) -> 2008-04-01 00:00:00 PDT (1207033200)]"] + + for got, expect in zip(monthlist, expected): + self.assertEqual(str(got), expect) + + def test_genDaySpans(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Should generate 2007-12-23 through 2008-1-5:" + start_ts = time.mktime((2007, 12, 23, 10, 15, 0, 0, 0, -1)) + stop_ts = time.mktime((2008, 1, 5, 9, 22, 0, 0, 0, -1)) + + daylist = [span for span in genDaySpans(start_ts, stop_ts)] + + expected = [ + "[2007-12-23 00:00:00 PST (1198396800) -> 2007-12-24 00:00:00 PST (1198483200)]", + "[2007-12-24 00:00:00 PST (1198483200) -> 2007-12-25 00:00:00 PST (1198569600)]", + "[2007-12-25 00:00:00 PST (1198569600) -> 2007-12-26 00:00:00 PST (1198656000)]", + "[2007-12-26 00:00:00 PST (1198656000) -> 2007-12-27 00:00:00 PST (1198742400)]", + "[2007-12-27 00:00:00 PST (1198742400) -> 2007-12-28 00:00:00 PST (1198828800)]", + "[2007-12-28 00:00:00 PST (1198828800) -> 2007-12-29 00:00:00 PST (1198915200)]", + "[2007-12-29 00:00:00 PST (1198915200) -> 2007-12-30 00:00:00 PST (1199001600)]", + "[2007-12-30 00:00:00 PST (1199001600) -> 2007-12-31 00:00:00 PST (1199088000)]", + "[2007-12-31 00:00:00 PST (1199088000) -> 2008-01-01 00:00:00 PST (1199174400)]", + "[2008-01-01 00:00:00 PST (1199174400) -> 2008-01-02 00:00:00 PST (1199260800)]", + "[2008-01-02 00:00:00 PST (1199260800) -> 2008-01-03 00:00:00 PST (1199347200)]", + "[2008-01-03 00:00:00 PST (1199347200) -> 2008-01-04 00:00:00 PST (1199433600)]", + "[2008-01-04 00:00:00 PST (1199433600) -> 2008-01-05 00:00:00 PST (1199520000)]", + "[2008-01-05 00:00:00 PST (1199520000) -> 2008-01-06 00:00:00 PST (1199606400)]"] + + for got, expect in zip(daylist, expected): + self.assertEqual(str(got), expect) + + # Should generate the single date 2007-12-1:" + daylist = [span for span in genDaySpans(time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 2, 0, 0, 0, 0, 0, -1)))] + + expected = [ + "[2007-12-01 00:00:00 PST (1196496000) -> 2007-12-02 00:00:00 PST (1196582400)]"] + for got, expect in zip(daylist, expected): + self.assertEqual(str(got), expect) + + def test_genHourSpans(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Should generate throught 2007-12-23 20:00:00 throught 2007-12-24 4:00:00 + start_ts = time.mktime((2007, 12, 23, 20, 15, 0, 0, 0, -1)) + stop_ts = time.mktime((2007, 12, 24, 3, 45, 0, 0, 0, -1)) + + hourlist = [span for span in genHourSpans(start_ts, stop_ts)] + + expected = [ + "[2007-12-23 20:00:00 PST (1198468800) -> 2007-12-23 21:00:00 PST (1198472400)]", + "[2007-12-23 21:00:00 PST (1198472400) -> 2007-12-23 22:00:00 PST (1198476000)]", + "[2007-12-23 22:00:00 PST (1198476000) -> 2007-12-23 23:00:00 PST (1198479600)]", + "[2007-12-23 23:00:00 PST (1198479600) -> 2007-12-24 00:00:00 PST (1198483200)]", + "[2007-12-24 00:00:00 PST (1198483200) -> 2007-12-24 01:00:00 PST (1198486800)]", + "[2007-12-24 01:00:00 PST (1198486800) -> 2007-12-24 02:00:00 PST (1198490400)]", + "[2007-12-24 02:00:00 PST (1198490400) -> 2007-12-24 03:00:00 PST (1198494000)]", + "[2007-12-24 03:00:00 PST (1198494000) -> 2007-12-24 04:00:00 PST (1198497600)]", ] + + for got, expect in zip(hourlist, expected): + self.assertEqual(str(got), expect) + + # Should generate the single hour 2007-12-1 03:00:00 + hourlist = [span for span in genHourSpans(time.mktime((2007, 12, 1, 3, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 1, 4, 0, 0, 0, 0, -1)))] + + expected = [ + "[2007-12-01 03:00:00 PST (1196506800) -> 2007-12-01 04:00:00 PST (1196510400)]"] + + for got, expect in zip(hourlist, expected): + self.assertEqual(str(got), expect) + + def test_daySpan(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # 2007-12-13 10:15:00 + self.assertEqual(daySpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 14, 0, 0, 0, 0, 0, -1)))) + # 2007-12-13 00:00:00 + self.assertEqual(daySpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 14, 0, 0, 0, 0, 0, -1)))) + # 2007-12-13 00:00:01 + self.assertEqual(daySpan(time.mktime((2007, 12, 13, 0, 0, 1, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 14, 0, 0, 0, 0, 0, -1)))) + + self.assertIsNone(daySpan(None)) + + def test_archiveDaySpan(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # 2007-12-13 10:15:00 + self.assertEqual(archiveDaySpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 14, 0, 0, 0, 0, 0, -1)))) + # 2007-12-13 00:00:00 + self.assertEqual(archiveDaySpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 12, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1)))) + # 2007-12-13 00:00:01 + self.assertEqual(archiveDaySpan(time.mktime((2007, 12, 13, 0, 0, 1, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 14, 0, 0, 0, 0, 0, -1)))) + + self.assertIsNone(archiveDaySpan(None)) + + def test_archiveWeekSpan(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Week around 2007-12-13 10:15:00 (Thursday 10:15) + self.assertEqual(archiveWeekSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 9, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 16, 0, 0, 0, 0, 0, -1)))) + + # Week around 2007-12-13 00:00:00 (midnight Thursday) + self.assertEqual(archiveWeekSpan(time.mktime((2007, 12, 13, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 9, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 16, 0, 0, 0, 0, 0, -1)))) + + # Week around 2007-12-9 00:00:00 (midnight Sunday) + self.assertEqual(archiveWeekSpan(time.mktime((2007, 12, 9, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 2, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 9, 0, 0, 0, 0, 0, -1)))) + + # Week around 2007-12-9 00:00:01 (one second after midnight on Sunday) + self.assertEqual(archiveWeekSpan(time.mktime((2007, 12, 9, 0, 0, 1, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 9, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 16, 0, 0, 0, 0, 0, -1)))) + + # Week around 2007-12-13 10:15:00 (Thursday 10:15) where the week starts on Monday + self.assertEqual(archiveWeekSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1)), + startOfWeek=0), + TimeSpan(time.mktime((2007, 12, 10, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 17, 0, 0, 0, 0, 0, -1)))) + + # Previous week around 2007-12-13 10:15:00 (Thursday 10:15) where the week starts on Monday + self.assertEqual(archiveWeekSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1)), + startOfWeek=0, + weeks_ago=1), + TimeSpan(time.mktime((2007, 12, 3, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 10, 0, 0, 0, 0, 0, -1)))) + + self.assertIsNone(archiveWeekSpan(None)) + + def test_archiveMonthSpan(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # 2007-12-13 10:15:00 + self.assertEqual(archiveMonthSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + # 2007-12-01 00:00:00 + self.assertEqual(archiveMonthSpan(time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 11, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)))) + # 2007-12-01 00:00:01 + self.assertEqual(archiveMonthSpan(time.mktime((2007, 12, 1, 0, 0, 1, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + # 2008-01-01 00:00:00 + self.assertEqual(archiveMonthSpan(time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + + # One month ago from 2008-01-01 00:00:00 + self.assertEqual(archiveMonthSpan(time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)), + months_ago=1), + TimeSpan(time.mktime((2007, 11, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)))) + + # One month ago from 2008-01-01 00:00:01 + self.assertEqual(archiveMonthSpan(time.mktime((2008, 1, 1, 0, 0, 1, 0, 0, -1)), + months_ago=1), + TimeSpan(time.mktime((2007, 12, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + + self.assertIsNone(archiveMonthSpan(None)) + + def test_archiveYearSpan(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + self.assertEqual(archiveYearSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 1, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(archiveYearSpan(time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1))), + TimeSpan(time.mktime((2007, 1, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(archiveYearSpan(time.mktime((2008, 1, 1, 0, 0, 1, 0, 0, -1))), + TimeSpan(time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2009, 1, 1, 0, 0, 0, 0, 0, -1)))) + + self.assertIsNone(archiveYearSpan(None)) + + def test_archiveRainYearSpan(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Rain year starts 1-Jan + self.assertEqual(archiveRainYearSpan(time.mktime((2007, 2, 13, 10, 15, 0, 0, 0, -1)), 1), + TimeSpan(time.mktime((2007, 1, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(archiveRainYearSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1)), 1), + TimeSpan(time.mktime((2007, 1, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 1, 1, 0, 0, 0, 0, 0, -1)))) + # Rain year starts 1-Oct + self.assertEqual(archiveRainYearSpan(time.mktime((2007, 2, 13, 10, 15, 0, 0, 0, -1)), 10), + TimeSpan(time.mktime((2006, 10, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 10, 1, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(archiveRainYearSpan(time.mktime((2007, 2, 13, 10, 15, 0, 0, 0, -1)), 10, + years_ago=1), + TimeSpan(time.mktime((2005, 10, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2006, 10, 1, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(archiveRainYearSpan(time.mktime((2007, 12, 13, 10, 15, 0, 0, 0, -1)), 10), + TimeSpan(time.mktime((2007, 10, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2008, 10, 1, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(archiveRainYearSpan(time.mktime((2007, 10, 1, 0, 0, 0, 0, 0, -1)), 10), + TimeSpan(time.mktime((2006, 10, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2007, 10, 1, 0, 0, 0, 0, 0, -1)))) + + self.assertIsNone(archiveRainYearSpan(None, 1)) + + def test_DST(self): + + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + + # Test start-of-day routines around a DST boundary: + start_ts = time.mktime((2007, 3, 11, 1, 0, 0, 0, 0, -1)) + start_of_day = startOfDay(start_ts) + start2 = startOfArchiveDay(start_of_day) + + # Check that this is, in fact, a DST boundary: + self.assertEqual(start_of_day, int(time.mktime((2007, 3, 11, 0, 0, 0, 0, 0, -1)))) + self.assertEqual(start2, int(time.mktime((2007, 3, 10, 0, 0, 0, 0, 0, -1)))) + + def test_start_of_archive_day(self): + """Test the function startOfArchiveDay()""" + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + # Exactly midnight 1-July-2022: + start_dt = datetime.datetime(2022, 7, 1) + start_ts = time.mktime(start_dt.timetuple()) + self.assertEqual(startOfArchiveDay(start_ts), 1656572400.0) + # Now try it at a smidge after midnight. Should be the next day + self.assertEqual(startOfArchiveDay(start_ts + 0.1), 1656658800.0) + + def test_dnt(self): + """test day/night transitions""" + + times = [(calendar.timegm((2012, 1, 2, 0, 0, 0, 0, 0, -1)), + calendar.timegm((2012, 1, 3, 0, 0, 0, 0, 0, -1))), + (calendar.timegm((2012, 1, 2, 22, 0, 0, 0, 0, -1)), + calendar.timegm((2012, 1, 3, 22, 0, 0, 0, 0, -1)))] + locs = [(-33.86, 151.21, 'sydney', 'Australia/Sydney'), # UTC+10:00 + (35.6895, 139.6917, 'seoul', 'Asia/Seoul'), # UTC+09:00 + (-33.93, 18.42, 'cape town', 'Africa/Johannesburg'), # UTC+02:00 + (51.4791, 0, 'greenwich', 'Europe/London'), # UTC 00:00 + (42.358, -71.060, 'boston', 'America/New_York'), # UTC-05:00 + (21.3, -157.8167, 'honolulu', 'Pacific/Honolulu'), # UTC-10:00 + ] + expected = [ + ( + ('lat: -33.86 lon: 151.21 sydney first: day', + '2012-01-02 00:00:00 UTC (1325462400) 2012-01-02 11:00:00 (1325462400)', + '2012-01-02 09:09:22 UTC (1325495362) 2012-01-02 20:09:22 (1325495362)', + '2012-01-02 18:48:02 UTC (1325530082) 2012-01-03 05:48:02 (1325530082)', + '2012-01-03 00:00:00 UTC (1325548800) 2012-01-03 11:00:00 (1325548800)' + ), + ('lat: 35.6895 lon: 139.6917 seoul first: day', + '2012-01-02 00:00:00 UTC (1325462400) 2012-01-02 09:00:00 (1325462400)', + '2012-01-02 07:38:01 UTC (1325489881) 2012-01-02 16:38:01 (1325489881)', + '2012-01-02 21:50:59 UTC (1325541059) 2012-01-03 06:50:59 (1325541059)', + '2012-01-03 00:00:00 UTC (1325548800) 2012-01-03 09:00:00 (1325548800)' + ), + ('lat: -33.93 lon: 18.42 cape town first: night', + '2012-01-02 00:00:00 UTC (1325462400) 2012-01-02 02:00:00 (1325462400)', + '2012-01-02 03:38:32 UTC (1325475512) 2012-01-02 05:38:32 (1325475512)', + '2012-01-02 18:00:47 UTC (1325527247) 2012-01-02 20:00:47 (1325527247)', + '2012-01-03 00:00:00 UTC (1325548800) 2012-01-03 02:00:00 (1325548800)' + ), + ('lat: 51.4791 lon: 0 greenwich first: night', + '2012-01-02 00:00:00 UTC (1325462400) 2012-01-02 00:00:00 (1325462400)', + '2012-01-02 08:05:24 UTC (1325491524) 2012-01-02 08:05:24 (1325491524)', + '2012-01-02 16:01:20 UTC (1325520080) 2012-01-02 16:01:20 (1325520080)', + '2012-01-03 00:00:00 UTC (1325548800) 2012-01-03 00:00:00 (1325548800)' + ), + ('lat: 42.358 lon: -71.06 boston first: night', + '2012-01-02 00:00:00 UTC (1325462400) 2012-01-01 19:00:00 (1325462400)', + '2012-01-02 12:13:21 UTC (1325506401) 2012-01-02 07:13:21 (1325506401)', + '2012-01-02 21:22:02 UTC (1325539322) 2012-01-02 16:22:02 (1325539322)', + '2012-01-03 00:00:00 UTC (1325548800) 2012-01-02 19:00:00 (1325548800)' + ), + ('lat: 21.3 lon: -157.8167 honolulu first: day', + '2012-01-02 00:00:00 UTC (1325462400) 2012-01-01 14:00:00 (1325462400)', + '2012-01-02 04:00:11 UTC (1325476811) 2012-01-01 18:00:11 (1325476811)', + '2012-01-02 17:08:52 UTC (1325524132) 2012-01-02 07:08:52 (1325524132)', + '2012-01-03 00:00:00 UTC (1325548800) 2012-01-02 14:00:00 (1325548800)' + )), + ( + ('lat: -33.86 lon: 151.21 sydney first: day', + '2012-01-02 22:00:00 UTC (1325541600) 2012-01-03 09:00:00 (1325541600)', + '2012-01-03 09:09:34 UTC (1325581774) 2012-01-03 20:09:34 (1325581774)', + '2012-01-03 18:48:48 UTC (1325616528) 2012-01-04 05:48:48 (1325616528)', + '2012-01-03 22:00:00 UTC (1325628000) 2012-01-04 09:00:00 (1325628000)' + ), + ('lat: 35.6895 lon: 139.6917 seoul first: day', + '2012-01-02 22:00:00 UTC (1325541600) 2012-01-03 07:00:00 (1325541600)', + '2012-01-03 07:38:47 UTC (1325576327) 2012-01-03 16:38:47 (1325576327)', + '2012-01-03 21:51:09 UTC (1325627469) 2012-01-04 06:51:09 (1325627469)', + '2012-01-03 22:00:00 UTC (1325628000) 2012-01-04 07:00:00 (1325628000)' + ), + ('lat: -33.93 lon: 18.42 cape town first: night', + '2012-01-02 22:00:00 UTC (1325541600) 2012-01-03 00:00:00 (1325541600)', + '2012-01-03 03:39:17 UTC (1325561957) 2012-01-03 05:39:17 (1325561957)', + '2012-01-03 18:00:58 UTC (1325613658) 2012-01-03 20:00:58 (1325613658)', + '2012-01-03 22:00:00 UTC (1325628000) 2012-01-04 00:00:00 (1325628000)' + ), + ('lat: 51.4791 lon: 0 greenwich first: night', + '2012-01-02 22:00:00 UTC (1325541600) 2012-01-02 22:00:00 (1325541600)', + '2012-01-03 08:05:17 UTC (1325577917) 2012-01-03 08:05:17 (1325577917)', + '2012-01-03 16:02:23 UTC (1325606543) 2012-01-03 16:02:23 (1325606543)', + '2012-01-03 22:00:00 UTC (1325628000) 2012-01-03 22:00:00 (1325628000)' + ), + ('lat: 42.358 lon: -71.06 boston first: night', + '2012-01-02 22:00:00 UTC (1325541600) 2012-01-02 17:00:00 (1325541600)', + '2012-01-03 12:13:26 UTC (1325592806) 2012-01-03 07:13:26 (1325592806)', + '2012-01-03 21:22:54 UTC (1325625774) 2012-01-03 16:22:54 (1325625774)', + '2012-01-03 22:00:00 UTC (1325628000) 2012-01-03 17:00:00 (1325628000)' + ), + ('lat: 21.3 lon: -157.8167 honolulu first: day', + '2012-01-02 22:00:00 UTC (1325541600) 2012-01-02 12:00:00 (1325541600)', + '2012-01-03 04:00:48 UTC (1325563248) 2012-01-02 18:00:48 (1325563248)', + '2012-01-03 17:09:11 UTC (1325610551) 2012-01-03 07:09:11 (1325610551)', + '2012-01-03 22:00:00 UTC (1325628000) 2012-01-03 12:00:00 (1325628000)' + ) + ) + ] + + self.assertEqual(times, [(1325462400, 1325548800), (1325541600, 1325628000)]) + + for i, t in enumerate(times): + for j, l in enumerate(locs): + os.environ['TZ'] = l[3] + time.tzset() + first, values = getDayNightTransitions(t[0], t[1], l[0], l[1]) + + self.assertEqual("lat: %s lon: %s %s first: %s" % (l[0], l[1], l[2], first), + expected[i][j][0], + msg="times=%s; location=%s" % (t, l)) + self.assertEqual("%s %s" % (timestamp_to_gmtime(t[0]), timestamp_to_local(t[0])), + expected[i][j][1], + msg="times=%s; location=%s" % (t, l)) + self.assertEqual("%s %s" % (timestamp_to_gmtime(values[0]), + timestamp_to_local(values[0])), + expected[i][j][2], + msg="times=%s; location=%s" % (t, l)) + self.assertEqual("%s %s" % (timestamp_to_gmtime(values[1]), + timestamp_to_local(values[1])), + expected[i][j][3], + msg="times=%s; location=%s" % (t, l)) + self.assertEqual("%s %s" % (timestamp_to_gmtime(t[1]), timestamp_to_local(t[1])), + expected[i][j][4], + msg="times=%s; location=%s" % (t, l)) + + def test_utc_conversions(self): + self.assertEqual(utc_to_ts(2009, 3, 27, 14.5), 1238164200.5) + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + tt = utc_to_local_tt(2009, 3, 27, 14.5) + self.assertEqual(tt[0:5], (2009, 3, 27, 7, 30)) + + def test_genWithPeek(self): + # Define a generator function: + def genfunc(N): + for i in range(N): + yield i + + # Now wrap it with the GenWithPeek object: + g_with_peek = GenWithPeek(genfunc(5)) + + # Define a generator function to test it + def tester(g): + for i in g: + yield str(i) + # Every second object, let's take a peek ahead + if i % 2: + # We can get a peek at the next object without disturbing the wrapped generator + yield "peek: %d" % g.peek() + + seq = [x for x in tester(g_with_peek)] + self.assertEqual(seq, ["0", "1", "peek: 2", "2", "3", "peek: 4", "4"]) + + def test_GenByBatch(self): + # Define a generator function: + def genfunc(N): + for i in range(N): + yield i + + # Now wrap it with the GenByBatch object. First fetch everything in one batch: + seq = [x for x in GenByBatch(genfunc(10), 0)] + self.assertEqual(seq, list(range(10))) + # Now try it again, fetching in batches of 2: + seq = [x for x in GenByBatch(genfunc(10), 2)] + self.assertEqual(seq, list(range(10))) + # Oddball batch size + seq = [x for x in GenByBatch(genfunc(10), 3)] + self.assertEqual(seq, list(range(10))) + + def test_to_bool(self): + + self.assertTrue(to_bool('TRUE')) + self.assertTrue(to_bool('true')) + self.assertTrue(to_bool(1)) + self.assertFalse(to_bool('FALSE')) + self.assertFalse(to_bool('false')) + self.assertFalse(to_bool(0)) + with self.assertRaises(ValueError): + to_bool(None) + with self.assertRaises(ValueError): + to_bool('foo') + + def test_to_int(self): + self.assertEqual(to_int(123), 123) + self.assertEqual(to_int('123'), 123) + self.assertEqual(to_int(u'123'), 123) + self.assertEqual(to_int('-5'), -5) + self.assertEqual(to_int('-5.2'), -5) + self.assertIsNone(to_int(None)) + self.assertIsNone(to_int('NONE')) + self.assertIsNone(to_int(u'NONE')) + + def test_to_float(self): + self.assertIsInstance(to_float(123), float) + self.assertIsInstance(to_float(123.0), float) + self.assertIsInstance(to_float('123'), float) + self.assertEqual(to_float(123), 123.0) + self.assertEqual(to_float('123'), 123.0) + self.assertEqual(to_float(u'123'), 123.0) + self.assertIsNone(to_float(None)) + self.assertIsNone(to_float('NONE')) + self.assertIsNone(to_float(u'NONE')) + + def test_to_complex(self): + self.assertAlmostEqual(to_complex(1.0, 0.0), complex(0.0, 1.0), 6) + self.assertAlmostEqual(to_complex(1.0, 90), complex(1.0, 0.0), 6) + self.assertIsNone(to_complex(None, 90.0)) + self.assertEqual(to_complex(0.0, 90.0), complex(0.0, 0.0)) + self.assertIsNone(to_complex(1.0, None)) + + def test_Polar(self): + p = Polar(1.0, 90.0) + self.assertEqual(p.mag, 1.0) + self.assertEqual(p.dir, 90.0) + p = Polar.from_complex(complex(1.0, 0.0)) + self.assertEqual(p.mag, 1.0) + self.assertEqual(p.dir, 90.0) + self.assertEqual(str(p), '(1.0, 90.0)') + + # def test_to_unicode(self): + # + # # To get a utf-8 byte string that we can convert, start with a unicode + # # string, then encode it. + # unicode_string = u"degree sign: °" + # byte_string = unicode_string.encode('utf-8') + # # Now use the byte string to test: + # self.assertEqual(to_unicode(byte_string), unicode_string) + # # Identity test + # self.assertEqual(unicode_string, unicode_string) + # self.assertIsNone(to_unicode(None)) + + def test_min_with_none(self): + + self.assertEqual(min_with_none([1, 2, None, 4]), 1) + + def test_max_with_none(self): + + self.assertEqual(max_with_none([1, 2, None, 4]), 4) + self.assertEqual(max_with_none([-1, -2, None, -4]), -1) + + def test_ListOfDicts(self): + # Try an empty dictionary: + lod = ListOfDicts() + self.assertEqual(lod.get('b'), None) + # Now initialize with a starting dictionary, using an overlap: + lod = ListOfDicts({'a': 1, 'b': 2, 'c': 3, 'd': 5}, {'d': 4, 'e': 5, 'f': 6}) + # Look up some keys known to be in there: + self.assertEqual(lod['b'], 2) + self.assertEqual(lod['e'], 5) + self.assertEqual(lod['d'], 5) + # Look for a non-existent key + self.assertEqual(lod.get('g'), None) + # Now extend the dictionary some more: + lod.extend({'g': 7, 'h': 8}) + # And try the lookup: + self.assertEqual(lod['g'], 7) + # Explicitly add a new key to the dictionary: + lod['i'] = 9 + # Try it: + self.assertEqual(lod['i'], 9) + + # Now check .keys() + lod2 = ListOfDicts({k: str(k) for k in range(5)}, + {k: str(k) for k in range(5, 10)}) + self.assertEqual(set(lod2.keys()), set(range(10))) + self.assertIn(3, lod2.keys()) + self.assertIn(6, lod2.keys()) + self.assertNotIn(11, lod2.keys()) + s = set(lod2.keys()) + self.assertIn(3, s) + self.assertIn(6, s) + self.assertNotIn(11, s) + + # ... and check .values() + self.assertEqual(set(lod2.values()), set(str(i) for i in range(10))) + self.assertIn('3', lod2.values()) + self.assertIn('6', lod2.values()) + self.assertNotIn('11', lod2.values()) + s = set(lod2.values()) + self.assertIn('3', s) + self.assertIn('6', s) + self.assertNotIn('11', s) + + def test_KeyDict(self): + a_dict = {'a': 1, 'b': 2} + kd = KeyDict(a_dict) + self.assertEqual(kd['a'], 1) + self.assertEqual(kd['bad_key'], 'bad_key') + + def test_is_iterable(self): + self.assertFalse(is_iterable('abc')) + self.assertTrue(is_iterable([1, 2, 3])) + i = iter([1, 2, 3]) + self.assertTrue(is_iterable(i)) + + # def test_secs_to_string(self): + # self.assertEqual(secs_to_string(86400 + 3600 + 312), '1 day, 1 hour, 5 minutes') + + def test_latlon_string(self): + self.assertEqual(latlon_string(-12.3, ('N', 'S'), 'lat'), ('12', '18.00', 'S')) + self.assertEqual(latlon_string(-123.3, ('E', 'W'), 'long'), ('123', '18.00', 'W')) + + def test_timespanbinder_length(self): + t = ((1667689200, 1667775600, 'day', 86400), + (1667257200, 1669849200, 'month', 86400*30), + (1640991600, 1672527600, 'year', 31536000)) + for i in t: + ts = TimeSpan(i[0], i[1]) + tsb = TimespanBinder(ts, None, context=i[2]) + self.assertEqual(tsb.length.raw, i[3]) + + def test_version_compare(self): + from weeutil.weeutil import version_compare + self.assertEqual(version_compare('1.2.3', '1.2.2'), 1) + self.assertEqual(version_compare('1.2.3', '1.2.3'), 0) + self.assertEqual(version_compare('1.2.2', '1.2.3'), -1) + self.assertEqual(version_compare('1.3', '1.2.2'), 1) + self.assertEqual(version_compare('1.3.0a1', '1.3.0a2'), -1) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weeutil/weeutil.py b/dist/weewx-5.0.2/src/weeutil/weeutil.py new file mode 100644 index 0000000..ba2fcf9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weeutil/weeutil.py @@ -0,0 +1,1885 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Various handy utilities that don't belong anywhere else. + + NB: To run the doctests, this code must be run as a module. For example: + cd ~/git/weewx/src + python -m weeutil.weeutil +""" + +import calendar +import cmath +import datetime +import importlib +import math +import os +import re +import shutil +import time +from collections import ChainMap + +# importlib.resources is 3.7 or later, importlib_resources is the backport +try: + import importlib.resources as importlib_resources +except: + import importlib_resources + +# For backwards compatibility: +from weeutil.config import accumulateLeaves, search_up + + +def convertToFloat(seq): + """Convert a sequence with strings to floats, honoring 'Nones' + + Args: + seq(None|list[str]): A sequence of strings representing floats, possibly with a 'none' + in there + + Returns: + list[float]: All strings will have been converted to floats. + + Example: + >>> print(convertToFloat(['1.2', '-2.5', 'none', '8.5'])) + [1.2, -2.5, None, 8.5] + """ + + if seq is None: + return None + res = [None if s in ('None', 'none') else float(s) for s in seq] + return res + + +def option_as_list(option): + if option is None: + return None + return [option] if not isinstance(option, list) else option + + +to_list = option_as_list + + +def list_as_string(option): + """Returns the argument as a string. + + Useful for insuring that ConfigObj options are always returned + as a string, despite the presence of a comma in the middle. + + Example: + >>> print(list_as_string('a string')) + a string + >>> print(list_as_string(['a', 'string'])) + a, string + >>> print(list_as_string('Reno, NV')) + Reno, NV + """ + # Check if it's already a string. + if option is not None and not isinstance(option, str): + return ', '.join(option) + return option + + +def startOfInterval(time_ts, interval): + """Find the start time of an interval. + + This algorithm assumes unit epoch time is divided up into + intervals of 'interval' length. Given a timestamp, it + figures out which interval it lies in, returning the start + time. + + Args: + + time_ts (float): A timestamp. The start of the interval containing this + timestamp will be returned. + interval (int): An interval length in seconds. + + Returns: + int: A timestamp with the start of the interval. + + Examples: + + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 01:55:00 2013' + >>> time.ctime(startOfInterval(start_ts, 300.0)) + 'Thu Jul 4 01:55:00 2013' + >>> time.ctime(startOfInterval(start_ts, 600)) + 'Thu Jul 4 01:50:00 2013' + >>> time.ctime(startOfInterval(start_ts, 900)) + 'Thu Jul 4 01:45:00 2013' + >>> time.ctime(startOfInterval(start_ts, 3600)) + 'Thu Jul 4 01:00:00 2013' + >>> time.ctime(startOfInterval(start_ts, 7200)) + 'Thu Jul 4 01:00:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:00:00", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 00:55:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:00:01", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 01:00:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 01:04:59", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Thu Jul 4 01:00:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 00:00:00", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 300)) + 'Wed Jul 3 23:55:00 2013' + >>> start_ts = time.mktime(time.strptime("2013-07-04 07:51:00", "%Y-%m-%d %H:%M:%S")) + >>> time.ctime(startOfInterval(start_ts, 60)) + 'Thu Jul 4 07:50:00 2013' + >>> start_ts += 0.1 + >>> time.ctime(startOfInterval(start_ts, 60)) + 'Thu Jul 4 07:51:00 2013' + """ + + start_interval_ts = int(time_ts / interval) * interval + + if time_ts == start_interval_ts: + start_interval_ts -= interval + return start_interval_ts + + +def _ord_to_ts(ord_date): + """Convert from ordinal date to unix epoch time. + + Args: + ord_date (int): A proleptic Gregorian ordinal. + + Returns: + int: Unix epoch time of the start of the corresponding day. + """ + d = datetime.date.fromordinal(ord_date) + t = int(time.mktime(d.timetuple())) + return t + + +# =============================================================================== +# What follows is a bunch of "time span" routines. Generally, time spans +# are used when start and stop times fall on calendar boundaries +# such as days, months, years. So, it makes sense to talk of "daySpans", +# "weekSpans", etc. They are generally not used between two random times. +# =============================================================================== + +class TimeSpan(tuple): + """Represents a time span, exclusive on the left, inclusive on the right.""" + + def __new__(cls, *args): + if args[0] > args[1]: + raise ValueError("start time (%d) is greater than stop time (%d)" % (args[0], args[1])) + return tuple.__new__(cls, args) + + @property + def start(self): + return self[0] + + @property + def stop(self): + return self[1] + + @property + def length(self): + return self[1] - self[0] + + def includesArchiveTime(self, timestamp): + """Test whether the span includes a timestamp, exclusive on the left, + inclusive on the right. + + Args: + timestamp(float): The timestamp to be tested. + + Returns: + bool: True if the span includes the time timestamp, otherwise False. + + """ + return self.start < timestamp <= self.stop + + def includes(self, span): + return self.start <= span.start <= self.stop and self.start <= span.stop <= self.stop + + def __eq__(self, other): + return self.start == other.start and self.stop == other.stop + + def __str__(self): + return "[%s -> %s]" % (timestamp_to_string(self.start), + timestamp_to_string(self.stop)) + + def __hash__(self): + return hash(self.start) ^ hash(self.stop) + + +nominal_intervals = { + 'hour': 3600, + 'day': 86400, + 'week': 7 * 86400, + 'month': int(365.25 / 12 * 86400), + 'year': int(365.25 * 86400), +} +duration_synonyms = { + 'hour': '1h', + 'day': '1d', + 'week': '1w', + 'month': '1m', + 'year': '1y', +} + + +def nominal_spans(label): + """Convert a (possible) string into a time. The string can include a duration suffix. + + Examples: + >>> print(nominal_spans(7200)) + 7200 + >>> print(nominal_spans(7200.0)) + 7200.0 + >>> print(nominal_spans('2h')) + 7200 + >>> print(nominal_spans('120M')) + 7200 + >>> print(nominal_spans(None)) + None + + Args: + label(str|float|int|None): A time, possibly with a duration suffix. + + Returns: + int|float|None: + """ + if label is None: + return None + + if isinstance(label, str): + label = duration_synonyms.get(label.lower(), label) + if label.endswith('M'): + # Minute + return int(label[:-1]) * 60 + elif label.endswith('h'): + # Hour + return int(label[:-1]) * nominal_intervals['hour'] + elif label.endswith('d'): + # Day + return int(label[:-1]) * nominal_intervals['day'] + elif label.endswith('w'): + # Week + return int(label[:-1]) * nominal_intervals['week'] + elif label.endswith('m'): + # Month + return int(label[:-1]) * nominal_intervals['month'] + elif label.endswith('y'): + # Year + return int(label[:-1]) * nominal_intervals['year'] + else: + return int(label) + return label + + +def isStartOfDay(time_ts): + """Is the indicated time at the start of the day, local time? + + This algorithm will work even in countries that switch to DST at midnight, such as Brazil. + + Args: + time_ts (float): A unix epoch timestamp. + + Returns: + bool: True if the timestamp is at midnight, False otherwise. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> print(isStartOfDay(time_ts)) + False + >>> time_ts = time.mktime(time.strptime("2013-07-04 00:00:00", "%Y-%m-%d %H:%M:%S")) + >>> print(isStartOfDay(time_ts)) + True + >>> os.environ['TZ'] = 'America/Sao_Paulo' + >>> time.tzset() + >>> time_ts = 1541300400 + >>> print(isStartOfDay(time_ts)) + True + >>> print(isStartOfDay(time_ts - 1)) + False + """ + + # Test the date of the time against the date a tenth of a second before. + # If they do not match, the time must have been the start of the day + dt1 = datetime.date.fromtimestamp(time_ts) + dt2 = datetime.date.fromtimestamp(time_ts - .1) + return not dt1 == dt2 + + +def isMidnight(time_ts): + """Is the indicated time on a midnight boundary, local time? + NB: This algorithm does not work in countries that switch to DST + at midnight, such as Brazil. + + Args: + time_ts (float): A unix epoch timestamp. + + Returns: + bool: True if the timestamp is at midnight, False otherwise. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> print(isMidnight(time_ts)) + False + >>> time_ts = time.mktime(time.strptime("2013-07-04 00:00:00", "%Y-%m-%d %H:%M:%S")) + >>> print(isMidnight(time_ts)) + True + """ + + time_tt = time.localtime(time_ts) + return time_tt.tm_hour == 0 and time_tt.tm_min == 0 and time_tt.tm_sec == 0 + + +def archiveSpanSpan(time_ts, time_delta=0, hour_delta=0, day_delta=0, week_delta=0, month_delta=0, + year_delta=0, boundary=None): + """ Returns a TimeSpan for the last xxx seconds where xxx equals + time_delta sec + hour_delta hours + day_delta days + week_delta weeks \ + + month_delta months + year_delta years + + NOTE: Use of month_delta and year_delta is deprecated. + See issue #436 (https://github.com/weewx/weewx/issues/436) + + Example: + >>> os.environ['TZ'] = 'Australia/Brisbane' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2015-07-21 09:05:35", "%Y-%m-%d %H:%M:%S")) + >>> print(archiveSpanSpan(time_ts, time_delta=3600)) + [2015-07-21 08:05:35 AEST (1437429935) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, hour_delta=6)) + [2015-07-21 03:05:35 AEST (1437411935) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, day_delta=1)) + [2015-07-20 09:05:35 AEST (1437347135) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, time_delta=3600, day_delta=1)) + [2015-07-20 08:05:35 AEST (1437343535) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, week_delta=4)) + [2015-06-23 09:05:35 AEST (1435014335) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, month_delta=1)) + [2015-06-21 09:05:35 AEST (1434841535) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts, year_delta=1)) + [2014-07-21 09:05:35 AEST (1405897535) -> 2015-07-21 09:05:35 AEST (1437433535)] + >>> print(archiveSpanSpan(time_ts)) + [2015-07-21 09:05:34 AEST (1437433534) -> 2015-07-21 09:05:35 AEST (1437433535)] + + Example over a DST boundary. Because Brisbane does not observe DST, we need to + switch timezones. + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1457888400 + >>> print(timestamp_to_string(time_ts)) + 2016-03-13 10:00:00 PDT (1457888400) + >>> span = archiveSpanSpan(time_ts, day_delta=1) + >>> print(span) + [2016-03-12 10:00:00 PST (1457805600) -> 2016-03-13 10:00:00 PDT (1457888400)] + + Note that there is not 24 hours of time over this span: + >>> print((span.stop - span.start) / 3600.0) + 23.0 + """ + + if time_ts is None: + return None + + # Use a datetime.timedelta so that it can take DST into account: + time_dt = datetime.datetime.fromtimestamp(time_ts) + time_dt -= datetime.timedelta(weeks=week_delta, days=day_delta, hours=hour_delta, + seconds=time_delta) + + # Now add the deltas for months and years. Because these can be variable in length, + # some special arithmetic is needed. Start by calculating the number of + # months since 0 AD: + total_months = 12 * time_dt.year + time_dt.month - 1 - 12 * year_delta - month_delta + # Convert back from total months since 0 AD to year and month: + year = total_months // 12 + month = total_months % 12 + 1 + # Apply the delta to our datetime object + start_dt = time_dt.replace(year=year, month=month) + + # Finally, convert to unix epoch time + if boundary is None: + start_ts = int(time.mktime(start_dt.timetuple())) + if start_ts == time_ts: + start_ts -= 1 + elif boundary.lower() == 'midnight': + start_ts = _ord_to_ts(start_dt.toordinal()) + else: + raise ValueError("Unknown boundary %s" % boundary) + + return TimeSpan(start_ts, time_ts) + + +def archiveHoursAgoSpan(time_ts, hours_ago=0): + """Returns a one-hour long TimeSpan for x hours ago that includes the given time. + + NB: A timestamp that falls exactly on the hour boundary is considered to belong to the + *previous* hour. + + Args: + time_ts (float|None): A timestamp. An hour long time span will be returned that encompasses + this timestamp. + hours_ago (int): Which hour we want. 0=this hour, 1=last hour, etc. Default is + zero (this hour). + + Returns: + TimeSpan: A TimeSpan object one hour long, that includes time_ts. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2013-07-04 01:57:35", "%Y-%m-%d %H:%M:%S")) + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=0)) + [2013-07-04 01:00:00 PDT (1372924800) -> 2013-07-04 02:00:00 PDT (1372928400)] + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=2)) + [2013-07-03 23:00:00 PDT (1372917600) -> 2013-07-04 00:00:00 PDT (1372921200)] + >>> time_ts = time.mktime(datetime.date(2013, 7, 4).timetuple()) + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=0)) + [2013-07-03 23:00:00 PDT (1372917600) -> 2013-07-04 00:00:00 PDT (1372921200)] + >>> print(archiveHoursAgoSpan(time_ts, hours_ago=24)) + [2013-07-02 23:00:00 PDT (1372831200) -> 2013-07-03 00:00:00 PDT (1372834800)] + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at an hour boundary, the start of the archive hour is actually + # the *previous* hour. + if time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + hours_ago += 1 + + # Find the start of the hour + start_of_hour_dt = time_dt.replace(minute=0, second=0, microsecond=0) + + start_span_dt = start_of_hour_dt - datetime.timedelta(hours=hours_ago) + stop_span_dt = start_span_dt + datetime.timedelta(hours=1) + + return TimeSpan(int(time.mktime(start_span_dt.timetuple())), + int(time.mktime(stop_span_dt.timetuple()))) + + +def daySpan(time_ts, days_ago=0, archive=False): + """Returns a one-day long TimeSpan for x days ago that includes a given time. + + Args: + time_ts (float|None): The day will include this timestamp. + days_ago (int): Which day we want. 0=today, 1=yesterday, etc. + archive (bool): True to calculate archive day; false otherwise. + + Returns: + TimeSpan: A TimeSpan object one day long. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2014-01-01 01:57:35", "%Y-%m-%d %H:%M:%S")) + + As for today: + >>> print(daySpan(time_ts)) + [2014-01-01 00:00:00 PST (1388563200) -> 2014-01-02 00:00:00 PST (1388649600)] + + Do it again, but on the midnight boundary + >>> time_ts = time.mktime(time.strptime("2014-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")) + + We should still get today (this differs from the function archiveDaySpan()) + >>> print(daySpan(time_ts)) + [2014-01-01 00:00:00 PST (1388563200) -> 2014-01-02 00:00:00 PST (1388649600)] +""" + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + if archive: + # If we are exactly at midnight, the start of the archive day is actually + # the *previous* day + if time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + days_ago += 1 + + # Find the start of the day + start_of_day_dt = time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + + start_span_dt = start_of_day_dt - datetime.timedelta(days=days_ago) + stop_span_dt = start_span_dt + datetime.timedelta(days=1) + + return TimeSpan(int(time.mktime(start_span_dt.timetuple())), + int(time.mktime(stop_span_dt.timetuple()))) + + +def archiveDaySpan(time_ts, days_ago=0): + """Returns a one-day long TimeSpan for x days ago that includes a given time. + + NB: A timestamp that falls exactly on midnight is considered to belong to the *previous* day. + + Args: + time_ts (float|None): The day will include this timestamp. + days_ago (int): Which day we want. 0=today, 1=yesterday, etc. + + Returns: + TimeSpan: A TimeSpan object one day long. + + Example, which spans the end-of-year boundary + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = time.mktime(time.strptime("2014-01-01 01:57:35", "%Y-%m-%d %H:%M:%S")) + + As for today: + >>> print(archiveDaySpan(time_ts)) + [2014-01-01 00:00:00 PST (1388563200) -> 2014-01-02 00:00:00 PST (1388649600)] + + Ask for yesterday: + >>> print(archiveDaySpan(time_ts, days_ago=1)) + [2013-12-31 00:00:00 PST (1388476800) -> 2014-01-01 00:00:00 PST (1388563200)] + + Day before yesterday + >>> print(archiveDaySpan(time_ts, days_ago=2)) + [2013-12-30 00:00:00 PST (1388390400) -> 2013-12-31 00:00:00 PST (1388476800)] + + Do it again, but on the midnight boundary + >>> time_ts = time.mktime(time.strptime("2014-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")) + + This time, we should get the previous day + >>> print(archiveDaySpan(time_ts)) + [2013-12-31 00:00:00 PST (1388476800) -> 2014-01-01 00:00:00 PST (1388563200)] + """ + return daySpan(time_ts, days_ago, True) + + +# For backwards compatibility. Not sure if anyone is actually using this +archiveDaysAgoSpan = archiveDaySpan + + +def archiveWeekSpan(time_ts, startOfWeek=6, weeks_ago=0): + """Returns a one-week long TimeSpan for x weeks ago that includes a given time. + + NB: The time at midnight at the end of the week is considered to + actually belong in the previous week. + + Args: + time_ts (float|None): The week will include this timestamp. + startOfWeek (int): The start of the week (0=Monday, 1=Tues, ..., 6 = Sun). Default + is 6 (Sunday). + weeks_ago (int): Which week we want. 0=this week, 1=last week, etc. Default + is zero (this week). + + Returns: + TimeSpan: A TimeSpan object one week long that contains time_ts. It will + start at midnight of the day considered the start of the week. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1483429962 + >>> print(timestamp_to_string(time_ts)) + 2017-01-02 23:52:42 PST (1483429962) + >>> print(archiveWeekSpan(time_ts)) + [2017-01-01 00:00:00 PST (1483257600) -> 2017-01-08 00:00:00 PST (1483862400)] + >>> print(archiveWeekSpan(time_ts, weeks_ago=1)) + [2016-12-25 00:00:00 PST (1482652800) -> 2017-01-01 00:00:00 PST (1483257600)] + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # Find the start of the day: + start_of_day_dt = time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + + # Find the relative start of the week + day_of_week = start_of_day_dt.weekday() + delta = day_of_week - startOfWeek + if delta < 0: + delta += 7 + + # If we are exactly at midnight, the start of the archive week is actually + # the *previous* week + if day_of_week == startOfWeek \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + delta += 7 + + # Finally, find the start of the requested week. + delta += weeks_ago * 7 + + start_of_week = start_of_day_dt - datetime.timedelta(days=delta) + end_of_week = start_of_week + datetime.timedelta(days=7) + + return TimeSpan(int(time.mktime(start_of_week.timetuple())), + int(time.mktime(end_of_week.timetuple()))) + + +def archiveMonthSpan(time_ts, months_ago=0): + """Returns a one-month long TimeSpan for x months ago that includes a given time. + + The time at midnight at the end of the month is considered to actually belong in the + previous week. + + Args: + time_ts (float|None): The month will include this timestamp. + months_ago (int): Which month we want. 0=this month, 1=last month, etc. Default + is zero (this month). + + Returns: + TimeSpan: A TimeSpan object one month long that contains time_ts. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1483429962 + >>> print(timestamp_to_string(time_ts)) + 2017-01-02 23:52:42 PST (1483429962) + >>> print(archiveMonthSpan(time_ts)) + [2017-01-01 00:00:00 PST (1483257600) -> 2017-02-01 00:00:00 PST (1485936000)] + >>> print(archiveMonthSpan(time_ts, months_ago=1)) + [2016-12-01 00:00:00 PST (1480579200) -> 2017-01-01 00:00:00 PST (1483257600)] + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at midnight of the first day of the month, + # the start of the archive month is actually the *previous* month + if time_dt.day == 1 \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + months_ago += 1 + + # Find the start of the month + start_of_month_dt = time_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # Total number of months since 0AD + total_months = 12 * start_of_month_dt.year + start_of_month_dt.month - 1 + + # Adjust for the requested delta: + total_months -= months_ago + + # Now rebuild the date + start_year = total_months // 12 + start_month = total_months % 12 + 1 + start_date = datetime.date(year=start_year, month=start_month, day=1) + + # Advance to the start of the next month. This will be the end of the time span. + total_months += 1 + stop_year = total_months // 12 + stop_month = total_months % 12 + 1 + stop_date = datetime.date(year=stop_year, month=stop_month, day=1) + + return TimeSpan(int(time.mktime(start_date.timetuple())), + int(time.mktime(stop_date.timetuple()))) + + +def archiveYearSpan(time_ts, years_ago=0): + """Returns a TimeSpan representing a year that includes a given time. + + NB: Midnight of the 1st of the January is considered to actually belong in the previous year. + + Args: + time_ts (float|None): The year will include this timestamp. + years_ago (int): Which year we want. 0=this year, 1=last year, etc. Default + is zero (this year). + + Returns: + TimeSpan: A TimeSpan object one year long that contains time_ts. It will + start at midnight of 1-Jan + """ + + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at midnight 1-Jan, then the start of the archive year is actually + # the *previous* year + if time_dt.month == 1 \ + and time_dt.day == 1 \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + years_ago += 1 + + return TimeSpan(int(time.mktime((time_dt.year - years_ago, 1, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((time_dt.year - years_ago + 1, 1, 1, 0, 0, 0, 0, 0, -1)))) + + +def archiveRainYearSpan(time_ts, sory_mon, years_ago=0): + """Returns a TimeSpan representing a rain year that includes a given time. + + NB: Midnight of the 1st of the month starting the rain year is considered to + actually belong in the previous rain year. + + Args: + time_ts (float|None): The rain year will include this timestamp. + sory_mon (int): The start of the rain year (1=Jan, 2=Feb, etc.) + years_ago (int): Which rain year we want. 0=this year, 1=last year, etc. Default + is zero (this year). + + Returns: + TimeSpan: A one-year long TimeSpan object containing the timestamp. + """ + if time_ts is None: + return None + + time_dt = datetime.datetime.fromtimestamp(time_ts) + + # If we are exactly at midnight of the start of the rain year, then the start is actually + # the *previous* year + if time_dt.month == sory_mon \ + and time_dt.day == 1 \ + and time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + years_ago += 1 + + if time_dt.month < sory_mon: + years_ago += 1 + + year = time_dt.year - years_ago + + return TimeSpan(int(time.mktime((year, sory_mon, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((year + 1, sory_mon, 1, 0, 0, 0, 0, 0, -1)))) + + +def timespan_by_name(label, time_ts, **kwargs): + """Calculate an an appropriate TimeSpan""" + return { + 'hour': archiveHoursAgoSpan, + 'day': archiveDaySpan, + 'week': archiveWeekSpan, + 'month': archiveMonthSpan, + 'year': archiveYearSpan, + 'rainyear': archiveRainYearSpan + }[label](time_ts, **kwargs) + + +def stampgen(startstamp, stopstamp, interval): + """Generator function yielding a sequence of timestamps, spaced interval apart. + + The sequence will fall on the same local time boundary as startstamp. + + Args: + startstamp (float): The start of the sequence in unix epoch time. + stopstamp (float): The end of the sequence in unix epoch time. + interval (int|float): The time length of an interval in seconds. + + Yields: + float: yields a sequence of timestamps between startstamp and endstamp, inclusive. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> startstamp = 1236560400 + >>> print(timestamp_to_string(startstamp)) + 2009-03-08 18:00:00 PDT (1236560400) + >>> stopstamp = 1236607200 + >>> print(timestamp_to_string(stopstamp)) + 2009-03-09 07:00:00 PDT (1236607200) + + >>> for stamp in stampgen(startstamp, stopstamp, 10800): + ... print(timestamp_to_string(stamp)) + 2009-03-08 18:00:00 PDT (1236560400) + 2009-03-08 21:00:00 PDT (1236571200) + 2009-03-09 00:00:00 PDT (1236582000) + 2009-03-09 03:00:00 PDT (1236592800) + 2009-03-09 06:00:00 PDT (1236603600) + + Note that DST started in the middle of the sequence and that therefore the + actual time deltas between stamps is not necessarily 3 hours. + """ + dt = datetime.datetime.fromtimestamp(startstamp) + stop_dt = datetime.datetime.fromtimestamp(stopstamp) + if interval == 365.25 / 12 * 24 * 3600: + # Interval is a nominal month. This algorithm is + # necessary because not all months have the same length. + while dt <= stop_dt: + t_tuple = dt.timetuple() + yield time.mktime(t_tuple) + year = t_tuple[0] + month = t_tuple[1] + month += 1 + if month > 12: + month -= 12 + year += 1 + dt = dt.replace(year=year, month=month) + else: + # This rather complicated algorithm is necessary (rather than just + # doing some time stamp arithmetic) because of the possibility that DST + # changes in the middle of an interval. + delta = datetime.timedelta(seconds=interval) + ts_last = 0 + while dt <= stop_dt: + ts = int(time.mktime(dt.timetuple())) + # This check is necessary because time.mktime() cannot + # disambiguate between 2am ST and 3am DST. For example, + # time.mktime((2013, 3, 10, 2, 0, 0, 0, 0, -1)) and + # time.mktime((2013, 3, 10, 3, 0, 0, 0, 0, -1)) + # both give the same value (1362909600) + if ts > ts_last: + yield ts + ts_last = ts + dt += delta + + +def intervalgen(start_ts, stop_ts, interval): + """Generator function yielding a sequence of time spans whose boundaries + are on constant local time. + + Args: + start_ts (float): The start of the first interval in unix epoch time. In unix epoch time. + stop_ts (float): The end of the last interval will be equal to or less than this. + In unix epoch time. + interval (int|float|str): The time length of an interval in seconds, or a shorthand + description (such as 'day', or 'hour', or '3d'). + + Yields: + TimeSpan: A sequence of TimeSpans. Both the start and end of the timespan + will be on the same time boundary as start_ts. See the example below. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> startstamp = 1236477600 + >>> print(timestamp_to_string(startstamp)) + 2009-03-07 18:00:00 PST (1236477600) + >>> stopstamp = 1236538800 + >>> print(timestamp_to_string(stopstamp)) + 2009-03-08 12:00:00 PDT (1236538800) + + >>> for span in intervalgen(startstamp, stopstamp, 10800): + ... print(span) + [2009-03-07 18:00:00 PST (1236477600) -> 2009-03-07 21:00:00 PST (1236488400)] + [2009-03-07 21:00:00 PST (1236488400) -> 2009-03-08 00:00:00 PST (1236499200)] + [2009-03-08 00:00:00 PST (1236499200) -> 2009-03-08 03:00:00 PDT (1236506400)] + [2009-03-08 03:00:00 PDT (1236506400) -> 2009-03-08 06:00:00 PDT (1236517200)] + [2009-03-08 06:00:00 PDT (1236517200) -> 2009-03-08 09:00:00 PDT (1236528000)] + [2009-03-08 09:00:00 PDT (1236528000) -> 2009-03-08 12:00:00 PDT (1236538800)] + + (Note how in this example the local time boundaries are constant, despite + DST kicking in. The interval length is not constant.) + + Another example, this one over the Fall DST boundary, and using 1-hour intervals: + + >>> startstamp = 1257051600 + >>> print(timestamp_to_string(startstamp)) + 2009-10-31 22:00:00 PDT (1257051600) + >>> stopstamp = 1257080400 + >>> print(timestamp_to_string(stopstamp)) + 2009-11-01 05:00:00 PST (1257080400) + >>> for span in intervalgen(startstamp, stopstamp, 3600): + ... print(span) + [2009-10-31 22:00:00 PDT (1257051600) -> 2009-10-31 23:00:00 PDT (1257055200)] + [2009-10-31 23:00:00 PDT (1257055200) -> 2009-11-01 00:00:00 PDT (1257058800)] + [2009-11-01 00:00:00 PDT (1257058800) -> 2009-11-01 01:00:00 PDT (1257062400)] + [2009-11-01 01:00:00 PDT (1257062400) -> 2009-11-01 02:00:00 PST (1257069600)] + [2009-11-01 02:00:00 PST (1257069600) -> 2009-11-01 03:00:00 PST (1257073200)] + [2009-11-01 03:00:00 PST (1257073200) -> 2009-11-01 04:00:00 PST (1257076800)] + [2009-11-01 04:00:00 PST (1257076800) -> 2009-11-01 05:00:00 PST (1257080400)] +""" + + dt1 = datetime.datetime.fromtimestamp(start_ts) + stop_dt = datetime.datetime.fromtimestamp(stop_ts) + + # If a string was passed in, convert to seconds using nominal time intervals. + interval = nominal_spans(interval) + + if interval == 365.25 / 12 * 24 * 3600: + # Interval is a nominal month. This algorithm is + # necessary because not all months have the same length. + while dt1 < stop_dt: + t_tuple = dt1.timetuple() + year = t_tuple[0] + month = t_tuple[1] + month += 1 + if month > 12: + month -= 12 + year += 1 + dt2 = min(dt1.replace(year=year, month=month), stop_dt) + stamp1 = time.mktime(t_tuple) + stamp2 = time.mktime(dt2.timetuple()) + yield TimeSpan(stamp1, stamp2) + dt1 = dt2 + else: + # This rather complicated algorithm is necessary (rather than just + # doing some time stamp arithmetic) because of the possibility that DST + # changes in the middle of an interval + delta = datetime.timedelta(seconds=interval) + last_stamp1 = 0 + while dt1 < stop_dt: + dt2 = min(dt1 + delta, stop_dt) + stamp1 = int(time.mktime(dt1.timetuple())) + stamp2 = int(time.mktime(dt2.timetuple())) + if stamp2 > stamp1 > last_stamp1: + yield TimeSpan(stamp1, stamp2) + last_stamp1 = stamp1 + dt1 = dt2 + + +def genHourSpans(start_ts, stop_ts): + """Generator function that generates start/stop of hours in an inclusive range. + + Args: + start_ts (float): A time stamp somewhere in the first day. + stop_ts (float): A time stamp somewhere in the last day. + + Yields: + TimeSpan: Instance of TimeSpan, where the start is the time stamp + of the start of the day, the stop is the time stamp of the start + of the next day. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = 1204796460 + >>> stop_ts = 1204818360 + + >>> print(timestamp_to_string(start_ts)) + 2008-03-06 01:41:00 PST (1204796460) + >>> print(timestamp_to_string(stop_ts)) + 2008-03-06 07:46:00 PST (1204818360) + + >>> for span in genHourSpans(start_ts, stop_ts): + ... print(span) + [2008-03-06 01:00:00 PST (1204794000) -> 2008-03-06 02:00:00 PST (1204797600)] + [2008-03-06 02:00:00 PST (1204797600) -> 2008-03-06 03:00:00 PST (1204801200)] + [2008-03-06 03:00:00 PST (1204801200) -> 2008-03-06 04:00:00 PST (1204804800)] + [2008-03-06 04:00:00 PST (1204804800) -> 2008-03-06 05:00:00 PST (1204808400)] + [2008-03-06 05:00:00 PST (1204808400) -> 2008-03-06 06:00:00 PST (1204812000)] + [2008-03-06 06:00:00 PST (1204812000) -> 2008-03-06 07:00:00 PST (1204815600)] + [2008-03-06 07:00:00 PST (1204815600) -> 2008-03-06 08:00:00 PST (1204819200)] + """ + _stop_dt = datetime.datetime.fromtimestamp(stop_ts) + _start_hour = int(start_ts / 3600) + _stop_hour = int(stop_ts / 3600) + if (_stop_dt.minute, _stop_dt.second) == (0, 0): + _stop_hour -= 1 + + for _hour in range(_start_hour, _stop_hour + 1): + yield TimeSpan(_hour * 3600, (_hour + 1) * 3600) + + +def genDaySpans(start_ts, stop_ts): + """Generator function that generates start/stop of days in an inclusive range. + + Args: + + start_ts (float): A time stamp somewhere in the first day. + stop_ts (float): A time stamp somewhere in the last day. + + Yields: + TimeSpan: A sequence of TimeSpans, where the start is the time stamp + of the start of the day, the stop is the time stamp of the start + of the next day. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = 1204796460 + >>> stop_ts = 1205265720 + + >>> print(timestamp_to_string(start_ts)) + 2008-03-06 01:41:00 PST (1204796460) + >>> print(timestamp_to_string(stop_ts)) + 2008-03-11 13:02:00 PDT (1205265720) + + >>> for span in genDaySpans(start_ts, stop_ts): + ... print(span) + [2008-03-06 00:00:00 PST (1204790400) -> 2008-03-07 00:00:00 PST (1204876800)] + [2008-03-07 00:00:00 PST (1204876800) -> 2008-03-08 00:00:00 PST (1204963200)] + [2008-03-08 00:00:00 PST (1204963200) -> 2008-03-09 00:00:00 PST (1205049600)] + [2008-03-09 00:00:00 PST (1205049600) -> 2008-03-10 00:00:00 PDT (1205132400)] + [2008-03-10 00:00:00 PDT (1205132400) -> 2008-03-11 00:00:00 PDT (1205218800)] + [2008-03-11 00:00:00 PDT (1205218800) -> 2008-03-12 00:00:00 PDT (1205305200)] + + Note that a daylight savings time change happened 8 March 2009. + """ + _start_dt = datetime.datetime.fromtimestamp(start_ts) + _stop_dt = datetime.datetime.fromtimestamp(stop_ts) + + _start_ord = _start_dt.toordinal() + _stop_ord = _stop_dt.toordinal() + if (_stop_dt.hour, _stop_dt.minute, _stop_dt.second) == (0, 0, 0): + _stop_ord -= 1 + + for _ord in range(_start_ord, _stop_ord + 1): + yield TimeSpan(_ord_to_ts(_ord), _ord_to_ts(_ord + 1)) + + +def genMonthSpans(start_ts, stop_ts): + """Generator function that generates start/stop of months in an + inclusive range. + + Args: + start_ts (float): A time stamp somewhere in the first month. + stop_ts (float): A time stamp somewhere in the last month. + + Yields: + TimeSpan: A sequence of TimeSpans, where the start is the time stamp of the start of the + month, the stop is the time stamp of the start of the next month. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> start_ts = 1196705700 + >>> stop_ts = 1206101100 + >>> print("start time is %s" % timestamp_to_string(start_ts)) + start time is 2007-12-03 10:15:00 PST (1196705700) + >>> print("stop time is %s" % timestamp_to_string(stop_ts)) + stop time is 2008-03-21 05:05:00 PDT (1206101100) + + >>> for span in genMonthSpans(start_ts, stop_ts): + ... print(span) + [2007-12-01 00:00:00 PST (1196496000) -> 2008-01-01 00:00:00 PST (1199174400)] + [2008-01-01 00:00:00 PST (1199174400) -> 2008-02-01 00:00:00 PST (1201852800)] + [2008-02-01 00:00:00 PST (1201852800) -> 2008-03-01 00:00:00 PST (1204358400)] + [2008-03-01 00:00:00 PST (1204358400) -> 2008-04-01 00:00:00 PDT (1207033200)] + + Note that a daylight savings time change happened 8 March 2009. + """ + if None in (start_ts, stop_ts): + return + _start_dt = datetime.date.fromtimestamp(start_ts) + _stop_date = datetime.datetime.fromtimestamp(stop_ts) + + _start_month = 12 * _start_dt.year + _start_dt.month + _stop_month = 12 * _stop_date.year + _stop_date.month + + if (_stop_date.day, _stop_date.hour, _stop_date.minute, _stop_date.second) == (1, 0, 0, 0): + _stop_month -= 1 + + for month in range(_start_month, _stop_month + 1): + _this_yr, _this_mo = divmod(month, 12) + _next_yr, _next_mo = divmod(month + 1, 12) + yield TimeSpan(int(time.mktime((_this_yr, _this_mo, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((_next_yr, _next_mo, 1, 0, 0, 0, 0, 0, -1)))) + + +def genYearSpans(start_ts, stop_ts): + if None in (start_ts, stop_ts): + return + _start_date = datetime.date.fromtimestamp(start_ts) + _stop_dt = datetime.datetime.fromtimestamp(stop_ts) + + _start_year = _start_date.year + _stop_year = _stop_dt.year + + if (_stop_dt.month, _stop_dt.day, _stop_dt.hour, + _stop_dt.minute, _stop_dt.second) == (1, 1, 0, 0, 0): + _stop_year -= 1 + + for year in range(_start_year, _stop_year + 1): + yield TimeSpan(int(time.mktime((year, 1, 1, 0, 0, 0, 0, 0, -1))), + int(time.mktime((year + 1, 1, 1, 0, 0, 0, 0, 0, -1)))) + + +def startOfDay(time_ts): + """Calculate the unix epoch time for the start of a (local time) day. + + Args: + time_ts (float): A timestamp somewhere in the day for which the start-of-day is desired. + + Returns: + float: The timestamp for the start-of-day (00:00) in unix epoch time. + + """ + _time_tt = time.localtime(time_ts) + _bod_ts = time.mktime((_time_tt.tm_year, + _time_tt.tm_mon, + _time_tt.tm_mday, + 0, 0, 0, 0, 0, -1)) + return int(_bod_ts) + + +def startOfGregorianDay(date_greg): + """Given a Gregorian day, returns the start of the day in unix epoch time. + + Args: + date_greg (int): A date as an ordinal Gregorian day. + + Returns: + float: The local start of the day as a unix epoch time. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> date_greg = 735973 # 10-Jan-2016 + >>> print(startOfGregorianDay(date_greg)) + 1452412800.0 + """ + date_dt = datetime.datetime.fromordinal(date_greg) + date_tt = date_dt.timetuple() + sod_ts = time.mktime(date_tt) + return sod_ts + + +def toGregorianDay(time_ts): + """Return the Gregorian day a timestamp belongs to. + + Args: + time_ts (float): A time in unix epoch time. + + Returns: + int: The ordinal Gregorian day that contains that time + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1452412800 # Midnight, 10-Jan-2016 + >>> print(toGregorianDay(time_ts)) + 735972 + >>> time_ts = 1452412801 # Just after midnight, 10-Jan-2016 + >>> print(toGregorianDay(time_ts)) + 735973 + """ + + date_dt = datetime.datetime.fromtimestamp(time_ts) + date_greg = date_dt.toordinal() + if date_dt.hour == date_dt.minute == date_dt.second == date_dt.microsecond == 0: + # Midnight actually belongs to the previous day + date_greg -= 1 + return date_greg + + +def startOfDayUTC(time_ts): + """Calculate the unix epoch time for the start of a UTC day. + + Args: + time_ts (float): A timestamp somewhere in the day for which the start-of-day + is desired. + + Returns: + int: The timestamp for the start-of-day (00:00) in unix epoch time. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> time_ts = 1452412800 # Midnight, 10-Jan-2016 + >>> print(startOfDayUTC(time_ts)) + 1452384000 + """ + _time_tt = time.gmtime(time_ts) + _bod_ts = calendar.timegm((_time_tt.tm_year, + _time_tt.tm_mon, + _time_tt.tm_mday, + 0, 0, 0, 0, 0, -1)) + return _bod_ts + + +def startOfArchiveDay(time_ts): + """Given an archive time stamp, calculate its start of day. + + Similar to startOfDay(), except that an archive stamped at midnight + actually belongs to the *previous* day. + + Args: + time_ts (float): A timestamp somewhere in the day for which the start-of-day + is desired. + + Returns: + float: The timestamp for the start-of-day (00:00) in unix epoch time.""" + + time_dt = datetime.datetime.fromtimestamp(time_ts) + start_of_day_dt = time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + # If we are exactly on the midnight boundary, the start of the archive day is actually + # the *previous* day. + if time_dt.hour == 0 \ + and time_dt.minute == 0 \ + and time_dt.second == 0 \ + and time_dt.microsecond == 0: + start_of_day_dt -= datetime.timedelta(days=1) + start_of_day_tt = start_of_day_dt.timetuple() + start_of_day_ts = int(time.mktime(start_of_day_tt)) + return start_of_day_ts + + +def getDayNightTransitions(start_ts, end_ts, lat, lon): + """Return the day-night transitions between the start and end times. + + Args: + + start_ts (float): A timestamp (UTC) indicating the beginning of the period + end_ts (float): A timestamp (UTC) indicating the end of the period + lat (float): The latitude in degrees + lon (float): The longitude in degrees + + Returns: + tuple[str,list[float]]: A two-way tuple, The first element is either the string 'day' + or 'night'. + If 'day', the first transition is from day to night. + If 'night', the first transition is from night to day. + The second element is a sequence of transition times in unix epoch times. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> startstamp = 1658428400 + >>> # Stop stamp is three days later: + >>> stopstamp = startstamp + 3 * 24 * 3600 + >>> print(timestamp_to_string(startstamp)) + 2022-07-21 11:33:20 PDT (1658428400) + >>> print(timestamp_to_string(stopstamp)) + 2022-07-24 11:33:20 PDT (1658687600) + >>> whichway, transitions = getDayNightTransitions(startstamp, stopstamp, 45, -122) + >>> print(whichway) + day + >>> for x in transitions: + ... print(timestamp_to_string(x)) + 2022-07-21 20:47:00 PDT (1658461620) + 2022-07-22 05:42:58 PDT (1658493778) + 2022-07-22 20:46:02 PDT (1658547962) + 2022-07-23 05:44:00 PDT (1658580240) + 2022-07-23 20:45:03 PDT (1658634303) + 2022-07-24 05:45:04 PDT (1658666704) + """ + from weeutil import Sun + + start_ts = int(start_ts) + end_ts = int(end_ts) + + first = None + values = [] + for t in range(start_ts - 3600 * 24, end_ts + 3600 * 24 + 1, 3600 * 24): + x = startOfDayUTC(t) + x_tt = time.gmtime(x) + y, m, d = x_tt[:3] + (sunrise_utc, sunset_utc) = Sun.sunRiseSet(y, m, d, lon, lat) + daystart_ts = calendar.timegm((y, m, d, 0, 0, 0, 0, 0, -1)) + sunrise_ts = int(daystart_ts + sunrise_utc * 3600.0 + 0.5) + sunset_ts = int(daystart_ts + sunset_utc * 3600.0 + 0.5) + + if start_ts < sunrise_ts < end_ts: + values.append(sunrise_ts) + if first is None: + first = 'night' + if start_ts < sunset_ts < end_ts: + values.append(sunset_ts) + if first is None: + first = 'day' + return first, values + + +def timestamp_to_string(ts, format_str="%Y-%m-%d %H:%M:%S %Z"): + """Return a string formatted from the timestamp + + Args: + ts (float): A unix-epoch timestamp + format_str(str): A format string + + Returns: + str: The time in local time as a string. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> print(timestamp_to_string(1196705700)) + 2007-12-03 10:15:00 PST (1196705700) + >>> print(timestamp_to_string(None)) + ******* N/A ******* ( N/A ) + """ + if ts is not None: + return "%s (%d)" % (time.strftime(format_str, time.localtime(ts)), ts) + else: + return "******* N/A ******* ( N/A )" + + +def timestamp_to_gmtime(ts): + """Return a string formatted for GMT + + Args: + ts (float): A unix-epoch timestamp + + Returns: + str: The time in UTC as a string + + Example: + >>> print(timestamp_to_gmtime(1196705700)) + 2007-12-03 18:15:00 UTC (1196705700) + >>> print(timestamp_to_gmtime(None)) + ******* N/A ******* ( N/A ) + """ + if ts: + return "%s (%d)" % (time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(ts)), ts) + else: + return "******* N/A ******* ( N/A )" + + +def utc_to_ts(y, m, d, hrs_utc): + """Converts from a tuple-time in UTC to unix epoch time. + + Args: + y (int): The year for which the conversion is desired. + m (int): The month. + d (int): The day. + hrs_utc (float): Floating point number with the number of hours since midnight in UTC. + + Returns: + float: The corresponding unix epoch time. + + Example: + >>> print(utc_to_ts(2009, 3, 27, 14.5)) + 1238164200.5 + """ + # Construct a time tuple with the time at midnight, UTC: + daystart_utc_tt = (y, m, d, 0, 0, 0, 0, 0, -1) + # Convert the time tuple to a time stamp and add on the number of seconds since midnight: + time_ts = calendar.timegm(daystart_utc_tt) + hrs_utc * 3600.0 + 0.5 + return time_ts + + +def utc_to_local_tt(y, m, d, hrs_utc): + """Converts from a UTC time to a local time. + + Args: + y (int): The year for which the conversion is desired. + m (int): The month. + d (int): The day. + hrs_utc (float): Floating point number with the number of hours since midnight in UTC. + + Returns: + time.struct_time: A timetuple with the local time. + + Example: + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> tt=utc_to_local_tt(2009, 3, 27, 14.5) + >>> print(tt.tm_year, tt.tm_mon, tt.tm_mday, tt.tm_hour, tt.tm_min) + 2009 3 27 7 30 + """ + # Get the UTC time: + time_ts = utc_to_ts(y, m, d, hrs_utc) + # Convert to local time: + time_local_tt = time.localtime(time_ts) + return time_local_tt + + +def latlon_string(ll, hemi, which, format_list=None): + """Decimal degrees into a string for degrees, and one for minutes. + + Args: + ll (float): The decimal latitude or longitude + hemi (list[str,str]|tuple[str,str]): A tuple holding strings representing positive or + negative values. E.g.: ('N', 'S') or ('E', 'W') + which (str): 'lat' for latitude, 'lon' for longitude + format_list (list[str,str,str]|None): A list or tuple holding the format strings to be + used. These are [whole degrees latitude, whole degrees longitude, minutes] + + Returns: + tuple[str,str,str]: A 3-way tuple holding (latlon whole degrees, latlon minutes, + hemisphere designator). Example: ('022', '08.3', 'N') + + Example: + >>> print(latlon_string(-22.3, ('N','S'), 'lat')) + ('22', '18.00', 'S') + >>> print(latlon_string(-95.5, ('E','W'), 'lon')) + ('095', '30.00', 'W') + """ + labs = abs(ll) + frac, deg = math.modf(labs) + minutes = frac * 60.0 + format_list = format_list or ["%02d", "%03d", "%05.2f"] + return ((format_list[0] if which == 'lat' else format_list[1]) % deg, + format_list[2] % minutes, + hemi[0] if ll >= 0 else hemi[1]) + + +def get_object(module_class): + """Given a string with a module class name, it imports and returns the class.""" + # Split the path into its parts + module_name, klass_name = module_class.rsplit('.', 1) + module = importlib.import_module(module_name) + klass = getattr(module, klass_name) + return klass + + +# For backwards compatibility: +_get_object = get_object + + +class GenWithPeek(object): + """Generator object which allows a peek at the next object to be returned. + + Sometimes Python solves a complicated problem with such elegance! This is + one of them. + + Example of usage: + >>> # Define a generator function: + >>> def genfunc(N): + ... for j in range(N): + ... yield j + >>> + >>> # Now wrap it with the GenWithPeek object: + >>> g_with_peek = GenWithPeek(genfunc(5)) + >>> # We can iterate through the object as normal: + >>> for i in g_with_peek: + ... print(i) + ... # Every second object, let's take a peek ahead + ... if i%2: + ... # We can get a peek at the next object without disturbing the wrapped generator: + ... print("peeking ahead, the next object will be: %s" % g_with_peek.peek()) + 0 + 1 + peeking ahead, the next object will be: 2 + 2 + 3 + peeking ahead, the next object will be: 4 + 4 + """ + + def __init__(self, generator): + """Initialize the generator object. + + generator: A generator object to be wrapped + """ + self.generator = generator + self.have_peek = False + self.peek_obj = None + + def __iter__(self): + return self + + def __next__(self): + """Advance to the next object""" + if self.have_peek: + self.have_peek = False + return self.peek_obj + else: + return next(self.generator) + + def peek(self): + """Take a peek at the next object""" + if not self.have_peek: + self.peek_obj = next(self.generator) + self.have_peek = True + return self.peek_obj + + +class GenByBatch(object): + """Generator wrapper. Calls the wrapped generator in batches of a specified size.""" + + def __init__(self, generator, batch_size=0): + """Initialize an instance of GenWithConvert + + Args: + generator: An iterator which will be wrapped. + batch_size (int): The number of items to fetch in a batch. + """ + self.generator = generator + self.batch_size = batch_size + self.batch_buffer = [] + + def __iter__(self): + return self + + def __next__(self): + # If there isn't anything in the buffer, fetch new items + if not self.batch_buffer: + # Fetch in batches of 'batch_size'. + count = 0 + for item in self.generator: + self.batch_buffer.append(item) + count += 1 + # If batch_size is zero, that means fetch everything in one big batch, so keep + # going. Otherwise, break when we have fetched 'batch_size' items. + if self.batch_size and count >= self.batch_size: + break + # If there's still nothing in the buffer, we're done. Stop the iteration. Otherwise, + # return the first item in the buffer. + if self.batch_buffer: + return self.batch_buffer.pop(0) + else: + raise StopIteration + + +def tobool(x): + """Convert an object to boolean. + + Examples: + >>> print(tobool('TRUE')) + True + >>> print(tobool(True)) + True + >>> print(tobool(1)) + True + >>> print(tobool('FALSE')) + False + >>> print(tobool(False)) + False + >>> print(tobool(0)) + False + >>> print(tobool('Foo')) + Traceback (most recent call last): + ValueError: Unknown boolean specifier: 'Foo'. + >>> print(tobool(None)) + Traceback (most recent call last): + ValueError: Unknown boolean specifier: 'None'. + """ + + try: + if x.lower() in ('true', 'yes', 'y'): + return True + elif x.lower() in ('false', 'no', 'n'): + return False + except AttributeError: + pass + try: + return bool(int(x)) + except (ValueError, TypeError): + pass + raise ValueError("Unknown boolean specifier: '%s'." % x) + + +to_bool = tobool + + +def to_int(x): + """Convert an object to an integer, unless it is None + + Examples: + >>> print(to_int(123)) + 123 + >>> print(to_int('123')) + 123 + >>> print(to_int(-5.2)) + -5 + >>> print(to_int(None)) + None + """ + if isinstance(x, str) and (x.lower() == 'none' or x == ''): + x = None + try: + return int(x) if x is not None else None + except ValueError: + # Perhaps it's a string, holding a floating point number? + return int(float(x)) + + +def to_float(x): + """Convert an object to a float, unless it is None + + Examples: + >>> print(to_float(12.3)) + 12.3 + >>> print(to_float('12.3')) + 12.3 + >>> print(to_float(None)) + None + """ + if isinstance(x, str) and x.lower() == 'none': + x = None + return float(x) if x is not None else None + + +def to_complex(magnitude, direction): + """Convert from magnitude and direction to a complex number.""" + if magnitude is None: + value = None + elif magnitude == 0: + # If magnitude is zero, it doesn't matter what direction is. Can even be None. + value = complex(0.0, 0.0) + elif direction is None: + # Magnitude must be non-zero, but we don't know the direction. + value = None + else: + # Magnitude is non-zero, and we have a good direction. + x = magnitude * math.cos(math.radians(90.0 - direction)) + y = magnitude * math.sin(math.radians(90.0 - direction)) + value = complex(x, y) + return value + + +def dirN(c): + """Given a complex number, return its phase as a compass heading""" + if c is None: + value = None + else: + value = (450 - math.degrees(cmath.phase(c))) % 360.0 + return value + + +class Polar(object): + """Polar notation, except the direction is a compass heading.""" + + def __init__(self, mag, direction): + self.mag = mag + self.dir = direction + + @classmethod + def from_complex(cls, c): + return cls(abs(c), dirN(c)) + + def __str__(self): + return "(%s, %s)" % (self.mag, self.dir) + + def __eq__(self, other): + return self.mag == other.mag and self.dir == other.dir + + +def rounder(x, ndigits): + """Round a number, or sequence of numbers, to a specified number of decimal digits + + Args: + x (None, float, complex, list): The number or sequence of numbers to be rounded. If the + argument is None, then None will be returned. + ndigits (int|None): The number of decimal digits to retain. Set to None to retain them all + + Returns: + None, float, complex, list: Returns the number, or sequence of numbers, with the requested + number of decimal digits. If 'None', no rounding is done, and the function returns + the original value. + """ + if ndigits is None: + return x + elif x is None: + return None + elif isinstance(x, complex): + return complex(round(x.real, ndigits), round(x.imag, ndigits)) + elif isinstance(x, Polar): + return Polar(round(x.mag, ndigits), round(x.dir, ndigits)) + elif isinstance(x, float): + return round(x, ndigits) if ndigits else int(x) + elif is_iterable(x): + return [rounder(v, ndigits) for v in x] + return x + + +def min_with_none(x_seq): + """Find the minimum in a (possibly empty) sequence, ignoring Nones""" + xmin = None + for x in x_seq: + if xmin is None: + xmin = x + elif x is not None: + xmin = min(x, xmin) + return xmin + + +def max_with_none(x_seq): + """Find the maximum in a (possibly empty) sequence, ignoring Nones. + + While this function is not necessary under Python 2, under Python 3 it is. + """ + xmax = None + for x in x_seq: + if xmax is None: + xmax = x + elif x is not None: + xmax = max(x, xmax) + return xmax + + +def move_with_timestamp(path): + """Save a file or directory to a path with a timestamp.""" + # Sometimes the target has a trailing '/'. This will take care of it: + path = os.path.normpath(path) + newpath = path + time.strftime(".%Y%m%d%H%M%S") + # Check to see if this name already exists + if os.path.exists(newpath): + # It already exists. Stick a version number on it: + version = 1 + while os.path.exists(newpath + '-' + str(version)): + version += 1 + newpath = newpath + '-' + str(version) + shutil.move(path, newpath) + return newpath + + +class ListOfDicts(ChainMap): + def extend(self, m): + self.maps.append(m) + + def prepend(self, m): + self.maps.insert(0, m) + + +class KeyDict(dict): + """A dictionary that returns the key for an unsuccessful lookup.""" + + def __missing__(self, key): + return key + + +def atoi(text): + return int(text) if text.isdigit() else text + + +def natural_keys(text): + """Natural key sort. + + Allows use of key=natural_keys to sort a list in human order, eg: + alist.sort(key=natural_keys) + + Ref: https://nedbatchelder.com/blog/200712/human_sorting.html + """ + + return [atoi(c) for c in re.split(natural_keys.compiled_re, text.lower())] + + +natural_keys.compiled_re = re.compile(r'(\d+)') + + +def natural_sort_keys(source_dict): + """Return a naturally sorted list of keys for a dict.""" + + # create a list of keys in the dict + keys_list = list(source_dict.keys()) + # naturally sort the list of keys such that, for example, xxxxx16 appears + # after xxxxx1 + keys_list.sort(key=natural_keys) + # return the sorted list + return keys_list + + +def to_sorted_string(rec, simple_sort=False): + """Return a string representation of a dict sorted by key. + + Default action is to perform a 'natural' sort by key, ie 'xxx1' appears + before 'xxx16'. If called with simple_sort=True a simple alphanumeric sort + is performed instead which will result in 'xxx16' appearing before 'xxx1'. + """ + + if simple_sort: + import locale + return ", ".join(["%s: %s" % (k, rec.get(k)) for k in sorted(rec, key=locale.strxfrm)]) + else: + # first obtain a list of key:value pairs sorted naturally by key + sorted_dict = ["'%s': '%s'" % (k, rec[k]) for k in natural_sort_keys(rec)] + # return as a string of comma separated key:value pairs in braces + return ", ".join(sorted_dict) + + +def y_or_n(msg, noprompt=False, default=None): + """Prompt and look for a 'y' or 'n' response + + Args: + msg(str): A prompting message + noprompt(bool): If truthy, don't prompt the user. Just do it. + default(str|None): Value to be returned if no prompting has been requested + Returns: + str: Either 'y', or 'n'. + """ + + # If noprompt is truthy, return the default + if noprompt: + return 'y' if default is None else default + + while True: + ans = input(msg).strip().lower() + if not ans and default is not None: + return default + elif ans in ('y', 'n'): + return ans + + +def deep_copy_path(path, dest_dir): + """Copy a path to a destination, making any subdirectories along the way. + The source path is relative to the current directory. + + Returns the number of files copied + """ + + ncopy = 0 + # Are we copying a directory? + if os.path.isdir(path): + # Yes. Walk it + for dirpath, _, filenames in os.walk(path): + for f in filenames: + # For each source file found, call myself recursively: + ncopy += deep_copy_path(os.path.join(dirpath, f), dest_dir) + else: + # path is a file. Get the directory it's in. + d = os.path.dirname(os.path.join(dest_dir, path)) + # Make the destination directory: + os.makedirs(d, exist_ok=True) + # This version of copy does not copy over modification time, + # so it will look like a new file, causing it to be (for + # example) ftp'd to the server: + shutil.copy(path, d) + ncopy += 1 + return ncopy + + +def is_iterable(x): + """Test if something is iterable, but not a string""" + return hasattr(x, '__iter__') and not isinstance(x, (bytes, str)) + + +class bcolors: + """Colors used for terminals""" + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKCYAN = '\033[96m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + + +def version_compare(v1, v2): + """Compare two version numbers + + Args: + v1(str): The first version number as a string. Can be something like '4.5.1a1' + v2(str): The second version number as a string. + + Returns: + int: Returns +1 if v1 is greater than v2, -1 if less than, 0 if they are the same. + """ + + import itertools + + mash = itertools.zip_longest(v1.split('.'), v2.split('.'), fillvalue='0') + + for x1, x2 in mash: + if x1 > x2: + return 1 + if x1 < x2: + return -1 + return 0 + + +def get_resource_path(package, resource): + """Return a path to a resource within a package. The resource can be a directory or a file.""" + import sys + + if sys.version_info.major == 3 and sys.version_info.minor < 9: + # For earlier Python versions, use the deprecated function path() + return importlib_resources.path(package, resource) + else: + # For later versions... + return importlib_resources.as_file(importlib_resources.files(package).joinpath(resource)) + + +def get_resource_fd(package, resource): + """Return a file descriptor to a resource within a package.""" + import sys + + if sys.version_info.major == 3 and sys.version_info.minor < 9: + # For earlier Python versions, use the deprecated function open_text + return importlib_resources.open_text(package, resource) + else: + # For later versions... + return importlib_resources.files(package).joinpath(resource).open('r') + + +if __name__ == '__main__': + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weewx/__init__.py b/dist/weewx-5.0.2/src/weewx/__init__.py new file mode 100644 index 0000000..464dc8a --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/__init__.py @@ -0,0 +1,186 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Package weewx, containing modules specific to the weewx runtime engine.""" +import time + +__version__ = "5.0.2" + +# Holds the program launch time in unix epoch seconds: +# Useful for calculating 'uptime.' +launchtime_ts = time.time() + +# Set to true for extra debug information: +debug = False + +# Exit return codes +CMD_ERROR = 2 +CONFIG_ERROR = 3 +IO_ERROR = 4 +DB_ERROR = 5 + +# Constants used to indicate a unit system: +METRIC = 0x10 +METRICWX = 0x11 +US = 0x01 + + +# ============================================================================= +# Define possible exceptions that could get thrown. +# ============================================================================= + +class WeeWxIOError(IOError): + """Base class of exceptions thrown when encountering an input/output error + with the hardware.""" + + +class WakeupError(WeeWxIOError): + """Exception thrown when unable to wake up or initially connect with the + hardware.""" + + +class CRCError(WeeWxIOError): + """Exception thrown when unable to pass a CRC check.""" + + +class RetriesExceeded(WeeWxIOError): + """Exception thrown when max retries exceeded.""" + + +class HardwareError(Exception): + """Exception thrown when an error is detected in the hardware.""" + + +class UnknownArchiveType(HardwareError): + """Exception thrown after reading an unrecognized archive type.""" + + +class UnsupportedFeature(Exception): + """Exception thrown when attempting to access a feature that is not + supported (yet).""" + + +class ViolatedPrecondition(Exception): + """Exception thrown when a function is called with violated + preconditions.""" + + +class StopNow(Exception): + """Exception thrown to stop the engine.""" + + +class UnknownDatabase(Exception): + """Exception thrown when attempting to use an unknown database.""" + + +class UnknownDatabaseType(Exception): + """Exception thrown when attempting to use an unknown database type.""" + + +class UnknownBinding(Exception): + """Exception thrown when attempting to use an unknown data binding.""" + + +class UnitError(ValueError): + """Exception thrown when there is a mismatch in unit systems.""" + + +class UnknownType(ValueError): + """Exception thrown for an unknown observation type""" + + +class UnknownAggregation(ValueError): + """Exception thrown for an unknown aggregation type""" + + +class CannotCalculate(ValueError): + """Exception raised when a type cannot be calculated.""" + + +class NoCalculate(Exception): + """Exception raised when a type does not need to be calculated.""" + + +# ============================================================================= +# Possible event types. +# ============================================================================= + +class STARTUP(object): + """Event issued when the engine first starts up. Services have been + loaded.""" + + +class PRE_LOOP(object): + """Event issued just before the main packet loop is entered. Services + have been loaded.""" + + +class NEW_LOOP_PACKET(object): + """Event issued when a new LOOP packet is available. The event contains + attribute 'packet', which is the new LOOP packet.""" + + +class CHECK_LOOP(object): + """Event issued in the main loop, right after a new LOOP packet has been + processed. Generally, it is used to throw an exception, breaking the main + loop, so the console can be used for other things.""" + + +class END_ARCHIVE_PERIOD(object): + """Event issued at the end of an archive period.""" + + +class NEW_ARCHIVE_RECORD(object): + """Event issued when a new archive record is available. The event contains + attribute 'record', which is the new archive record.""" + + +class POST_LOOP(object): + """Event issued right after the main loop has been broken. Services hook + into this to access the console for things other than generating LOOP + packet.""" + + +# ============================================================================= +# Service groups. +# ============================================================================= + +# All existent service groups and the order in which they should be run: +all_service_groups = ['prep_services', 'data_services', 'process_services', 'xtype_services', + 'archive_services', 'restful_services', 'report_services'] + + +# ============================================================================= +# Class Event +# ============================================================================= +class Event(object): + """Represents an event.""" + + def __init__(self, event_type, **argv): + self.event_type = event_type + + for key in argv: + setattr(self, key, argv[key]) + + def __str__(self): + """Return a string with a reasonable representation of the event.""" + et = "Event type: %s | " % self.event_type + s = "; ".join("%s: %s" % (k, self.__dict__[k]) for k in self.__dict__ if k != "event_type") + return et + s + + +# ============================================================================= +# Utilities +# ============================================================================= + + +def require_weewx_version(module, required_version): + """utility to check for version compatibility""" + from weeutil.weeutil import version_compare + if version_compare(__version__, required_version) < 0: + raise UnsupportedFeature("%s requires weewx %s or greater, found %s" + % (module, required_version, __version__)) + + diff --git a/dist/weewx-5.0.2/src/weewx/accum.py b/dist/weewx-5.0.2/src/weewx/accum.py new file mode 100644 index 0000000..e3d17f6 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/accum.py @@ -0,0 +1,725 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Statistical accumulators. They accumulate the highs, lows, averages, etc., +of a sequence of records.""" +# +# General strategy. +# +# Most observation types are scalars, so they can be treated simply. Values are added to a scalar +# accumulator, which keeps track of highs, lows, and a sum. When it comes time for extraction, the +# average over the archive period is typically produced. +# +# However, wind is a special case. It is a vector, which has been flatted over at least two +# scalars, windSpeed and windDir. Some stations, notably the Davis Vantage, add windGust and +# windGustDir. The accumulators cannot simply treat them individually as if they were just another +# scalar. Instead, they must be grouped together. This is done by treating windSpeed as a 'special' +# scalar. When it appears, it is coupled with windDir and, if available, windGust and windGustDir, +# and added to a vector accumulator. When the other types ( windDir, windGust, and windGustDir) +# appear, they are ignored, having already been handled during the processing of type windSpeed. +# +# When it comes time to extract wind, vector averages are calculated, then the results are +# flattened again. +# +import logging +import math + +import weewx +from weeutil.weeutil import ListOfDicts, to_float, timestamp_to_string +import weeutil.config + +log = logging.getLogger(__name__) + +# +# Default mappings from observation types to accumulator classes and functions +# + +DEFAULTS_INI = """ +[Accumulator] + [[consBatteryVoltage]] + extractor = last + [[dateTime]] + adder = noop + [[dayET]] + extractor = last + [[dayRain]] + extractor = last + [[ET]] + extractor = sum + [[hourRain]] + extractor = last + [[rain]] + extractor = sum + [[rain24]] + extractor = last + [[monthET]] + extractor = last + [[monthRain]] + extractor = last + [[stormRain]] + extractor = last + [[totalRain]] + extractor = last + [[txBatteryStatus]] + extractor = last + [[usUnits]] + adder = check_units + [[wind]] + accumulator = vector + extractor = wind + [[windDir]] + extractor = noop + [[windGust]] + extractor = noop + [[windGustDir]] + extractor = noop + [[windGust10]] + extractor = last + [[windGustDir10]] + extractor = last + [[windrun]] + extractor = sum + [[windSpeed]] + adder = add_wind + merger = avg + extractor = noop + [[windSpeed2]] + extractor = last + [[windSpeed10]] + extractor = last + [[yearET]] + extractor = last + [[yearRain]] + extractor = last + [[lightning_strike_count]] + extractor = sum +""" +defaults_dict = weeutil.config.config_from_str(DEFAULTS_INI) + +accum_dict = ListOfDicts(defaults_dict['Accumulator'].dict()) + + +class OutOfSpan(ValueError): + """Raised when attempting to add a record outside the timespan held by an accumulator""" + + +# =============================================================================== +# FirstLastAccum +# =============================================================================== + +class FirstLastAccum(object): + """Minimal accumulator, suitable for strings. + It can only return the first and last value it has seen, along with their timestamps. + """ + + default_init = (None, None, None, None, 0.0, 0, 0.0, 0) + + def __init__(self, stats_tuple=None): + self.first = None + self.firsttime = None + self.last = None + self.lasttime = None + + def setStats(self, stats_tuple=None): + pass + + def getStatsTuple(self): + """Return a stats-tuple. That is, a tuple containing the gathered statistics. + This tuple can be used to update the stats database""" + return FirstLastAccum.default_init + + def mergeHiLo(self, x_stats): + """Merge the highs and lows of another accumulator into myself.""" + if x_stats.firsttime is not None: + if self.firsttime is None or x_stats.firsttime < self.firsttime: + self.firsttime = x_stats.firsttime + self.first = x_stats.first + if x_stats.lasttime is not None: + if self.lasttime is None or x_stats.lasttime >= self.lasttime: + self.lasttime = x_stats.lasttime + self.last = x_stats.last + + def mergeSum(self, x_stats): + """Merge the count of another accumulator into myself.""" + pass + + def addHiLo(self, val, ts): + """Include a value in my stats. + val: A value of almost any type. + ts: The timestamp. + """ + if val is not None: + if self.firsttime is None or ts < self.firsttime: + self.first = val + self.firsttime = ts + if self.lasttime is None or ts >= self.lasttime: + self.last = val + self.lasttime = ts + + def addSum(self, val, weight=1): + """Add a scalar value to my running count.""" + pass + + +# =============================================================================== +# ScalarStats +# =============================================================================== + +class ScalarStats(FirstLastAccum): + """Accumulates statistics (min, max, average, etc.) for a scalar value.""" + + def __init__(self, stats_tuple=None): + # Call my superclass's version + FirstLastAccum.__init__(self, stats_tuple) + self.setStats(stats_tuple) + + def setStats(self, stats_tuple=None): + (self.min, self.mintime, + self.max, self.maxtime, + self.sum, self.count, + self.wsum, self.sumtime) = stats_tuple if stats_tuple else FirstLastAccum.default_init + + def getStatsTuple(self): + """Return a stats-tuple. That is, a tuple containing the gathered statistics. + This tuple can be used to update the stats database""" + return (self.min, self.mintime, self.max, self.maxtime, + self.sum, self.count, self.wsum, self.sumtime) + + def mergeHiLo(self, x_stats): + """Merge the highs and lows of another accumulator into myself.""" + + # Call my superclass's version + FirstLastAccum.mergeHiLo(self, x_stats) + + if x_stats.min is not None: + if self.min is None or x_stats.min < self.min: + self.min = x_stats.min + self.mintime = x_stats.mintime + if x_stats.max is not None: + if self.max is None or x_stats.max > self.max: + self.max = x_stats.max + self.maxtime = x_stats.maxtime + + def mergeSum(self, x_stats): + """Merge the sum and count of another accumulator into myself.""" + self.sum += x_stats.sum + self.count += x_stats.count + self.wsum += x_stats.wsum + self.sumtime += x_stats.sumtime + + def addHiLo(self, val, ts): + """Include a scalar value in my highs and lows. + val: A scalar value + ts: The timestamp. """ + + # Call my superclass's version: + FirstLastAccum.addHiLo(self, val, ts) + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + val = to_float(val) + except ValueError: + val = None + + # Check for None and NaN: + if val is not None and val == val: + if self.min is None or val < self.min: + self.min = val + self.mintime = ts + if self.max is None or val > self.max: + self.max = val + self.maxtime = ts + + def addSum(self, val, weight=1): + """Add a scalar value to my running sum and count.""" + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + val = to_float(val) + except ValueError: + val = None + + # Check for None and NaN: + if val is not None and val == val: + self.sum += val + self.count += 1 + self.wsum += val * weight + self.sumtime += weight + + @property + def avg(self): + return self.wsum / self.sumtime if self.count else None + + +# =============================================================================== +# VecStats +# =============================================================================== + +class VecStats(object): + """Accumulates statistics for a vector value. + + Property 'last' is the last non-None value seen. It is a two-way tuple (mag, dir). + Property 'lasttime' is the time it was seen. """ + + default_init = (None, None, None, None, + 0.0, 0, 0.0, 0, None, 0.0, 0.0, 0, 0.0, 0.0) + + def __init__(self, stats_tuple=None): + self.setStats(stats_tuple) + self.last = (None, None) + self.lasttime = None + + def setStats(self, stats_tuple=None): + (self.min, self.mintime, + self.max, self.maxtime, + self.sum, self.count, + self.wsum, self.sumtime, + self.max_dir, self.xsum, self.ysum, + self.dirsumtime, self.squaresum, + self.wsquaresum) = stats_tuple if stats_tuple else VecStats.default_init + + def getStatsTuple(self): + """Return a stats-tuple. That is, a tuple containing the gathered statistics.""" + return (self.min, self.mintime, + self.max, self.maxtime, + self.sum, self.count, + self.wsum, self.sumtime, + self.max_dir, self.xsum, self.ysum, + self.dirsumtime, self.squaresum, self.wsquaresum) + + def mergeHiLo(self, x_stats): + """Merge the highs and lows of another accumulator into myself.""" + if x_stats.min is not None: + if self.min is None or x_stats.min < self.min: + self.min = x_stats.min + self.mintime = x_stats.mintime + if x_stats.max is not None: + if self.max is None or x_stats.max > self.max: + self.max = x_stats.max + self.maxtime = x_stats.maxtime + self.max_dir = x_stats.max_dir + if x_stats.lasttime is not None: + if self.lasttime is None or x_stats.lasttime >= self.lasttime: + self.lasttime = x_stats.lasttime + self.last = x_stats.last + + def mergeSum(self, x_stats): + """Merge the sum and count of another accumulator into myself.""" + self.sum += x_stats.sum + self.count += x_stats.count + self.wsum += x_stats.wsum + self.sumtime += x_stats.sumtime + self.xsum += x_stats.xsum + self.ysum += x_stats.ysum + self.dirsumtime += x_stats.dirsumtime + self.squaresum += x_stats.squaresum + self.wsquaresum += x_stats.wsquaresum + + def addHiLo(self, val, ts): + """Include a vector value in my highs and lows. + val: A vector value. It is a 2-way tuple (mag, dir). + ts: The timestamp. + """ + speed, dirN = val + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + speed = to_float(speed) + except ValueError: + speed = None + try: + dirN = to_float(dirN) + except ValueError: + dirN = None + + # Check for None and NaN: + if speed is not None and speed == speed: + if self.min is None or speed < self.min: + self.min = speed + self.mintime = ts + if self.max is None or speed > self.max: + self.max = speed + self.maxtime = ts + self.max_dir = dirN + if self.lasttime is None or ts >= self.lasttime: + self.last = (speed, dirN) + self.lasttime = ts + + def addSum(self, val, weight=1): + """Add a vector value to my sum and squaresum. + val: A vector value. It is a 2-way tuple (mag, dir) + """ + speed, dirN = val + + # If necessary, convert to float. Be prepared to catch an exception if not possible. + try: + speed = to_float(speed) + except ValueError: + speed = None + try: + dirN = to_float(dirN) + except ValueError: + dirN = None + + # Check for None and NaN: + if speed is not None and speed == speed: + self.sum += speed + self.count += 1 + self.wsum += weight * speed + self.sumtime += weight + self.squaresum += speed ** 2 + self.wsquaresum += weight * speed ** 2 + if dirN is not None: + self.xsum += weight * speed * math.cos(math.radians(90.0 - dirN)) + self.ysum += weight * speed * math.sin(math.radians(90.0 - dirN)) + # It's OK for direction to be None, provided speed is zero: + if dirN is not None or speed == 0: + self.dirsumtime += weight + + @property + def avg(self): + return self.wsum / self.sumtime if self.count else None + + @property + def rms(self): + return math.sqrt(self.wsquaresum / self.sumtime) if self.count else None + + @property + def vec_avg(self): + if self.count: + return math.sqrt((self.xsum ** 2 + self.ysum ** 2) / self.sumtime ** 2) + + @property + def vec_dir(self): + if self.dirsumtime and (self.ysum or self.xsum): + _result = 90.0 - math.degrees(math.atan2(self.ysum, self.xsum)) + if _result < 0.0: + _result += 360.0 + return _result + # Return the last known direction when our vector sum is 0 + return self.last[1] + + +# =============================================================================== +# Class Accum +# =============================================================================== + +class Accum(dict): + """Accumulates statistics for a set of observation types.""" + + def __init__(self, timespan, unit_system=None): + """Initialize an Accum. + + timespan: The time period over which stats will be accumulated. + unit_system: The unit system used by the accumulator""" + + self.timespan = timespan + # Set the accumulator's unit system. Usually left unspecified until the + # first observation comes in for normal operation or pre-set if + # obtaining a historical accumulator. + self.unit_system = unit_system + + def addRecord(self, record, add_hilo=True, weight=1): + """Add a record to my running statistics. + + The record must have keys 'dateTime' and 'usUnits'.""" + + # Check to see if the record is within my observation timespan + if not self.timespan.includesArchiveTime(record['dateTime']): + raise OutOfSpan("Attempt to add out-of-interval record (%s) to timespan (%s)" + % (timestamp_to_string(record['dateTime']), self.timespan)) + + for obs_type in record: + # Get the proper function ... + func = get_add_function(obs_type) + # ... then call it. + func(self, record, obs_type, add_hilo, weight) + + def updateHiLo(self, accumulator): + """Merge the high/low stats of another accumulator into me.""" + if accumulator.timespan.start < self.timespan.start \ + or accumulator.timespan.stop > self.timespan.stop: + raise OutOfSpan("Attempt to merge an accumulator whose timespan is not a subset") + + self._check_units(accumulator.unit_system) + + for obs_type in accumulator: + # Initialize the type if we have not seen it before + self._init_type(obs_type) + + # Get the proper function ... + func = get_merge_function(obs_type) + # ... then call it + func(self, accumulator, obs_type) + + def getRecord(self): + """Extract a record out of the results in the accumulator.""" + + # All records have a timestamp and unit type + record = {'dateTime': self.timespan.stop, + 'usUnits': self.unit_system} + + return self.augmentRecord(record) + + def augmentRecord(self, record): + + # Go through all observation types. + for obs_type in self: + # If the type does not appear in the record, then add it: + if obs_type not in record: + # Get the proper extraction function... + func = get_extract_function(obs_type) + # ... then call it + func(self, record, obs_type) + + return record + + def set_stats(self, obs_type, stats_tuple): + + self._init_type(obs_type) + self[obs_type].setStats(stats_tuple) + + # + # Begin add functions. These add a record to the accumulator. + # + + def add_value(self, record, obs_type, add_hilo, weight): + """Add a single observation to myself.""" + + val = record[obs_type] + + # If the type has not been seen before, initialize it + self._init_type(obs_type) + # Then add to highs/lows, and to the running sum: + if add_hilo: + self[obs_type].addHiLo(val, record['dateTime']) + self[obs_type].addSum(val, weight=weight) + + def add_wind_value(self, record, obs_type, add_hilo, weight): + """Add a single observation of type wind to myself.""" + + if obs_type in ['windDir', 'windGust', 'windGustDir']: + return + if weewx.debug: + assert (obs_type == 'windSpeed') + + # First add it to regular old 'windSpeed', then + # treat it like a vector. + self.add_value(record, obs_type, add_hilo, weight) + + # If the type has not been seen before, initialize it. + self._init_type('wind') + # Then add to highs/lows. + if add_hilo: + # If the station does not provide windGustDir, then substitute windDir. + # See issue #320, https://bit.ly/2HSo0ju + wind_gust_dir = record['windGustDir'] \ + if 'windGustDir' in record else record.get('windDir') + # Do windGust first, so that the last value entered is windSpeed, not windGust + # See Slack discussion https://bit.ly/3qV1nBV + self['wind'].addHiLo((record.get('windGust'), wind_gust_dir), + record['dateTime']) + self['wind'].addHiLo((record.get('windSpeed'), record.get('windDir')), + record['dateTime']) + # Add to the running sum. + self['wind'].addSum((record['windSpeed'], record.get('windDir')), weight=weight) + + def check_units(self, record, obs_type, add_hilo, weight): + if weewx.debug: + assert (obs_type == 'usUnits') + self._check_units(record['usUnits']) + + def noop(self, record, obs_type, add_hilo=True, weight=1): + pass + + # + # Begin hi/lo merge functions. These are called when merging two accumulators + # + + def merge_minmax(self, x_accumulator, obs_type): + """Merge value in another accumulator, using min/max""" + + self[obs_type].mergeHiLo(x_accumulator[obs_type]) + + def merge_avg(self, x_accumulator, obs_type): + """Merge value in another accumulator, using avg for max""" + x_stats = x_accumulator[obs_type] + if x_stats.min is not None: + if self[obs_type].min is None or x_stats.min < self[obs_type].min: + self[obs_type].min = x_stats.min + self[obs_type].mintime = x_stats.mintime + if x_stats.avg is not None: + if self[obs_type].max is None or x_stats.avg > self[obs_type].max: + self[obs_type].max = x_stats.avg + self[obs_type].maxtime = x_accumulator.timespan.stop + if x_stats.lasttime is not None: + if self[obs_type].lasttime is None or x_stats.lasttime >= self[obs_type].lasttime: + self[obs_type].lasttime = x_stats.lasttime + self[obs_type].last = x_stats.last + + # + # Begin extraction functions. These extract a record out of the accumulator. + # + + def extract_wind(self, record, obs_type): + """Extract wind values from myself, and put in a record.""" + # Wind records must be flattened into the separate categories: + if 'windSpeed' not in record: + record['windSpeed'] = self[obs_type].avg + if 'windDir' not in record: + record['windDir'] = self[obs_type].vec_dir + if 'windGust' not in record: + record['windGust'] = self[obs_type].max + if 'windGustDir' not in record: + record['windGustDir'] = self[obs_type].max_dir + + def extract_sum(self, record, obs_type): + record[obs_type] = self[obs_type].sum if self[obs_type].count else None + + def extract_first(self, record, obs_type): + record[obs_type] = self[obs_type].first + + def extract_last(self, record, obs_type): + record[obs_type] = self[obs_type].last + + def extract_avg(self, record, obs_type): + record[obs_type] = self[obs_type].avg + + def extract_min(self, record, obs_type): + record[obs_type] = self[obs_type].min + + def extract_max(self, record, obs_type): + record[obs_type] = self[obs_type].max + + def extract_count(self, record, obs_type): + record[obs_type] = self[obs_type].count + + # + # Miscellaneous, utility functions + # + + def _init_type(self, obs_type): + """Add a given observation type to my dictionary.""" + # Do nothing if this type has already been initialized: + if obs_type in self: + return + + # Get a new accumulator of the proper type + self[obs_type] = new_accumulator(obs_type) + + def _check_units(self, new_unit_system): + # If no unit system has been specified for me yet, adopt the incoming + # system + if self.unit_system is None: + self.unit_system = new_unit_system + else: + # Otherwise, make sure they match + if self.unit_system != new_unit_system: + raise ValueError("Unit system mismatch %d v. %d" % (self.unit_system, + new_unit_system)) + + @property + def isEmpty(self): + return self.unit_system is None + + +# =============================================================================== +# Configuration dictionaries +# =============================================================================== + +# +# Mappings from convenient string nicknames, which can be used in a config file, +# to actual functions and classes +# + +ACCUM_TYPES = { + 'scalar': ScalarStats, + 'vector': VecStats, + 'firstlast': FirstLastAccum +} + +ADD_FUNCTIONS = { + 'add': Accum.add_value, + 'add_wind': Accum.add_wind_value, + 'check_units': Accum.check_units, + 'noop': Accum.noop +} + +MERGE_FUNCTIONS = { + 'minmax': Accum.merge_minmax, + 'avg': Accum.merge_avg +} + +EXTRACT_FUNCTIONS = { + 'avg': Accum.extract_avg, + 'count': Accum.extract_count, + 'first' : Accum.extract_first, + 'last': Accum.extract_last, + 'max': Accum.extract_max, + 'min': Accum.extract_min, + 'noop': Accum.noop, + 'sum': Accum.extract_sum, + 'wind': Accum.extract_wind, +} + +# The default actions for an individual observation type +OBS_DEFAULTS = { + 'accumulator': 'scalar', + 'adder': 'add', + 'merger': 'minmax', + 'extractor': 'avg' +} + + +def initialize(config_dict): + # Add the configuration dictionary to the beginning of the list of maps. + # This will cause it to override the defaults + global accum_dict + accum_dict.maps.insert(0, config_dict.get('Accumulator', {})) + + +def new_accumulator(obs_type): + """Instantiate an accumulator, appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the accumulator. Default is 'scalar' + accum_nickname = obs_options.get('accumulator', 'scalar') + # Instantiate and return the accumulator. + # If we don't know this nickname, then fail hard with a KeyError + return ACCUM_TYPES[accum_nickname]() + + +def get_add_function(obs_type): + """Get an adder function appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the adder. Default is 'add' + add_nickname = obs_options.get('adder', 'add') + # If we don't know this nickname, then fail hard with a KeyError + return ADD_FUNCTIONS[add_nickname] + + +def get_merge_function(obs_type): + """Get a merge function appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the merger. Default is 'minmax' + add_nickname = obs_options.get('merger', 'minmax') + # If we don't know this nickname, then fail hard with a KeyError + return MERGE_FUNCTIONS[add_nickname] + + +def get_extract_function(obs_type): + """Get an extraction function appropriate for type 'obs_type'.""" + global accum_dict + # Get the options for this type. Substitute the defaults if they have not been specified + obs_options = accum_dict.get(obs_type, OBS_DEFAULTS) + # Get the nickname of the extractor. Default is 'avg' + add_nickname = obs_options.get('extractor', 'avg') + # If we don't know this nickname, then fail hard with a KeyError + return EXTRACT_FUNCTIONS[add_nickname] diff --git a/dist/weewx-5.0.2/src/weewx/almanac.py b/dist/weewx-5.0.2/src/weewx/almanac.py new file mode 100644 index 0000000..cef687e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/almanac.py @@ -0,0 +1,576 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Almanac data + +This module can optionally use PyEphem, which offers high quality +astronomical calculations. See http://rhodesmill.org/pyephem. """ + +import copy +import math +import sys +import time + +import weeutil.Moon +import weewx.units +from weewx.units import ValueTuple + +# If the user has installed ephem, use it. Otherwise, fall back to the weeutil algorithms: +try: + import ephem +except ImportError: + import weeutil.Sun + + +# NB: Have Almanac inherit from 'object'. However, this will cause +# an 'autocall' bug in Cheetah versions before 2.1. +class Almanac(object): + """Almanac data. + + ATTRIBUTES. + + As a minimum, the following attributes are available: + + sunrise: Time (local) upper limb of the sun rises above the horizon, formatted using the format 'timeformat'. + sunset: Time (local) upper limb of the sun sinks below the horizon, formatted using the format 'timeformat'. + moon_phase: A description of the moon phase (e.g. "new moon", Waxing crescent", etc.) + moon_fullness: Percent fullness of the moon (0=new moon, 100=full moon) + + If the module 'ephem' is used, them many other attributes are available. + Here are a few examples: + + sun.rise: Time upper limb of sun will rise above the horizon today in unix epoch time + sun.transit: Time of transit today (sun over meridian) in unix epoch time + sun.previous_sunrise: Time of last sunrise in unix epoch time + sun.az: Azimuth (in degrees) of sun + sun.alt: Altitude (in degrees) of sun + mars.rise: Time when upper limb of mars will rise above horizon today in unix epoch time + mars.ra: Right ascension of mars + etc. + + EXAMPLES: + + These examples require pyephem to be installed. + >>> if "ephem" not in sys.modules: + ... raise KeyboardInterrupt("Almanac examples require 'pyephem'") + + These examples are designed to work in the Pacific timezone + >>> import os + >>> os.environ['TZ'] = 'America/Los_Angeles' + >>> time.tzset() + >>> from weeutil.weeutil import timestamp_to_string, timestamp_to_gmtime + >>> t = 1238180400 + >>> print(timestamp_to_string(t)) + 2009-03-27 12:00:00 PDT (1238180400) + + Test conversions to Dublin Julian Days + >>> t_djd = timestamp_to_djd(t) + >>> print("%.5f" % t_djd) + 39898.29167 + + Test the conversion back + >>> print("%.0f" % djd_to_timestamp(t_djd)) + 1238180400 + + >>> almanac = Almanac(t, 46.0, -122.0, formatter=weewx.units.get_default_formatter()) + + Test backwards compatibility with attribute 'moon_fullness': + >>> print("Fullness of the moon (rounded) is %.2f%% [%s]" % (almanac._moon_fullness, almanac.moon_phase)) + Fullness of the moon (rounded) is 3.00% [new (totally dark)] + + Now get a more precise result for fullness of the moon: + >>> print("Fullness of the moon (more precise) is %.2f%%" % almanac.moon.moon_fullness) + Fullness of the moon (more precise) is 1.70% + + Test backwards compatibility with attributes 'sunrise' and 'sunset' + >>> print("Sunrise, sunset: %s, %s" % (almanac.sunrise, almanac.sunset)) + Sunrise, sunset: 06:56:36, 19:30:41 + + Get sunrise, sun transit, and sunset using the new 'ephem' syntax: + >>> print("Sunrise, sun transit, sunset: %s, %s, %s" % (almanac.sun.rise, almanac.sun.transit, almanac.sun.set)) + Sunrise, sun transit, sunset: 06:56:36, 13:13:13, 19:30:41 + + Do the same with the moon: + >>> print("Moon rise, transit, set: %s, %s, %s" % (almanac.moon.rise, almanac.moon.transit, almanac.moon.set)) + Moon rise, transit, set: 06:59:14, 14:01:57, 21:20:06 + + And Mars + >>> print("Mars rise, transit, set: %s, %s, %s" % (almanac.mars.rise, almanac.mars.transit, almanac.mars.set)) + Mars rise, transit, set: 06:08:57, 11:34:13, 17:00:04 + + Finally, try a star + >>> print("Rigel rise, transit, set: %s, %s, %s" % (almanac.rigel.rise, almanac.rigel.transit, almanac.rigel.set)) + Rigel rise, transit, set: 12:32:32, 18:00:38, 23:28:43 + + Exercise sidereal time... + >>> print("%.4f" % almanac.sidereal_time) + 348.3400 + + ... and angle + >>> print(almanac.sidereal_angle) + 348° + + Exercise equinox, solstice routines + >>> print(almanac.next_vernal_equinox) + 03/20/10 10:32:11 + >>> print(almanac.next_autumnal_equinox) + 09/22/09 14:18:39 + >>> print(almanac.next_summer_solstice) + 06/20/09 22:45:40 + >>> print(almanac.previous_winter_solstice) + 12/21/08 04:03:36 + >>> print(almanac.next_winter_solstice) + 12/21/09 09:46:38 + + Exercise moon state routines + >>> print(almanac.next_full_moon) + 04/09/09 07:55:49 + >>> print(almanac.next_new_moon) + 04/24/09 20:22:33 + >>> print(almanac.next_first_quarter_moon) + 04/02/09 07:33:42 + + Now location of the sun and moon + >>> print("Solar azimuth, altitude = (%.2f, %.2f)" % (almanac.sun.az, almanac.sun.alt)) + Solar azimuth, altitude = (154.14, 44.02) + >>> print("Moon azimuth, altitude = (%.2f, %.2f)" % (almanac.moon.az, almanac.moon.alt)) + Moon azimuth, altitude = (133.55, 47.89) + + Again, but returning ValueHelpers + >>> print("Solar azimuth, altitude = (%s, %s)" % (almanac.sun.azimuth, almanac.sun.altitude)) + Solar azimuth, altitude = (154°, 44°) + >>> print("Moon azimuth, altitude = (%s, %s)" % (almanac.moon.azimuth, almanac.moon.altitude)) + Moon azimuth, altitude = (134°, 48°) + + Try a time and location where the sun is always up + >>> t = 1371044003 + >>> print(timestamp_to_string(t)) + 2013-06-12 06:33:23 PDT (1371044003) + >>> almanac = Almanac(t, 64.0, 0.0) + >>> print(almanac(horizon=-6).sun(use_center=1).rise) + N/A + + Try the pyephem "Naval Observatory" example. + >>> t = 1252256400 + >>> print(timestamp_to_gmtime(t)) + 2009-09-06 17:00:00 UTC (1252256400) + >>> atlanta = Almanac(t, 33.8, -84.4, pressure=0, horizon=-34.0/60.0) + >>> # Print it in GMT, so it can easily be compared to the example: + >>> print(timestamp_to_gmtime(atlanta.sun.previous_rising.raw)) + 2009-09-06 11:14:56 UTC (1252235696) + >>> print(timestamp_to_gmtime(atlanta.moon.next_setting.raw)) + 2009-09-07 14:05:29 UTC (1252332329) + + Now try the civil twilight examples: + >>> print(timestamp_to_gmtime(atlanta(horizon=-6).sun(use_center=1).previous_rising.raw)) + 2009-09-06 10:49:40 UTC (1252234180) + >>> print(timestamp_to_gmtime(atlanta(horizon=-6).sun(use_center=1).next_setting.raw)) + 2009-09-07 00:21:22 UTC (1252282882) + + Try sun rise again, to make sure the horizon value cleared: + >>> print(timestamp_to_gmtime(atlanta.sun.previous_rising.raw)) + 2009-09-06 11:14:56 UTC (1252235696) + + Try an attribute that does not explicitly appear in the class Almanac + >>> print("%.3f" % almanac.mars.sun_distance) + 1.494 + + Try a specialized attribute for Jupiter + >>> print(almanac.jupiter.cmlI) + 191:16:58.0 + + Should fail if applied to a different body + >>> print(almanac.venus.cmlI) + Traceback (most recent call last): + ... + AttributeError: 'Venus' object has no attribute 'cmlI' + + Try a nonsense body: + >>> x = almanac.bar.rise + Traceback (most recent call last): + ... + KeyError: 'Bar' + + Try a nonsense tag: + >>> x = almanac.sun.foo + Traceback (most recent call last): + ... + AttributeError: 'Sun' object has no attribute 'foo' + """ + + def __init__(self, time_ts, lat, lon, + altitude=None, + temperature=None, + pressure=None, + horizon=None, + moon_phases=weeutil.Moon.moon_phases, + formatter=None, + converter=None): + """Initialize an instance of Almanac + + Args: + + time_ts (int): A unix epoch timestamp with the time of the almanac. If None, the + present time will be used. + lat (float): Observer's latitude in degrees. + lon (float): Observer's longitude in degrees. + altitude: (float|None) Observer's elevation in **meters**. [Optional. Default + is 0 (sea level)] + temperature (float|None): Observer's temperature in **degrees Celsius**. + [Optional. Default is 15.0] + pressure (float|None): Observer's atmospheric pressure in **mBars**. + [Optional. Default is 1010] + horizon (float|None): Angle of the horizon in degrees [Optional. Default is zero] + moon_phases (list): An array of 8 strings with descriptions of the moon + phase. [optional. If not given, then weeutil.Moon.moon_phases will be used] + formatter (weewx.units.Formatter|None): An instance of weewx.units.Formatter + with the formatting information to be used. + converter (weewx.units.Converter|None): An instance of weewx.units.Converter + with the conversion information to be used. + """ + self.time_ts = time_ts if time_ts else time.time() + self.lat = lat + self.lon = lon + self.altitude = altitude if altitude is not None else 0.0 + self.temperature = temperature if temperature is not None else 15.0 + self.pressure = pressure if pressure is not None else 1010.0 + self.horizon = horizon if horizon is not None else 0.0 + self.moon_phases = moon_phases + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self._precalc() + + def _precalc(self): + """Precalculate local variables.""" + self.moon_index, self._moon_fullness = weeutil.Moon.moon_phase_ts(self.time_ts) + self.moon_phase = self.moon_phases[self.moon_index] + self.time_djd = timestamp_to_djd(self.time_ts) + + # Check to see whether the user has module 'ephem'. + if 'ephem' in sys.modules: + + self.hasExtras = True + + else: + + # No ephem package. Use the weeutil algorithms, which supply a minimum of functionality + (y, m, d) = time.localtime(self.time_ts)[0:3] + (sunrise_utc_h, sunset_utc_h) = weeutil.Sun.sunRiseSet(y, m, d, self.lon, self.lat) + sunrise_ts = weeutil.weeutil.utc_to_ts(y, m, d, sunrise_utc_h) + sunset_ts = weeutil.weeutil.utc_to_ts(y, m, d, sunset_utc_h) + self._sunrise = weewx.units.ValueHelper( + ValueTuple(sunrise_ts, "unix_epoch", "group_time"), + context="ephem_day", + formatter=self.formatter, + converter=self.converter) + self._sunset = weewx.units.ValueHelper( + ValueTuple(sunset_ts, "unix_epoch", "group_time"), + context="ephem_day", + formatter=self.formatter, + converter=self.converter) + self.hasExtras = False + + # Shortcuts, used for backwards compatibility + @property + def sunrise(self): + return self.sun.rise if self.hasExtras else self._sunrise + + @property + def sunset(self): + return self.sun.set if self.hasExtras else self._sunset + + @property + def moon_fullness(self): + return int(self.moon.moon_fullness + 0.5) if self.hasExtras else self._moon_fullness + + def __call__(self, **kwargs): + """Call an almanac object as a functor. This allows overriding the values + used when the Almanac instance was initialized. + + Named arguments: + + almanac_time: The observer's time in unix epoch time. + lat: The observer's latitude in degrees + lon: The observer's longitude in degrees + altitude: The observer's altitude in meters + horizon: The horizon angle in degrees + temperature: The observer's temperature (used to calculate refraction) + pressure: The observer's pressure (used to calculate refraction) + """ + # Make a copy of myself. + almanac = copy.copy(self) + # Now set a new value for any named arguments. + for key in kwargs: + if key == 'almanac_time': + almanac.time_ts = kwargs['almanac_time'] + else: + setattr(almanac, key, kwargs[key]) + almanac._precalc() + + return almanac + + def separation(self, body1, body2): + return ephem.separation(body1, body2) + + def __getattr__(self, attr): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if attr.startswith('__') or attr == 'has_key': + raise AttributeError(attr) + + if not self.hasExtras: + # If the Almanac does not have extended capabilities, we can't + # do any of the following. Raise an exception. + raise AttributeError("Unknown attribute %s" % attr) + + # We do have extended capability. Check to see if the attribute is a calendar event: + elif attr in {'previous_equinox', 'next_equinox', + 'previous_solstice', 'next_solstice', + 'previous_autumnal_equinox', 'next_autumnal_equinox', + 'previous_vernal_equinox', 'next_vernal_equinox', + 'previous_winter_solstice', 'next_winter_solstice', + 'previous_summer_solstice', 'next_summer_solstice', + 'previous_new_moon', 'next_new_moon', + 'previous_first_quarter_moon', 'next_first_quarter_moon', + 'previous_full_moon', 'next_full_moon', + 'previous_last_quarter_moon', 'next_last_quarter_moon'}: + # This is how you call a function on an instance when all you have + # is the function's name as a string + djd = getattr(ephem, attr)(self.time_djd) + return weewx.units.ValueHelper(ValueTuple(djd, "dublin_jd", "group_time"), + context="ephem_year", + formatter=self.formatter, + converter=self.converter) + # Check to see if the attribute is a sidereal angle + elif attr == 'sidereal_time' or attr == 'sidereal_angle': + # sidereal time is obtained from an ephem Observer object... + observer = _get_observer(self, self.time_djd) + # ... then get the angle in degrees ... + val = math.degrees(observer.sidereal_time()) + # ... finally, depending on the attribute name, pick the proper return type: + if attr == 'sidereal_time': + return val + else: + vt = ValueTuple(val, 'degree_compass', 'group_direction') + return weewx.units.ValueHelper(vt, + context = 'ephem_day', + formatter=self.formatter, + converter=self.converter) + else: + # The attribute must be a heavenly body (such as 'sun', or 'jupiter'). + # Bind the almanac and the heavenly body together and return as an + # AlmanacBinder + return AlmanacBinder(self, attr) + + +fn_map = {'rise': 'next_rising', + 'set': 'next_setting', + 'transit': 'next_transit'} + + +class AlmanacBinder(object): + """This class binds the observer properties held in Almanac, with the heavenly + body to be observed.""" + + pyephem_map = {'azimuth': 'az', 'altitude': 'alt', 'astro_ra': 'a_ra', 'astro_dec': 'a_dec', + 'geo_ra': 'g_ra', 'topo_ra': 'ra', 'geo_dec': 'g_dec','topo_dec': 'dec', + 'elongation':'elong', 'radius_size': 'radius', + 'hlongitude': 'hlon', 'hlatitude': 'hlat', + 'sublatitude': 'sublat', 'sublongitude': 'sublong'} + + def __init__(self, almanac, heavenly_body): + self.almanac = almanac + + # Calculate and store the start-of-day in Dublin Julian Days. + y, m, d = time.localtime(self.almanac.time_ts)[0:3] + self.sod_djd = timestamp_to_djd(time.mktime((y, m, d, 0, 0, 0, 0, 0, -1))) + + self.heavenly_body = heavenly_body + self.use_center = False + + def __call__(self, use_center=False): + self.use_center = use_center + return self + + @property + def visible(self): + """Calculate how long the body has been visible today""" + ephem_body = _get_ephem_body(self.heavenly_body) + observer = _get_observer(self.almanac, self.sod_djd) + try: + time_rising_djd = observer.next_rising(ephem_body, use_center=self.use_center) + time_setting_djd = observer.next_setting(ephem_body, use_center=self.use_center) + except ephem.AlwaysUpError: + visible = 86400 + except ephem.NeverUpError: + visible = 0 + else: + visible = (time_setting_djd - time_rising_djd) * weewx.units.SECS_PER_DAY + + return weewx.units.ValueHelper(ValueTuple(visible, "second", "group_deltatime"), + context="day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + def visible_change(self, days_ago=1): + """Change in visibility of the heavenly body compared to 'days_ago'.""" + # Visibility for today: + today_visible = self.visible + # The time to compare to + then_time = self.almanac.time_ts - days_ago * 86400 + # Get a new almanac, set up for the time back then + then_almanac = self.almanac(almanac_time=then_time) + # Find the visibility back then + then_visible = getattr(then_almanac, self.heavenly_body).visible + # Take the difference + diff = today_visible.raw - then_visible.raw + return weewx.units.ValueHelper(ValueTuple(diff, "second", "group_deltatime"), + context="hour", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + def __getattr__(self, attr): + """Get the requested observation, such as when the body will rise.""" + + # Don't try any attributes that start with a double underscore, or any of these + # special names: they are used by the Python language: + if attr.startswith('__') or attr in ['mro', 'im_func', 'func_code']: + raise AttributeError(attr) + + # Many of these functions have the unfortunate side effect of changing the state of the + # body being examined. So, create a temporary body and then throw it away + ephem_body = _get_ephem_body(self.heavenly_body) + + if attr in ['rise', 'set', 'transit']: + # These verbs refer to the time the event occurs anytime in the day, which + # is not necessarily the *next* sunrise. + attr = fn_map[attr] + # These functions require the time at the start of day + observer = _get_observer(self.almanac, self.sod_djd) + # Call the function. Be prepared to catch an exception if the body is always up. + try: + if attr in ['next_rising', 'next_setting']: + time_djd = getattr(observer, attr)(ephem_body, use_center=self.use_center) + else: + time_djd = getattr(observer, attr)(ephem_body) + except (ephem.AlwaysUpError, ephem.NeverUpError): + time_djd = None + return weewx.units.ValueHelper(ValueTuple(time_djd, "dublin_jd", "group_time"), + context="ephem_day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + elif attr in {'next_rising', 'next_setting', 'next_transit', 'next_antitransit', + 'previous_rising', 'previous_setting', 'previous_transit', + 'previous_antitransit'}: + # These functions require the time of the observation + observer = _get_observer(self.almanac, self.almanac.time_djd) + # Call the function. Be prepared to catch an exception if the body is always up. + try: + if attr in ['next_rising', 'next_setting', 'previous_rising', 'previous_setting']: + time_djd = getattr(observer, attr)(ephem_body, use_center=self.use_center) + else: + time_djd = getattr(observer, attr)(ephem_body) + except (ephem.AlwaysUpError, ephem.NeverUpError): + time_djd = None + return weewx.units.ValueHelper(ValueTuple(time_djd, "dublin_jd", "group_time"), + context="ephem_day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + + else: + # These functions need the current time in Dublin Julian Days + observer = _get_observer(self.almanac, self.almanac.time_djd) + ephem_body.compute(observer) + # V5.0 changed the name of some attributes, so they could be returned as + # a ValueHelper, instead of a floating point number. This would break existing skins, + # so new attribute names are being used. + if attr in AlmanacBinder.pyephem_map: + # Map the name to the name pyephem uses... + pyephem_name = AlmanacBinder.pyephem_map[attr] + # ... then calculate the value in radians ... + val = getattr(ephem_body, pyephem_name) + # ... form the proper ValueTuple ... + if attr in {'azimuth', 'astro_ra', 'geo_ra', 'topo_ra', + 'hlongitude', 'sublongitude'}: + vt = ValueTuple(math.degrees(val), 'degree_compass', 'group_direction') + else: + vt = ValueTuple(val, 'radian', 'group_angle') + # ... and, finally, return the ValueHelper: + return weewx.units.ValueHelper(vt, + context="ephem_day", + formatter=self.almanac.formatter, + converter=self.almanac.converter) + elif attr in {'az', 'alt', 'a_ra', 'a_dec', + 'g_ra', 'ra', 'g_dec', 'dec', + 'elong', 'radius', + 'hlong', 'hlat', + 'sublat', 'sublong'}: + # These are the old names, which return a floating point number in decimal degrees. + return math.degrees(getattr(ephem_body, attr)) + elif attr == 'moon_fullness': + # The attribute "moon_fullness" is the percentage of the moon surface that is + # illuminated. Unfortunately, phephem calls it "moon_phase", so call ephem with + # that name. Return the result in percent. + return 100.0 * ephem_body.moon_phase + else: + # Just return the result unchanged. This will raise an AttributeError exception + # if the attribute does not exist. + return getattr(ephem_body, attr) + + +def _get_observer(almanac_obj, time_ts): + # Build an ephem Observer object + observer = ephem.Observer() + observer.lat = math.radians(almanac_obj.lat) + observer.long = math.radians(almanac_obj.lon) + observer.elevation = almanac_obj.altitude + observer.horizon = math.radians(almanac_obj.horizon) + observer.temp = almanac_obj.temperature + observer.pressure = almanac_obj.pressure + observer.date = time_ts + return observer + + +def _get_ephem_body(heavenly_body): + # The library 'ephem' refers to heavenly bodies using a capitalized + # name. For example, the module used for 'mars' is 'ephem.Mars'. + cap_name = heavenly_body.title() + + # If the heavenly body is a star, or if the body does not exist, then an + # exception will be raised. Be prepared to catch it. + try: + ephem_body = getattr(ephem, cap_name)() + except AttributeError: + # That didn't work. Try a star. If this doesn't work either, + # then a KeyError exception will be raised. + ephem_body = ephem.star(cap_name) + except TypeError: + # Heavenly bodies added by a ephem.readdb() statement are not functions. + # So, just return the attribute, without calling it: + ephem_body = getattr(ephem, cap_name) + + return ephem_body + + +def timestamp_to_djd(time_ts): + """Convert from a unix time stamp to the number of days since 12/31/1899 12:00 UTC + (aka "Dublin Julian Days")""" + # The number 25567.5 is the start of the Unix epoch (1/1/1970). Just add on the + # number of days since then + return 25567.5 + time_ts / 86400.0 + + +def djd_to_timestamp(djd): + """Convert from number of days since 12/31/1899 12:00 UTC ("Dublin Julian Days") to + unix time stamp""" + return (djd - 25567.5) * 86400.0 + + +if __name__ == '__main__': + + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weewx/cheetahgenerator.py b/dist/weewx-5.0.2/src/weewx/cheetahgenerator.py new file mode 100644 index 0000000..d16260e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/cheetahgenerator.py @@ -0,0 +1,805 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# Class Gettext is Copyright (C) 2021 Johanna Karen Roedenbeck +# +# See the file LICENSE.txt for your full rights. +# +"""Generate files from templates using the Cheetah template engine. + +For more information about Cheetah, see http://www.cheetahtemplate.org + +Configuration Options + + encoding = (html_entities|utf8|strict_ascii|normalized_ascii) + template = filename.tmpl # must end with .tmpl + stale_age = s # age in seconds + search_list = a, b, c + search_list_extensions = d, e, f + +The strings YYYY, MM, DD and WW will be replaced if they appear in the filename. + +search_list will override the default search_list + +search_list_extensions will be appended to search_list + +Both search_list and search_list_extensions must be lists of classes. Each +class in the list must be derived from SearchList. + +Generally it is better to extend by using search_list_extensions rather than +search_list, just in case the default search list changes. + +Example: + +[CheetahGenerator] + # How to specify search list extensions: + search_list_extensions = user.forecast.ForecastVariables, user.extstats.ExtStatsVariables + encoding = html_entities + [[SummaryByMonth]] # period + [[[NOAA_month]]] # report + encoding = normalized_ascii + template = NOAA-YYYY-MM.txt.tmpl + [[SummaryByYear]] + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA-YYYY.txt.tmpl + [[ToDate]] + [[[day]]] + template = index.html.tmpl + [[[week]]] + template = week.html.tmpl + [[wuforecast_details]] # period/report + stale_age = 3600 # how old before regenerating + template = wuforecast.html.tmpl + [[nwsforecast_details]] # period/report + stale_age = 10800 # how old before generating + template = nwsforecast.html.tmpl + +""" + +import datetime +import json +import logging +import os.path +import time +import unicodedata + +import Cheetah.Filters +import Cheetah.Template + +import weedb +import weeutil.logger +import weeutil.weeutil +import weewx.almanac +import weewx.reportengine +import weewx.station +import weewx.tags +import weewx.units +from weeutil.config import search_up, accumulateLeaves, deep_copy +from weeutil.weeutil import to_bool, to_int, timestamp_to_string + +log = logging.getLogger(__name__) + +# The default search list includes standard information sources that should be +# useful in most templates. +default_search_list = [ + "weewx.cheetahgenerator.Almanac", + "weewx.cheetahgenerator.Current", + "weewx.cheetahgenerator.DisplayOptions", + "weewx.cheetahgenerator.Extras", + "weewx.cheetahgenerator.Gettext", + "weewx.cheetahgenerator.JSONHelpers", + "weewx.cheetahgenerator.PlotInfo", + "weewx.cheetahgenerator.SkinInfo", + "weewx.cheetahgenerator.Station", + "weewx.cheetahgenerator.Stats", + "weewx.cheetahgenerator.UnitInfo", +] + + +# ============================================================================= +# CheetahGenerator +# ============================================================================= + +class CheetahGenerator(weewx.reportengine.ReportGenerator): + """Class for generating files from cheetah templates. + + Useful attributes (some inherited from ReportGenerator): + + config_dict: The weewx configuration dictionary + skin_dict: The dictionary for this skin + gen_ts: The generation time + first_run: Is this the first time the generator has been run? + stn_info: An instance of weewx.station.StationInfo + record: A copy of the "current" record. May be None. + formatter: An instance of weewx.units.Formatter + converter: An instance of weewx.units.Converter + search_list_objs: A list holding search list extensions + db_binder: An instance of weewx.manager.DBBinder from which the + data should be extracted + """ + + generator_dict = {'SummaryByDay' : weeutil.weeutil.genDaySpans, + 'SummaryByMonth': weeutil.weeutil.genMonthSpans, + 'SummaryByYear' : weeutil.weeutil.genYearSpans} + + format_dict = {'SummaryByDay' : "%Y-%m-%d", + 'SummaryByMonth': "%Y-%m", + 'SummaryByYear' : "%Y"} + + def __init__(self, config_dict, skin_dict, *args, **kwargs): + """Initialize an instance of CheetahGenerator""" + # Initialize my superclass + weewx.reportengine.ReportGenerator.__init__(self, config_dict, skin_dict, *args, **kwargs) + + self.search_list_objs = [] + self.formatter = weewx.units.Formatter.fromSkinDict(skin_dict) + self.converter = weewx.units.Converter.fromSkinDict(skin_dict) + + # This dictionary will hold the formatted dates of all generated files + self.outputted_dict = {k: [] for k in CheetahGenerator.generator_dict} + + def run(self): + """Main entry point for file generation using Cheetah Templates.""" + + t1 = time.time() + + # Make a deep copy of the skin dictionary (we will be modifying it): + gen_dict = deep_copy(self.skin_dict) + + # Look for options in [CheetahGenerator], + section_name = "CheetahGenerator" + # but accept options from [FileGenerator] for backward compatibility. + if "FileGenerator" in gen_dict and "CheetahGenerator" not in gen_dict: + section_name = "FileGenerator" + + # The default summary time span is 'None'. + gen_dict[section_name]['summarize_by'] = 'None' + + # determine how much logging is desired + log_success = to_bool(search_up(gen_dict[section_name], 'log_success', True)) + + # configure the search list extensions + self.init_extensions(gen_dict[section_name]) + + # Generate any templates in the given dictionary: + ngen = self.generate(gen_dict[section_name], section_name, self.gen_ts) + + self.teardown() + + elapsed_time = time.time() - t1 + if log_success: + log.info("Generated %d files for report %s in %.2f seconds", + ngen, self.skin_dict['REPORT_NAME'], elapsed_time) + + def init_extensions(self, gen_dict): + """Load the search list""" + + # Build the search list. Start with user extensions: + search_list = weeutil.weeutil.option_as_list(gen_dict.get('search_list_extensions', [])) + + # Add on the default search list: + search_list.extend(weeutil.weeutil.option_as_list(gen_dict.get('search_list', + default_search_list))) + + # Provide feedback about the final list + log.debug("Using search list %s", search_list) + + # Now go through search_list (which is a list of strings holding the + # names of the extensions), and instantiate each one + for c in search_list: + x = c.strip() + if x: + # Get the class + klass = weeutil.weeutil.get_object(x) + # Then instantiate the class, passing self as the sole argument + self.search_list_objs.append(klass(self)) + + def teardown(self): + """Delete any extension objects we created to prevent back references + from slowing garbage collection""" + while self.search_list_objs: + self.search_list_objs[-1].finalize() + del self.search_list_objs[-1] + + def generate(self, section, section_name, gen_ts): + """Generate one or more reports for the indicated section. Each + section in a period is a report. A report has one or more templates. + + section: A ConfigObj dictionary, holding the templates to be + generated. Any subsections in the dictionary will be recursively + processed as well. + + gen_ts: The report will be current to this time. + """ + + ngen = 0 + # Go through each subsection (if any) of this section, + # generating from any templates they may contain + for subsection in section.sections: + # Sections 'SummaryByMonth' and 'SummaryByYear' imply summarize_by + # certain time spans + if 'summarize_by' not in section[subsection]: + if subsection in CheetahGenerator.generator_dict: + section[subsection]['summarize_by'] = subsection + # Call recursively, to generate any templates in this subsection + ngen += self.generate(section[subsection], subsection, gen_ts) + + # We have finished recursively processing any subsections in this + # section. Time to do the section itself. If there is no option + # 'template', then there isn't anything to do. Return. + if 'template' not in section: + return ngen + + report_dict = accumulateLeaves(section) + + generate_once = to_bool(report_dict.get('generate_once', False)) + if generate_once and not self.first_run: + return ngen + + (template, dest_dir, encoding, default_binding) = self._prepGen(report_dict) + + # Get start and stop times + default_archive = self.db_binder.get_manager(default_binding) + start_ts = default_archive.firstGoodStamp() + if not start_ts: + log.info('Skipping template %s: cannot find start time', section['template']) + return ngen + + if gen_ts: + record = default_archive.getRecord(gen_ts, + max_delta=to_int(report_dict.get('max_delta'))) + if record: + stop_ts = record['dateTime'] + else: + log.info('Skipping template %s: generate time %s not in database', + section['template'], timestamp_to_string(gen_ts)) + return ngen + else: + stop_ts = default_archive.lastGoodStamp() + + # Get an appropriate generator function + summarize_by = report_dict['summarize_by'] + if summarize_by in CheetahGenerator.generator_dict: + _spangen = CheetahGenerator.generator_dict[summarize_by] + else: + # Just a single timespan to generate. Use a lambda expression. + _spangen = lambda start_ts, stop_ts: [weeutil.weeutil.TimeSpan(start_ts, stop_ts)] + + # Use the generator function + for timespan in _spangen(start_ts, stop_ts): + start_tt = time.localtime(timespan.start) + stop_tt = time.localtime(timespan.stop) + + if summarize_by in CheetahGenerator.format_dict: + # This is a "SummaryBy" type generation. If it hasn't been done already, save the + # date as a string, to be used inside the document + date_str = time.strftime(CheetahGenerator.format_dict[summarize_by], start_tt) + if date_str not in self.outputted_dict[summarize_by]: + self.outputted_dict[summarize_by].append(date_str) + # For these "SummaryBy" generations, the file name comes from the start of the timespan: + _filename = self._getFileName(template, start_tt) + else: + # This is a "ToDate" generation. File name comes + # from the stop (i.e., present) time: + _filename = self._getFileName(template, stop_tt) + + # Get the absolute path for the target of this template + _fullname = os.path.join(dest_dir, _filename) + + # Skip summary files outside the timespan + if report_dict['summarize_by'] in CheetahGenerator.generator_dict \ + and os.path.exists(_fullname) \ + and not timespan.includesArchiveTime(stop_ts): + continue + + # skip files that are fresh, but only if staleness is defined + stale = to_int(report_dict.get('stale_age')) + if stale is not None: + t_now = time.time() + try: + last_mod = os.path.getmtime(_fullname) + if t_now - last_mod < stale: + log.debug("Skip '%s': last_mod=%s age=%s stale=%s", + _filename, last_mod, t_now - last_mod, stale) + continue + except os.error: + pass + + searchList = self._getSearchList(encoding, timespan, + default_binding, section_name, + os.path.join( + os.path.dirname(report_dict['template']), + _filename)) + + # First, compile the template + try: + # TODO: Look into caching the compiled template. + compiled_template = Cheetah.Template.Template( + file=template, + searchList=searchList, + filter='AssureUnicode', + filtersLib=weewx.cheetahgenerator) + except Exception as e: + log.error("Compilation of template %s failed with exception '%s'", template, type(e)) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + weeutil.logger.log_traceback(log.error, "**** ") + continue + + # Second, evaluate the compiled template + try: + # We have a compiled template in hand. Evaluate it. The result will be a long + # Unicode string. + unicode_string = compiled_template.respond() + except Cheetah.Parser.ParseError as e: + log.error("Parse error while evaluating file %s", template) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + continue + except Cheetah.NameMapper.NotFound as e: + log.error("Evaluation of template %s failed.", template) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + log.error("**** To debug, try inserting '#errorCatcher Echo' at top of template") + continue + except Exception as e: + log.error("Evaluation of template %s failed with exception '%s'", template, type(e)) + log.error("**** Ignoring template %s", template) + log.error("**** Reason: %s", e) + weeutil.logger.log_traceback(log.error, "**** ") + continue + + # Third, convert the results to a byte string, using the strategy chosen by the user. + if encoding == 'html_entities': + byte_string = unicode_string.encode('ascii', 'xmlcharrefreplace') + elif encoding == 'strict_ascii': + byte_string = unicode_string.encode('ascii', 'ignore') + elif encoding == 'normalized_ascii': + # Normalize the string, replacing accented characters with non-accented + # equivalents + normalized = unicodedata.normalize('NFD', unicode_string) + byte_string = normalized.encode('ascii', 'ignore') + else: + byte_string = unicode_string.encode(encoding) + + # Finally, write the byte string to the target file + try: + # Write to a temporary file first + tmpname = _fullname + '.tmp' + # Open it in binary mode. We are writing a byte-string, not a string + with open(tmpname, mode='wb') as fd: + fd.write(byte_string) + # Now move the temporary file into place + os.rename(tmpname, _fullname) + ngen += 1 + finally: + try: + os.unlink(tmpname) + except OSError: + pass + + return ngen + + def _getSearchList(self, encoding, timespan, default_binding, section_name, file_name): + """Get the complete search list to be used by Cheetah.""" + + # Get the basic search list + timespan_start_tt = time.localtime(timespan.start) + search_list = [{'month_name' : time.strftime("%b", timespan_start_tt), + 'year_name' : timespan_start_tt[0], + 'encoding' : encoding, + 'page' : section_name, + 'filename' : file_name}, + self.outputted_dict] + + # Bind to the default_binding: + db_lookup = self.db_binder.bind_default(default_binding) + + # Then add the V3.X style search list extensions + for obj in self.search_list_objs: + search_list += obj.get_extension_list(timespan, db_lookup) + + return search_list + + def _getFileName(self, template, ref_tt): + """Calculate a destination filename given a template filename. + + For backwards compatibility replace 'YYYY' with the year, 'MM' with the + month, 'DD' with the day. Also observe any strftime format strings in + the filename. Finally, strip off any trailing .tmpl.""" + + _filename = os.path.basename(template).replace('.tmpl', '') + + # If the filename contains YYYY, MM, DD or WW, then do the replacement + if 'YYYY' in _filename or 'MM' in _filename or 'DD' in _filename or 'WW' in _filename: + # Get strings representing year, month, and day + _yr_str = "%4d" % ref_tt[0] + _mo_str = "%02d" % ref_tt[1] + _day_str = "%02d" % ref_tt[2] + _week_str = "%02d" % datetime.date(ref_tt[0], ref_tt[1], ref_tt[2]).isocalendar()[1]; + # Replace any instances of 'YYYY' with the year string + _filename = _filename.replace('YYYY', _yr_str) + # Do the same thing with the month... + _filename = _filename.replace('MM', _mo_str) + # ... the week ... + _filename = _filename.replace('WW', _week_str) + # ... and the day + _filename = _filename.replace('DD', _day_str) + # observe any strftime format strings in the base file name + # first obtain a datetime object from our timetuple + ref_dt = datetime.datetime.fromtimestamp(time.mktime(ref_tt)) + # then apply any strftime formatting + _filename = ref_dt.strftime(_filename) + + return _filename + + def _prepGen(self, report_dict): + """Get the template, destination directory, encoding, and default + binding.""" + + # -------- Template --------- + template = os.path.join(self.config_dict['WEEWX_ROOT'], + self.config_dict['StdReport']['SKIN_ROOT'], + report_dict['skin'], + report_dict['template']) + + # ------ Destination directory -------- + destination_dir = os.path.join(self.config_dict['WEEWX_ROOT'], + report_dict['HTML_ROOT'], + os.path.dirname(report_dict['template'])) + try: + # Create the directory that is to receive the generated files. If + # it already exists an exception will be thrown, so be prepared to + # catch it. + os.makedirs(destination_dir) + except OSError: + pass + + # ------ Encoding ------ + encoding = report_dict.get('encoding', 'html_entities').strip().lower() + # Convert to 'utf8'. This is because 'utf-8' cannot be a class name + if encoding == 'utf-8': + encoding = 'utf8' + + # ------ Default binding --------- + default_binding = report_dict['data_binding'] + + return (template, destination_dir, encoding, default_binding) + + +# ============================================================================= +# Classes used to implement the Search list +# ============================================================================= + +class SearchList(object): + """Abstract base class used for search list extensions.""" + + def __init__(self, generator): + """Create an instance of SearchList. + + generator: The generator that is using this search list + """ + self.generator = generator + + def get_extension_list(self, timespan, db_lookup): # @UnusedVariable + """For weewx V3.x extensions. Should return a list + of objects whose attributes or keys define the extension. + + timespan: An instance of weeutil.weeutil.TimeSpan. This will hold the + start and stop times of the domain of valid times. + + db_lookup: A function with call signature db_lookup(data_binding), + which returns a database manager and where data_binding is + an optional binding name. If not given, then a default + binding will be used. + """ + return [self] + + def finalize(self): + """Called when the extension is no longer needed""" + + +class Almanac(SearchList): + """Class that implements the '$almanac' tag.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + + celestial_ts = generator.gen_ts + + # For better accuracy, the almanac requires the current temperature + # and barometric pressure, so retrieve them from the default archive, + # using celestial_ts as the time + + # The default values of temperature and pressure + temperature_C = 15.0 + pressure_mbar = 1010.0 + + # See if we can get more accurate values by looking them up in the + # weather database. The database might not exist, so be prepared for + # a KeyError exception. + try: + binding = self.generator.skin_dict.get('data_binding', 'wx_binding') + archive = self.generator.db_binder.get_manager(binding) + except (KeyError, weewx.UnknownBinding, weedb.NoDatabaseError): + pass + else: + # If a specific time has not been specified, then use the timestamp + # of the last record in the database. + if not celestial_ts: + celestial_ts = archive.lastGoodStamp() + + # Check to see whether we have a good time. If so, retrieve the + # record from the database + if celestial_ts: + # Look for the record closest in time. Up to one hour off is + # acceptable: + rec = archive.getRecord(celestial_ts, max_delta=3600) + if rec is not None: + if 'outTemp' in rec: + temperature_C = weewx.units.convert(weewx.units.as_value_tuple(rec, 'outTemp'), "degree_C")[0] + if 'barometer' in rec: + pressure_mbar = weewx.units.convert(weewx.units.as_value_tuple(rec, 'barometer'), "mbar")[0] + + self.moonphases = generator.skin_dict.get('Almanac', {}).get('moon_phases', weeutil.Moon.moon_phases) + + altitude_vt = weewx.units.convert(generator.stn_info.altitude_vt, "meter") + + self.almanac = weewx.almanac.Almanac(celestial_ts, + generator.stn_info.latitude_f, + generator.stn_info.longitude_f, + altitude=altitude_vt[0], + temperature=temperature_C, + pressure=pressure_mbar, + moon_phases=self.moonphases, + formatter=generator.formatter, + converter=generator.converter) + + +class Station(SearchList): + """Class that implements the $station tag.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + self.station = weewx.station.Station(generator.stn_info, + generator.formatter, + generator.converter, + generator.skin_dict) + + +class Current(SearchList): + """Class that implements the $current tag""" + + def get_extension_list(self, timespan, db_lookup): + record_binder = weewx.tags.RecordBinder(db_lookup, timespan.stop, + self.generator.formatter, self.generator.converter, + record=self.generator.record) + return [record_binder] + + +class Stats(SearchList): + """Class that implements the time-based statistical tags, such + as $day.outTemp.max""" + + def get_extension_list(self, timespan, db_lookup): + try: + trend_dict = self.generator.skin_dict['Units']['Trend'] + except KeyError: + trend_dict = {'time_delta': 10800, + 'time_grace': 300} + + stats = weewx.tags.TimeBinder( + db_lookup, + timespan.stop, + formatter=self.generator.formatter, + converter=self.generator.converter, + week_start=self.generator.stn_info.week_start, + rain_year_start=self.generator.stn_info.rain_year_start, + trend=trend_dict, + skin_dict=self.generator.skin_dict) + + return [stats] + + +class UnitInfo(SearchList): + """Class that implements the $unit and $obs tags.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + # This implements the $unit tag: + self.unit = weewx.units.UnitInfoHelper(generator.formatter, + generator.converter) + # This implements the $obs tag: + self.obs = weewx.units.ObsInfoHelper(generator.skin_dict) + + +# Dictionaries in Python 3 no longer have the "has_key()" function. +# This will break a lot of skins. Use a wrapper to provide it +class ExtraDict(dict): + + def has_key(self, key): + return key in self + + +class Extras(SearchList): + """Class for exposing the [Extras] section in the skin config dictionary + as tag $Extras.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + # If the user has supplied an '[Extras]' section in the skin + # dictionary, include it in the search list. Otherwise, just include + # an empty dictionary. + self.Extras = ExtraDict(generator.skin_dict.get('Extras', {})) + + +class JSONHelpers(SearchList): + """Helper functions for formatting JSON""" + + @staticmethod + def jsonize(arg): + """ + Format my argument as JSON + + Args: + arg (iterable): An iterable, such as a list, or zip structure + + Returns: + str: The argument formatted as JSON. + """ + val = list(arg) + return json.dumps(val, cls=weewx.units.ComplexEncoder) + + @staticmethod + def rnd(arg, ndigits): + """Round a number, or sequence of numbers, to a specified number of decimal digits + + Args: + arg (None, float, complex, list): The number or sequence of numbers to be rounded. + If the argument is None, then None will be returned. + ndigits (int): The number of decimal digits to retain. + + Returns: + None, float, complex, list: Returns the number, or sequence of numbers, with the + requested number of decimal digits + """ + return weeutil.weeutil.rounder(arg, ndigits) + + @staticmethod + def to_int(arg): + """Convert the argument into an integer, honoring 'None' + + Args: + arg (None, float, str): + + Returns: + int: The argument converted to an integer. + """ + return weeutil.weeutil.to_int(arg) + + @staticmethod + def to_bool(arg): + """Convert the argument to boolean True or False, if possible.""" + return weeutil.weeutil.to_bool(arg) + + @staticmethod + def to_list(arg): + """Convert the argment into a list""" + return weeutil.weeutil.to_list(arg) + +class Gettext(SearchList): + """Values provided by $gettext() are found in the [Texts] section of the localization file.""" + + def gettext(self, key): + try: + v = self.generator.skin_dict['Texts'].get(key, key) + except KeyError: + v = key + return v + + def pgettext(self, context, key): + try: + v = self.generator.skin_dict['Texts'][context].get(key, key) + except KeyError: + v = key + return v + + # An underscore is a common alias for gettext: + _ = gettext + + +class PlotInfo(SearchList): + """Return information about plots, based on what's in the [ImageGenerator] section.""" + + def getobs(self, plot_name): + """ + Given a plot name, return the set of observations in that plot. + If there is no plot by the indicated name, return an empty set. + """ + obs = set() + # If there is no [ImageGenerator] section, return the empty set. + try: + timespan_names = self.generator.skin_dict['ImageGenerator'].sections + except (KeyError, AttributeError): + return obs + + # Scan all the timespans, looking for plot_name + for timespan_name in timespan_names: + if plot_name in self.generator.skin_dict['ImageGenerator'][timespan_name]: + # Found it. To make things manageable, get just the plot dictionary: + plot_dict = self.generator.skin_dict['ImageGenerator'][timespan_name][plot_name] + # Now extract all observation names from it + for obs_name in plot_dict.sections: + # The observation name might be specified directly, + # or it might be specified by the data_type field. + if 'data_type' in plot_dict[obs_name]: + data_name = plot_dict[obs_name]['data_type'] + else: + data_name = obs_name + # A data type of 'windvec' or 'windgustvec' requires special treatment + if data_name == 'windvec': + data_name = 'windSpeed' + elif data_name == 'windgustvec': + data_name = 'windGust' + + obs.add(data_name) + break + return obs + + +class DisplayOptions(SearchList): + """Class for exposing the [DisplayOptions] section in the skin config + dictionary as tag $DisplayOptions.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + self.DisplayOptions = dict(generator.skin_dict.get('DisplayOptions', {})) + + +class SkinInfo(SearchList): + """Class for exposing information about the skin.""" + + def __init__(self, generator): + SearchList.__init__(self, generator) + for k in ['HTML_ROOT', 'lang', 'REPORT_NAME', 'skin', + 'SKIN_NAME', 'SKIN_ROOT', 'SKIN_VERSION', 'unit_system' + ]: + setattr(self, k, generator.skin_dict.get(k, 'unknown')) + + +# ============================================================================= +# Filter +# ============================================================================= + +class AssureUnicode(Cheetah.Filters.Filter): + """Assures that whatever a search list extension might return, it will be converted into + Unicode. """ + + def filter(self, val, **kwargs): + """Convert the expression 'val' to a string (unicode).""" + if val is None: + return u'' + + # Is it already a string? + if isinstance(val, str): + filtered = val + # Is val a byte string? + elif isinstance(val, bytes): + filtered = val.decode('utf-8') + # That means val is an object, such as a ValueHelper + else: + # Must be an object. Convert to string + try: + # This invokes __str__(), which can force an XTypes query. For a tag such as + # $day.foobar.min, where 'foobar' is an unknown type, this will cause an attribute + # error. Be prepared to catch it. + filtered = str(val) + except AttributeError as e: + # Offer a debug message. + log.debug("Unrecognized: %s", kwargs.get('rawExpr', e)) + # Return the raw expression, if available. Otherwise, the exception message + # concatenated with a question mark. + filtered = kwargs.get('rawExpr', str(e) + '?') + + return filtered diff --git a/dist/weewx-5.0.2/src/weewx/crc16.py b/dist/weewx-5.0.2/src/weewx/crc16.py new file mode 100644 index 0000000..00033ff --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/crc16.py @@ -0,0 +1,67 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Routines for calculating a 16-bit CRC check. """ + +from functools import reduce + +_table=[ +0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, # 0x00 +0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, # 0x08 +0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, # 0x10 +0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, # 0x18 +0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, # 0x20 +0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, # 0x28 +0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, # 0x30 +0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, # 0x38 +0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, # 0x40 +0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, # 0x48 +0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, # 0x50 +0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, # 0x58 +0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, # 0x60 +0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, # 0x68 +0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, # 0x70 +0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, # 0x78 +0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, # 0x80 +0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, # 0x88 +0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, # 0x90 +0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, # 0x98 +0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, # 0xA0 +0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, # 0xA8 +0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, # 0xB0 +0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, # 0xB8 +0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, # 0xC0 +0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, # 0xC8 +0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, # 0xD0 +0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, # 0xD8 +0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, # 0xE0 +0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, # 0xE8 +0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, # 0xF0 +0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 # 0xF8 +] + + +def crc16(bytes, crc_start=0): + """ Calculate CRC16 sum""" + + # We need something that returns integers when iterated over. + try: + # Python 2 + byte_iter = [ord(x) for x in bytes] + except TypeError: + # Python 3 + byte_iter = bytes + + crc_sum = reduce(lambda crc, ch : (_table[(crc >> 8) ^ ch] ^ (crc << 8)) & 0xffff, byte_iter, crc_start) + + return crc_sum + + +if __name__ == '__main__' : + import struct + # This is the example given in the Davis documentation: + test_bytes = struct.pack(" +# +# See the file LICENSE.txt for your full rights. +# +''' + This module is used to fork the current process into a daemon. + Almost none of this is necessary (or advisable) if your daemon + is being started by inetd. In that case, stdin, stdout and stderr are + all set up for you to refer to the network connection, and the fork()s + and session manipulation should not be done (to avoid confusing inetd). + Only the chdir() and umask() steps remain as useful. + References: + UNIX Programming FAQ + 1.7 How do I get my program to act like a daemon? + http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 + Advanced Programming in the Unix Environment + W. Richard Stevens, 1992, Addison-Wesley, ISBN 0-201-56317-7. + + History: + 2001/07/10 by Jürgen Hermann + 2002/08/28 by Noah Spurrier + 2003/02/24 by Clark Evans + + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012 +''' +import sys, os + +done = False + +def daemonize(stdout='/dev/null', stderr=None, stdin='/dev/null', + pidfile=None, startmsg = 'started with pid %s' ): + ''' + This forks the current process into a daemon. + The stdin, stdout, and stderr arguments are file names that + will be opened and be used to replace the standard file descriptors + in sys.stdin, sys.stdout, and sys.stderr. + These arguments are optional and default to /dev/null. + Note that stderr is opened unbuffered, so + if it shares a file with stdout then interleaved output + may not appear in the order that you expect. + ''' + global done + # Don't proceed if we have already daemonized. + if done: + return + # Do first fork. + try: + pid = os.fork() + if pid > 0: sys.exit(0) # Exit first parent. + except OSError as e: + sys.stderr.write("fork #1 failed: (%d) %s\n" % (e.errno, e.strerror)) + sys.exit(1) + + # Decouple from parent environment. + os.chdir("/") + os.umask(0o022) + os.setsid() + + # Do second fork. + try: + pid = os.fork() + if pid > 0: sys.exit(0) # Exit second parent. + except OSError as e: + sys.stderr.write("fork #2 failed: (%d) %s\n" % (e.errno, e.strerror)) + sys.exit(1) + + # Open file descriptors and print start message + if not stderr: stderr = stdout + si = open(stdin, 'r') + so = open(stdout, 'a+') + se = open(stderr, 'a+') + pid = str(os.getpid()) +# sys.stderr.write("\n%s\n" % startmsg % pid) +# sys.stderr.flush() + if pidfile: open(pidfile,'w+').write("%s\n" % pid) + # Redirect standard file descriptors. + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + done = True diff --git a/dist/weewx-5.0.2/src/weewx/defaults.py b/dist/weewx-5.0.2/src/weewx/defaults.py new file mode 100644 index 0000000..a5d7a28 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/defaults.py @@ -0,0 +1,323 @@ +# coding: utf-8 +# +# Copyright (c) 2019-2022 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# + +"""Backstop defaults used in the absence of any other values.""" + +import weeutil.config + +DEFAULT_STR = """# Copyright (c) 2009-2021 Tom Keffer +# See the file LICENSE.txt for your rights. + +# Where the skins reside, relative to WEEWX_ROOT +SKIN_ROOT = skins + +# Where the generated reports should go, relative to WEEWX_ROOT +HTML_ROOT = public_html + +# The database binding indicates which data should be used in reports. +data_binding = wx_binding + +# Whether to log a successful operation +log_success = True + +# Whether to log an unsuccessful operation +log_failure = False + +# The following section determines the selection and formatting of units. +[Units] + + # The following section sets what unit to use for each unit group. + # NB: The unit is always in the singular. I.e., 'mile_per_hour', + # NOT 'miles_per_hour' + [[Groups]] + + group_altitude = foot # Options are 'foot' or 'meter' + group_amp = amp + group_concentration= microgram_per_meter_cubed + group_data = byte + group_db = dB + group_degree_day = degree_F_day # Options are 'degree_F_day' or 'degree_C_day' + group_deltatime = second + group_direction = degree_compass + group_distance = mile # Options are 'mile' or 'km' + group_energy = watt_hour + group_energy2 = watt_second + group_fraction = ppm + group_frequency = hertz + group_illuminance = lux + group_length = inch + group_moisture = centibar + group_percent = percent + group_power = watt + group_pressure = inHg # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + group_pressure_rate= inHq_per_hour + group_radiation = watt_per_meter_squared + group_rain = inch # Options are 'inch', 'cm', or 'mm' + group_rainrate = inch_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + group_speed = mile_per_hour # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + group_speed2 = mile_per_hour2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + group_uv = uv_index + group_volt = volt + group_volume = gallon + + # The following are used internally and should not be changed: + group_boolean = boolean + group_count = count + group_elapsed = second + group_interval = minute + group_time = unix_epoch + + # The following section sets the formatting for each type of unit. + [[StringFormats]] + + amp = %.1f + bit = %.0f + boolean = %d + byte = %.0f + centibar = %.0f + cm = %.2f + cm_per_hour = %.2f + count = %d + cubic_foot = %.1f + day = %.1f + dB = %.0f + degree_C = %.1f + degree_C_day = %.1f + degree_angle = %02.0f + degree_compass = %03.0f + degree_E = %.1f + degree_F = %.1f + degree_F_day = %.1f + degree_K = %.1f + foot = %.0f + gallon = %.1f + hertz = %.1f + hour = %.1f + hPa = %.1f + hPa_per_hour = %.3f + inch = %.2f + inch_per_hour = %.2f + inHg = %.3f + inHg_per_hour = %.5f + kilowatt = %.1f + kilowatt_hour = %.1f + km = %.1f + km_per_hour = %.0f + km_per_hour2 = %.1f + knot = %.0f + knot2 = %.1f + kPa = %.2f + kPa_per_hour = %.4f + liter = %.1f + litre = %.1f + lux = %.0f + mbar = %.1f + mbar_per_hour = %.4f + mega_joule = %.0f + meter = %.0f + meter_per_second = %.1f + meter_per_second2 = %.1f + microgram_per_meter_cubed = %.0f + mile = %.1f + mile_per_hour = %.0f + mile_per_hour2 = %.1f + minute = %.1f + mm = %.1f + mm_per_hour = %.1f + mmHg = %.1f + mmHg_per_hour = %.4f + percent = %.0f + ppm = %.0f + radian = %.3f + second = %.0f + uv_index = %.1f + volt = %.1f + watt = %.1f + watt_hour = %.1f + watt_per_meter_squared = %.0f + watt_second = %.0f + NONE = " N/A" + + # The following section sets the label to be used for each type of unit + [[Labels]] + + amp = " A" + bit = " b" + boolean = "" + byte = " B" + centibar = " cb" + cm = " cm" + cm_per_hour = " cm/h" + count = "" + cubic_foot = " ft³" + day = " day", " days" + dB = " dB" + degree_C = "°C" + degree_C_day = "°C-day" + degree_angle = "°" + degree_compass = "°" + degree_E = "°E" + degree_F = "°F" + degree_F_day = "°F-day" + degree_K = "°K" + foot = " feet" + gallon = " gal" + hertz = " Hz" + hour = " hour", " hours" + hPa = " hPa" + hPa_per_hour = " hPa/h" + inch = " in" + inch_per_hour = " in/h" + inHg = " inHg" + inHg_per_hour = " inHg/h" + kilowatt = " kW" + kilowatt_hour = " kWh" + km = " km" + km_per_hour = " km/h" + km_per_hour2 = " km/h" + knot = " knots" + knot2 = " knots" + kPa = " kPa", + kPa_per_hour = " kPa/h", + liter = " l", + litre = " l", + lux = " lx", + mbar = " mbar" + mbar_per_hour = " mbar/h" + mega_joule = " MJ" + meter = " meter", " meters" + meter_per_second = " m/s" + meter_per_second2 = " m/s" + microgram_per_meter_cubed = " µg/m³", + mile = " mile", " miles" + mile_per_hour = " mph" + mile_per_hour2 = " mph" + minute = " minute", " minutes" + mm = " mm" + mm_per_hour = " mm/h" + mmHg = " mmHg" + mmHg_per_hour = " mmHg/h" + percent = % + ppm = " ppm" + radian = " rad" + second = " second", " seconds" + uv_index = "" + volt = " V" + watt = " W" + watt_hour = " Wh" + watt_per_meter_squared = " W/m²" + watt_second = " Ws" + NONE = "" + + # The following section sets the format to be used for each time scale. + # The values below will work in every locale, but they may not look + # particularly attractive. See the Customization Guide for alternatives. + [[TimeFormats]] + + hour = %H:%M + day = %X + week = %X (%A) + month = %x %X + year = %x %X + rainyear = %x %X + current = %x %X + ephem_day = %X + ephem_year = %x %X + + [[DeltaTimeFormats]] + current = "%(minute)d%(minute_label)s, %(second)d%(second_label)s" + hour = "%(minute)d%(minute_label)s, %(second)d%(second_label)s" + day = "%(hour)d%(hour_label)s, %(minute)d%(minute_label)s, %(second)d%(second_label)s" + week = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s" + month = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s" + year = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, %(minute)d%(minute_label)s" + + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + + # The following section sets the base temperatures used for the + # calculation of heating and cooling degree-days. + [[DegreeDays]] + + # Base temperature for heating days, with unit: + heating_base = 65, degree_F + # Base temperature for cooling days, with unit: + cooling_base = 65, degree_F + # Base temperature for growing days, with unit: + growing_base = 50, degree_F + + # A trend takes a difference across a time period. The following + # section sets the time period, and how big an error is allowed to + # still be counted as the start or end of a period. + [[Trend]] + + time_delta = 10800 # 3 hours + time_grace = 300 # 5 minutes + +# The labels are applied to observations or any other strings. +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + # Formats to be used for latitude whole degrees, longitude whole + # degrees, and minutes: + latlon_formats = "%02d", "%03d", "%05.2f" + + # Generic labels, keyed by an observation type. + [[Generic]] + barometer = Barometer + barometerRate = Barometer Change Rate + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Outside Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + + # Sensor status indicators + + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent +""" + +defaults = weeutil.config.config_from_str(DEFAULT_STR) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/__init__.py b/dist/weewx-5.0.2/src/weewx/drivers/__init__.py new file mode 100644 index 0000000..f303bc9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/__init__.py @@ -0,0 +1,161 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Device drivers for the weewx weather system.""" + +import sys + +import weewx + + +class AbstractDevice(object): + """Device drivers should inherit from this class.""" + + @property + def hardware_name(self): + raise NotImplementedError("Property 'hardware_name' not implemented") + + @property + def archive_interval(self): + raise NotImplementedError("Property 'archive_interval' not implemented") + + def genStartupRecords(self, last_ts): + return self.genArchiveRecords(last_ts) + + def genLoopPackets(self): + raise NotImplementedError("Method 'genLoopPackets' not implemented") + + def genArchiveRecords(self, lastgood_ts): + raise NotImplementedError("Method 'genArchiveRecords' not implemented") + + def getTime(self): + raise NotImplementedError("Method 'getTime' not implemented") + + def setTime(self): + raise NotImplementedError("Method 'setTime' not implemented") + + def closePort(self): + pass + + +class AbstractConfigurator(object): + """The configurator class defines an interface for configuring devices. + Inherit from this class to provide a comman-line interface for setting + up a device, querying device status, and other setup/maintenance + operations. + + Used by 'weectl device'. + """ + + @property + def description(self): + return "Configuration utility for weewx devices." + + @property + def usage(self): + return "%prog [FILENAME|--config=FILENAME] [options] [-y] [--debug] [--help]" + + @property + def epilog(self): + return "Be sure to stop weewxd first before using. Mutating actions will request " \ + "confirmation before proceeding.\n" + + def configure(self, config_dict): + parser = self.get_parser() + self.add_options(parser) + options, _ = parser.parse_args() + if options.debug: + weewx.debug = options.debug + self.do_options(options, parser, config_dict, not options.noprompt) + + def get_parser(self): + import optparse + return optparse.OptionParser(description=self.description, + usage=self.usage, + epilog=self.epilog, + prog=f"{sys.argv[0]} device") + + def add_options(self, parser): + """Add command line options. Derived classes should override this + method to add more options.""" + import weecfg + + parser.add_option("--config", metavar="FILENAME", + help='Path to configuration file. ' + f'Default is "{weecfg.default_config_path}".') + parser.add_option("--debug", dest="debug", + action="store_true", + help="display diagnostic information while running") + parser.add_option("-y", dest="noprompt", + action="store_true", + help="answer yes to every prompt") + + def do_options(self, options, parser, config_dict, prompt): + """Derived classes must implement this to actually do something.""" + raise NotImplementedError("Method 'do_options' not implemented") + + +class AbstractConfEditor(object): + """The conf editor class provides methods for producing and updating + configuration stanzas for use in configuration file. + """ + + @property + def default_stanza(self): + """Return a plain text stanza. This will look something like: + +[Acme] + # This section is for the Acme weather station + + # The station model + model = acme100 + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The driver to use: + driver = weewx.drivers.acme + """ + raise NotImplementedError("property 'default_stanza' is not defined") + + def get_conf(self, orig_stanza=None): + """Given a configuration stanza, return a possibly modified copy + that will work with the current version of the device driver. + + The default behavior is to return the original stanza, unmodified. + + Derived classes should override this if they need to modify previous + configuration options or warn about deprecated or harmful options. + + The return value should be a long string. See default_stanza above + for an example string stanza.""" + return orig_stanza if orig_stanza else self.default_stanza + + def prompt_for_settings(self): + """Prompt for settings required for proper operation of this driver. + """ + return dict() + + def _prompt(self, label, dflt=None, opts=None): + if label in self.existing_options: + dflt = self.existing_options[label] + import weecfg + val = weecfg.prompt_with_options(label, dflt, opts) + del weecfg + return val + + def modify_config(self, config_dict): + """Given a configuration dictionary, make any modifications required + by the driver. + + The default behavior is to make no changes. + + This method gives a driver the opportunity to modify configuration + settings that affect its performance. For example, if a driver can + support hardware archive record generation, but software archive record + generation is preferred, the driver can change that parameter using + this method. + """ + pass diff --git a/dist/weewx-5.0.2/src/weewx/drivers/acurite.py b/dist/weewx-5.0.2/src/weewx/drivers/acurite.py new file mode 100644 index 0000000..c9f28fd --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/acurite.py @@ -0,0 +1,1000 @@ +# Copyright 2014-2024 Matthew Wall +# See the file LICENSE.txt for your rights. +# +# Credits: +# Thanks to Rich of Modern Toil (2012) +# http://moderntoil.com/?p=794 +# +# Thanks to George Nincehelser +# http://nincehelser.com/ipwx/ +# +# Thanks to Dave of 'desert home' (2014) +# http://www.desert-home.com/2014/11/acurite-weather-station-raspberry-pi.html +# +# Thanks to Brett Warden +# figured out a linear function for the pressure sensor in the 02032 +# +# Thanks to Weather Guy and Andrew Daviel (2015) +# decoding of the R3 messages and R3 reports +# decoding of the windspeed +# +# golf clap to Michael Walsh +# http://forum1.valleyinfosys.com/index.php +# +# No thanks to AcuRite or Chaney instruments. They refused to provide any +# technical details for the development of this driver. + +"""Driver for AcuRite weather stations. + +There are many variants of the AcuRite weather stations and sensors. This +driver is known to work with the consoles that have a USB interface such as +models 01025, 01035, 02032C, and 02064C. + +The AcuRite stations were introduced in 2011. The 02032 model was introduced +in 2013 or 2014. The 02032 appears to be a low-end model - it has fewer +buttons, and a different pressure sensor. The 02064 model was introduced in +2015 and appears to be an attempt to fix problems in the 02032. + +AcuRite publishes the following specifications: + + temperature outdoor: -40F to 158F; -40C to 70C + temperature indoor: 32F to 122F; 0C to 50C + humidity outdoor: 1% to 99% + humidity indoor: 16% to 98% + wind speed: 0 to 99 mph; 0 to 159 kph + wind direction: 16 points + rainfall: 0 to 99.99 in; 0 to 99.99 mm + wireless range: 330 ft; 100 m + operating frequency: 433 MHz + display power: 4.5V AC adapter (6 AA bateries, optional) + sensor power: 4 AA batteries + +The memory size is 512 KB and is not expandable. The firmware cannot be +modified or upgraded. + +According to AcuRite specs, the update frequencies are as follows: + + wind speed: 18 second updates + wind direction: 30 second updates + outdoor temperature and humidity: 60 second updates + pc connect csv data logging: 12 minute intervals + pc connect to acurite software: 18 second updates + +In fact, because of the message structure and the data logging design, these +are the actual update frequencies: + + wind speed: 18 seconds + outdoor temperature, outdoor humidity: 36 seconds + wind direction, rain total: 36 seconds + indoor temperature, pressure: 60 seconds + indoor humidity: 12 minutes (only when in USB mode 3) + +These are the frequencies possible when reading data via USB. + +There is no known way to change the archive interval of 12 minutes. + +There is no known way to clear the console memory via software. + +The AcuRite stations have no notion of wind gust. + +The pressure sensor in the console reports a station pressure, but the +firmware does some kind of averaging to it so the console displays a pressure +that is usually nothing close to the station pressure. + +According to AcuRite they use a 'patented, self-adjusting altitude pressure +compensation' algorithm. Not helpful, and in practice not accurate. + +Apparently the AcuRite bridge uses the HP03S integrated pressure sensor: + + http://www.hoperf.com/upload/sensor/HP03S.pdf + +The calculation in that specification happens to work for some of the AcuRite +consoles (01035, 01036, others?). However, some AcuRite consoles (only the +02032?) use the MS5607-02BA03 sensor: + + http://www.meas-spec.com/downloads/MS5607-02BA03.pdf + +Communication + +The AcuRite station has 4 modes: + + show data store data stream data + 1 x x + 2 x + 3 x x x + 4 x x + +The console does not respond to USB requests when in mode 1 or mode 2. + +There is no known way to change the mode via software. + +The acurite stations are probably a poor choice for remote operation. If +the power cycles on the console, communication might not be possible. Some +consoles (but not all?) default to mode 2, which means no USB communication. + +The console shows up as a USB device even if it is turned off. If the console +is powered on and communication has been established, then power is removed, +the communication will continue. So the console appears to draw some power +from the bus. + +Apparently some stations have issues when the history buffer fills up. Some +reports say that the station stops recording data. Some reports say that +the 02032 (and possibly other stations) should not use history mode at all +because the data are written to flash memory, which wears out, sometimes +quickly. Some reports say this 'bricks' the station, however those reports +mis-use the term 'brick', because the station still works and communication +can be re-established by power cycling and/or resetting the USB. + +There may be firmware timing issues that affect USB communication. Reading +R3 messages too frequently can cause the station to stop responding via USB. +Putting the station in mode 3 sometimes interferes with the collection of +data from the sensors; it can cause the station to report bad values for R1 +messages (this was observed on a 01036 console, but not consistantly). + +Testing with a 01036 showed no difference between opening the USB port once +during driver initialization and opening the USB port for each read. However, +tests with a 02032 showed that opening for each read was much more robust. + +Message Types + +The AcuRite stations show up as USB Human Interface Device (HID). This driver +uses the lower-level, raw USB API. However, the communication is standard +requests for data from a HID. + +The AcuRite station emits three different data strings, R1, R2 and R3. The R1 +string is 10 bytes long, contains readings from the remote sensors, and comes +in different flavors. One contains wind speed, wind direction, and rain +counter. Another contains wind speed, temperature, and humidity. The R2 +string is 25 bytes long and contains the temperature and pressure readings +from the console, plus a bunch of calibration constants required to +figure out the actual pressure and temperature. The R3 string is 33 bytes +and contains historical data and (apparently) the humidity readings from the +console sensors. + +The contents of the R2 message depends on the pressure sensor. For stations +that use the HP03S sensor (e.g., 01035, 01036) the R2 message contains +factory-set constants for calculating temperature and pressure. For stations +that use the MS5607-02BA03 sensor (e.g., 02032) the R2 message contents are +unknown. In both cases, the last 4 bytes appear to contain raw temperature +and pressure readings, while the rest of the message bytes are constant. + +Message Maps + +R1 - 10 bytes + 0 1 2 3 4 5 6 7 8 9 +01 CS SS ?1 ?W WD 00 RR ?r ?? +01 CS SS ?8 ?W WT TT HH ?r ?? + +01 CF FF FF FF FF FF FF 00 00 no sensor unit found +01 FF FF FF FF FF FF FF FF 00 no sensor unit found +01 8b fa 71 00 06 00 0c 00 00 connection to sensor unit lost +01 8b fa 78 00 08 75 24 01 00 connection to sensor unit weak/lost +01 8b fa 78 00 08 48 25 03 ff flavor 8 +01 8b fa 71 00 06 00 02 03 ff flavor 1 +01 C0 5C 78 00 08 1F 53 03 FF flavor 8 +01 C0 5C 71 00 05 00 0C 03 FF flavor 1 +01 cd ff 71 00 6c 39 71 03 ff +01 cd ff 78 00 67 3e 59 03 ff +01 cd ff 71 01 39 39 71 03 ff +01 cd ff 78 01 58 1b 4c 03 ff + +0: identifier 01 indicates R1 messages +1: channel x & 0xf0 observed values: 0xC=A, 0x8=B, 0x0=C +1: sensor_id hi x & 0x0f +2: sensor_id lo +3: ?status x & 0xf0 7 is 5-in-1? 7 is battery ok? +3: message flavor x & 0x0f type 1 is windSpeed, windDir, rain +4: wind speed (x & 0x1f) << 3 +5: wind speed (x & 0x70) >> 4 +5: wind dir (x & 0x0f) +6: ? always seems to be 0 +7: rain (x & 0x7f) +8: ? +8: rssi (x & 0x0f) observed values: 0,1,2,3 +9: ? observed values: 0x00, 0xff + +0: identifier 01 indicates R1 messages +1: channel x & 0xf0 observed values: 0xC=A, 0x8=B, 0x0=C +1: sensor_id hi x & 0x0f +2: sensor_id lo +3: ?status x & 0xf0 7 is 5-in-1? 7 is battery ok? +3: message flavor x & 0x0f type 8 is windSpeed, outTemp, outHumidity +4: wind speed (x & 0x1f) << 3 +5: wind speed (x & 0x70) >> 4 +5: temp (x & 0x0f) << 7 +6: temp (x & 0x7f) +7: humidity (x & 0x7f) +8: ? +8: rssi (x & 0x0f) observed values: 0,1,2,3 +9: ? observed values: 0x00, 0xff + + +R2 - 25 bytes + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 +02 00 00 C1 C1 C2 C2 C3 C3 C4 C4 C5 C5 C6 C6 C7 C7 AA BB CC DD TR TR PR PR + +02 00 00 4C BE 0D EC 01 52 03 62 7E 38 18 EE 09 C4 08 22 06 07 7B A4 8A 46 +02 00 00 80 00 00 00 00 00 04 00 10 00 00 00 09 60 01 01 01 01 8F C7 4C D3 + +for HP03S sensor: + + 0: identifier 02 indicates R2 messages + 1: ? always seems to be 0 + 2: ? always seems to be 0 + 3-4: C1 sensitivity coefficient 0x100 - 0xffff + 5-6: C2 offset coefficient 0x00 - 0x1fff + 7-8: C3 temperature coefficient of sensitivity 0x00 - 0x400 + 9-10: C4 temperature coefficient of offset 0x00 - 0x1000 + 11-12: C5 reference temperature 0x1000 - 0xffff + 13-14: C6 temperature coefficient of temperature 0x00 - 0x4000 + 15-16: C7 offset fine-tuning 0x960 - 0xa28 + 17: A sensor-specific parameter 0x01 - 0x3f + 18: B sensor-specific parameter 0x01 - 0x3f + 19: C sensor-specific parameter 0x01 - 0x0f + 20: D sensor-specific parameter 0x01 - 0x0f + 21-22: TR measured temperature 0x00 - 0xffff + 23-24: PR measured pressure 0x00 - 0xffff + +for MS5607-02BA03 sensor: + + 0: identifier 02 indicates R2 messages + 1: ? always seems to be 0 + 2: ? always seems to be 0 + 3-4: C1 sensitivity coefficient 0x800 + 5-6: C2 offset coefficient 0x00 + 7-8: C3 temperature coefficient of sensitivity 0x00 + 9-10: C4 temperature coefficient of offset 0x0400 + 11-12: C5 reference temperature 0x1000 + 13-14: C6 temperature coefficient of temperature 0x00 + 15-16: C7 offset fine-tuning 0x0960 + 17: A sensor-specific parameter 0x01 + 18: B sensor-specific parameter 0x01 + 19: C sensor-specific parameter 0x01 + 20: D sensor-specific parameter 0x01 + 21-22: TR measured temperature 0x00 - 0xffff + 23-24: PR measured pressure 0x00 - 0xffff + + +R3 - 33 bytes + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ... +03 aa 55 01 00 00 00 20 20 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ... + +An R3 report consists of multiple R3 messages. Each R3 report contains records +that are delimited by the sequence 0xaa 0x55. There is a separator sequence +prior to the first record, but not after the last record. + +There are 6 types of records, each type identified by number: + + 1,2 8-byte chunks of historical min/max data. Each 8-byte chunk + appears to contain two data bytes plus a 5-byte timestamp + indicating when the event occurred. + 3 Timestamp indicating when the most recent history record was + stored, based on the console clock. + 4 History data + 5 Timestamp indicating when the request for history data was + received, based on the console clock. + 6 End marker indicating that no more data follows in the report. + +Each record has the following header: + + 0: record id possible values are 1-6 + 1,2: unknown always seems to be 0 + 3,4: size size of record, in 'chunks' + 5: checksum total of bytes 0..4 minus one + + where the size of a 'chunk' depends on the record id: + + id chunk size + + 1,2,3,5 8 bytes + 4 32 bytes + 6 n/a + +For all but ID6, the total record size should be equal to 6 + chunk_size * size +ID6 never contains data, but a size of 4 is always declared. + +Timestamp records (ID3 and ID5): + + 0-1: for ID3, the number of history records when request was received + 0-1: for ID5, unknown + 2: year + 3: month + 4: day + 5: hour + 6: minute + 7: for ID3, checksum - sum of bytes 0..6 (do not subtract 1) + 7: for ID5, unknown (always 0xff) + +History Records (ID4): + +Bytes 3,4 contain the number of history records that follow, say N. After +stripping off the 6-byte record header there should be N*32 bytes of history +data. If not, then the data are corrupt or there was an incomplete transfer. + +The most recent history record is first, so the timestamp on record ID applies +to the first 32-byte chunk, and each record is 12 minutes into the past from +the previous. Each 32-byte chunk has the following decoding: + + 0-1: indoor temperature (r[0]*256 + r[1])/18 - 100 C + 2-3: outdoor temperature (r[2]*256 + r[3])/18 - 100 C + 4: unknown + 5: indoor humidity r[5] percent + 6: unknown + 7: outdoor humidity r[7] percent + 8-9: windchill (r[8]*256 + r[9])/18 - 100 C + 10-11: heat index (r[10]*256 + r[11])/18 - 100 C + 12-13: dewpoint (r[12]*256 + r[13])/18 - 100 C + 14-15: barometer ((r[14]*256 + r[15]) & 0x07ff)/10 kPa + 16: unknown + 17: unknown 0xf0 + 17: wind direction dirmap(r[17] & 0x0f) + 18-19: wind speed (r[18]*256 + r[19])/16 kph + 20-21: wind max (r[20]*256 + r[21])/16 kph + 22-23: wind average (r[22]*256 + r[23])/16 kph + 24-25: rain (r[24]*256 + r[25]) * 0.254 mm + 26-30: rain timestamp 0xff if no rain event + 31: unknown + +bytes 4 and 6 always seem to be 0 +byte 16 is always zero on 02032 console, but is a copy of byte 21 on 01035. +byte 31 is always zero on 02032 console, but is a copy of byte 30 on 01035. + + +X1 - 2 bytes + 0 2 +7c e2 +84 e2 + +0: ? +1: ? +""" + +# FIXME: how to detect mode via software? +# FIXME: what happens when memory fills up? overwrite oldest? +# FIXME: how to detect console type? +# FIXME: how to set station time? +# FIXME: how to get station time? +# FIXME: decode console battery level +# FIXME: decode sensor type - hi byte of byte 3 in R1 message? + +# FIXME: decode inside humidity +# FIXME: decode historical records +# FIXME: perhaps retry read when dodgey data or short read? + +import logging +import time +import usb + +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import to_bool + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'AcuRite' +DRIVER_VERSION = '0.4' +DEBUG_RAW = 0 + +# USB constants for HID +USB_HID_GET_REPORT = 0x01 +USB_HID_SET_REPORT = 0x09 +USB_HID_INPUT_REPORT = 0x0100 +USB_HID_OUTPUT_REPORT = 0x0200 + +def loader(config_dict, engine): + return AcuRiteDriver(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return AcuRiteConfEditor() + +def _fmt_bytes(data): + return ' '.join(['%02x' % x for x in data]) + + +class AcuRiteDriver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with an AcuRite weather station. + + model: Which station model is this? + [Optional. Default is 'AcuRite'] + + max_tries - How often to retry communication before giving up. + [Optional. Default is 10] + + use_constants - Indicates whether to use calibration constants when + decoding pressure and temperature. For consoles that use the HP03 sensor, + use the constants reported by the sensor. Otherwise, use a linear + approximation to derive pressure and temperature values from the sensor + readings. + [Optional. Default is True] + + ignore_bounds - Indicates how to treat calibration constants from the + pressure/temperature sensor. Some consoles report constants that are + outside the limits specified by the sensor manufacturer. Typically, this + would indicate bogus data - perhaps a bad transmission or noisy USB. + But in some cases, the apparently bogus constants actually work, and + no amount of power cycling or resetting of the console changes the values + that the console emits. Use this flag to indicate that this is one of + those quirky consoles. + [Optional. Default is False] + """ + _R1_INTERVAL = 18 # 5-in-1 sensor updates every 18 seconds + _R2_INTERVAL = 60 # console sensor updates every 60 seconds + _R3_INTERVAL = 12*60 # historical records updated every 12 minutes + + def __init__(self, **stn_dict): + log.info('driver version is %s' % DRIVER_VERSION) + self.model = stn_dict.get('model', 'AcuRite') + self.max_tries = int(stn_dict.get('max_tries', 10)) + self.retry_wait = int(stn_dict.get('retry_wait', 30)) + self.polling_interval = int(stn_dict.get('polling_interval', 6)) + self.use_constants = to_bool(stn_dict.get('use_constants', True)) + self.ignore_bounds = to_bool(stn_dict.get('ignore_bounds', False)) + if self.use_constants: + log.info('R2 will be decoded using sensor constants') + if self.ignore_bounds: + log.info('R2 bounds on constants will be ignored') + self.enable_r3 = int(stn_dict.get('enable_r3', 0)) + if self.enable_r3: + log.info('R3 data will be attempted') + self.last_rain = None + self.last_r3 = None + self.r3_fail_count = 0 + self.r3_max_fail = 3 + self.r1_next_read = 0 + self.r2_next_read = 0 + global DEBUG_RAW + DEBUG_RAW = int(stn_dict.get('debug_raw', 0)) + + @property + def hardware_name(self): + return self.model + + def genLoopPackets(self): + last_raw2 = None + ntries = 0 + while ntries < self.max_tries: + ntries += 1 + try: + packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + raw1 = raw2 = None + with Station() as station: + if time.time() >= self.r1_next_read: + raw1 = station.read_R1() + self.r1_next_read = time.time() + self._R1_INTERVAL + if DEBUG_RAW > 0 and raw1: + log.debug("R1: %s" % _fmt_bytes(raw1)) + if time.time() >= self.r2_next_read: + raw2 = station.read_R2() + self.r2_next_read = time.time() + self._R2_INTERVAL + if DEBUG_RAW > 0 and raw2: + log.debug("R2: %s" % _fmt_bytes(raw2)) + if self.enable_r3: + raw3 = self.read_R3_block(station) + if DEBUG_RAW > 0 and raw3: + for row in raw3: + log.debug("R3: %s" % _fmt_bytes(row)) + if raw1: + packet.update(Station.decode_R1(raw1)) + if raw2: + Station.check_pt_constants(last_raw2, raw2) + last_raw2 = raw2 + packet.update(Station.decode_R2( + raw2, self.use_constants, self.ignore_bounds)) + self._augment_packet(packet) + ntries = 0 + yield packet + next_read = min(self.r1_next_read, self.r2_next_read) + delay = max(int(next_read - time.time() + 1), + self.polling_interval) + log.debug("next read in %s seconds" % delay) + time.sleep(delay) + except (usb.USBError, weewx.WeeWxIOError) as e: + log.error("Failed attempt %d of %d to get LOOP data: %s" % + (ntries, self.max_tries, e)) + time.sleep(self.retry_wait) + else: + msg = "Max retries (%d) exceeded for LOOP data" % self.max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def _augment_packet(self, packet): + # calculate the rain delta from the total + if 'rain_total' in packet: + total = packet['rain_total'] + if (total is not None and self.last_rain is not None and + total < self.last_rain): + log.info("rain counter decrement ignored:" + " new: %s old: %s" % (total, self.last_rain)) + packet['rain'] = weewx.wxformulas.calculate_rain(total, self.last_rain) + self.last_rain = total + + # if there is no connection to sensors, clear the readings + if 'rssi' in packet and packet['rssi'] == 0: + packet['outTemp'] = None + packet['outHumidity'] = None + packet['windSpeed'] = None + packet['windDir'] = None + packet['rain'] = None + + # map raw data to observations in the default database schema + if 'sensor_battery' in packet: + if packet['sensor_battery'] is not None: + packet['outTempBatteryStatus'] = 1 if packet['sensor_battery'] else 0 + else: + packet['outTempBatteryStatus'] = None + if 'rssi' in packet and packet['rssi'] is not None: + packet['rxCheckPercent'] = 100 * packet['rssi'] / Station.MAX_RSSI + + def read_R3_block(self, station): + # attempt to read R3 every 12 minutes. if the read fails multiple + # times, make a single log message about enabling usb mode 3 then do + # not try it again. + # + # when the station is not in mode 3, attempts to read R3 leave + # it in an uncommunicative state. doing a reset, close, then open + # will sometimes, but not always, get communication started again on + # 01036 stations. + r3 = [] + if self.r3_fail_count >= self.r3_max_fail: + return r3 + if (self.last_r3 is None or + time.time() - self.last_r3 > self._R3_INTERVAL): + try: + x = station.read_x() + for i in range(17): + r3.append(station.read_R3()) + self.last_r3 = time.time() + except usb.USBError as e: + self.r3_fail_count += 1 + log.debug("R3: read failed %d of %d: %s" % + (self.r3_fail_count, self.r3_max_fail, e)) + if self.r3_fail_count >= self.r3_max_fail: + log.info("R3: put station in USB mode 3 to enable R3 data") + return r3 + + +class Station(object): + # these identify the weather station on the USB + VENDOR_ID = 0x24c0 + PRODUCT_ID = 0x0003 + + # map the raw wind direction index to degrees on the compass + IDX_TO_DEG = {6: 0.0, 14: 22.5, 12: 45.0, 8: 67.5, 10: 90.0, 11: 112.5, + 9: 135.0, 13: 157.5, 15: 180.0, 7: 202.5, 5: 225.0, 1: 247.5, + 3: 270.0, 2: 292.5, 0: 315.0, 4: 337.5} + + # map the raw channel value to something we prefer + # A is 1, B is 2, C is 3 + CHANNELS = {12: 1, 8: 2, 0: 3} + + # maximum value for the rssi + MAX_RSSI = 3.0 + + def __init__(self, vend_id=VENDOR_ID, prod_id=PRODUCT_ID, dev_id=None): + self.vendor_id = vend_id + self.product_id = prod_id + self.device_id = dev_id + self.handle = None + self.timeout = 1000 + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self): + dev = self._find_dev(self.vendor_id, self.product_id, self.device_id) + if not dev: + log.critical("Cannot find USB device with " + "VendorID=0x%04x ProductID=0x%04x DeviceID=%s" % + (self.vendor_id, self.product_id, self.device_id)) + raise weewx.WeeWxIOError('Unable to find station on USB') + + self.handle = dev.open() + if not self.handle: + raise weewx.WeeWxIOError('Open USB device failed') + +# self.handle.reset() + + # the station shows up as a HID with only one interface + interface = 0 + + # for linux systems, be sure kernel does not claim the interface + try: + self.handle.detachKernelDriver(interface) + except (AttributeError, usb.USBError): + pass + + # FIXME: is it necessary to set the configuration? + try: + self.handle.setConfiguration(dev.configurations[0]) + except (AttributeError, usb.USBError) as e: + pass + + # attempt to claim the interface + try: + self.handle.claimInterface(interface) + except usb.USBError as e: + self.close() + log.critical("Unable to claim USB interface %s: %s" % (interface, e)) + raise weewx.WeeWxIOError(e) + + # FIXME: is it necessary to set the alt interface? + try: + self.handle.setAltInterface(interface) + except (AttributeError, usb.USBError) as e: + pass + + def close(self): + if self.handle is not None: + try: + self.handle.releaseInterface() + except (ValueError, usb.USBError) as e: + log.error("release interface failed: %s" % e) + self.handle = None + + def reset(self): + self.handle.reset() + + def read(self, report_number, nbytes): + return self.handle.controlMsg( + requestType=usb.RECIP_INTERFACE + usb.TYPE_CLASS + usb.ENDPOINT_IN, + request=USB_HID_GET_REPORT, + buffer=nbytes, + value=USB_HID_INPUT_REPORT + report_number, + index=0x0, + timeout=self.timeout) + + def read_R1(self): + return self.read(1, 10) + + def read_R2(self): + return self.read(2, 25) + + def read_R3(self): + return self.read(3, 33) + + def read_x(self): + # FIXME: what do the two bytes mean? + return self.handle.controlMsg( + requestType=usb.RECIP_INTERFACE + usb.TYPE_CLASS, + request=USB_HID_SET_REPORT, + buffer=2, + value=USB_HID_OUTPUT_REPORT + 0x01, + index=0x0, + timeout=self.timeout) + + @staticmethod + def decode_R1(raw): + data = dict() + if len(raw) == 10 and raw[0] == 0x01: + if Station.check_R1(raw): + data['channel'] = Station.decode_channel(raw) + data['sensor_id'] = Station.decode_sensor_id(raw) + data['rssi'] = Station.decode_rssi(raw) + if data['rssi'] == 0: + data['sensor_battery'] = None + log.info("R1: ignoring stale data (rssi indicates no communication from sensors): %s" % _fmt_bytes(raw)) + else: + data['sensor_battery'] = Station.decode_sensor_battery(raw) + data['windSpeed'] = Station.decode_windspeed(raw) + if raw[3] & 0x0f == 1: + data['windDir'] = Station.decode_winddir(raw) + data['rain_total'] = Station.decode_rain(raw) + else: + data['outTemp'] = Station.decode_outtemp(raw) + data['outHumidity'] = Station.decode_outhumid(raw) + else: + data['channel'] = None + data['sensor_id'] = None + data['rssi'] = None + data['sensor_battery'] = None + elif len(raw) != 10: + log.error("R1: bad length: %s" % _fmt_bytes(raw)) + else: + log.error("R1: bad format: %s" % _fmt_bytes(raw)) + return data + + @staticmethod + def check_R1(raw): + ok = True + if raw[1] & 0x0f == 0x0f and raw[3] == 0xff: + log.info("R1: no sensors found: %s" % _fmt_bytes(raw)) + ok = False + else: + if raw[3] & 0x0f != 1 and raw[3] & 0x0f != 8: + log.info("R1: bogus message flavor (%02x): %s" % (raw[3], _fmt_bytes(raw))) + ok = False + if raw[9] != 0xff and raw[9] != 0x00: + log.info("R1: bogus final byte (%02x): %s" % (raw[9], _fmt_bytes(raw))) + ok = False + if raw[8] & 0x0f < 0 or raw[8] & 0x0f > 3: + log.info("R1: bogus signal strength (%02x): %s" % (raw[8], _fmt_bytes(raw))) + ok = False + return ok + + @staticmethod + def decode_R2(raw, use_constants=True, ignore_bounds=False): + data = dict() + if len(raw) == 25 and raw[0] == 0x02: + data['pressure'], data['inTemp'] = Station.decode_pt( + raw, use_constants, ignore_bounds) + elif len(raw) != 25: + log.error("R2: bad length: %s" % _fmt_bytes(raw)) + else: + log.error("R2: bad format: %s" % _fmt_bytes(raw)) + return data + + @staticmethod + def decode_R3(raw): + data = dict() + buf = [] + fail = False + for i, r in enumerate(raw): + if len(r) == 33 and r[0] == 0x03: + try: + for b in r: + buf.append(int(b, 16)) + except ValueError as e: + log.error("R3: bad value in row %d: %s" % (i, _fmt_bytes(r))) + fail = True + elif len(r) != 33: + log.error("R3: bad length in row %d: %s" % (i, _fmt_bytes(r))) + fail = True + else: + log.error("R3: bad format in row %d: %s" % (i, _fmt_bytes(r))) + fail = True + if fail: + return data + for i in range(2, len(buf)-2): + if buf[i-2] == 0xff and buf[i-1] == 0xaa and buf[i] == 0x55: + data['numrec'] = buf[i+1] + buf[i+2] * 0x100 + break + data['raw'] = raw + return data + + @staticmethod + def decode_channel(data): + return Station.CHANNELS.get(data[1] & 0xf0) + + @staticmethod + def decode_sensor_id(data): + return ((data[1] & 0x0f) << 8) | data[2] + + @staticmethod + def decode_rssi(data): + # signal strength goes from 0 to 3, inclusive + # according to nincehelser, this is a measure of the number of failed + # sensor queries, not the actual RF signal strength + return data[8] & 0x0f + + @staticmethod + def decode_sensor_battery(data): + # 0x7 indicates battery ok, 0xb indicates low battery? + a = (data[3] & 0xf0) >> 4 + return 0 if a == 0x7 else 1 + + @staticmethod + def decode_windspeed(data): + # extract the wind speed from an R1 message + # return value is kph + # for details see http://www.wxforum.net/index.php?topic=27244.0 + # minimum measurable speed is 1.83 kph + n = ((data[4] & 0x1f) << 3) | ((data[5] & 0x70) >> 4) + if n == 0: + return 0.0 + return 0.8278 * n + 1.0 + + @staticmethod + def decode_winddir(data): + # extract the wind direction from an R1 message + # decoded value is one of 16 points, convert to degrees + v = data[5] & 0x0f + return Station.IDX_TO_DEG.get(v) + + @staticmethod + def decode_outtemp(data): + # extract the temperature from an R1 message + # return value is degree C + a = (data[5] & 0x0f) << 7 + b = (data[6] & 0x7f) + return (a | b) / 18.0 - 40.0 + + @staticmethod + def decode_outhumid(data): + # extract the humidity from an R1 message + # decoded value is percentage + return data[7] & 0x7f + + @staticmethod + def decode_rain(data): + # decoded value is a count of bucket tips + # each tip is 0.01 inch, return value is cm + return (((data[6] & 0x3f) << 7) | (data[7] & 0x7f)) * 0.0254 + + @staticmethod + def decode_pt(data, use_constants=True, ignore_bounds=False): + # decode pressure and temperature from the R2 message + # decoded pressure is mbar, decoded temperature is degree C + c1,c2,c3,c4,c5,c6,c7,a,b,c,d = Station.get_pt_constants(data) + + if not use_constants: + # use a linear approximation for pressure and temperature + d2 = ((data[21] & 0x0f) << 8) + data[22] + if d2 >= 0x0800: + d2 -= 0x1000 + d1 = (data[23] << 8) + data[24] + return Station.decode_pt_acurite(d1, d2) + elif (c1 == 0x8000 and c2 == c3 == 0x0 and c4 == 0x0400 + and c5 == 0x1000 and c6 == 0x0 and c7 == 0x0960 + and a == b == c == d == 0x1): + # this is a MS5607 sensor, typical in 02032 consoles + d2 = ((data[21] & 0x0f) << 8) + data[22] + if d2 >= 0x0800: + d2 -= 0x1000 + d1 = (data[23] << 8) + data[24] + return Station.decode_pt_MS5607(d1, d2) + elif (0x100 <= c1 <= 0xffff and + 0x0 <= c2 <= 0x1fff and + 0x0 <= c3 <= 0x400 and + 0x0 <= c4 <= 0x1000 and + 0x1000 <= c5 <= 0xffff and + 0x0 <= c6 <= 0x4000 and + 0x960 <= c7 <= 0xa28 and + (0x01 <= a <= 0x3f and 0x01 <= b <= 0x3f and + 0x01 <= c <= 0x0f and 0x01 <= d <= 0x0f) or ignore_bounds): + # this is a HP038 sensor. some consoles return values outside the + # specified limits, but their data still seem to be ok. if the + # ignore_bounds flag is set, then permit values for A, B, C, or D + # that are out of bounds, but enforce constraints on the other + # constants C1-C7. + d2 = (data[21] << 8) + data[22] + d1 = (data[23] << 8) + data[24] + return Station.decode_pt_HP03S(c1,c2,c3,c4,c5,c6,c7,a,b,c,d,d1,d2) + log.error("R2: unknown calibration constants: %s" % _fmt_bytes(data)) + return None, None + + @staticmethod + def decode_pt_HP03S(c1,c2,c3,c4,c5,c6,c7,a,b,c,d,d1,d2): + # for devices with the HP03S pressure sensor + if d2 >= c5: + dut = d2 - c5 - ((d2-c5)/128) * ((d2-c5)/128) * a / (2<<(c-1)) + else: + dut = d2 - c5 - ((d2-c5)/128) * ((d2-c5)/128) * b / (2<<(c-1)) + off = 4 * (c2 + (c4 - 1024) * dut / 16384) + sens = c1 + c3 * dut / 1024 + x = sens * (d1 - 7168) / 16384 - off + p = 0.1 * (x * 10 / 32 + c7) + t = 0.1 * (250 + dut * c6 / 65536 - dut / (2<<(d-1))) + return p, t + + @staticmethod + def decode_pt_MS5607(d1, d2): + # for devices with the MS5607 sensor, do a linear scaling + return Station.decode_pt_acurite(d1, d2) + + @staticmethod + def decode_pt_acurite(d1, d2): + # apparently the new (2015) acurite software uses this function, which + # is quite close to andrew daviel's reverse engineered function of: + # p = 0.062585727 * d1 - 209.6211 + # t = 25.0 + 0.05 * d2 + p = d1 / 16.0 - 208 + t = 25.0 + 0.05 * d2 + return p, t + + @staticmethod + def decode_inhumid(data): + # FIXME: decode inside humidity + return None + + @staticmethod + def get_pt_constants(data): + c1 = (data[3] << 8) + data[4] + c2 = (data[5] << 8) + data[6] + c3 = (data[7] << 8) + data[8] + c4 = (data[9] << 8) + data[10] + c5 = (data[11] << 8) + data[12] + c6 = (data[13] << 8) + data[14] + c7 = (data[15] << 8) + data[16] + a = data[17] + b = data[18] + c = data[19] + d = data[20] + return (c1,c2,c3,c4,c5,c6,c7,a,b,c,d) + + @staticmethod + def check_pt_constants(a, b): + if a is None or len(a) != 25 or len(b) != 25: + return + c1 = Station.get_pt_constants(a) + c2 = Station.get_pt_constants(b) + if c1 != c2: + log.error("R2: constants changed: old: [%s] new: [%s]" % ( + _fmt_bytes(a), _fmt_bytes(b))) + + @staticmethod + def _find_dev(vendor_id, product_id, device_id=None): + """Find the vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vendor_id and dev.idProduct == product_id: + if device_id is None or dev.filename == device_id: + log.debug('Found station at bus=%s device=%s' % + (bus.dirname, dev.filename)) + return dev + return None + + +class AcuRiteConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[AcuRite] + # This section is for AcuRite weather stations. + + # The station model, e.g., 'AcuRite 01025' or 'AcuRite 02032C' + model = 'AcuRite 01035' + + # The driver to use: + driver = weewx.drivers.acurite +""" + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/acurite.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('wee_acurite') + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + (options, args) = parser.parse_args() + + if options.version: + print("acurite driver version %s" % DRIVER_VERSION) + exit(0) + + test_r1 = True + test_r2 = True + test_r3 = False + delay = 12*60 + with Station() as s: + while True: + ts = int(time.time()) + tstr = "%s (%d)" % (time.strftime("%Y-%m-%d %H:%M:%S %Z", + time.localtime(ts)), ts) + if test_r1: + r1 = s.read_R1() + print(tstr, _fmt_bytes(r1), Station.decode_R1(r1)) + delay = min(delay, 18) + if test_r2: + r2 = s.read_R2() + print(tstr, _fmt_bytes(r2), Station.decode_R2(r2)) + delay = min(delay, 60) + if test_r3: + try: + x = s.read_x() + print(tstr, _fmt_bytes(x)) + for i in range(17): + r3 = s.read_R3() + print(tstr, _fmt_bytes(r3)) + except usb.USBError as e: + print(tstr, e) + delay = min(delay, 12*60) + time.sleep(delay) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/cc3000.py b/dist/weewx-5.0.2/src/weewx/drivers/cc3000.py new file mode 100644 index 0000000..a29cf9e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/cc3000.py @@ -0,0 +1,1504 @@ +# Copyright 2014-2024 Matthew Wall +# See the file LICENSE.txt for your rights. + +"""Driver for CC3000 data logger + +http://www.rainwise.com/products/attachments/6832/20110518125531.pdf + +There are a few variants: + +CC-3000_ - __ + | | + | 41 = 418 MHz + | 42 = 433 MHz + | __ = 2.4 GHz (LR compatible) + R = serial (RS232, RS485) + _ = USB 2.0 + +The CC3000 communicates using FTDI USB serial bridge. The CC3000R has both +RS-232 and RS-485 serial ports, only one of which may be used at a time. +A long range (LR) version transmits up to 2 km using 2.4GHz. + +The RS232 communicates using 115200 N-8-1 + +The instrument cluster contains a DIP switch controls with value 0-3 and a +default of 0. This setting prevents interference when there are multiple +weather stations within radio range. + +The CC3000 includes a temperature sensor - that is the source of inTemp. The +manual indicates that the CC3000 should run for 3 or 4 hours before applying +any calibration to offset the heat generated by CC3000 electronics. + +The CC3000 uses 4 AA batteries to maintain its clock. Use only rechargeable +NiMH batteries. + +The logger contains 2MB of memory, with a capacity of 49834 records (over 11 +months of data at a 10-minute logging interval). The exact capacity depends +on the sensors; the basic sensor record is 42 bytes. + +The logger does not delete old records when it fills up; once the logger is +full, new data are lost. So the driver must periodically clear the logger +memory. + +This driver does not support hardware record_generation. It does support +catchup on startup. + +If you request many history records, then interrupt the receive, the logger will +continue to send history records until it sends all that were requested. As a +result, any queries made while the logger is still sending will fail. + +The rainwise rain bucket measures 0.01 inches per tip. The logger firmware +automatically converts the bucket tip count to the measure of rain in ENGLISH +or METRIC units. + +The historical records (DOWNLOAD), as well as current readings (NOW) track +the amount of rain since midnight; i.e., DOWNLOAD records rain value resets to 0 +at midnight and NOW records do the same. + +The RAIN=? returns a rain counter that only resets with the RAIN=RESET command. +This counter isn't used by weewx. Also, RAIN=RESET doesn't just reset this +counter, it also resets the daily rain count. + +Logger uses the following units: + ENGLISH METRIC + wind mph m/s + rain inch mm + pressure inHg mbar + temperature F C + +The CC3000 has the habit of failing to execute about 1 in 6000 +commands. That the bad news. The good news is that the +condition is easily detected and the driver can recover in about 1s. +The telltale sign of failure is the first read after sending +the command (to read the echo of the command) times out. As such, +the timeout is set to 1s. If the timeout is hit, the buffers +are flushed and the command is retried. Oh, and there is one +more pecurliar part to this. On the retry, the command is echoed +as an empty string. That empty string is expected on the retry +and execution continues. + +weewx includes a logwatch script that makes it easy to see the above +behavior in action. In the snippet below, 3 NOW commands and one +IME=? were retried successfully. The Retry Info section shows +that all succeeded on the second try. + --------------------- weewx Begin ------------------------ + + average station clock skew: 0.0666250000000001 + min: -0.53 max: 0.65 samples: 160 + + counts: + archive: records added 988 + cc3000: NOW cmd echo timed out 3 + cc3000: NOW echoed as empty string 3 + cc3000: NOW successful retries 3 + cc3000: TIME=? cmd echo timed out 1 + cc3000: TIME=? echoed as empty string 1 + cc3000: TIME=? successful retries 1 + .... + cc3000 Retry Info: + Dec 29 00:50:04 ella weewx[24145] INFO weewx.drivers.cc3000: TIME=?: Retry worked. Total tries: 2 + Dec 29 04:46:21 ella weewx[24145] INFO weewx.drivers.cc3000: NOW: Retry worked. Total tries: 2 + Dec 29 08:31:11 ella weewx[22295] INFO weewx.drivers.cc3000: NOW: Retry worked. Total tries: 2 + Dec 29 08:50:51 ella weewx[22295] INFO weewx.drivers.cc3000: NOW: Retry worked. Total tries: 2 + .... + ---------------------- weewx End ------------------------- + + +Clearing memory on the CC3000 takes about 12s. As such, the 1s +timeout mentioned above won't work for this command. Consequently, +when executing MEM=CLEAR, the timeout is set to 20s. Should this +command fail, rather than losing 1 second retrying, 20 sexconds +will be lost. + + +The CC3000 very rarely stops returning observation values. +[Observed once in 28 months of operation over two devices.] +Operation returns to normal after the CC3000 is rebooted. +This driver now reboots when this situation is detected. +If this happens, the log will show: + INFO weewx.drivers.cc3000: No data from sensors, rebooting. + INFO weewx.drivers.cc3000: Back from a reboot: + INFO weewx.drivers.cc3000: .................... + INFO weewx.drivers.cc3000: + INFO weewx.drivers.cc3000: Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 + INFO weewx.drivers.cc3000: Flash ID 202015 + INFO weewx.drivers.cc3000: Initializing memory...OK. + +This driver was tested with: + Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 + +Earlier versions of this driver were tested with: + Rainwise CC-3000 Version: 1.3 Build 006 Sep 04 2013 + Rainwise CC-3000 Version: 1.3 Build 016 Aug 21 2014 +""" + +# FIXME: Come up with a way to deal with firmware inconsistencies. if we do +# a strict protocol where we wait for an OK response, but one version of +# the firmware responds whereas another version does not, this leads to +# comm problems. specializing the code to handle quirks of each +# firmware version is not desirable. +# UPDATE: As of 0.30, the driver does a flush of the serial buffer before +# doing any command. The problem detailed above (OK not being returned) +# was probably because the timeout was too short for the MEM=CLEAR +# command. That command gets a longer timeout in version 0.30. + +# FIXME: Figure out why system log messages are lost. When reading from the logger +# there are many messages to the log that just do not show up, or msgs +# that appear in one run but not in a second, identical run. I suspect +# that system log cannot handle the load? or its buffer is not big enough? +# Update: +# With debug=0, this has never been observed in v1.3 Build 22 Dec 02 2016. +# With debug=1, tailing the log looks like everything is running, but no +# attempt was made to compuare log data between runs. Observations on +# NUC7i5 running Debian Buster. + +import datetime +import logging +import math +import time + +import serial + +import weeutil.weeutil +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import to_int +from weewx.crc16 import crc16 + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'CC3000' +DRIVER_VERSION = '0.5' + +def loader(config_dict, engine): + return CC3000Driver(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): + return CC3000Configurator() + +def confeditor_loader(): + return CC3000ConfEditor() + +DEBUG_SERIAL = 0 +DEBUG_CHECKSUM = 0 +DEBUG_OPENCLOSE = 0 + + +class ChecksumError(weewx.WeeWxIOError): + def __init__(self, msg): + weewx.WeeWxIOError.__init__(self, msg) + +class ChecksumMismatch(ChecksumError): + def __init__(self, a, b, buf=None): + msg = "Checksum mismatch: 0x%04x != 0x%04x" % (a, b) + if buf is not None: + msg = "%s (%s)" % (msg, buf) + ChecksumError.__init__(self, msg) + +class BadCRC(ChecksumError): + def __init__(self, a, b, buf=None): + msg = "Bad CRC: 0x%04x != '%s'" % (a, b) + if buf is not None: + msg = "%s (%s)" % (msg, buf) + ChecksumError.__init__(self, msg) + + +class CC3000Configurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super().add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="display current weather readings") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N records (0 for all records)") + parser.add_option("--history-since", dest="nminutes", metavar="N", + type=int, help="display records since N minutes ago") + parser.add_option("--clear-memory", dest="clear", action="store_true", + help="clear station memory") + parser.add_option("--get-header", dest="gethead", action="store_true", + help="display data header") + parser.add_option("--get-rain", dest="getrain", action="store_true", + help="get the rain counter") + parser.add_option("--reset-rain", dest="resetrain", action="store_true", + help="reset the rain counter") + parser.add_option("--get-max", dest="getmax", action="store_true", + help="get the max values observed") + parser.add_option("--reset-max", dest="resetmax", action="store_true", + help="reset the max counters") + parser.add_option("--get-min", dest="getmin", action="store_true", + help="get the min values observed") + parser.add_option("--reset-min", dest="resetmin", action="store_true", + help="reset the min counters") + parser.add_option("--get-clock", dest="getclock", action="store_true", + help="display station clock") + parser.add_option("--set-clock", dest="setclock", action="store_true", + help="set station clock to computer time") + parser.add_option("--get-interval", dest="getint", action="store_true", + help="display logger archive interval, in seconds") + parser.add_option("--set-interval", dest="interval", metavar="N", + type=int, + help="set logging interval to N seconds") + parser.add_option("--get-units", dest="getunits", action="store_true", + help="show units of logger") + parser.add_option("--set-units", dest="units", metavar="UNITS", + help="set units to METRIC or ENGLISH") + parser.add_option('--get-dst', dest='getdst', action='store_true', + help='display daylight savings settings') + parser.add_option('--set-dst', dest='setdst', + metavar='mm/dd HH:MM,mm/dd HH:MM,[MM]M', + help='set daylight savings start, end, and amount') + parser.add_option("--get-channel", dest="getch", action="store_true", + help="display the station channel") + parser.add_option("--set-channel", dest="ch", metavar="CHANNEL", + type=int, + help="set the station channel") + + def do_options(self, options, parser, config_dict, prompt): # @UnusedVariable + self.driver = CC3000Driver(**config_dict[DRIVER_NAME]) + if options.current: + print(self.driver.get_current()) + elif options.nrecords is not None: + for r in self.driver.station.gen_records(options.nrecords): + print(r) + elif options.nminutes is not None: + since_ts = time.mktime((datetime.datetime.now()-datetime.timedelta( + minutes=options.nminutes)).timetuple()) + for r in self.driver.gen_records_since_ts(since_ts): + print(r) + elif options.clear: + self.clear_memory(options.noprompt) + elif options.gethead: + print(self.driver.station.get_header()) + elif options.getrain: + print(self.driver.station.get_rain()) + elif options.resetrain: + self.reset_rain(options.noprompt) + elif options.getmax: + print(self.driver.station.get_max()) + elif options.resetmax: + self.reset_max(options.noprompt) + elif options.getmin: + print(self.driver.station.get_min()) + elif options.resetmin: + self.reset_min(options.noprompt) + elif options.getclock: + print(self.driver.station.get_time()) + elif options.setclock: + self.set_clock(options.noprompt) + elif options.getdst: + print(self.driver.station.get_dst()) + elif options.setdst: + self.set_dst(options.setdst, options.noprompt) + elif options.getint: + print(self.driver.station.get_interval() * 60) + elif options.interval is not None: + self.set_interval(options.interval / 60, options.noprompt) + elif options.getunits: + print(self.driver.station.get_units()) + elif options.units is not None: + self.set_units(options.units, options.noprompt) + elif options.getch: + print(self.driver.station.get_channel()) + elif options.ch is not None: + self.set_channel(options.ch, options.noprompt) + else: + print("Firmware:", self.driver.station.get_version()) + print("Time:", self.driver.station.get_time()) + print("DST:", self.driver.station.get_dst()) + print("Units:", self.driver.station.get_units()) + print("Memory:", self.driver.station.get_memory_status()) + print("Interval:", self.driver.station.get_interval() * 60) + print("Channel:", self.driver.station.get_channel()) + print("Charger:", self.driver.station.get_charger()) + print("Baro:", self.driver.station.get_baro()) + print("Rain:", self.driver.station.get_rain()) + print("HEADER:", self.driver.station.get_header()) + print("MAX:", self.driver.station.get_max()) + print("MIN:", self.driver.station.get_min()) + self.driver.closePort() + + def clear_memory(self, noprompt): + print(self.driver.station.get_memory_status()) + ans = weeutil.weeutil.y_or_n("Clear console memory (y/n)? ", + noprompt) + if ans == 'y': + print('Clearing memory (takes approx. 12s)') + self.driver.station.clear_memory() + print(self.driver.station.get_memory_status()) + else: + print("Clear memory cancelled.") + + def reset_rain(self, noprompt): + print(self.driver.station.get_rain()) + ans = weeutil.weeutil.y_or_n("Reset rain counter (y/n)? ", + noprompt) + if ans == 'y': + print('Resetting rain counter') + self.driver.station.reset_rain() + print(self.driver.station.get_rain()) + else: + print("Reset rain cancelled.") + + def reset_max(self, noprompt): + print(self.driver.station.get_max()) + ans = weeutil.weeutil.y_or_n("Reset max counters (y/n)? ", + noprompt) + if ans == 'y': + print('Resetting max counters') + self.driver.station.reset_max() + print(self.driver.station.get_max()) + else: + print("Reset max cancelled.") + + def reset_min(self, noprompt): + print(self.driver.station.get_min()) + ans = weeutil.weeutil.y_or_n("Reset min counters (y/n)? ", + noprompt) + if ans == 'y': + print('Resetting min counters') + self.driver.station.reset_min() + print(self.driver.station.get_min()) + else: + print("Reset min cancelled.") + + def set_interval(self, interval, noprompt): + if interval < 0 or 60 < interval: + raise ValueError("Logger interval must be 0-60 minutes") + print("Interval is", self.driver.station.get_interval(), " minutes.") + ans = weeutil.weeutil.y_or_n("Set interval to %d minutes (y/n)? " % interval, + noprompt) + if ans == 'y': + print("Setting interval to %d minutes" % interval) + self.driver.station.set_interval(interval) + print("Interval is now", self.driver.station.get_interval()) + else: + print("Set interval cancelled.") + + def set_clock(self, noprompt): + print("Station clock is", self.driver.station.get_time()) + print("Current time is", datetime.datetime.now()) + ans = weeutil.weeutil.y_or_n("Set station time to current time (y/n)? ", + noprompt) + if ans == 'y': + print("Setting station clock to %s" % datetime.datetime.now()) + self.driver.station.set_time() + print("Station clock is now", self.driver.station.get_time()) + else: + print("Set clock cancelled.") + + def set_units(self, units, noprompt): + if units.lower() not in ['metric', 'english']: + raise ValueError("Units must be METRIC or ENGLISH") + print("Station units is", self.driver.station.get_units()) + ans = weeutil.weeutil.y_or_n("Set station units to %s (y/n)? " % units, + noprompt) + if ans == 'y': + print("Setting station units to %s" % units) + self.driver.station.set_units(units) + print("Station units is now", self.driver.station.get_units()) + else: + print("Set units cancelled.") + + def set_dst(self, dst, noprompt): + if dst != '0' and len(dst.split(',')) != 3: + raise ValueError("DST must be 0 (disabled) or start, stop, amount " + "with the format mm/dd HH:MM, mm/dd HH:MM, [MM]M") + print("Station DST is", self.driver.station.get_dst()) + ans = weeutil.weeutil.y_or_n("Set station DST to %s (y/n)? " % dst, + noprompt) + if ans == 'y': + print("Setting station DST to %s" % dst) + self.driver.station.set_dst(dst) + print("Station DST is now", self.driver.station.get_dst()) + else: + print("Set DST cancelled.") + + def set_channel(self, ch, noprompt): + if ch not in [0, 1, 2, 3]: + raise ValueError("Channel must be one of 0, 1, 2, or 3") + print("Station channel is", self.driver.station.get_channel()) + ans = weeutil.weeutil.y_or_n("Set station channel to %s (y/n)? " % ch, + noprompt) + if ans == 'y': + print("Setting station channel to %s" % ch) + self.driver.station.set_channel(ch) + print("Station channel is now", self.driver.station.get_channel()) + else: + print("Set channel cancelled.") + + +class CC3000Driver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with a RainWise CC3000 data logger.""" + + # map rainwise names to database schema names + DEFAULT_SENSOR_MAP = { + 'dateTime': 'TIMESTAMP', + 'outTemp': 'TEMP OUT', + 'outHumidity': 'HUMIDITY', + 'windDir': 'WIND DIRECTION', + 'windSpeed': 'WIND SPEED', + 'windGust': 'WIND GUST', + 'pressure': 'PRESSURE', + 'inTemp': 'TEMP IN', + 'extraTemp1': 'TEMP 1', + 'extraTemp2': 'TEMP 2', + 'day_rain_total': 'RAIN', + 'supplyVoltage': 'STATION BATTERY', + 'consBatteryVoltage': 'BATTERY BACKUP', + 'radiation': 'SOLAR RADIATION', + 'UV': 'UV INDEX', + } + + def __init__(self, **stn_dict): + log.info('Driver version is %s' % DRIVER_VERSION) + + global DEBUG_SERIAL + DEBUG_SERIAL = int(stn_dict.get('debug_serial', 0)) + global DEBUG_CHECKSUM + DEBUG_CHECKSUM = int(stn_dict.get('debug_checksum', 0)) + global DEBUG_OPENCLOSE + DEBUG_OPENCLOSE = int(stn_dict.get('debug_openclose', 0)) + + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.model = stn_dict.get('model', 'CC3000') + port = stn_dict.get('port', CC3000.DEFAULT_PORT) + log.info('Using serial port %s' % port) + self.polling_interval = float(stn_dict.get('polling_interval', 2)) + log.info('Polling interval is %s seconds' % self.polling_interval) + self.use_station_time = weeutil.weeutil.to_bool( + stn_dict.get('use_station_time', True)) + log.info('Using %s time for loop packets' % + ('station' if self.use_station_time else 'computer')) + # start with the default sensormap, then augment with user-specified + self.sensor_map = dict(self.DEFAULT_SENSOR_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('Sensor map is %s' % self.sensor_map) + + # periodically check the logger memory, then clear it if necessary. + # these track the last time a check was made, and how often to make + # the checks. threshold of None indicates do not clear logger. + self.logger_threshold = to_int( + stn_dict.get('logger_threshold', 0)) + self.last_mem_check = 0 + self.mem_interval = 7 * 24 * 3600 + if self.logger_threshold != 0: + log.info('Clear logger at %s records' % self.logger_threshold) + + # track the last rain counter value, so we can determine deltas + self.last_rain = None + + self.station = CC3000(port) + self.station.open() + + # report the station configuration + settings = self._init_station_with_retries(self.station, self.max_tries) + log.info('Firmware: %s' % settings['firmware']) + self.arcint = settings['arcint'] + log.info('Archive interval: %s' % self.arcint) + self.header = settings['header'] + log.info('Header: %s' % self.header) + self.units = weewx.METRICWX if settings['units'] == 'METRIC' else weewx.US + log.info('Units: %s' % settings['units']) + log.info('Channel: %s' % settings['channel']) + log.info('Charger status: %s' % settings['charger']) + log.info('Memory: %s' % self.station.get_memory_status()) + + def time_to_next_poll(self): + now = time.time() + next_poll_event = int(now / self.polling_interval) * self.polling_interval + self.polling_interval + log.debug('now: %f, polling_interval: %d, next_poll_event: %f' % (now, self.polling_interval, next_poll_event)) + secs_to_poll = next_poll_event - now + log.debug('Next polling event in %f seconds' % secs_to_poll) + return secs_to_poll + + def genLoopPackets(self): + cmd_mode = True + if self.polling_interval == 0: + self.station.set_auto() + cmd_mode = False + + reboot_attempted = False + ntries = 0 + while ntries < self.max_tries: + ntries += 1 + try: + # Poll on polling_interval boundaries. + if self.polling_interval != 0: + time.sleep(self.time_to_next_poll()) + values = self.station.get_current_data(cmd_mode) + now = int(time.time()) + ntries = 0 + log.debug("Values: %s" % values) + if values: + packet = self._parse_current( + values, self.header, self.sensor_map) + log.debug("Parsed: %s" % packet) + if packet and 'dateTime' in packet: + if not self.use_station_time: + packet['dateTime'] = int(time.time() + 0.5) + packet['usUnits'] = self.units + if 'day_rain_total' in packet: + packet['rain'] = self._rain_total_to_delta( + packet['day_rain_total'], self.last_rain) + self.last_rain = packet['day_rain_total'] + else: + log.debug("No rain in packet: %s" % packet) + log.debug("Packet: %s" % packet) + yield packet + else: + if not reboot_attempted: + # To be on the safe side, max of one reboot per execution. + reboot_attempted = True + log.info("No data from sensors, rebooting.") + startup_msgs = self.station.reboot() + log.info("Back from a reboot:") + for line in startup_msgs: + log.info(line) + + # periodically check memory, clear if necessary + if time.time() - self.last_mem_check > self.mem_interval: + nrec = self.station.get_history_usage() + self.last_mem_check = time.time() + if nrec is None: + log.info("Memory check: Cannot determine memory usage") + else: + log.info("Logger is at %d records, " + "logger clearing threshold is %d" % + (nrec, self.logger_threshold)) + if self.logger_threshold != 0 and nrec >= self.logger_threshold: + log.info("Clearing all records from logger") + self.station.clear_memory() + except (serial.serialutil.SerialException, weewx.WeeWxIOError) as e: + log.error("Failed attempt %d of %d to get data: %s" % + (ntries, self.max_tries, e)) + else: + msg = "Max retries (%d) exceeded" % self.max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def genStartupRecords(self, since_ts): + """Return archive records from the data logger. Download all records + then return the subset since the indicated timestamp. + + Assumptions: + - the units are consistent for the entire history. + - the archive interval is constant for entire history. + - the HDR for archive records is the same as current HDR + """ + log.debug("GenStartupRecords: since_ts=%s" % since_ts) + log.info('Downloading new records (if any).') + last_rain = None + new_records = 0 + for pkt in self.gen_records_since_ts(since_ts): + log.debug("Packet: %s" % pkt) + pkt['usUnits'] = self.units + pkt['interval'] = self.arcint + if 'day_rain_total' in pkt: + pkt['rain'] = self._rain_total_to_delta( + pkt['day_rain_total'], last_rain) + last_rain = pkt['day_rain_total'] + else: + log.debug("No rain in record: %s" % r) + log.debug("Packet: %s" % pkt) + new_records += 1 + yield pkt + log.info('Downloaded %d new records.' % new_records) + + def gen_records_since_ts(self, since_ts): + return self.station.gen_records_since_ts(self.header, self.sensor_map, since_ts) + + @property + def hardware_name(self): + return self.model + + @property + def archive_interval(self): + return self.arcint + + def getTime(self): + try: + v = self.station.get_time() + return _to_ts(v) + except ValueError as e: + log.error("getTime failed: %s" % e) + return 0 + + def setTime(self): + self.station.set_time() + + @staticmethod + def _init_station_with_retries(station, max_tries): + for cnt in range(max_tries): + try: + return CC3000Driver._init_station(station) + except (serial.serialutil.SerialException, weewx.WeeWxIOError) as e: + log.error("Failed attempt %d of %d to initialize station: %s" % + (cnt + 1, max_tries, e)) + else: + raise weewx.RetriesExceeded("Max retries (%d) exceeded while initializing station" % max_tries) + + @staticmethod + def _init_station(station): + station.flush() + station.wakeup() + station.set_echo() + settings = dict() + settings['firmware'] = station.get_version() + settings['arcint'] = station.get_interval() * 60 # arcint is in seconds + settings['header'] = CC3000Driver._parse_header(station.get_header()) + settings['units'] = station.get_units() + settings['channel'] = station.get_channel() + settings['charger'] = station.get_charger() + return settings + + @staticmethod + def _rain_total_to_delta(rain_total, last_rain): + # calculate the rain delta between the current and previous rain totals. + return weewx.wxformulas.calculate_rain(rain_total, last_rain) + + @staticmethod + def _parse_current(values, header, sensor_map): + return CC3000Driver._parse_values(values, header, sensor_map, + "%Y/%m/%d %H:%M:%S") + + @staticmethod + def _parse_values(values, header, sensor_map, fmt): + """parse the values and map them into the schema names. if there is + a failure for any one value, then the entire record fails.""" + pkt = dict() + if len(values) != len(header) + 1: + log.info("Values/header mismatch: %s %s" % (values, header)) + return pkt + for i, v in enumerate(values): + if i >= len(header): + continue + label = None + for m in sensor_map: + if sensor_map[m] == header[i]: + label = m + if label is None: + continue + try: + if header[i] == 'TIMESTAMP': + pkt[label] = _to_ts(v, fmt) + else: + pkt[label] = float(v) + except ValueError as e: + log.error("Parse failed for '%s' '%s': %s (idx=%s values=%s)" % + (header[i], v, e, i, values)) + return dict() + return pkt + + @staticmethod + def _parse_header(header): + h = [] + for v in header: + if v == 'HDR' or v[0:1] == '!': + continue + h.append(v.replace('"', '')) + return h + + def get_current(self): + data = self.station.get_current_data() + return self._parse_current(data, self.header, self.sensor_map) + +def _to_ts(tstr, fmt="%Y/%m/%d %H:%M:%S"): + return time.mktime(time.strptime(tstr, fmt)) + +def _format_bytes(buf): + return ' '.join(['%0.2X' % c for c in buf]) + +def _check_crc(buf): + idx = buf.find(b'!') + if idx < 0: + return + a = 0 + b = 0 + cs = b'' + try: + cs = buf[idx+1:idx+5] + if DEBUG_CHECKSUM: + log.debug("Found checksum at %d: %s" % (idx, cs)) + a = crc16(buf[0:idx]) # calculate checksum + if DEBUG_CHECKSUM: + log.debug("Calculated checksum %x" % a) + b = int(cs, 16) # checksum provided in data + if a != b: + raise ChecksumMismatch(a, b, buf) + except ValueError as e: + raise BadCRC(a, cs, buf) + +class CC3000(object): + DEFAULT_PORT = '/dev/ttyUSB0' + + def __init__(self, port): + self.port = port + self.baudrate = 115200 + self.timeout = 1 # seconds for everyting except MEM=CLEAR + # MEM=CLEAR of even two records needs a timeout of 13 or more. 20 is probably safe. + # flush cmd echo value + # 0.000022 0.000037 12.819934 0.000084 + # 0.000018 0.000036 12.852024 0.000088 + self.mem_clear_timeout = 20 # reopen w/ bigger timeout for MEM=CLEAR + self.serial_port = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self, timeoutOverride=None): + if DEBUG_OPENCLOSE: + log.debug("Open serial port %s" % self.port) + to = timeoutOverride if timeoutOverride is not None else self.timeout + self.serial_port = serial.Serial(self.port, self.baudrate, + timeout=to) + + def close(self): + if self.serial_port is not None: + if DEBUG_OPENCLOSE: + log.debug("Close serial port %s" % self.port) + self.serial_port.close() + self.serial_port = None + + def write(self, data): + # Encode could perhaps fail on bad user input (DST?). + # If so, this will be handled later when it is observed that the + # command does not do what is expected. + data = data.encode('ascii', 'ignore') + if DEBUG_SERIAL: + log.debug("Write: '%s'" % data) + n = self.serial_port.write(data) + if n is not None and n != len(data): + raise weewx.WeeWxIOError("Write expected %d chars, sent %d" % + (len(data), n)) + + def read(self): + """The station sends CR NL before and after any response. Some + responses have a 4-byte CRC checksum at the end, indicated with an + exclamation. Not every response has a checksum. + """ + data = self.serial_port.readline() + if DEBUG_SERIAL: + log.debug("Read: '%s' (%s)" % (data, _format_bytes(data))) + data = data.strip() + _check_crc(data) + # CRC passed, so this is unlikely. + # Ignore as irregular data will be handled later. + data = data.decode('ascii', 'ignore') + return data + + def flush(self): + self.flush_input() + self.flush_output() + + def flush_input(self): + log.debug("Flush input buffer") + self.serial_port.flushInput() + + def flush_output(self): + log.debug("Flush output buffer") + self.serial_port.flushOutput() + + def queued_bytes(self): + return self.serial_port.inWaiting() + + def send_cmd(self, cmd): + """Any command must be terminated with a CR""" + self.write("%s\r" % cmd) + + def command(self, cmd): + # Sample timings for first fifteen NOW commands after startup. + # Flush CMD ECHO VALUE + # -------- -------- -------- -------- + # 0.000021 0.000054 0.041557 0.001364 + # 0.000063 0.000109 0.040432 0.001666 + # 0.000120 0.000123 0.024272 0.016871 + # 0.000120 0.000127 0.025148 0.016657 + # 0.000119 0.000126 0.024966 0.016665 + # 0.000130 0.000142 0.041037 0.001791 + # 0.000120 0.000126 0.023533 0.017023 + # 0.000120 0.000137 0.024336 0.016747 + # 0.000117 0.000133 0.026254 0.016684 + # 0.000120 0.000140 0.025014 0.016739 + # 0.000121 0.000134 0.024801 0.016779 + # 0.000120 0.000141 0.024635 0.016906 + # 0.000118 0.000129 0.024354 0.016894 + # 0.000120 0.000133 0.024214 0.016861 + # 0.000118 0.000122 0.024599 0.016865 + + # MEM=CLEAR needs a longer timeout. >12s to clear a small number of records has been observed. + # It also appears to be highly variable. The two examples below are from two different CC3000s. + # + # In this example, clearing at 11,595 records took > 6s. + # Aug 18 06:46:21 charlemagne weewx[684]: cc3000: logger is at 11595 records, logger clearing threshold is 10000 + # Aug 18 06:46:21 charlemagne weewx[684]: cc3000: clearing all records from logger + # Aug 18 06:46:21 charlemagne weewx[684]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.000779 seconds. + # Aug 18 06:46:28 charlemagne weewx[684]: cc3000: MEM=CLEAR: times: 0.000016 0.000118 6.281638 0.000076 + # Aug 18 06:46:28 charlemagne weewx[684]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001444 seconds. + # + # In this example, clearing at 11,475 records took > 12s. + # Aug 18 07:17:14 ella weewx[615]: cc3000: logger is at 11475 records, logger clearing threshold is 10000 + # Aug 18 07:17:14 ella weewx[615]: cc3000: clearing all records from logger + # Aug 18 07:17:14 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.001586 seconds. + # Aug 18 07:17:27 ella weewx[615]: cc3000: MEM=CLEAR: times: 0.000020 0.000058 12.459346 0.000092 + # Aug 18 07:17:27 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001755 seconds. + # + # Here, clearing 90 records took very close to 13 seconds. + # Aug 18 14:46:00 ella weewx[24602]: cc3000: logger is at 91 records, logger clearing threshold is 90 + # Aug 18 14:46:00 ella weewx[24602]: cc3000: clearing all records from logger + # Aug 18 14:46:00 ella weewx[24602]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.000821 seconds. + # Aug 18 14:46:13 ella weewx[24602]: cc3000: MEM=CLEAR: times: 0.000037 0.000061 12.970494 0.000084 + # Aug 18 14:46:13 ella weewx[24602]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001416 seconds. + + reset_timeout = False + + # MEM=CLEAR needs a much larger timeout value. Reopen with that larger timeout and reset below. + # + # Closing and reopening with a different timeout is quick: + # Aug 18 07:17:14 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.001586 seconds. + # Aug 18 07:17:27 ella weewx[615]: cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001755 seconds. + if cmd == 'MEM=CLEAR': + reset_timeout = True # Reopen with default timeout in finally. + t1 = time.time() + self.close() + self.open(self.mem_clear_timeout) + t2 = time.time() + close_open_time = t2 - t1 + log.info("%s: The resetting of timeout to %d took %f seconds." % (cmd, self.mem_clear_timeout, close_open_time)) + + try: + return self.exec_cmd_with_retries(cmd) + finally: + if reset_timeout: + t1 = time.time() + self.close() + self.open() + reset_timeout = True + t2 = time.time() + close_open_time = t2 - t1 + log.info("%s: The resetting of timeout to %d took %f seconds." % (cmd, self.timeout, close_open_time)) + + def exec_cmd_with_retries(self, cmd): + """Send cmd. Time the reading of the echoed command. If the measured + time is >= timeout, the cc3000 is borked. The input and output buffers + will be flushed and the command retried. Try up to 10 times. + In practice, one retry does the trick. + cc3000s. + """ + attempts = 0 + while attempts < 10: + attempts += 1 + t1 = time.time() + self.flush() # flush + t2 = time.time() + flush_time = t2 - t1 + self.send_cmd(cmd) # send cmd + t3 = time.time() + cmd_time = t3 - t2 + data = self.read() # read the cmd echo + t4 = time.time() + echo_time = t4 - t3 + + if ((cmd != 'MEM=CLEAR' and echo_time >= self.timeout) + or (cmd == 'MEM=CLEAR' and echo_time >= self.mem_clear_timeout)): + # The command timed out reading back the echo of the command. + # No need to read the values as it will also time out. + # Log it and retry. In practice, the retry always works. + log.info("%s: times: %f %f %f -retrying-" % + (cmd, flush_time, cmd_time, echo_time)) + log.info('%s: Reading cmd echo timed out (%f seconds), retrying.' % + (cmd, echo_time)) + # Retrying setting the time must be special cased as now a little + # more than one second has passed. As such, redo the command + # with the current time. + if cmd.startswith("TIME=") and cmd != "TIME=?": + cmd = self._compose_set_time_command() + # Retry + else: + # Success, the reading of the echoed command did not time out. + break + + if data != cmd and attempts > 1: + # After retrying, the cmd always echoes back as an empty string. + if data == '': + log.info("%s: Accepting empty string as cmd echo." % cmd) + else: + raise weewx.WeeWxIOError( + "command: Command failed: cmd='%s' reply='%s'" % (cmd, data)) + + t5 = time.time() + retval = self.read() + t6 = time.time() + value_time = t6 - t5 + if cmd == 'MEM=CLEAR': + log.info("%s: times: %f %f %f %f" % + (cmd, flush_time, cmd_time, echo_time, value_time)) + + if attempts > 1: + if retval != '': + log.info("%s: Retry worked. Total tries: %d" % (cmd, attempts)) + else: + log.info("%s: Retry failed." % cmd) + log.info("%s: times: %f %f %f %f" % + (cmd, flush_time, cmd_time, echo_time, value_time)) + + return retval + + def get_version(self): + log.debug("Get firmware version") + return self.command("VERSION") + + def reboot(self): + # Reboot outputs the following (after the reboot): + # .................... + # + # Rainwise CC-3000 Version: 1.3 Build 022 Dec 02 2016 + # Flash ID 202015 + # Initializing memory...OK. + log.debug("Rebooting CC3000.") + self.send_cmd("REBOOT") + time.sleep(5) + dots = self.read() + blank = self.read() + ver = self.read() + flash_id = self.read() + init_msg = self.read() + return [dots, blank, ver, flash_id, init_msg] + + # give the station some time to wake up. when we first hit it with a + # command, it often responds with an empty string. then subsequent + # commands get the proper response. so for a first command, send something + # innocuous and wait a bit. hopefully subsequent commands will then work. + # NOTE: This happens periodically and does not appear to be related to + # "waking up". Getter commands now retry, so removing the sleep. + def wakeup(self): + self.command('ECHO=?') + + def set_echo(self, cmd='ON'): + log.debug("Set echo to %s" % cmd) + data = self.command('ECHO=%s' % cmd) + if data != 'OK': + raise weewx.WeeWxIOError("Set ECHO failed: %s" % data) + + def get_header(self): + log.debug("Get header") + data = self.command("HEADER") + cols = data.split(',') + if cols[0] != 'HDR': + raise weewx.WeeWxIOError("Expected HDR, got %s" % cols[0]) + return cols + + def set_auto(self): + # auto does not echo the command + self.send_cmd("AUTO") + + def get_current_data(self, send_now=True): + data = '' + if send_now: + data = self.command("NOW") + else: + data = self.read() + if data == 'NO DATA' or data == 'NO DATA RECEIVED': + log.debug("No data from sensors") + return [] + return data.split(',') + + def get_time(self): + # unlike all the other accessor methods, the TIME command returns + # OK after it returns the requested parameter. so we have to pop the + # OK off the serial, so it does not trip up other commands. + log.debug("Get time") + tstr = self.command("TIME=?") + if tstr not in ['ERROR', 'OK']: + data = self.read() + if data != 'OK': + raise weewx.WeeWxIOError("Failed to get time: %s, %s" % (tstr, data)) + return tstr + + @staticmethod + def _compose_set_time_command(): + ts = time.time() + tstr = time.strftime("%Y/%m/%d %H:%M:%S", time.localtime(ts)) + log.info("Set time to %s (%s)" % (tstr, ts)) + return "TIME=%s" % tstr + + def set_time(self): + s = self._compose_set_time_command() + data = self.command(s) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set time to %s: %s" % + (s, data)) + + def get_dst(self): + log.debug("Get daylight saving") + return self.command("DST=?") + + def set_dst(self, dst): + log.debug("Set DST to %s" % dst) + # Firmware 1.3 Build 022 Dec 02 2016 returns 3 lines (,'',OK) + data = self.command("DST=%s" % dst) # echoed input dst + if data != dst: + raise weewx.WeeWxIOError("Failed to set DST to %s: %s" % + (dst, data)) + data = self.read() # read '' + if data not in ['ERROR', 'OK']: + data = self.read() # read OK + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set DST to %s: %s" % + (dst, data)) + + def get_units(self): + log.debug("Get units") + return self.command("UNITS=?") + + def set_units(self, units): + log.debug("Set units to %s" % units) + data = self.command("UNITS=%s" % units) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set units to %s: %s" % + (units, data)) + + def get_interval(self): + log.debug("Get logging interval") + return int(self.command("LOGINT=?")) + + def set_interval(self, interval=5): + log.debug("Set logging interval to %d minutes" % interval) + data = self.command("LOGINT=%d" % interval) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set logging interval: %s" % + data) + + def get_channel(self): + log.debug("Get channel") + return self.command("STATION") + + def set_channel(self, channel): + log.debug("Set channel to %d" % channel) + if channel < 0 or 3 < channel: + raise ValueError("Channel must be 0-3") + data = self.command("STATION=%d" % channel) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set channel: %s" % data) + + def get_charger(self): + log.debug("Get charger") + return self.command("CHARGER") + + def get_baro(self): + log.debug("Get baro") + return self.command("BARO") + + def set_baro(self, offset): + log.debug("Set barometer offset to %d" % offset) + if offset != '0': + parts = offset.split('.') + if (len(parts) != 2 or + (not (len(parts[0]) == 2 and len(parts[1]) == 2) and + not (len(parts[0]) == 3 and len(parts[1]) == 1))): + raise ValueError("Offset must be 0, XX.XX (inHg), or XXXX.X (mbar)") + data = self.command("BARO=%d" % offset) + if data != 'OK': + raise weewx.WeeWxIOError("Failed to set baro: %s" % data) + + def get_memory_status(self): + # query for logger memory use. output is something like this: + # 6438 bytes, 111 records, 0% + log.debug("Get memory status") + return self.command("MEM=?") + + def get_max(self): + log.debug("Get max values") + # Return outside temperature, humidity, pressure, wind direction, + # wind speed, rainfall (daily total), station voltage, inside + # temperature. + return self.command("MAX=?").split(',') + + def reset_max(self): + log.debug("Reset max values") + data = self.command("MAX=RESET") + if data != 'OK': + raise weewx.WeeWxIOError("Failed to reset max values: %s" % data) + + def get_min(self): + log.debug("Get min values") + # Return outside temperature, humidity, pressure, wind direction, + # wind speed, rainfall (ignore), station voltage, inside temperature. + return self.command("MIN=?").split(',') + + def reset_min(self): + log.debug("Reset min values") + data = self.command("MIN=RESET") + if data != 'OK': + raise weewx.WeeWxIOError("Failed to reset min values: %s" % data) + + def get_history_usage(self): + # return the number of records in the logger + s = self.get_memory_status() + if 'records' in s: + return int(s.split(',')[1].split()[0]) + return None + + def clear_memory(self): + log.debug("Clear memory") + data = self.command("MEM=CLEAR") + # It's a long wait for the OK. With a greatly increased timeout + # just for MEM=CLEAR, we should be able to read the OK. + if data == 'OK': + log.info("MEM=CLEAR succeeded.") + else: + raise weewx.WeeWxIOError("Failed to clear memory: %s" % data) + + def get_rain(self): + log.debug("Get rain total") + # Firmware 1.3 Build 022 Dec 02 2017 returns OK after the rain count + # This is like TIME=? + rstr = self.command("RAIN") + if rstr not in ['ERROR', 'OK']: + data = self.read() + if data != 'OK': + raise weewx.WeeWxIOError("Failed to get rain: %s" % data) + return rstr + + def reset_rain(self): + log.debug("Reset rain counter") + data = self.command("RAIN=RESET") + if data != 'OK': + raise weewx.WeeWxIOError("Failed to reset rain: %s" % data) + + def gen_records_since_ts(self, header, sensor_map, since_ts): + if since_ts is None: + since_ts = 0.0 + num_records = 0 + else: + now_ts = time.mktime(datetime.datetime.now().timetuple()) + nseconds = now_ts - since_ts + nminutes = math.ceil(nseconds / 60.0) + num_records = math.ceil(nminutes / float(self.get_interval())) + if num_records == 0: + log.debug('gen_records_since_ts: Asking for all records.') + else: + log.debug('gen_records_since_ts: Asking for %d records.' % num_records) + for r in self.gen_records(nrec=num_records): + pkt = CC3000Driver._parse_values(r[1:], header, sensor_map, "%Y/%m/%d %H:%M") + if 'dateTime' in pkt and pkt['dateTime'] > since_ts: + yield pkt + + def gen_records(self, nrec=0): + """ + Generator function for getting nrec records from the device. A value + of 0 indicates all records. + + The CC3000 returns a header ('HDR,'), the archive records + we are interested in ('REC,'), daily max and min records + ('MAX,', 'MIN,') as well as messages for various events such as a + reboot ('MSG,'). + + Things get interesting when nrec is non-zero. + + DOWNLOAD=n returns the latest n records in memory. The CC3000 does + not distinguish between REC, MAX, MIN and MSG records in memory. + As such, DOWNLOAD=5 does NOT mean fetch the latest 5 REC records. + For example, if the latest 5 records include a MIN and a MAX record, + only 3 REC records will be returned (along with the MIN and MAX + records). + + Given that one can't ask pecisely ask for a given number of archive + records, a heuristic is used and errs on the side of asking for + too many records. + + The heurisitic for number of records to ask for is: + the sum of: + nrec + 7 * the number of days convered in the request (rounded up) + Note: One can determine the number of days from the number of + records requested because the archive interval is known. + + Asking for an extra seven records per day allows for the one MIN and + one MAX records generated per day, plus a buffer for up to five MSG + records each day. Unless one is rebooting the CC3000 all day, this + will be plenty. Typically, there will be zero MSG records. Clearing + memory and rebooting actions generate MSG records. Both are uncommon. + As a result, gen_records will overshoot the records asked for, but this + is not a problem in practice. Also, if a new archive record is written + while this operation is taking place, it will be returned. As such, + the number wouldn't be precise anyway. One could work around this by + accumulating records before returning, and then returning an exact + amount, but it simply isn't worth it. + + Examining the records in the CC3000 (808 records at the time of the + examination) shows the following records found: + HDR: 1 (the header record, per the spec) + REC: 800 (the archive records -- ~2.8 days worth) + MSG: 1 (A clear command that executed ~2.8 days ago: + MSG 2019/12/20 15:48 CLEAR ON COMMAND!749D) + MIN: 3 (As expected for 3 days.) + MAX: 3 (As expected for 3 days.) + + Interrogating the CC3000 for a large number of records fails miserably + if, while reading the responses, the responses are parsed and added + to the datbase. (Check sum mismatches, partical records, etc.). If + these last two steps are skipped, reading from the CC3000 is very + reliable. This can be observed by asing for history with wee_config. + Observed with > 11K of records. + + To address the above problem, all records are read into memory. Reading + all records into memory before parsing and inserting into the database + is very reliable. For smaller amounts of recoreds, the reading into + memory could be skipped, but what would be the point? + """ + + log.debug('gen_records(%d)' % nrec) + totrec = self.get_history_usage() + log.debug('gen_records: Requested %d latest of %d records.' % (nrec, totrec)) + + if nrec == 0: + num_to_ask = 0 + else: + # Determine the number of records to ask for. + # See heuristic above. + num_mins_asked = nrec * self.get_interval() + num_days_asked = math.ceil(num_mins_asked / (24.0*60)) + num_to_ask = nrec + 7 * num_days_asked + + if num_to_ask == 0: + cmd = 'DOWNLOAD' + else: + cmd = 'DOWNLOAD=%d' % num_to_ask + log.debug('%s' % cmd) + + # Note: It takes about 14s to read 1000 records into memory. + if num_to_ask == 0: + log.info('Reading all records into memory. This could take some time.') + elif num_to_ask < 1000: + log.info('Reading %d records into memory.' % num_to_ask) + else: + log.info('Reading %d records into memory. This could take some time.' % num_to_ask) + yielded = 0 + recs = [] + data = self.command(cmd) + while data != 'OK': + recs.append(data) + data = self.read() + log.info('Finished reading %d records.' % len(recs)) + yielded = 0 + for data in recs: + values = data.split(',') + if values[0] == 'REC': + yielded += 1 + yield values + elif (values[0] == 'HDR' or values[0] == 'MSG' or + values[0] == 'MIN' or values[0] == 'MAX' or + values[0].startswith('DOWNLOAD')): + pass + else: + log.error("Unexpected record '%s' (%s)" % (values[0], data)) + log.debug('Downloaded %d records' % yielded) + +class CC3000ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[CC3000] + # This section is for RainWise MarkIII weather stations and CC3000 logger. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = %s + + # The station model, e.g., CC3000 or CC3000R + model = CC3000 + + # The driver to use: + driver = weewx.drivers.cc3000 +""" % (CC3000.DEFAULT_PORT,) + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + port = self._prompt('port', CC3000.DEFAULT_PORT) + return {'port': port} + + +# define a main entry point for basic testing. invoke from the weewx root dir: +# +# PYTHONPATH=bin python -m weewx.drivers.cc3000 --help +# +# FIXME: This duplicates all the functionality in CC3000Conigurator. +# Perhaps pare this down to a version option and, by default, +# polling and printing records (a la, the vantage driver). + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', action='store_true', + help='display driver version') + parser.add_option('--test-crc', dest='testcrc', action='store_true', + help='test crc') + parser.add_option('--port', metavar='PORT', + help='port to which the station is connected', + default=CC3000.DEFAULT_PORT) + parser.add_option('--get-version', dest='getver', action='store_true', + help='display firmware version') + parser.add_option('--debug', action='store_true', default=False, + help='emit additional diagnostic information') + parser.add_option('--get-status', dest='status', action='store_true', + help='display memory status') + parser.add_option('--get-channel', dest='getch', action='store_true', + help='display station channel') + parser.add_option('--set-channel', dest='setch', metavar='CHANNEL', + help='set station channel') + parser.add_option('--get-battery', dest='getbat', action='store_true', + help='display battery status') + parser.add_option('--get-current', dest='getcur', action='store_true', + help='display current data') + parser.add_option('--get-memory', dest='getmem', action='store_true', + help='display memory status') + parser.add_option('--get-records', dest='getrec', metavar='NUM_RECORDS', + help='display records from station memory') + parser.add_option('--get-header', dest='gethead', action='store_true', + help='display data header') + parser.add_option('--get-units', dest='getunits', action='store_true', + help='display units') + parser.add_option('--set-units', dest='setunits', metavar='UNITS', + help='set units to ENGLISH or METRIC') + parser.add_option('--get-time', dest='gettime', action='store_true', + help='display station time') + parser.add_option('--set-time', dest='settime', action='store_true', + help='set station time to computer time') + parser.add_option('--get-dst', dest='getdst', action='store_true', + help='display daylight savings settings') + parser.add_option('--set-dst', dest='setdst', + metavar='mm/dd HH:MM,mm/dd HH:MM,[MM]M', + help='set daylight savings start, end, and amount') + parser.add_option('--get-interval', dest='getint', action='store_true', + help='display logging interval, in seconds') + parser.add_option('--set-interval', dest='setint', metavar='INTERVAL', + type=int, help='set logging interval, in seconds') + parser.add_option('--clear-memory', dest='clear', action='store_true', + help='clear logger memory') + parser.add_option('--get-rain', dest='getrain', action='store_true', + help='get rain counter') + parser.add_option('--reset-rain', dest='resetrain', action='store_true', + help='reset rain counter') + parser.add_option('--get-max', dest='getmax', action='store_true', + help='get max counter') + parser.add_option('--reset-max', dest='resetmax', action='store_true', + help='reset max counters') + parser.add_option('--get-min', dest='getmin', action='store_true', + help='get min counter') + parser.add_option('--reset-min', dest='resetmin', action='store_true', + help='reset min counters') + parser.add_option('--poll', metavar='POLL_INTERVAL', type=int, + help='poll interval in seconds') + parser.add_option('--reboot', dest='reboot', action='store_true', + help='reboot the station') + (options, args) = parser.parse_args() + + if options.version: + print("%s driver version %s" % (DRIVER_NAME, DRIVER_VERSION)) + exit(0) + + if options.debug: + DEBUG_SERIAL = 1 + DEBUG_CHECKSUM = 1 + DEBUG_OPENCLOSE = 1 + weewx.debug = 1 + + weeutil.logger.setup('wee_cc3000') + + if options.testcrc: + _check_crc(b'OK') + _check_crc(b'REC,2010/01/01 14:12, 64.5, 85,29.04,349, 2.4, 4.2, 0.00, 6.21, 0.25, 73.2,!B82C') + _check_crc(b'MSG,2010/01/01 20:22,CHARGER ON,!4CED') + exit(0) + + with CC3000(options.port) as s: + s.flush() + s.wakeup() + s.set_echo() + if options.getver: + print(s.get_version()) + if options.reboot: + print('rebooting...') + startup_msgs = s.reboot() + for line in startup_msgs: + print(line) + if options.status: + print("Firmware:", s.get_version()) + print("Time:", s.get_time()) + print("DST:", s.get_dst()) + print("Units:", s.get_units()) + print("Memory:", s.get_memory_status()) + print("Interval:", s.get_interval() * 60) + print("Channel:", s.get_channel()) + print("Charger:", s.get_charger()) + print("Baro:", s.get_baro()) + print("Rain:", s.get_rain()) + print("Max values:", s.get_max()) + print("Min values:", s.get_min()) + if options.getch: + print(s.get_channel()) + if options.setch is not None: + s.set_channel(int(options.setch)) + if options.getbat: + print(s.get_charger()) + if options.getcur: + print(s.get_current_data()) + if options.getmem: + print(s.get_memory_status()) + if options.getrec is not None: + i = 0 + for r in s.gen_records(int(options.getrec)): + print(i, r) + i += 1 + if options.gethead: + print(s.get_header()) + if options.getunits: + print(s.get_units()) + if options.setunits: + s.set_units(options.setunits) + if options.gettime: + print(s.get_time()) + if options.settime: + s.set_time() + if options.getdst: + print(s.get_dst()) + if options.setdst: + s.set_dst(options.setdst) + if options.getint: + print(s.get_interval() * 60) + if options.setint: + s.set_interval(int(options.setint) / 60) + if options.clear: + s.clear_memory() + if options.getrain: + print(s.get_rain()) + if options.resetrain: + print(s.reset_rain()) + if options.getmax: + print(s.get_max()) + if options.resetmax: + print(s.reset_max()) + if options.getmin: + print(s.get_min()) + if options.resetmin: + print(s.reset_min()) + if options.poll is not None: + cmd_mode = True + if options.poll == 0: + cmd_mode = False + s.set_auto() + while True: + print(s.get_current_data(cmd_mode)) + time.sleep(options.poll) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/fousb.py b/dist/weewx-5.0.2/src/weewx/drivers/fousb.py new file mode 100644 index 0000000..e188345 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/fousb.py @@ -0,0 +1,1871 @@ +# Copyright 2012-2024 Matthew Wall +# See the file LICENSE.txt for your full rights. +# +# Thanks to Jim Easterbrook for pywws. This implementation includes +# significant portions that were copied directly from pywws. +# +# pywws was derived from wwsr.c by Michael Pendec (michael.pendec@gmail.com), +# wwsrdump.c by Svend Skafte (svend@skafte.net), modified by Dave Wells, +# and other sources. +# +# Thanks also to Mark Teel for the C implementation in wview. +# +# FineOffset support in wview was inspired by the fowsr project by Arne-Jorgen +# Auberg (arne.jorgen.auberg@gmail.com) with hidapi mods by Bill Northcott. + +# USB Lockups +# +# the ws2080 will frequently lock up and require a power cycle to regain +# communications. my sample size is small (3 ws2080 consoles running +# for 1.5 years), but fairly repeatable. one of the consoles has never +# locked up. the other two lock up after a month or so. the monitoring +# software will detect bad magic numbers, then the only way to clear the +# bad magic is to power cycle the console. this was with wview and pywws. +# i am collecting a table of bad magic numbers to see if there is a pattern. +# hopefully the device is simply trying to tell us something. on the other +# hand it could just be bad firmware. it seems to happen when the data +# logging buffer on the console is full, but not always when the buffer +# is full. +# --mwall 30dec2012 +# +# the magic numbers do not seem to be correlated with lockups. in some cases, +# a lockup happens immediately following an unknown magic number. in other +# cases, data collection continues with no problem. for example, a brand new +# WS2080A console reports 44 bf as its magic number, but performs just fine. +# --mwall 02oct2013 +# +# fine offset documentation indicates that set_clock should work, but so far +# it has not worked on any ambient weather WS2080 or WS1090 station i have +# tried. it looks like the station clock is set, but at some point the fixed +# block reverts to the previous clock value. also unclear is the behavior +# when the station attempts to sync with radio clock signal from sensor. +# -- mwall 14feb2013 + +"""Classes and functions for interfacing with FineOffset weather stations. + +FineOffset stations are branded by many vendors, including + * Ambient Weather + * Watson + * National Geographic + * Elecsa + * Tycon + +There are many variants, for example WS1080, WS1090, WH2080, WH2081, WA2080, +WA2081, WH2081, WH1080, WH1081. The variations include uv/luminance, solar +charging, touch screen, single instrument cluster versus separate cluster. + +This implementation supports the 1080, 2080, and 3080 series devices via USB. +The 1080 and 2080 use the same data format, referred to as the 1080 data format +in this code. The 3080 has an expanded data format, referred to as the 3080 +data format, that includes ultraviolet and luminance. + +It should not be necessary to specify the station type. The default behavior +is to expect the 1080 data format, then change to 3080 format if additional +data are available. + +The FineOffset station console updates every 48 seconds. UV data update every +60 seconds. This implementation defaults to sampling the station console via +USB for live data every 60 seconds. Use the parameter 'polling_interval' to +adjust this. An adaptive polling mode is also available. This mode attempts +to read only when the console is not writing to memory or reading data from +the sensors. + +This implementation maps the values decoded by pywws to the names needed +by weewx. The pywws code is mostly untouched - it has been modified to +conform to weewx error handling and reporting, and some additional error +checks have been added. + +Rainfall and Spurious Sensor Readings + +The rain counter occasionally reports incorrect rainfall. On some stations, +the counter decrements then increments. Or the counter may increase by more +than the number of bucket tips that actually occurred. The max_rain_rate +helps filter these bogus readings. This filter is applied to any sample +period. If the volume of the samples in the period divided by the sample +period interval are greater than the maximum rain rate, the samples are +ignored. + +Spurious rain counter decrements often accompany what appear to be noisy +sensor readings. So if we detect a spurious rain counter decrement, we ignore +the rest of the sensor data as well. The suspect sensor readings appear +despite the double reading (to ensure the read is not happening mid-write) +and do not seem to correlate to unstable reads. + +A single bucket tip is equivalent to 0.3 mm of rain. The default maximum +rate is 24 cm/hr (9.44 in/hr). For a sample period of 5 minutes this would +be 2 cm (0.78 in) or about 66 bucket tips, or one tip every 4 seconds. For +a sample period of 30 minutes this would be 12 cm (4.72 in) + +The rain counter is two bytes, so the maximum value is 0xffff or 65535. This +translates to 19660.5 mm of rainfall (19.66 m or 64.9 ft). The console would +have to run for two years with 2 inches of rainfall a day before the counter +wraps around. + +Pressure Calculations + +Pressures are calculated and reported differently by pywws and wview. These +are the variables: + + - abs_pressure - the raw sensor reading + - fixed_block_rel_pressure - value entered in console, then follows + abs_pressure as it changes + - fixed_block_abs_pressure - seems to follow abs_pressure, sometimes + with a lag of a minute or two + - pressure - station pressure (SP) - adjusted raw sensor reading + - barometer - sea level pressure derived from SP using temperaure and altitude + - altimeter - sea level pressure derived from SP using altitude + +wview reports the following: + + pressure = abs_pressure * calMPressure + calCPressure + barometer = sp2bp(pressure, altitude, temperature) + altimeter = sp2ap(pressure, altitude) + +pywws reports the following: + + pressure = abs_pressure + pressure_offset + +where pressure_offset is + + pressure_offset = fixed_block_relative_pressure - fixed_block_abs_pressure + +so that + + pressure = fixed_block_relative_pressure + +pywws does not do barometer or altimeter calculations. + +this implementation reports the abs_pressure from the hardware as 'pressure'. +altimeter and barometer are calculated by weewx. + +Illuminance and Radiation + +The 30xx stations include a sensor that reports illuminance (lux). The +conversion from lux to radiation is a function of the angle of the sun and +altitude, but this driver uses a single multiplier as an approximation. + +Apparently the display on fine offset stations is incorrect. The display +reports radiation with a lux-to-W/m^2 multiplier of 0.001464. Apparently +Cumulus and WeatherDisplay use a multiplier of 0.0079. The multiplier for +sea level with sun directly overhead is 0.01075. + +This driver uses the sea level multiplier of 0.01075. Use an entry in +StdCalibrate to adjust this for your location and altitude. + +From Jim Easterbrook: + +The weather station memory has two parts: a "fixed block" of 256 bytes +and a circular buffer of 65280 bytes. As each weather reading takes 16 +bytes the station can store 4080 readings, or 14 days of 5-minute +interval readings. (The 3080 type stations store 20 bytes per reading, +so store a maximum of 3264.) As data is read in 32-byte chunks, but +each weather reading is 16 or 20 bytes, a small cache is used to +reduce USB traffic. The caching behaviour can be over-ridden with the +``unbuffered`` parameter to ``get_data`` and ``get_raw_data``. + +Decoding the data is controlled by the static dictionaries +``reading_format``, ``lo_fix_format`` and ``fixed_format``. The keys +are names of data items and the values can be an ``(offset, type, +multiplier)`` tuple or another dictionary. So, for example, the +reading_format dictionary entry ``'rain' : (13, 'us', 0.3)`` means +that the rain value is an unsigned short (two bytes), 13 bytes from +the start of the block, and should be multiplied by 0.3 to get a +useful value. + +The use of nested dictionaries in the ``fixed_format`` dictionary +allows useful subsets of data to be decoded. For example, to decode +the entire block ``get_fixed_block`` is called with no parameters:: + + print get_fixed_block() + +To get the stored minimum external temperature, ``get_fixed_block`` is +called with a sequence of keys:: + + print get_fixed_block(['min', 'temp_out', 'val']) + +Often there is no requirement to read and decode the entire fixed +block, as its first 64 bytes contain the most useful data: the +interval between stored readings, the buffer address where the current +reading is stored, and the current date & time. The +``get_lo_fix_block`` method provides easy access to these. + +From Mark Teel: + +The WH1080 protocol is undocumented. The following was observed +by sniffing the USB interface: + +A1 is a read command: +It is sent as A1XX XX20 A1XX XX20 where XXXX is the offset in the +memory map. The WH1080 responds with 4 8 byte blocks to make up a +32 byte read of address XXXX. + +A0 is a write command: +It is sent as A0XX XX20 A0XX XX20 where XXXX is the offset in the +memory map. It is followed by 4 8 byte chunks of data to be written +at the offset. The WH1080 acknowledges the write with an 8 byte +chunk: A5A5 A5A5. + +A2 is a one byte write command. +It is used as: A200 1A20 A2AA 0020 to indicate a data refresh. +The WH1080 acknowledges the write with an 8 byte chunk: A5A5 A5A5. +""" + +# Python imports +import datetime +import logging +import sys +import time + +# Third party imports +import usb + +# WeeWX imports +import weewx.drivers +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'FineOffsetUSB' +DRIVER_VERSION = '1.3' + +def loader(config_dict, engine): + return FineOffsetUSB(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): + return FOUSBConfigurator() + +def confeditor_loader(): + return FOUSBConfEditor() + + +# flags for enabling/disabling debug verbosity +DEBUG_SYNC = 0 +DEBUG_RAIN = 0 + + +def stash(slist, s): + if s.find('settings') != -1: + slist['settings'].append(s) + elif s.find('display') != -1: + slist['display_settings'].append(s) + elif s.find('alarm') != -1: + slist['alarm_settings'].append(s) + elif s.find('min.') != -1 or s.find('max.') != -1: + slist['minmax_values'].append(s) + else: + slist['values'].append(s) + return slist + +def fmtparam(label, value): + fmt = '%s' + if label in list(datum_display_formats.keys()): + fmt = datum_display_formats[label] + fmt = '%s: ' + fmt + return fmt % (label.rjust(30), value) + +def getvalues(station, name, value): + values = {} + if type(value) is tuple: + values[name] = station.get_fixed_block(name.split('.')) + elif type(value) is dict: + for x in value.keys(): + n = x + if len(name) > 0: + n = name + '.' + x + values.update(getvalues(station, n, value[x])) + return values + +def raw_dump(date, pos, data): + print(date, end=' ') + print("%04x" % pos, end=' ') + for item in data: + print("%02x" % item, end=' ') + print() + +def table_dump(date, data, showlabels=False): + if showlabels: + print('# date time', end=' ') + for key in data.keys(): + print(key, end=' ') + print() + print(date, end=' ') + for key in data.keys(): + print(data[key], end=' ') + print() + + +class FOUSBConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[FineOffsetUSB] + # This section is for the Fine Offset series of weather stations. + + # The station model, e.g., WH1080, WS1090, WS2080, WH3081 + model = WS2080 + + # How often to poll the station for data, in seconds + polling_interval = 60 + + # The driver to use: + driver = weewx.drivers.fousb +""" + + def get_conf(self, orig_stanza=None): + if orig_stanza is None: + return self.default_stanza + import configobj + stanza = configobj.ConfigObj(orig_stanza.splitlines()) + if 'pressure_offset' in stanza[DRIVER_NAME]: + print(""" +The pressure_offset is no longer supported by the FineOffsetUSB driver. Move +the pressure calibration constant to [StdCalibrate] instead.""") + if ('polling_mode' in stanza[DRIVER_NAME] and + stanza[DRIVER_NAME]['polling_mode'] == 'ADAPTIVE'): + print(""" +Using ADAPTIVE as the polling_mode can lead to USB lockups.""") + if ('polling_interval' in stanza[DRIVER_NAME] and + int(stanza[DRIVER_NAME]['polling_interval']) < 48): + print(""" +A polling_interval of anything less than 48 seconds is not recommened.""") + return orig_stanza + + def modify_config(self, config_dict): + print(""" +Setting record_generation to software.""") + config_dict['StdArchive']['record_generation'] = 'software' + + +class FOUSBConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super().add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display records since N minutes ago") + parser.add_option("--clear-memory", dest="clear", action="store_true", + help="clear station memory") + parser.add_option("--set-time", dest="clock", action="store_true", + help="set station clock to computer time") + parser.add_option("--set-interval", dest="interval", + type=int, metavar="N", + help="set logging interval to N minutes") + parser.add_option("--live", dest="live", action="store_true", + help="display live readings from the station") + parser.add_option("--logged", dest="logged", action="store_true", + help="display logged readings from the station") + parser.add_option("--fixed-block", dest="showfb", action="store_true", + help="display the contents of the fixed block") + parser.add_option("--check-usb", dest="chkusb", action="store_true", + help="test the quality of the USB connection") + parser.add_option("--check-fixed-block", dest="chkfb", + action="store_true", + help="monitor the contents of the fixed block") + parser.add_option("--format", dest="format", + type=str, metavar="FORMAT", + help="format for output, one of raw, table, or dict") + + def do_options(self, options, parser, config_dict, prompt): + if options.format is None: + options.format = 'table' + elif (options.format.lower() != 'raw' and + options.format.lower() != 'table' and + options.format.lower() != 'dict'): + parser.error("Unknown format '%s'. Known formats include 'raw', 'table', and 'dict'." % options.format) + + self.station = FineOffsetUSB(**config_dict[DRIVER_NAME]) + if options.current: + self.show_current() + elif options.nrecords is not None: + self.show_history(0, options.nrecords, options.format) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(ts, 0, options.format) + elif options.live: + self.show_readings(False) + elif options.logged: + self.show_readings(True) + elif options.showfb: + self.show_fixedblock() + elif options.chkfb: + self.check_fixedblock() + elif options.chkusb: + self.check_usb() + elif options.clock: + self.set_clock(prompt) + elif options.interval is not None: + self.set_interval(options.interval, prompt) + elif options.clear: + self.clear_history(prompt) + else: + self.show_info() + self.station.closePort() + + def show_info(self): + """Query the station then display the settings.""" + + print("Querying the station...") + val = getvalues(self.station, '', fixed_format) + + print('Fine Offset station settings:') + print('%s: %s' % ('local time'.rjust(30), + time.strftime('%Y.%m.%d %H:%M:%S %Z', + time.localtime()))) + print('%s: %s' % ('polling mode'.rjust(30), self.station.polling_mode)) + + slist = {'values':[], 'minmax_values':[], 'settings':[], + 'display_settings':[], 'alarm_settings':[]} + for x in sorted(val.keys()): + if type(val[x]) is dict: + for y in val[x].keys(): + label = x + '.' + y + s = fmtparam(label, val[x][y]) + slist = stash(slist, s) + else: + s = fmtparam(x, val[x]) + slist = stash(slist, s) + for k in ('values', 'minmax_values', 'settings', + 'display_settings', 'alarm_settings'): + print('') + for s in slist[k]: + print(s) + + def check_usb(self): + """Run diagnostics on the USB connection.""" + print("This will read from the station console repeatedly to see if") + print("there are errors in the USB communications. Leave this running") + print("for an hour or two to see if any bad reads are encountered.") + print("Bad reads will be reported in the system log. A few bad reads") + print("per hour is usually acceptable.") + ptr = data_start + total_count = 0 + bad_count = 0 + while True: + if total_count % 1000 == 0: + active = self.station.current_pos() + while True: + ptr += 0x20 + if ptr >= 0x10000: + ptr = data_start + if active < ptr - 0x10 or active >= ptr + 0x20: + break + result_1 = self.station._read_block(ptr, retry=False) + result_2 = self.station._read_block(ptr, retry=False) + if result_1 != result_2: + log.info('read_block change %06x' % ptr) + log.info(' %s' % str(result_1)) + log.info(' %s' % str(result_2)) + bad_count += 1 + total_count += 1 + print("\rbad/total: %d/%d " % (bad_count, total_count), end=' ') + sys.stdout.flush() + + def check_fixedblock(self): + """Display changes to fixed block as they occur.""" + print('This will read the fixed block then display changes as they') + print('occur. Typically the most common change is the incrementing') + print('of the data pointer, which happens whenever readings are saved') + print('to the station memory. For example, if the logging interval') + print('is set to 5 minutes, the fixed block should change at least') + print('every 5 minutes.') + raw_fixed = self.station.get_raw_fixed_block() + while True: + new_fixed = self.station.get_raw_fixed_block(unbuffered=True) + for ptr in range(len(new_fixed)): + if new_fixed[ptr] != raw_fixed[ptr]: + print(datetime.datetime.now().strftime('%H:%M:%S'), end=' ') + print(' %04x (%d) %02x -> %02x' % ( + ptr, ptr, raw_fixed[ptr], new_fixed[ptr])) + raw_fixed = new_fixed + time.sleep(0.5) + + def show_fixedblock(self): + """Display the raw fixed block contents.""" + fb = self.station.get_raw_fixed_block(unbuffered=True) + for i, ptr in enumerate(range(len(fb))): + print('%02x' % fb[ptr], end=' ') + if (i+1) % 16 == 0: + print() + + def show_readings(self, logged_only): + """Display live readings from the station.""" + for data,ptr,_ in self.station.live_data(logged_only): + print('%04x' % ptr, end=' ') + print(data['idx'].strftime('%H:%M:%S'), end=' ') + del data['idx'] + print(data) + + def show_current(self): + """Display latest readings from the station.""" + for packet in self.station.genLoopPackets(): + print(packet) + break + + def show_history(self, ts=0, count=0, fmt='raw'): + """Display the indicated number of records or the records since the + specified timestamp (local time, in seconds)""" + records = self.station.get_records(since_ts=ts, num_rec=count) + for i,r in enumerate(records): + if fmt.lower() == 'raw': + raw_dump(r['datetime'], r['ptr'], r['raw_data']) + elif fmt.lower() == 'table': + table_dump(r['datetime'], r['data'], i==0) + else: + print(r['datetime'], r['data']) + + def clear_history(self, prompt): + ans = None + while ans not in ['y', 'n']: + v = self.station.get_fixed_block(['data_count'], True) + print("Records in memory:", v) + if prompt: + ans = input("Clear console memory (y/n)? ") + else: + print('Clearing console memory') + ans = 'y' + if ans == 'y' : + self.station.clear_history() + v = self.station.get_fixed_block(['data_count'], True) + print("Records in memory:", v) + elif ans == 'n': + print("Clear memory cancelled.") + + def set_interval(self, interval, prompt): + v = self.station.get_fixed_block(['read_period'], True) + ans = None + while ans not in ['y', 'n']: + print("Interval is", v) + if prompt: + ans = input("Set interval to %d minutes (y/n)? " % interval) + else: + print("Setting interval to %d minutes" % interval) + ans = 'y' + if ans == 'y' : + self.station.set_read_period(interval) + v = self.station.get_fixed_block(['read_period'], True) + print("Interval is now", v) + elif ans == 'n': + print("Set interval cancelled.") + + def set_clock(self, prompt): + ans = None + while ans not in ['y', 'n']: + v = self.station.get_fixed_block(['date_time'], True) + print("Station clock is", v) + now = datetime.datetime.now() + if prompt: + ans = input("Set station clock to %s (y/n)? " % now) + else: + print("Setting station clock to %s" % now) + ans = 'y' + if ans == 'y' : + self.station.set_clock() + v = self.station.get_fixed_block(['date_time'], True) + print("Station clock is now", v) + elif ans == 'n': + print("Set clock cancelled.") + + +# these are the raw data we get from the station: +# param values invalid description +# +# delay [1,240] the number of minutes since last stored reading +# hum_in [1,99] 0xff indoor relative humidity; % +# temp_in [-40,60] 0xffff indoor temp; multiply by 0.1 to get C +# hum_out [1,99] 0xff outdoor relative humidity; % +# temp_out [-40,60] 0xffff outdoor temp; multiply by 0.1 to get C +# abs_pres [920,1080] 0xffff pressure; multiply by 0.1 to get hPa (mbar) +# wind_ave [0,50] 0xff average wind speed; multiply by 0.1 to get m/s +# wind_gust [0,50] 0xff average wind speed; multiply by 0.1 to get m/s +# wind_dir [0,15] bit 7 wind direction; multiply by 22.5 to get degrees +# rain rain; multiply by 0.33 to get mm +# status +# illuminance +# uv + +# map between the pywws keys and the weewx keys +# 'weewx-key' : ( 'pywws-key', multiplier ) +# rain is total measure so must split into per-period and calculate rate +keymap = { + 'inHumidity' : ('hum_in', 1.0), + 'inTemp' : ('temp_in', 1.0), # station is C + 'outHumidity' : ('hum_out', 1.0), + 'outTemp' : ('temp_out', 1.0), # station is C + 'pressure' : ('abs_pressure', 1.0), # station is mbar + 'windSpeed' : ('wind_ave', 3.6), # station is m/s, weewx wants km/h + 'windGust' : ('wind_gust', 3.6), # station is m/s, weewx wants km/h + 'windDir' : ('wind_dir', 22.5), # station is 0-15, weewx wants deg + 'rain' : ('rain', 0.1), # station is mm, weewx wants cm + 'radiation' : ('illuminance', 0.01075), # lux, weewx wants W/m^2 + 'UV' : ('uv', 1.0), + 'status' : ('status', 1.0), +} + +# formats for displaying fixed_format fields +datum_display_formats = { + 'magic_1' : '0x%2x', + 'magic_2' : '0x%2x', + } + +# wrap value for rain counter +rain_max = 0x10000 + +# values for status: +rain_overflow = 0x80 +lost_connection = 0x40 +# unknown = 0x20 +# unknown = 0x10 +# unknown = 0x08 +# unknown = 0x04 +# unknown = 0x02 +# unknown = 0x01 + +def decode_status(status): + result = {} + if status is None: + return result + for key, mask in (('rain_overflow', 0x80), + ('lost_connection', 0x40), + ('unknown', 0x3f), + ): + result[key] = status & mask + return result + +def get_status(code, status): + return 1 if status & code == code else 0 + +def pywws2weewx(p, ts, last_rain, last_rain_ts, max_rain_rate): + """Map the pywws dictionary to something weewx understands. + + p: dictionary of pywws readings + + ts: timestamp in UTC + + last_rain: last rain total in cm + + last_rain_ts: timestamp of last rain total + + max_rain_rate: maximum value for rain rate in cm/hr. rainfall readings + resulting in a rain rate greater than this value will be ignored. + """ + + packet = {} + # required elements + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = ts + + # everything else... + for k in keymap.keys(): + if keymap[k][0] in p and p[keymap[k][0]] is not None: + packet[k] = p[keymap[k][0]] * keymap[k][1] + else: + packet[k] = None + + # track the pointer used to obtain the data + packet['ptr'] = int(p['ptr']) if 'ptr' in p else None + packet['delay'] = int(p['delay']) if 'delay' in p else None + + # station status is an integer + if packet['status'] is not None: + packet['status'] = int(packet['status']) + packet['rxCheckPercent'] = 0 if get_status(lost_connection, packet['status']) else 100 + packet['outTempBatteryStatus'] = get_status(rain_overflow, packet['status']) + + # calculate the rain increment from the rain total + # watch for spurious rain counter decrement. if decrement is significant + # then it is a counter wraparound. a small decrement is either a sensor + # glitch or a read from a previous record. if the small decrement persists + # across multiple samples, it was probably a firmware glitch rather than + # a sensor glitch or old read. a spurious increment will be filtered by + # the bogus rain rate check. + total = packet['rain'] + packet['rainTotal'] = packet['rain'] + if packet['rain'] is not None and last_rain is not None: + if packet['rain'] < last_rain: + pstr = '0x%04x' % packet['ptr'] if packet['ptr'] is not None else 'None' + if last_rain - packet['rain'] < rain_max * 0.3 * 0.5: + log.info('ignoring spurious rain counter decrement (%s): ' + 'new: %s old: %s' % (pstr, packet['rain'], last_rain)) + else: + log.info('rain counter wraparound detected (%s): ' + 'new: %s old: %s' % (pstr, packet['rain'], last_rain)) + total += rain_max * 0.3 + packet['rain'] = weewx.wxformulas.calculate_rain(total, last_rain) + + # report rainfall in log to diagnose rain counter issues + if DEBUG_RAIN and packet['rain'] is not None and packet['rain'] > 0: + log.debug('got rainfall of %.2f cm (new: %.2f old: %.2f)' % + (packet['rain'], packet['rainTotal'], last_rain)) + + return packet + +USB_RT_PORT = (usb.TYPE_CLASS | usb.RECIP_OTHER) +USB_PORT_FEAT_POWER = 8 + +def power_cycle_station(hub, port): + '''Power cycle the port on the specified hub. This works only with USB + hubs that support per-port power switching such as the linksys USB2HUB4.''' + log.info("Attempting to power cycle") + busses = usb.busses() + if not busses: + raise weewx.WeeWxIOError("Power cycle failed: cannot find USB busses") + device = None + for bus in busses: + for dev in bus.devices: + if dev.deviceClass == usb.CLASS_HUB: + devid = "%s:%03d" % (bus.dirname, dev.devnum) + if devid == hub: + device = dev + if device is None: + raise weewx.WeeWxIOError("Power cycle failed: cannot find hub %s" % hub) + handle = device.open() + try: + log.info("Power off port %d on hub %s" % (port, hub)) + handle.controlMsg(requestType=USB_RT_PORT, + request=usb.REQ_CLEAR_FEATURE, + value=USB_PORT_FEAT_POWER, + index=port, buffer=None, timeout=1000) + log.info("Waiting 30 seconds for station to power down") + time.sleep(30) + log.info("Power on port %d on hub %s" % (port, hub)) + handle.controlMsg(requestType=USB_RT_PORT, + request=usb.REQ_SET_FEATURE, + value=USB_PORT_FEAT_POWER, + index=port, buffer=None, timeout=1000) + log.info("Waiting 60 seconds for station to power up") + time.sleep(60) + finally: + del handle + log.info("Power cycle complete") + +# decode weather station raw data formats +def _signed_byte(raw, offset): + res = raw[offset] + if res == 0xFF: + return None + sign = 1 + if res >= 128: + sign = -1 + res = res - 128 + return sign * res +def _signed_short(raw, offset): + lo = raw[offset] + hi = raw[offset+1] + if lo == 0xFF and hi == 0xFF: + return None + sign = 1 + if hi >= 128: + sign = -1 + hi = hi - 128 + return sign * ((hi * 256) + lo) +def _unsigned_short(raw, offset): + lo = raw[offset] + hi = raw[offset+1] + if lo == 0xFF and hi == 0xFF: + return None + return (hi * 256) + lo +def _unsigned_int3(raw, offset): + lo = raw[offset] + md = raw[offset+1] + hi = raw[offset+2] + if lo == 0xFF and md == 0xFF and hi == 0xFF: + return None + return (hi * 256 * 256) + (md * 256) + lo +def _bcd_decode(byte): + hi = (byte // 16) & 0x0F + lo = byte & 0x0F + return (hi * 10) + lo +def _date_time(raw, offset): + year = _bcd_decode(raw[offset]) + month = _bcd_decode(raw[offset+1]) + day = _bcd_decode(raw[offset+2]) + hour = _bcd_decode(raw[offset+3]) + minute = _bcd_decode(raw[offset+4]) + return '%4d-%02d-%02d %02d:%02d' % (year + 2000, month, day, hour, minute) +def _bit_field(raw, offset): + mask = 1 + result = [] + for i in range(8): # @UnusedVariable + result.append(raw[offset] & mask != 0) + mask = mask << 1 + return result +def _decode(raw, fmt): + if not raw: + return None + if isinstance(fmt, dict): + result = {} + for key, value in fmt.items(): + result[key] = _decode(raw, value) + else: + pos, typ, scale = fmt + if typ == 'ub': + result = raw[pos] + if result == 0xFF: + result = None + elif typ == 'sb': + result = _signed_byte(raw, pos) + elif typ == 'us': + result = _unsigned_short(raw, pos) + elif typ == 'u3': + result = _unsigned_int3(raw, pos) + elif typ == 'ss': + result = _signed_short(raw, pos) + elif typ == 'dt': + result = _date_time(raw, pos) + elif typ == 'tt': + result = '%02d:%02d' % (_bcd_decode(raw[pos]), + _bcd_decode(raw[pos+1])) + elif typ == 'pb': + result = raw[pos] + elif typ == 'wa': + # wind average - 12 bits split across a byte and a nibble + result = raw[pos] + ((raw[pos+2] & 0x0F) << 8) + if result == 0xFFF: + result = None + elif typ == 'wg': + # wind gust - 12 bits split across a byte and a nibble + result = raw[pos] + ((raw[pos+1] & 0xF0) << 4) + if result == 0xFFF: + result = None + elif typ == 'wd': + # wind direction - check bit 7 for invalid + result = raw[pos] + if result & 0x80: + result = None + elif typ == 'bf': + # bit field - 'scale' is a list of bit names + result = {} + for k, v in zip(scale, _bit_field(raw, pos)): + result[k] = v + return result + else: + raise weewx.WeeWxIOError('decode failure: unknown type %s' % typ) + if scale and result: + result = float(result) * scale + return result +def _bcd_encode(value): + hi = value // 10 + lo = value % 10 + return (hi * 16) + lo + + +class ObservationError(Exception): + pass + +# mechanisms for polling the station +PERIODIC_POLLING = 'PERIODIC' +ADAPTIVE_POLLING = 'ADAPTIVE' + +class FineOffsetUSB(weewx.drivers.AbstractDevice): + """Driver for FineOffset USB stations.""" + + def __init__(self, **stn_dict) : + """Initialize the station object. + + model: Which station model is this? + [Optional. Default is 'WH1080 (USB)'] + + polling_mode: The mechanism to use when polling the station. PERIODIC + polling queries the station console at regular intervals. ADAPTIVE + polling adjusts the query interval in an attempt to avoid times when + the console is writing to memory or communicating with the sensors. + The polling mode applies only when the weewx StdArchive is set to + 'software', otherwise weewx reads archived records from the console. + [Optional. Default is 'PERIODIC'] + + polling_interval: How often to sample the USB interface for data. + [Optional. Default is 60 seconds] + + max_rain_rate: Maximum sane value for rain rate for a single polling + interval or archive interval, measured in cm/hr. If the rain sample + for a single period is greater than this rate, the sample will be + logged but not added to the loop or archive data. + [Optional. Default is 24] + + timeout: How long to wait, in seconds, before giving up on a response + from the USB port. + [Optional. Default is 15 seconds] + + wait_before_retry: How long to wait after a failure before retrying. + [Optional. Default is 30 seconds] + + max_tries: How many times to try before giving up. + [Optional. Default is 3] + + device_id: The USB device ID for the station. Specify this if there + are multiple devices of the same type on the bus. + [Optional. No default] + """ + + self.model = stn_dict.get('model', 'WH1080 (USB)') + self.polling_mode = stn_dict.get('polling_mode', PERIODIC_POLLING) + self.polling_interval = int(stn_dict.get('polling_interval', 60)) + self.max_rain_rate = int(stn_dict.get('max_rain_rate', 24)) + self.timeout = float(stn_dict.get('timeout', 15.0)) + self.wait_before_retry = float(stn_dict.get('wait_before_retry', 30.0)) + self.max_tries = int(stn_dict.get('max_tries', 3)) + self.device_id = stn_dict.get('device_id', None) + + # FIXME: prefer 'power_cycle_on_fail = (True|False)' + self.pc_hub = stn_dict.get('power_cycle_hub', None) + self.pc_port = stn_dict.get('power_cycle_port', None) + if self.pc_port is not None: + self.pc_port = int(self.pc_port) + + self.data_format = stn_dict.get('data_format', '1080') + self.vendor_id = 0x1941 + self.product_id = 0x8021 + self.usb_interface = 0 + self.usb_endpoint = 0x81 + self.usb_read_size = 0x20 + + # avoid USB activity this many seconds each side of the time when + # console is believed to be writing to memory. + self.avoid = 3.0 + # minimum interval between polling for data change + self.min_pause = 0.5 + + self.devh = None + self._arcint = None + self._last_rain_loop = None + self._last_rain_ts_loop = None + self._last_rain_arc = None + self._last_rain_ts_arc = None + self._last_status = None + self._fixed_block = None + self._data_block = None + self._data_pos = None + self._current_ptr = None + self._station_clock = None + self._sensor_clock = None + # start with known magic numbers. report any additional we encounter. + # these are from wview: 55??, ff??, 01??, 001e, 0001 + # these are from pywws: 55aa, ffff, 5555, c400 + self._magic_numbers = ['55aa'] + self._last_magic = None + + # FIXME: get last_rain_arc and last_rain_ts_arc from database + + global DEBUG_SYNC + DEBUG_SYNC = int(stn_dict.get('debug_sync', 0)) + global DEBUG_RAIN + DEBUG_RAIN = int(stn_dict.get('debug_rain', 0)) + + log.info('driver version is %s' % DRIVER_VERSION) + if self.pc_hub is not None: + log.info('power cycling enabled for port %s on hub %s' % + (self.pc_port, self.pc_hub)) + log.info('polling mode is %s' % self.polling_mode) + if self.polling_mode.lower() == PERIODIC_POLLING.lower(): + log.info('polling interval is %s' % self.polling_interval) + + self.openPort() + + # Unfortunately there is no provision to obtain the model from the station + # itself, so use what is specified from the configuration file. + @property + def hardware_name(self): + return self.model + + # weewx wants the archive interval in seconds, but the database record + # follows the wview convention of minutes and the console uses minutes. + @property + def archive_interval(self): + return self._archive_interval_minutes() * 60 + + # if power cycling is enabled, loop forever until we get a response from + # the weather station. + def _archive_interval_minutes(self): + if self._arcint is not None: + return self._arcint + if self.pc_hub is not None: + while True: + try: + self.openPort() + self._arcint = self._get_arcint() + break + except weewx.WeeWxIOError: + self.closePort() + power_cycle_station(self.pc_hub, self.pc_port) + else: + self._arcint = self._get_arcint() + return self._arcint + + def _get_arcint(self): + ival = None + for i in range(self.max_tries): + try: + ival = self.get_fixed_block(['read_period']) + break + except usb.USBError as e: + log.critical("Get archive interval failed attempt %d of %d: %s" + % (i+1, self.max_tries, e)) + else: + raise weewx.WeeWxIOError("Unable to read archive interval after %d tries" % self.max_tries) + if ival is None: + raise weewx.WeeWxIOError("Cannot determine archive interval") + return ival + + def openPort(self): + if self.devh is not None: + return + + dev = self._find_device() + if not dev: + log.critical("Cannot find USB device with Vendor=0x%04x ProdID=0x%04x Device=%s" + % (self.vendor_id, self.product_id, self.device_id)) + raise weewx.WeeWxIOError("Unable to find USB device") + + self.devh = dev.open() + if not self.devh: + raise weewx.WeeWxIOError("Open USB device failed") + + # be sure kernel does not claim the interface + try: + self.devh.detachKernelDriver(self.usb_interface) + except: + pass + + # attempt to claim the interface + try: + self.devh.claimInterface(self.usb_interface) + except usb.USBError as e: + self.closePort() + log.critical("Unable to claim USB interface %s: %s" % (self.usb_interface, e)) + raise weewx.WeeWxIOError(e) + + def closePort(self): + try: + self.devh.releaseInterface() + except: + pass + self.devh = None + + def _find_device(self): + """Find the vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == self.vendor_id and dev.idProduct == self.product_id: + if self.device_id is None or dev.filename == self.device_id: + log.info('found station on USB bus=%s device=%s' % (bus.dirname, dev.filename)) + return dev + return None + +# There is no point in using the station clock since it cannot be trusted and +# since we cannot synchronize it with the computer clock. + +# def getTime(self): +# return self.get_clock() + +# def setTime(self): +# self.set_clock() + + def genLoopPackets(self): + """Generator function that continuously returns decoded packets.""" + + for p in self.get_observations(): + ts = int(time.time() + 0.5) + packet = pywws2weewx(p, ts, + self._last_rain_loop, self._last_rain_ts_loop, + self.max_rain_rate) + self._last_rain_loop = packet['rainTotal'] + self._last_rain_ts_loop = ts + if packet['status'] != self._last_status: + log.info('station status %s (%s)' % + (decode_status(packet['status']), packet['status'])) + self._last_status = packet['status'] + yield packet + + def genArchiveRecords(self, since_ts): + """Generator function that returns records from the console. + + since_ts: local timestamp in seconds. All data since (but not + including) this time will be returned. A value of None + results in all data. + + yields: a sequence of dictionaries containing the data, each with + local timestamp in seconds. + """ + records = self.get_records(since_ts) + log.debug('found %d archive records' % len(records)) + epoch = datetime.datetime.utcfromtimestamp(0) + for r in records: + delta = r['datetime'] - epoch + # FIXME: deal with daylight saving corner case + ts = delta.days * 86400 + delta.seconds + data = pywws2weewx(r['data'], ts, + self._last_rain_arc, self._last_rain_ts_arc, + self.max_rain_rate) + data['interval'] = r['interval'] + data['ptr'] = r['ptr'] + self._last_rain_arc = data['rainTotal'] + self._last_rain_ts_arc = ts + log.debug('returning archive record %s' % ts) + yield data + + def get_observations(self): + """Get data from the station. + + There are a few types of non-fatal failures we might encounter while + reading. When we encounter one, log the failure then retry. + + Sometimes current_pos returns None for the pointer. This is useless to + us, so keep querying until we get a valid pointer. + + In live_data, sometimes the delay is None. This prevents calculation + of the timing intervals, so bail out and retry. + + If we get USB read failures, retry until we get something valid. + """ + nerr = 0 + old_ptr = None + interval = self._archive_interval_minutes() + while True: + try: + if self.polling_mode.lower() == ADAPTIVE_POLLING.lower(): + for data,ptr,logged in self.live_data(): # @UnusedVariable + nerr = 0 + data['ptr'] = ptr + yield data + elif self.polling_mode.lower() == PERIODIC_POLLING.lower(): + new_ptr = self.current_pos() + if new_ptr < data_start: + raise ObservationError('bad pointer: 0x%04x' % new_ptr) + block = self.get_raw_data(new_ptr, unbuffered=True) + if len(block) != reading_len[self.data_format]: + raise ObservationError('wrong block length: expected: %d actual: %d' % (reading_len[self.data_format], len(block))) + data = _decode(block, reading_format[self.data_format]) + delay = data.get('delay', None) + if delay is None: + raise ObservationError('no delay found in observation') + if new_ptr != old_ptr and delay >= interval: + raise ObservationError('ignoring suspected bogus data from 0x%04x (delay=%s interval=%s)' % (new_ptr, delay, interval)) + old_ptr = new_ptr + data['ptr'] = new_ptr + nerr = 0 + yield data + time.sleep(self.polling_interval) + else: + raise Exception("unknown polling mode '%s'" % self.polling_mode) + + except (IndexError, usb.USBError, ObservationError) as e: + log.error('get_observations failed: %s' % e) + nerr += 1 + if nerr > self.max_tries: + raise weewx.WeeWxIOError("Max retries exceeded while fetching observations") + time.sleep(self.wait_before_retry) + +#============================================================================== +# methods for reading from and writing to usb +# +# end mark: 0x20 +# read command: 0xA1 +# write command: 0xA0 +# write command word: 0xA2 +# +# FIXME: to support multiple usb drivers, these should be abstracted to a class +# FIXME: refactor the _read_usb methods to pass read_size down the chain +#============================================================================== + + def _read_usb_block(self, address): + addr1 = (address >> 8) & 0xff + addr2 = address & 0xff + self.devh.controlMsg(usb.TYPE_CLASS + usb.RECIP_INTERFACE, + 0x0000009, + [0xA1,addr1,addr2,0x20,0xA1,addr1,addr2,0x20], + 0x0000200, + 0x0000000, + 1000) + data = self.devh.interruptRead(self.usb_endpoint, + self.usb_read_size, # bytes to read + int(self.timeout*1000)) + return list(data) + + def _read_usb_bytes(self, size): + data = self.devh.interruptRead(self.usb_endpoint, + size, + int(self.timeout*1000)) + if data is None or len(data) < size: + raise weewx.WeeWxIOError('Read from USB failed') + return list(data) + + def _write_usb(self, address, data): + addr1 = (address >> 8) & 0xff + addr2 = address & 0xff + buf = [0xA2,addr1,addr2,0x20,0xA2,data,0,0x20] + result = self.devh.controlMsg( + usb.ENDPOINT_OUT + usb.TYPE_CLASS + usb.RECIP_INTERFACE, + usb.REQ_SET_CONFIGURATION, # 0x09 + buf, + value = 0x200, + index = 0, + timeout = int(self.timeout*1000)) + if result != len(buf): + return False + buf = self._read_usb_bytes(8) + if buf is None: + return False + for byte in buf: + if byte != 0xA5: + return False + return True + +#============================================================================== +# methods for configuring the weather station +# the following were adapted from various pywws utilities +#============================================================================== + + def decode(self, raw_data): + return _decode(raw_data, reading_format[self.data_format]) + + def clear_history(self): + ptr = fixed_format['data_count'][0] + data = [] + data.append((ptr, 1)) + data.append((ptr+1, 0)) + self.write_data(data) + + def set_pressure(self, pressure): + pressure = int(float(pressure) * 10.0 + 0.5) + ptr = fixed_format['rel_pressure'][0] + data = [] + data.append((ptr, pressure % 256)) + data.append((ptr+1, pressure // 256)) + self.write_data(data) + + def set_read_period(self, read_period): + read_period = int(read_period) + data = [] + data.append((fixed_format['read_period'][0], read_period)) + self.write_data(data) + + def set_clock(self, ts=0): + if ts == 0: + now = datetime.datetime.now() + if now.second >= 55: + time.sleep(10) + now = datetime.datetime.now() + now += datetime.timedelta(minutes=1) + else: + now = datetime.datetime.fromtimestamp(ts) + ptr = fixed_format['date_time'][0] + data = [] + data.append((ptr, _bcd_encode(now.year - 2000))) + data.append((ptr+1, _bcd_encode(now.month))) + data.append((ptr+2, _bcd_encode(now.day))) + data.append((ptr+3, _bcd_encode(now.hour))) + data.append((ptr+4, _bcd_encode(now.minute))) + time.sleep(59 - now.second) + self.write_data(data) + + def get_clock(self): + tstr = self.get_fixed_block(['date_time'], True) + tt = time.strptime(tstr, '%Y-%m-%d %H:%M') + ts = time.mktime(tt) + return int(ts) + + def get_records(self, since_ts=0, num_rec=0): + """Get data from station memory. + + The weather station contains a circular buffer of data, but there is + no absolute date or time for each record, only relative offsets. So + the best we can do is to use the 'delay' and 'read_period' to guess + when each record was made. + + Use the computer clock since we cannot trust the station clock. + + Return an array of dict, with each dict containing a datetimestamp + in UTC, the pointer, the decoded data, and the raw data. Items in the + array go from oldest to newest. + """ + nerr = 0 + while True: + try: + fixed_block = self.get_fixed_block(unbuffered=True) + if fixed_block['read_period'] is None: + raise weewx.WeeWxIOError('invalid read_period in get_records') + if fixed_block['data_count'] is None: + raise weewx.WeeWxIOError('invalid data_count in get_records') + if since_ts: + dt = datetime.datetime.utcfromtimestamp(since_ts) + dt += datetime.timedelta(seconds=fixed_block['read_period']*30) + else: + dt = datetime.datetime.min + max_count = fixed_block['data_count'] - 1 + if num_rec == 0 or num_rec > max_count: + num_rec = max_count + log.debug('get %d records since %s' % (num_rec, dt)) + dts, ptr = self.sync(read_period=fixed_block['read_period']) + count = 0 + records = [] + while dts > dt and count < num_rec: + raw_data = self.get_raw_data(ptr) + data = self.decode(raw_data) + if data['delay'] is None or data['delay'] < 1 or data['delay'] > 30: + log.error('invalid data in get_records at 0x%04x, %s' % + (ptr, dts.isoformat())) + dts -= datetime.timedelta(minutes=fixed_block['read_period']) + else: + record = dict() + record['ptr'] = ptr + record['datetime'] = dts + record['data'] = data + record['raw_data'] = raw_data + record['interval'] = data['delay'] + records.insert(0, record) + count += 1 + dts -= datetime.timedelta(minutes=data['delay']) + ptr = self.dec_ptr(ptr) + return records + except (IndexError, usb.USBError, ObservationError) as e: + log.error('get_records failed: %s' % e) + nerr += 1 + if nerr > self.max_tries: + raise weewx.WeeWxIOError("Max retries exceeded while fetching records") + time.sleep(self.wait_before_retry) + + def sync(self, quality=None, read_period=None): + """Synchronise with the station to determine the date and time of the + latest record. Return the datetime stamp in UTC and the record + pointer. The quality determines the accuracy of the synchronisation. + + 0 - low quality, synchronisation to within 12 seconds + 1 - high quality, synchronisation to within 2 seconds + + The high quality synchronisation could take as long as a logging + interval to complete. + """ + if quality is None: + if read_period is not None and read_period <= 5: + quality = 1 + else: + quality = 0 + log.info('synchronising to the weather station (quality=%d)' % quality) + range_hi = datetime.datetime.max + range_lo = datetime.datetime.min + ptr = self.current_pos() + data = self.get_data(ptr, unbuffered=True) + last_delay = data['delay'] + if last_delay is None or last_delay == 0: + prev_date = datetime.datetime.min + else: + prev_date = datetime.datetime.utcnow() + maxcount = 10 + count = 0 + for data, last_ptr, logged in self.live_data(logged_only=(quality>1)): + last_date = data['idx'] + log.debug('packet timestamp is %s' % last_date.strftime('%H:%M:%S')) + if logged: + break + if data['delay'] is None: + log.error('invalid data while synchronising at 0x%04x' % last_ptr) + count += 1 + if count > maxcount: + raise weewx.WeeWxIOError('repeated invalid delay while synchronising') + continue + if quality < 2 and self._station_clock: + err = last_date - datetime.datetime.fromtimestamp(self._station_clock) + last_date -= datetime.timedelta(minutes=data['delay'], + seconds=err.seconds % 60) + log.debug('log timestamp is %s' % last_date.strftime('%H:%M:%S')) + last_ptr = self.dec_ptr(last_ptr) + break + if quality < 1: + hi = last_date - datetime.timedelta(minutes=data['delay']) + if last_date - prev_date > datetime.timedelta(seconds=50): + lo = hi - datetime.timedelta(seconds=60) + elif data['delay'] == last_delay: + lo = hi - datetime.timedelta(seconds=60) + hi = hi - datetime.timedelta(seconds=48) + else: + lo = hi - datetime.timedelta(seconds=48) + last_delay = data['delay'] + prev_date = last_date + range_hi = min(range_hi, hi) + range_lo = max(range_lo, lo) + err = (range_hi - range_lo) / 2 + last_date = range_lo + err + log.debug('estimated log time %s +/- %ds (%s..%s)' % + (last_date.strftime('%H:%M:%S'), err.seconds, + lo.strftime('%H:%M:%S'), hi.strftime('%H:%M:%S'))) + if err < datetime.timedelta(seconds=15): + last_ptr = self.dec_ptr(last_ptr) + break + log.debug('synchronised to %s for ptr 0x%04x' % (last_date, last_ptr)) + return last_date, last_ptr + +#============================================================================== +# methods for reading data from the weather station +# the following were adapted from WeatherStation.py in pywws +# +# commit 7d2e8ec700a652426c0114e7baebcf3460b1ef0f +# Author: Jim Easterbrook +# Date: Thu Oct 31 13:04:29 2013 +0000 +#============================================================================== + + def live_data(self, logged_only=False): + # There are two things we want to synchronise to - the data is + # updated every 48 seconds and the address is incremented + # every 5 minutes (or 10, 15, ..., 30). Rather than getting + # data every second or two, we sleep until one of the above is + # due. (During initialisation we get data every two seconds + # anyway.) + read_period = self.get_fixed_block(['read_period']) + if read_period is None: + raise ObservationError('invalid read_period in live_data') + log_interval = float(read_period * 60) + live_interval = 48.0 + old_ptr = self.current_pos() + old_data = self.get_data(old_ptr, unbuffered=True) + if old_data['delay'] is None: + raise ObservationError('invalid delay at 0x%04x' % old_ptr) + now = time.time() + if self._sensor_clock: + next_live = now + next_live -= (next_live - self._sensor_clock) % live_interval + next_live += live_interval + else: + next_live = None + if self._station_clock and next_live: + # set next_log + next_log = next_live - live_interval + next_log -= (next_log - self._station_clock) % 60 + next_log -= old_data['delay'] * 60 + next_log += log_interval + else: + next_log = None + self._station_clock = None + ptr_time = 0 + data_time = 0 + last_log = now - (old_data['delay'] * 60) + last_status = None + while True: + if not self._station_clock: + next_log = None + if not self._sensor_clock: + next_live = None + now = time.time() + # wake up just before next reading is due + advance = now + max(self.avoid, self.min_pause) + self.min_pause + pause = 600.0 + if next_live: + if not logged_only: + pause = min(pause, next_live - advance) + else: + pause = self.min_pause + if next_log: + pause = min(pause, next_log - advance) + elif old_data['delay'] < read_period - 1: + pause = min( + pause, ((read_period - old_data['delay']) * 60.0) - 110.0) + else: + pause = self.min_pause + pause = max(pause, self.min_pause) + if DEBUG_SYNC: + log.debug('delay %s, pause %g' % (str(old_data['delay']), pause)) + time.sleep(pause) + # get new data + last_data_time = data_time + new_data = self.get_data(old_ptr, unbuffered=True) + if new_data['delay'] is None: + raise ObservationError('invalid delay at 0x%04x' % old_ptr) + data_time = time.time() + # log any change of status + if new_data['status'] != last_status: + log.debug('status %s (%s)' % (str(decode_status(new_data['status'])), new_data['status'])) + last_status = new_data['status'] + # 'good' time stamp if we haven't just woken up from long + # pause and data read wasn't delayed + valid_time = data_time - last_data_time < (self.min_pause * 2.0) - 0.1 + # make sure changes because of logging interval aren't + # mistaken for new live data + if new_data['delay'] >= read_period: + for key in ('delay', 'hum_in', 'temp_in', 'abs_pressure'): + old_data[key] = new_data[key] + # ignore solar data which changes every 60 seconds + if self.data_format == '3080': + for key in ('illuminance', 'uv'): + old_data[key] = new_data[key] + if new_data != old_data: + log.debug('new data') + result = dict(new_data) + if valid_time: + # data has just changed, so definitely at a 48s update time + if self._sensor_clock: + diff = (data_time - self._sensor_clock) % live_interval + if diff > 2.0 and diff < (live_interval - 2.0): + log.debug('unexpected sensor clock change') + self._sensor_clock = None + if not self._sensor_clock: + self._sensor_clock = data_time + log.debug('setting sensor clock %g' % + (data_time % live_interval)) + if not next_live: + log.debug('live synchronised') + next_live = data_time + elif next_live and data_time < next_live - self.min_pause: + log.debug('lost sync %g' % (data_time - next_live)) + next_live = None + self._sensor_clock = None + if next_live and not logged_only: + while data_time > next_live + live_interval: + log.debug('missed interval') + next_live += live_interval + result['idx'] = datetime.datetime.utcfromtimestamp(int(next_live)) + next_live += live_interval + yield result, old_ptr, False + old_data = new_data + # get new pointer + if old_data['delay'] < read_period - 1: + continue + last_ptr_time = ptr_time + new_ptr = self.current_pos() + ptr_time = time.time() + valid_time = ptr_time - last_ptr_time < (self.min_pause * 2.0) - 0.1 + if new_ptr != old_ptr: + log.debug('new ptr: %06x (%06x)' % (new_ptr, old_ptr)) + last_log = ptr_time + # re-read data, to be absolutely sure it's the last + # logged data before the pointer was updated + new_data = self.get_data(old_ptr, unbuffered=True) + if new_data['delay'] is None: + raise ObservationError('invalid delay at 0x%04x' % old_ptr) + result = dict(new_data) + if valid_time: + # pointer has just changed, so definitely at a logging time + if self._station_clock: + diff = (ptr_time - self._station_clock) % 60 + if diff > 2 and diff < 58: + log.debug('unexpected station clock change') + self._station_clock = None + if not self._station_clock: + self._station_clock = ptr_time + log.debug('setting station clock %g' % (ptr_time % 60.0)) + if not next_log: + log.debug('log synchronised') + next_log = ptr_time + elif next_log and ptr_time < next_log - self.min_pause: + log.debug('lost log sync %g' % (ptr_time - next_log)) + next_log = None + self._station_clock = None + if next_log: + result['idx'] = datetime.datetime.utcfromtimestamp(int(next_log)) + next_log += log_interval + yield result, old_ptr, True + if new_ptr != self.inc_ptr(old_ptr): + log.error('unexpected ptr change %06x -> %06x' % (old_ptr, new_ptr)) + old_ptr = new_ptr + old_data['delay'] = 0 + elif ptr_time > last_log + ((new_data['delay'] + 2) * 60): + # if station stops logging data, don't keep reading + # USB until it locks up + raise ObservationError('station is not logging data') + elif valid_time and next_log and ptr_time > next_log + 6.0: + log.debug('log extended') + next_log += 60.0 + + def inc_ptr(self, ptr): + """Get next circular buffer data pointer.""" + result = ptr + reading_len[self.data_format] + if result >= 0x10000: + result = data_start + return result + + def dec_ptr(self, ptr): + """Get previous circular buffer data pointer.""" + result = ptr - reading_len[self.data_format] + if result < data_start: + result = 0x10000 - reading_len[self.data_format] + return result + + def get_raw_data(self, ptr, unbuffered=False): + """Get raw data from circular buffer. + + If unbuffered is false then a cached value that was obtained + earlier may be returned.""" + if unbuffered: + self._data_pos = None + # round down ptr to a 'block boundary' + idx = ptr - (ptr % 0x20) + ptr -= idx + count = reading_len[self.data_format] + if self._data_pos == idx: + # cache contains useful data + result = self._data_block[ptr:ptr + count] + if len(result) >= count: + return result + else: + result = list() + if ptr + count > 0x20: + # need part of next block, which may be in cache + if self._data_pos != idx + 0x20: + self._data_pos = idx + 0x20 + self._data_block = self._read_block(self._data_pos) + result += self._data_block[0:ptr + count - 0x20] + if len(result) >= count: + return result + # read current block + self._data_pos = idx + self._data_block = self._read_block(self._data_pos) + result = self._data_block[ptr:ptr + count] + result + return result + + def get_data(self, ptr, unbuffered=False): + """Get decoded data from circular buffer. + + If unbuffered is false then a cached value that was obtained + earlier may be returned.""" + return _decode(self.get_raw_data(ptr, unbuffered), + reading_format[self.data_format]) + + def current_pos(self): + """Get circular buffer location where current data is being written.""" + new_ptr = _decode(self._read_fixed_block(0x0020), + lo_fix_format['current_pos']) + if new_ptr is None: + raise ObservationError('current_pos is None') + if new_ptr == self._current_ptr: + return self._current_ptr + if self._current_ptr and new_ptr != self.inc_ptr(self._current_ptr): + for k in reading_len: + if (new_ptr - self._current_ptr) == reading_len[k]: + log.error('changing data format from %s to %s' % (self.data_format, k)) + self.data_format = k + break + self._current_ptr = new_ptr + return self._current_ptr + + def get_raw_fixed_block(self, unbuffered=False): + """Get the raw "fixed block" of settings and min/max data.""" + if unbuffered or not self._fixed_block: + self._fixed_block = self._read_fixed_block() + return self._fixed_block + + def get_fixed_block(self, keys=[], unbuffered=False): + """Get the decoded "fixed block" of settings and min/max data. + + A subset of the entire block can be selected by keys.""" + if unbuffered or not self._fixed_block: + self._fixed_block = self._read_fixed_block() + fmt = fixed_format + # navigate down list of keys to get to wanted data + for key in keys: + fmt = fmt[key] + return _decode(self._fixed_block, fmt) + + def _wait_for_station(self): + # avoid times when station is writing to memory + while True: + pause = 60.0 + if self._station_clock: + phase = time.time() - self._station_clock + if phase > 24 * 3600: + # station clock was last measured a day ago, so reset it + self._station_clock = None + else: + pause = min(pause, (self.avoid - phase) % 60) + if self._sensor_clock: + phase = time.time() - self._sensor_clock + if phase > 24 * 3600: + # sensor clock was last measured 6 hrs ago, so reset it + self._sensor_clock = None + else: + pause = min(pause, (self.avoid - phase) % 48) + if pause >= self.avoid * 2.0: + return + log.debug('avoid %s' % str(pause)) + time.sleep(pause) + + def _read_block(self, ptr, retry=True): + # Read block repeatedly until it's stable. This avoids getting corrupt + # data when the block is read as the station is updating it. + old_block = None + while True: + self._wait_for_station() + new_block = self._read_usb_block(ptr) + if new_block: + if (new_block == old_block) or not retry: + break + if old_block is not None: + log.info('unstable read: blocks differ for ptr 0x%06x' % ptr) + old_block = new_block + return new_block + + def _read_fixed_block(self, hi=0x0100): + result = [] + for mempos in range(0x0000, hi, 0x0020): + result += self._read_block(mempos) + # check 'magic number'. log each new one we encounter. + magic = '%02x%02x' % (result[0], result[1]) + if magic not in self._magic_numbers: + log.error('unrecognised magic number %s' % magic) + self._magic_numbers.append(magic) + if magic != self._last_magic: + if self._last_magic is not None: + log.error('magic number changed old=%s new=%s' % + (self._last_magic, magic)) + self._last_magic = magic + return result + + def _write_byte(self, ptr, value): + self._wait_for_station() + if not self._write_usb(ptr, value): + raise weewx.WeeWxIOError('Write to USB failed') + + def write_data(self, data): + """Write a set of single bytes to the weather station. Data must be an + array of (ptr, value) pairs.""" + # send data + for ptr, value in data: + self._write_byte(ptr, value) + # set 'data changed' + self._write_byte(fixed_format['data_changed'][0], 0xAA) + # wait for station to clear 'data changed' + while True: + ack = _decode(self._read_fixed_block(0x0020), + fixed_format['data_changed']) + if ack == 0: + break + log.debug('waiting for ack') + time.sleep(6) + +# Tables of "meanings" for raw weather station data. Each key +# specifies an (offset, type, multiplier) tuple that is understood +# by _decode. +# depends on weather station type +reading_format = {} +reading_format['1080'] = { + 'delay' : (0, 'ub', None), + 'hum_in' : (1, 'ub', None), + 'temp_in' : (2, 'ss', 0.1), + 'hum_out' : (4, 'ub', None), + 'temp_out' : (5, 'ss', 0.1), + 'abs_pressure' : (7, 'us', 0.1), + 'wind_ave' : (9, 'wa', 0.1), + 'wind_gust' : (10, 'wg', 0.1), + 'wind_dir' : (12, 'wd', None), + 'rain' : (13, 'us', 0.3), + 'status' : (15, 'pb', None), + } +reading_format['3080'] = { + 'illuminance' : (16, 'u3', 0.1), + 'uv' : (19, 'ub', None), + } +reading_format['3080'].update(reading_format['1080']) + +lo_fix_format = { + 'magic_1' : (0, 'pb', None), + 'magic_2' : (1, 'pb', None), + 'model' : (2, 'us', None), + 'version' : (4, 'pb', None), + 'id' : (5, 'us', None), + 'rain_coef' : (7, 'us', None), + 'wind_coef' : (9, 'us', None), + 'read_period' : (16, 'ub', None), + 'settings_1' : (17, 'bf', ('temp_in_F', 'temp_out_F', 'rain_in', + 'bit3', 'bit4', 'pressure_hPa', + 'pressure_inHg', 'pressure_mmHg')), + 'settings_2' : (18, 'bf', ('wind_mps', 'wind_kmph', 'wind_knot', + 'wind_mph', 'wind_bft', 'bit5', + 'bit6', 'bit7')), + 'display_1' : (19, 'bf', ('pressure_rel', 'wind_gust', 'clock_12hr', + 'date_mdy', 'time_scale_24', 'show_year', + 'show_day_name', 'alarm_time')), + 'display_2' : (20, 'bf', ('temp_out_temp', 'temp_out_chill', + 'temp_out_dew', 'rain_hour', 'rain_day', + 'rain_week', 'rain_month', 'rain_total')), + 'alarm_1' : (21, 'bf', ('bit0', 'time', 'wind_dir', 'bit3', + 'hum_in_lo', 'hum_in_hi', + 'hum_out_lo', 'hum_out_hi')), + 'alarm_2' : (22, 'bf', ('wind_ave', 'wind_gust', + 'rain_hour', 'rain_day', + 'pressure_abs_lo', 'pressure_abs_hi', + 'pressure_rel_lo', 'pressure_rel_hi')), + 'alarm_3' : (23, 'bf', ('temp_in_lo', 'temp_in_hi', + 'temp_out_lo', 'temp_out_hi', + 'wind_chill_lo', 'wind_chill_hi', + 'dew_point_lo', 'dew_point_hi')), + 'timezone' : (24, 'sb', None), + 'unknown_01' : (25, 'pb', None), + 'data_changed' : (26, 'ub', None), + 'data_count' : (27, 'us', None), + 'display_3' : (29, 'bf', ('illuminance_fc', 'bit1', 'bit2', 'bit3', + 'bit4', 'bit5', 'bit6', 'bit7')), + 'current_pos' : (30, 'us', None), + } + +fixed_format = { + 'rel_pressure' : (32, 'us', 0.1), + 'abs_pressure' : (34, 'us', 0.1), + 'lux_wm2_coeff' : (36, 'us', 0.1), + 'wind_mult' : (38, 'us', None), + 'temp_out_offset' : (40, 'us', None), + 'temp_in_offset' : (42, 'us', None), + 'hum_out_offset' : (44, 'us', None), + 'hum_in_offset' : (46, 'us', None), + 'date_time' : (43, 'dt', None), # conflict with temp_in_offset + 'unknown_18' : (97, 'pb', None), + 'alarm' : { + 'hum_in' : {'hi': (48, 'ub', None), 'lo': (49, 'ub', None)}, + 'temp_in' : {'hi': (50, 'ss', 0.1), 'lo': (52, 'ss', 0.1)}, + 'hum_out' : {'hi': (54, 'ub', None), 'lo': (55, 'ub', None)}, + 'temp_out' : {'hi': (56, 'ss', 0.1), 'lo': (58, 'ss', 0.1)}, + 'windchill' : {'hi': (60, 'ss', 0.1), 'lo': (62, 'ss', 0.1)}, + 'dewpoint' : {'hi': (64, 'ss', 0.1), 'lo': (66, 'ss', 0.1)}, + 'abs_pressure' : {'hi': (68, 'us', 0.1), 'lo': (70, 'us', 0.1)}, + 'rel_pressure' : {'hi': (72, 'us', 0.1), 'lo': (74, 'us', 0.1)}, + 'wind_ave' : {'bft': (76, 'ub', None), 'ms': (77, 'ub', 0.1)}, + 'wind_gust' : {'bft': (79, 'ub', None), 'ms': (80, 'ub', 0.1)}, + 'wind_dir' : (82, 'ub', None), + 'rain' : {'hour': (83,'us',0.3), 'day': (85,'us',0.3)}, + 'time' : (87, 'tt', None), + 'illuminance' : (89, 'u3', 0.1), + 'uv' : (92, 'ub', None), + }, + 'max' : { + 'uv' : {'val': (93, 'ub', None)}, + 'illuminance' : {'val': (94, 'u3', 0.1)}, + 'hum_in' : {'val': (98, 'ub', None), 'date' : (141, 'dt', None)}, + 'hum_out' : {'val': (100, 'ub', None), 'date': (151, 'dt', None)}, + 'temp_in' : {'val': (102, 'ss', 0.1), 'date' : (161, 'dt', None)}, + 'temp_out' : {'val': (106, 'ss', 0.1), 'date' : (171, 'dt', None)}, + 'windchill' : {'val': (110, 'ss', 0.1), 'date' : (181, 'dt', None)}, + 'dewpoint' : {'val': (114, 'ss', 0.1), 'date' : (191, 'dt', None)}, + 'abs_pressure' : {'val': (118, 'us', 0.1), 'date' : (201, 'dt', None)}, + 'rel_pressure' : {'val': (122, 'us', 0.1), 'date' : (211, 'dt', None)}, + 'wind_ave' : {'val': (126, 'us', 0.1), 'date' : (221, 'dt', None)}, + 'wind_gust' : {'val': (128, 'us', 0.1), 'date' : (226, 'dt', None)}, + 'rain' : { + 'hour' : {'val': (130, 'us', 0.3), 'date' : (231, 'dt', None)}, + 'day' : {'val': (132, 'us', 0.3), 'date' : (236, 'dt', None)}, + 'week' : {'val': (134, 'us', 0.3), 'date' : (241, 'dt', None)}, + 'month' : {'val': (136, 'us', 0.3), 'date' : (246, 'dt', None)}, + 'total' : {'val': (138, 'us', 0.3), 'date' : (251, 'dt', None)}, + }, + }, + 'min' : { + 'hum_in' : {'val': (99, 'ub', None), 'date' : (146, 'dt', None)}, + 'hum_out' : {'val': (101, 'ub', None), 'date': (156, 'dt', None)}, + 'temp_in' : {'val': (104, 'ss', 0.1), 'date' : (166, 'dt', None)}, + 'temp_out' : {'val': (108, 'ss', 0.1), 'date' : (176, 'dt', None)}, + 'windchill' : {'val': (112, 'ss', 0.1), 'date' : (186, 'dt', None)}, + 'dewpoint' : {'val': (116, 'ss', 0.1), 'date' : (196, 'dt', None)}, + 'abs_pressure' : {'val': (120, 'us', 0.1), 'date' : (206, 'dt', None)}, + 'rel_pressure' : {'val': (124, 'us', 0.1), 'date' : (216, 'dt', None)}, + }, + } +fixed_format.update(lo_fix_format) + +# start of readings / end of fixed block +data_start = 0x0100 # 256 + +# bytes per reading, depends on weather station type +reading_len = { + '1080' : 16, + '3080' : 20, + } diff --git a/dist/weewx-5.0.2/src/weewx/drivers/simulator.py b/dist/weewx-5.0.2/src/weewx/drivers/simulator.py new file mode 100644 index 0000000..0b39bc7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/simulator.py @@ -0,0 +1,393 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Console simulator for the weewx weather system""" + +import math +import random +import time + +import weewx.drivers +import weeutil.weeutil + +DRIVER_NAME = 'Simulator' +DRIVER_VERSION = "3.3" + + +def loader(config_dict, engine): + + start_ts, resume_ts = extract_starts(config_dict, DRIVER_NAME) + + stn = Simulator(start_time=start_ts, resume_time=resume_ts, **config_dict[DRIVER_NAME]) + + return stn + + +def extract_starts(config_dict, driver_name): + """Extract the start and resume times out of the configuration dictionary. + + Args: + config_dict (dict): The configuration dictionary + driver_name (str): The name of the driver. Something like 'Simulator' + + Returns + tuple(float|None, float|None): A two-way tuple, start time and the resume time. + """ + + # This uses a bit of a hack to have the simulator resume at a later + # time. It's not bad, but I'm not enthusiastic about having special + # knowledge about the database in a driver, albeit just the loader. + + start_ts = resume_ts = None + if 'start' in config_dict[driver_name]: + # A start has been specified. Extract the time stamp. + start_tt = time.strptime(config_dict[driver_name]['start'], "%Y-%m-%dT%H:%M") + start_ts = time.mktime(start_tt) + # If the 'resume' keyword is present and True, then get the last + # archive record out of the database and resume with that. + if weeutil.weeutil.to_bool(config_dict[driver_name].get('resume', False)): + import weewx.manager + import weedb + try: + # Resume with the last time in the database. If there is no such + # time, then fall back to the time specified in the configuration + # dictionary. + with weewx.manager.open_manager_with_config(config_dict, + 'wx_binding') as dbmanager: + resume_ts = dbmanager.lastGoodStamp() + except weedb.OperationalError: + pass + else: + # The resume keyword is not present. Start with the seed time: + resume_ts = start_ts + + return start_ts, resume_ts + + +class Simulator(weewx.drivers.AbstractDevice): + """Station simulator""" + + def __init__(self, **stn_dict): + """Initialize the simulator + + NAMED ARGUMENTS: + + loop_interval (float|None): The time (in seconds) between emitting LOOP packets. + Default is 2.5. + + start_time (float|None): The start (seed) time for the generator in unix epoch time + If 'None', or not present, then present time will be used. + + resume_time (float|None): The start time for the loop. + If 'None', or not present, then start_time will be used. + + mode (str): Controls the frequency of packets. One of either: + 'simulator': Real-time simulator - sleep between LOOP packets + 'generator': Emit packets as fast as possible (useful for testing) + Default is 'simulator' + + observations (list[str]|None): A list of observation types that should be generated. + If nothing is specified (the default), then all observations will be generated. + """ + + self.loop_interval = float(stn_dict.get('loop_interval', 2.5)) + if 'start_time' in stn_dict and stn_dict['start_time'] is not None: + # A start time has been specified. We are not in real time mode. + self.real_time = False + # Extract the generator start time: + start_ts = float(stn_dict['start_time']) + # If a resume time keyword is present (and it's not None), + # then have the generator resume with that time. + if 'resume_time' in stn_dict and stn_dict['resume_time'] is not None: + self.the_time = float(stn_dict['resume_time']) + else: + self.the_time = start_ts + else: + # No start time specified. We are in realtime mode. + self.real_time = True + start_ts = self.the_time = time.time() + + # default to simulator mode + self.mode = stn_dict.get('mode', 'simulator') + + # The following doesn't make much meteorological sense, but it is + # easy to program! + self.observations = { + 'outTemp' : Observation(magnitude=20.0, average= 50.0, period=24.0, phase_lag=14.0, start=start_ts), + 'inTemp' : Observation(magnitude=5.0, average= 68.0, period=24.0, phase_lag=12.0, start=start_ts), + 'barometer' : Observation(magnitude=1.0, average= 30.1, period=48.0, phase_lag= 0.0, start=start_ts), + 'pressure' : Observation(magnitude=1.0, average= 30.1, period=48.0, phase_lag= 0.0, start=start_ts), + 'windSpeed' : Observation(magnitude=5.0, average= 5.0, period=48.0, phase_lag=24.0, start=start_ts), + 'windDir' : Observation(magnitude=180.0, average=180.0, period=48.0, phase_lag= 0.0, start=start_ts), + 'windGust' : Observation(magnitude=6.0, average= 6.0, period=48.0, phase_lag=24.0, start=start_ts), + 'windGustDir': Observation(magnitude=180.0, average=180.0, period=48.0, phase_lag= 0.0, start=start_ts), + 'outHumidity': Observation(magnitude=30.0, average= 50.0, period=48.0, phase_lag= 0.0, start=start_ts), + 'inHumidity' : Observation(magnitude=10.0, average= 20.0, period=24.0, phase_lag= 0.0, start=start_ts), + 'radiation' : Solar(magnitude=1000, solar_start=6, solar_length=12), + 'UV' : Solar(magnitude=14, solar_start=6, solar_length=12), + 'rain' : Rain(rain_start=0, rain_length=3, total_rain=0.2, loop_interval=self.loop_interval), + 'txBatteryStatus': BatteryStatus(), + 'windBatteryStatus': BatteryStatus(), + 'rainBatteryStatus': BatteryStatus(), + 'outTempBatteryStatus': BatteryStatus(), + 'inTempBatteryStatus': BatteryStatus(), + 'consBatteryVoltage': BatteryVoltage(), + 'heatingVoltage': BatteryVoltage(), + 'supplyVoltage': BatteryVoltage(), + 'referenceVoltage': BatteryVoltage(), + 'rxCheckPercent': SignalStrength()} + + self.trim_observations(stn_dict) + + def trim_observations(self, stn_dict): + """Calculate only the specified observations, or all if none specified""" + if stn_dict.get('observations'): + desired = {x.strip() for x in stn_dict['observations']} + for obs in list(self.observations): + if obs not in desired: + del self.observations[obs] + + def genLoopPackets(self): + + while True: + + # If we are in simulator mode, sleep first (as if we are gathering + # observations). If we are in generator mode, don't sleep at all. + if self.mode == 'simulator': + # Determine how long to sleep + if self.real_time: + # We are in real time mode. Try to keep synched up with the + # wall clock + sleep_time = self.the_time + self.loop_interval - time.time() + if sleep_time > 0: + time.sleep(sleep_time) + else: + # A start time was specified, so we are not in real time. + # Just sleep the appropriate interval + time.sleep(self.loop_interval) + + # Update the simulator clock: + self.the_time += self.loop_interval + + # Because a packet represents the measurements observed over the + # time interval, we want the measurement values at the middle + # of the interval. + avg_time = self.the_time - self.loop_interval/2.0 + + _packet = {'dateTime': int(self.the_time+0.5), + 'usUnits' : weewx.US } + for obs_type in self.observations: + _packet[obs_type] = self.observations[obs_type].value_at(avg_time) + yield _packet + + def getTime(self): + return self.the_time + + @property + def hardware_name(self): + return "Simulator" + + +class Observation(object): + + def __init__(self, magnitude=1.0, average=0.0, period=96.0, phase_lag=0.0, start=None): + """Initialize an observation function. + + Args: + magnitude (float): The value at max. The range will be twice this value + average (float): The average value, averaged over a full cycle. + period (float): The cycle period in hours. + phase_lag (float): The number of hours after the start time when the + observation hits its max + start (float|None): Time zero for the observation in unix epoch time.""" + + if not start: + raise ValueError("No start time specified") + self.magnitude = magnitude + self.average = average + self.period = period * 3600.0 + self.phase_lag = phase_lag * 3600.0 + self.start = start + + def value_at(self, time_ts): + """Return the observation value at the given time. + + time_ts: The time in unix epoch time.""" + + phase = 2.0 * math.pi * (time_ts - self.start - self.phase_lag) / self.period + return self.magnitude * math.cos(phase) + self.average + + +class Rain(object): + + bucket_tip = 0.01 + + def __init__(self, rain_start=0, rain_length=1, total_rain=0.1, loop_interval=2.5): + """Initialize a rain simulator + + Args: + rain_start (float): When the rain should start in hours, relative to midnight. + rain_length (float): How long it should rain in hours. + total_rain (float): How much rain should fall. + loop_interval (float): Interval between LOOP packets. + """ + npackets = 3600 * rain_length / loop_interval + n_rain_packets = total_rain / Rain.bucket_tip + self.period = int(npackets/n_rain_packets) + self.rain_start = 3600* rain_start + self.rain_end = self.rain_start + 3600 * rain_length + self.packet_number = 0 + + def value_at(self, time_ts): + time_tt = time.localtime(time_ts) + secs_since_midnight = time_tt.tm_hour * 3600 + time_tt.tm_min * 60.0 + time_tt.tm_sec + if self.rain_start < secs_since_midnight <= self.rain_end: + amt = Rain.bucket_tip if self.packet_number % self.period == 0 else 0.0 + self.packet_number += 1 + else: + self.packet_number = 0 + amt = 0 + return amt + + +class Solar(object): + + def __init__(self, magnitude=10, solar_start=6, solar_length=12): + """Initialize a solar simulator. The simulator will follow a simple wave sine function + starting and ending at 0. The solar day starts at time solar_start and + finishes after solar_length hours. + + Args: + magnitude (float): The value at max, the range will be twice this value + solar_start (float): The decimal hour of day that observation will start + (6.75=6:45am, 6:20=6:12am) + solar_length (float): The ength of day in decimal hours + (10.75=10hr 45min, 10:10=10hr 6min) + """ + + self.magnitude = magnitude + self.solar_start = 3600 * solar_start + self.solar_end = self.solar_start + 3600 * solar_length + self.solar_length = 3600 * solar_length + + def value_at(self, time_ts): + time_tt = time.localtime(time_ts) + secs_since_midnight = time_tt.tm_hour * 3600 + time_tt.tm_min * 60.0 + time_tt.tm_sec + if self.solar_start < secs_since_midnight <= self.solar_end: + amt = self.magnitude * (1 + math.cos(math.pi * (1 + 2.0 * ((secs_since_midnight - self.solar_start) / self.solar_length - 1))))/2 + else: + amt = 0 + return amt + + +class BatteryStatus(object): + + def __init__(self, chance_of_failure=None, min_recovery_time=None): + """Initialize a battery status. + + Args: + chance_of_failure (float|None): Likeliness that the battery should fail [0,1]. If + None, then use 0.05% (about once every 30 minutes). + min_recovery_time (float|None): Minimum time until the battery recovers in seconds. + Default is to pick a random time between 300 and 1800 seconds (5-60 minutes). + """ + if chance_of_failure is None: + chance_of_failure = 0.0005 # about once every 30 minutes + if min_recovery_time is None: + min_recovery_time = random.randint(300, 1800) # 5 to 15 minutes + self.chance_of_failure = chance_of_failure + self.min_recovery_time = min_recovery_time + self.state = 0 + self.fail_ts = 0 + + def value_at(self, time_ts): + if self.state == 1: + # recover if sufficient time has passed + if time_ts - self.fail_ts > self.min_recovery_time: + self.state = 0 + else: + # see if we need a failure + if random.random() < self.chance_of_failure: + self.state = 1 + self.fail_ts = time_ts + return self.state + + +class BatteryVoltage(object): + + def __init__(self, nominal_value=None, max_variance=None): + """Initialize a battery voltage. + + Args: + nominal_value (float|None): The voltage averaged over time. Default is 12 + max_variance (float|None): How much it should fluctuate in volts. Default is 10% of + the nominal_value. + """ + if nominal_value is None: + nominal_value = 12.0 + if max_variance is None: + max_variance = 0.1 * nominal_value + self.nominal = nominal_value + self.variance = max_variance + + def value_at(self, time_ts): + return self.nominal + self.variance * random.random() * random.randint(-1, 1) + + +class SignalStrength(object): + + def __init__(self, minval=0.0, maxval=100.0): + """Initialize a signal strength simulator. + + Args: + minval (float): The minimum signal strength in percent. + maxval (float): The maximum signal strength in percent. + """ + self.minval = minval + self.maxval = maxval + self.max_variance = 0.1 * (self.maxval - self.minval) + self.value = self.minval + random.random() * (self.maxval - self.minval) + + def value_at(self, time_ts): + newval = self.value + self.max_variance * random.random() * random.randint(-1, 1) + newval = max(self.minval, newval) + newval = min(self.maxval, newval) + self.value = newval + return self.value + + +def confeditor_loader(): + return SimulatorConfEditor() + + +class SimulatorConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[Simulator] + # This section is for the weewx weather station simulator + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. Format is YYYY-mm-ddTHH:MM. If not specified, the default + # is to use the present time. + #start = 2011-01-01T00:00 + + # The driver to use: + driver = weewx.drivers.simulator +""" + + +if __name__ == "__main__": + station = Simulator(mode='simulator',loop_interval=2.0) + for packet in station.genLoopPackets(): + print(weeutil.weeutil.timestamp_to_string(packet['dateTime']), packet) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/te923.py b/dist/weewx-5.0.2/src/weewx/drivers/te923.py new file mode 100644 index 0000000..0165a22 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/te923.py @@ -0,0 +1,2426 @@ +# Copyright 2013-2024 Matthew Wall, Andrew Miles +# See the file LICENSE.txt for your full rights. +# +# Thanks to Andrew Miles for figuring out how to read history records +# and many station parameters. +# Thanks to Sebastian John for the te923tool written in C (v0.6.1): +# http://te923.fukz.org/ +# Thanks to Mark Teel for the te923 implementation in wview: +# http://www.wviewweather.com/ +# Thanks to mrbalky: +# https://github.com/mrbalky/te923/blob/master/README.md + +"""Classes and functions for interfacing with te923 weather stations. + +These stations were made by Hideki and branded as Honeywell, Meade, IROX Pro X, +Mebus TE923, and TFA Nexus. They date back to at least 2007 and are still +sold (sparsely in the US, more commonly in Europe) as of 2013. + +Apparently there are at least two different memory sizes. One version can +store about 200 records, a newer version can store about 3300 records. + +The firmware version of each component can be read by talking to the station, +assuming that the component has a wireless connection to the station, of +course. + +To force connection between station and sensors, press and hold DOWN button. + +To reset all station parameters: + - press and hold SNOOZE and UP for 4 seconds + - press SET button; main unit will beep + - wait until beeping stops + - remove batteries and wait 10 seconds + - reinstall batteries + +From the Meade TE9233W manual (TE923W-M_IM(ENG)_BK_010511.pdf): + + Remote temperature/humidty sampling interval: 10 seconds + Remote temperature/humidity transmit interval: about 47 seconds + Indoor temperature/humidity sampling interval: 10 seconds + Indoor pressure sampling interval: 20 minutes + Rain counter transmitting interval: 183 seconds + Wind direction transmitting interval: 33 seconds + Wind/Gust speed display update interval: 33 seconds + Wind/Gust sampling interval: 11 seconds + UV transmitting interval: 300 seconds + Rain counter resolution: 0.03 in (0.6578 mm) + (but console shows instead: 1/36 in (0.705556 mm)) + Battery status of each sensor is checked every hour + +This implementation polls the station for data. Use the polling_interval to +control the frequency of polling. Default is 10 seconds. + +The manual claims that a single bucket tip is 0.03 inches or 0.6578 mm but +neither matches the console display. In reality, a single bucket tip is +between 0.02 and 0.03 in (0.508 to 0.762 mm). This driver uses a value of 1/36 +inch as observed in 36 bucket tips per 1.0 inches displayed on the console. +1/36 = 0.02777778 inch = 0.705555556 mm, or 1.0725989 times larger than the +0.02589 inch = 0.6578 mm that was used prior to version 0.41.1. + +The station has altitude, latitude, longitude, and time. + +Setting the time does not persist. If you set the station time using weewx, +the station initially indicates that it is set to the new time, but then it +reverts. + +Notes From/About Other Implementations + +Apparently te923tool came first, then wview copied a bit from it. te923tool +provides more detail about the reason for invalid values, for example, values +out of range versus no link with sensors. However, these error states have not +yet been corroborated. + +There are some disagreements between the wview and te923tool implementations. + +From the te923tool: +- reading from usb in 8 byte chunks instead of all at once +- length of buffer is 35, but reads are 32-byte blocks +- windspeed and windgust state can never be -1 +- index 29 in rain count, also in wind dir + +From wview: +- wview does the 8-byte reads using interruptRead +- wview ignores the windchill value from the station +- wview treats the pressure reading as barometer (SLP), then calculates the + station pressure and altimeter pressure + +Memory Map + +0x020000 - Last sample: + +[00] = Month (Bits 0-3), Weekday (1 = Monday) (Bits 7:4) +[01] = Day +[02] = Hour +[03] = Minute +[04] ... reading as below + +0x020001 - Current readings: + +[00] = Temp In Low BCD +[01] = Temp In High BCD (Bit 5 = 0.05 deg, Bit 7 = -ve) +[02] = Humidity In +[03] = Temp Channel 1 Low (No link = Xa) +[04] = Temp Channel 1 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[05] = Humidity Channel 1 (No link = Xa) +[06] = Temp Channel 2 Low (No link = Xa) +[07] = Temp Channel 2 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[08] = Humidity Channel 2 (No link = Xa) +[09] = Temp Channel 3 Low (No link = Xa) +[10] = Temp Channel 3 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[11] = Humidity Channel 3 (No link = Xa) +[12] = Temp Channel 4 Low (No link = Xa) +[13] = Temp Channel 4 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[14] = Humidity Channel 4 (No link = Xa) +[15] = Temp Channel 5 Low (No link = Xa) +[16] = Temp Channel 5 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[17] = Humidity Channel 5 (No link = Xa) +[18] = UV Low (No link = ff) +[19] = UV High (No link = ff) +[20] = Sea-Level Pressure Low +[21] = Sea-Level Pressure High +[22] = Forecast (Bits 0-2) Storm (Bit 3) +[23] = Wind Chill Low (No link = ff) +[24] = Wind Chill High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve, No link = ff) +[25] = Gust Low (No link = ff) +[26] = Gust High (No link = ff) +[27] = Wind Low (No link = ff) +[28] = Wind High (No link = ff) +[29] = Wind Dir (Bits 0-3) +[30] = Rain Low +[31] = Rain High + +(1) Memory map values related to sensors use same coding as above +(2) Checksum are via subtraction: 0x100 - sum of all values, then add 0x100 + until positive i.e. 0x100 - 0x70 - 0x80 - 0x28 = -0x18, 0x18 + 0x100 = 0xE8 + +SECTION 1: Date & Local location + +0x000000 - Unknown - changes if date section is modified but still changes if + same data is written so not a checksum +0x000001 - Unknown (always 0) +0x000002 - Day (Reverse BCD) (Changes at midday!) +0x000003 - Unknown +0x000004 - Year (Reverse BCD) +0x000005 - Month (Bits 7:4), Weekday (Bits 3:1) +0x000006 - Latitude (degrees) (reverse BCD) +0x000007 - Latitude (minutes) (reverse BCD) +0x000008 - Longitude (degrees) (reverse BCD) +0x000009 - Longitude (minutes) (reverse BCD) +0x00000A - Bit 7 - Set if Latitude southerly + Bit 6 - Set if Longitude easterly + Bit 4 - Set if DST is always on + Bit 3 - Set if -ve TZ + Bits 0 & 1 - Set if half-hour TZ +0x00000B - Longitude (100 degrees) (Bits 7:4), DST zone (Bits 3:0) +0x00000C - City code (High) (Bits 7:4) + Language (Bits 3:0) + 0 - English + 1 - German + 2 - French + 3 - Italian + 4 - Spanish + 6 - Dutch +0x00000D - Timezone (hour) (Bits 7:4), City code (Low) (Bits 3:0) +0x00000E - Bit 2 - Set if 24hr time format + Bit 1 - Set if 12hr time format +0x00000F - Checksum of 00:0E + +SECTION 2: Time Alarms + +0x000010 - Weekday alarm (hour) (reverse BCD) + Bit 3 - Set if single alarm active + Bit 2 - Set if weekday-alarm active +0x000011 - Weekday alarm (minute) (reverse BCD) +0x000012 - Single alarm (hour) (reverse BCD) (Bit 3 - Set if pre-alarm active) +0x000013 - Single alarm (minute) (reverse BCD) +0x000014 - Bits 7-4: Pre-alarm (1-5 = 15,30,45,60 or 90 mins) + Bits 3-0: Snooze value +0x000015 - Checksum of 10:14 + +SECTION 3: Alternate Location + +0x000016 - Latitude (degrees) (reverse BCD) +0x000017 - Latitude (minutes) (reverse BCD) +0x000018 - Longitude (degrees) (reverse BCD) +0x000019 - Longitude (minutes) (reverse BCD) +0x00001A - Bit 7 - Set if Latitude southerly + Bit 6 - Set if Longitude easterly + Bit 4 - Set if DST is always on + Bit 3 - Set if -ve TZ + Bits 0 & 1 - Set if half-hour TZ +0x00001B - Longitude (100 degrees) (Bits 7:4), DST zone (Bits 3:0) +0x00001C - City code (High) (Bits 7:4), Unknown (Bits 3:0) +0x00001D - Timezone (hour) (Bits 7:4), City code (Low) (Bits 3:0) +0x00001E - Checksum of 16:1D + +SECTION 4: Temperature Alarms + +0x00001F:20 - High Temp Alarm Value +0x000021:22 - Low Temp Alarm Value +0x000023 - Checksum of 1F:22 + +SECTION 5: Min/Max 1 + +0x000024:25 - Min In Temp +0x000026:27 - Max in Temp +0x000028 - Min In Humidity +0x000029 - Max In Humidity +0x00002A:2B - Min Channel 1 Temp +0x00002C:2D - Max Channel 1 Temp +0x00002E - Min Channel 1 Humidity +0x00002F - Max Channel 1 Humidity +0x000030:31 - Min Channel 2 Temp +0x000032:33 - Max Channel 2 Temp +0x000034 - Min Channel 2 Humidity +0x000035 - Max Channel 2 Humidity +0x000036:37 - Min Channel 3 Temp +0x000038:39 - Max Channel 3 Temp +0x00003A - Min Channel 3 Humidity +0x00003B - Max Channel 3 Humidity +0x00003C:3D - Min Channel 4 Temp +0x00003F - Checksum of 24:3E + +SECTION 6: Min/Max 2 + +0x00003E,40 - Max Channel 4 Temp +0x000041 - Min Channel 4 Humidity +0x000042 - Max Channel 4 Humidity +0x000043:44 - Min Channel 4 Temp +0x000045:46 - Max Channel 4 Temp +0x000047 - Min Channel 4 Humidity +0x000048 - Max Channel 4 Humidity +0x000049 - ? Values rising/falling ? + Bit 5 : Chan 1 temp falling + Bit 2 : In temp falling +0x00004A:4B - 0xFF (Unused) +0x00004C - Battery status + Bit 7: Rain + Bit 6: Wind + Bit 5: UV + Bits 4:0: Channel 5:1 +0x00004D:58 - 0xFF (Unused) +0x000059 - Checksum of 3E:58 + +SECTION 7: Altitude + +0x00005A:5B - Altitude (Low:High) +0x00005C - Bit 3 - Set if altitude negative + Bit 2 - Pressure falling? + Bit 1 - Always set +0X00005D - Checksum of 5A:5C + +0x00005E:5F - Unused (0xFF) + +SECTION 8: Pressure 1 + +0x000060 - Month of last reading (Bits 0-3), Weekday (1 = Monday) (Bits 7:4) +0x000061 - Day of last reading +0x000062 - Hour of last reading +0x000063 - Minute of last reading +0x000064:65 - T -0 Hours +0x000066:67 - T -1 Hours +0x000068:69 - T -2 Hours +0x00006A:6B - T -3 Hours +0x00006C:6D - T -4 Hours +0x00006E:6F - T -5 Hours +0x000070:71 - T -6 Hours +0x000072:73 - T -7 Hours +0x000074:75 - T -8 Hours +0x000076:77 - T -9 Hours +0x000078:79 - T -10 Hours +0x00007B - Checksum of 60:7A + +SECTION 9: Pressure 2 + +0x00007A,7C - T -11 Hours +0x00007D:7E - T -12 Hours +0x00007F:80 - T -13 Hours +0x000081:82 - T -14 Hours +0x000083:84 - T -15 Hours +0x000085:86 - T -16 Hours +0x000087:88 - T -17 Hours +0x000089:90 - T -18 Hours +0x00008B:8C - T -19 Hours +0x00008D:8E - T -20 Hours +0x00008f:90 - T -21 Hours +0x000091:92 - T -22 Hours +0x000093:94 - T -23 Hours +0x000095:96 - T -24 Hours +0x000097 - Checksum of 7C:96 + +SECTION 10: Versions + +0x000098 - firmware versions (barometer) +0x000099 - firmware versions (uv) +0x00009A - firmware versions (rcc) +0x00009B - firmware versions (wind) +0x00009C - firmware versions (system) +0x00009D - Checksum of 98:9C + +0x00009E:9F - 0xFF (Unused) + +SECTION 11: Rain/Wind Alarms 1 + +0x0000A0 - Alarms + Bit2 - Set if rain alarm active + Bit 1 - Set if wind alarm active + Bit 0 - Set if gust alarm active +0x0000A1:A2 - Rain alarm value (High:Low) (BCD) +0x0000A3 - Unknown +0x0000A4:A5 - Wind speed alarm value +0x0000A6 - Unknown +0x0000A7:A8 - Gust alarm value +0x0000A9 - Checksum of A0:A8 + +SECTION 12: Rain/Wind Alarms 2 + +0x0000AA:AB - Max daily wind speed +0x0000AC:AD - Max daily gust speed +0x0000AE:AF - Rain bucket count (yesterday) (Low:High) +0x0000B0:B1 - Rain bucket count (week) (Low:High) +0x0000B2:B3 - Rain bucket count (month) (Low:High) +0x0000B4 - Checksum of AA:B3 + +0x0000B5:E0 - 0xFF (Unused) + +SECTION 13: Unknownn + +0x0000E1:F9 - 0x15 (Unknown) +0x0000FA - Checksum of E1:F9 + +SECTION 14: Archiving + +0x0000FB - Unknown +0x0000FC - Memory size (0 = 0x1fff, 2 = 0x20000) +0x0000FD - Number of records (High) +0x0000FE - Archive interval + 1-11 = 5, 10, 20, 30, 60, 90, 120, 180, 240, 360, 1440 mins +0x0000FF - Number of records (Low) +0x000100 - Checksum of FB:FF + +0x000101 - Start of historical records: + +[00] = Month (Bits 0-3), Weekday (1 = Monday) (Bits 7:4) +[01] = Day +[02] = Hour +[03] = Minute +[04] = Temp In Low BCD +[05] = Temp In High BCD (Bit 5 = 0.05 deg, Bit 7 = -ve) +[06] = Humidity In +[07] = Temp Channel 1 Low (No link = Xa) +[08] = Temp Channel 1 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[09] = Humidity Channel 1 (No link = Xa) +[10] = Temp Channel 2 Low (No link = Xa) +[11] = Temp Channel 2 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[12] = Humidity Channel 2 (No link = Xa) +[13] = Temp Channel 3 Low (No link = Xa) +[14] = Temp Channel 3 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[15] = Checksum of bytes 0:14 +[16] = Humidity Channel 3 (No link = Xa) +[17] = Temp Channel 4 Low (No link = Xa) +[18] = Temp Channel 4 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[19] = Humidity Channel 4 (No link = Xa) +[20] = Temp Channel 5 Low (No link = Xa) +[21] = Temp Channel 5 High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve) +[22] = Humidity Channel 5 (No link = Xa) +[23] = UV Low (No link = ff) +[24] = UV High (No link = ff) +[25] = Sea-Level Pressure Low +[26] = Sea-Level Pressure High +[27] = Forecast (Bits 0-2) Storm (Bit 3) +[28] = Wind Chill Low (No link = ff) +[29] = Wind Chill High (Bit 6 = 1, Bit 5 = 0.05 deg, Bit 7 = +ve, No link = ee) +[30] = Gust Low (No link = ff) +[31] = Gust High (No link = ff) +[32] = Wind Low (No link = ff) +[33] = Wind High (No link = ff) +[34] = Wind Dir (Bits 0-3) +[35] = Rain Low +[36] = Rain High +[37] = Checksum of bytes 16:36 + +USB Protocol + +The station shows up on the USB as a HID. Control packet is 8 bytes. + +Read from station: + 0x05 (Length) + 0xAF (Read) + Addr (Bit 17:16), Addr (Bits 15:8), Addr (Bits 7:0), CRC, Unused, Unused + +Read acknowledge: + 0x24 (Ack) + 0xAF (Read) + Addr (Bit 17:16), Addr (Bits 15:8), Addr (Bits 7:0), CRC, Unused, Unused + +Write to station: + 0x07 (Length) + 0xAE (Write) + Addr (Bit 17:16), Addr (Bits 15:8), Addr (Bits 7:0), Data1, Data2, Data3 + ... Data continue with 3 more packets of length 7 then ... + 0x02 (Length), Data32, CRC, Unused, Unused, Unused, Unused, Unused, Unused + +Reads returns 32 bytes. Write expects 32 bytes as well, but address must be +aligned to a memory-map section start address and will only write to that +section. + +Schema Additions + +The station emits more sensor data than the default schema (wview schema) can +handle. This driver includes a mapping between the sensor data and the wview +schema, plus additional fields. To use the default mapping with the wview +schema, these are the additional fields that must be added to the schema: + + ('extraTemp4', 'REAL'), + ('extraHumid3', 'REAL'), + ('extraHumid4', 'REAL'), + ('extraBatteryStatus1', 'REAL'), + ('extraBatteryStatus2', 'REAL'), + ('extraBatteryStatus3', 'REAL'), + ('extraBatteryStatus4', 'REAL'), + ('windLinkStatus', 'REAL'), + ('rainLinkStatus', 'REAL'), + ('uvLinkStatus', 'REAL'), + ('outLinkStatus', 'REAL'), + ('extraLinkStatus1', 'REAL'), + ('extraLinkStatus2', 'REAL'), + ('extraLinkStatus3', 'REAL'), + ('extraLinkStatus4', 'REAL'), + ('forecast', 'REAL'), + ('storm', 'REAL'), +""" + +# TODO: figure out how to read gauge pressure instead of slp +# TODO: figure out how to clear station memory +# TODO: add option to reset rain total + +# FIXME: set-date and sync-date do not work - something reverts the clock +# FIXME: is there any way to get rid of the bad header byte on first read? + +import logging +import time +import usb + +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'TE923' +DRIVER_VERSION = '0.41.1' + +def loader(config_dict, engine): # @UnusedVariable + return TE923Driver(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): # @UnusedVariable + return TE923Configurator() + +def confeditor_loader(): + return TE923ConfEditor() + +DEBUG_READ = 1 +DEBUG_WRITE = 1 +DEBUG_DECODE = 0 + +# map the station data to the default database schema, plus extensions +DEFAULT_MAP = { + 'windLinkStatus': 'link_wind', + 'windBatteryStatus': 'bat_wind', + 'rainLinkStatus': 'link_rain', + 'rainBatteryStatus': 'bat_rain', + 'uvLinkStatus': 'link_uv', + 'uvBatteryStatus': 'bat_uv', + 'inTemp': 't_in', + 'inHumidity': 'h_in', + 'outTemp': 't_1', + 'outHumidity': 'h_1', + 'outTempBatteryStatus': 'bat_1', + 'outLinkStatus': 'link_1', + 'extraTemp1': 't_2', + 'extraHumid1': 'h_2', + 'extraBatteryStatus1': 'bat_2', + 'extraLinkStatus1': 'link_2', + 'extraTemp2': 't_3', + 'extraHumid2': 'h_3', + 'extraBatteryStatus2': 'bat_3', + 'extraLinkStatus2': 'link_3', + 'extraTemp3': 't_4', + 'extraHumid3': 'h_4', + 'extraBatteryStatus3': 'bat_4', + 'extraLinkStatus3': 'link_4', + 'extraTemp4': 't_5', + 'extraHumid4': 'h_5', + 'extraBatteryStatus4': 'bat_5', + 'extraLinkStatus4': 'link_5' +} + + +class TE923ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[TE923] + # This section is for the Hideki TE923 series of weather stations. + + # The station model, e.g., 'Meade TE923W' or 'TFA Nexus' + model = TE923 + + # The driver to use: + driver = weewx.drivers.te923 + + # The default configuration associates the channel 1 sensor with outTemp + # and outHumidity. To change this, or to associate other channels with + # specific columns in the database schema, use the following map. + #[[sensor_map]] +%s +""" % "\n".join([" # %s = %s" % (x, DEFAULT_MAP[x]) for x in DEFAULT_MAP]) + + +class TE923Configurator(weewx.drivers.AbstractConfigurator): + LOCSTR = "CITY|USR,LONG_DEG,LONG_MIN,E|W,LAT_DEG,LAT_MIN,N|S,TZ,DST" + ALMSTR = "WEEKDAY,SINGLE,PRE_ALARM,SNOOZE,MAXTEMP,MINTEMP,RAIN,WIND,GUST" + + idx_to_interval = { + 1: "5 min", 2: "10 min", 3: "20 min", 4: "30 min", 5: "60 min", + 6: "90 min", 7: "2 hour", 8: "3 hour", 9: "4 hour", 10: "6 hour", + 11: "1 day"} + + interval_to_idx = { + "5m": 1, "10m": 2, "20m": 3, "30m": 4, "60m": 5, "90m": 6, + "2h": 7, "3h": 8, "4h": 9, "6h": 10, "1d": 11} + + forecast_dict = { + 0: 'heavy snow', + 1: 'light snow', + 2: 'heavy rain', + 3: 'light rain', + 4: 'heavy clouds', + 5: 'light clouds', + 6: 'sunny', + } + + dst_dict = { + 0: ["NO", 'None'], + 1: ["SA", 'Australian'], + 2: ["SB", 'Brazilian'], + 3: ["SC", 'Chilian'], + 4: ["SE", 'European'], + 5: ["SG", 'Eqyptian'], + 6: ["SI", 'Cuban'], + 7: ["SJ", 'Iraq and Syria'], + 8: ["SK", 'Irkutsk and Moscow'], + 9: ["SM", 'Uruguayan'], + 10: ["SN", 'Nambian'], + 11: ["SP", 'Paraguayan'], + 12: ["SQ", 'Iranian'], + 13: ["ST", 'Tasmanian'], + 14: ["SU", 'American'], + 15: ["SZ", 'New Zealand'], + } + + city_dict = { + 0: ["ADD", 3, 0, 9, 1, "N", 38, 44, "E", "Addis Ababa, Ethiopia"], + 1: ["ADL", 9.5, 1, 34, 55, "S", 138, 36, "E", "Adelaide, Australia"], + 2: ["AKR", 2, 4, 39, 55, "N", 32, 55, "E", "Ankara, Turkey"], + 3: ["ALG", 1, 0, 36, 50, "N", 3, 0, "E", "Algiers, Algeria"], + 4: ["AMS", 1, 4, 52, 22, "N", 4, 53, "E", "Amsterdam, Netherlands"], + 5: ["ARN", 1, 4, 59, 17, "N", 18, 3, "E", "Stockholm Arlanda, Sweden"], + 6: ["ASU", -3, 11, 25, 15, "S", 57, 40, "W", "Asuncion, Paraguay"], + 7: ["ATH", 2, 4, 37, 58, "N", 23, 43, "E", "Athens, Greece"], + 8: ["ATL", -5, 14, 33, 45, "N", 84, 23, "W", "Atlanta, Ga."], + 9: ["AUS", -6, 14, 30, 16, "N", 97, 44, "W", "Austin, Tex."], + 10: ["BBU", 2, 4, 44, 25, "N", 26, 7, "E", "Bucharest, Romania"], + 11: ["BCN", 1, 4, 41, 23, "N", 2, 9, "E", "Barcelona, Spain"], + 12: ["BEG", 1, 4, 44, 52, "N", 20, 32, "E", "Belgrade, Yugoslavia"], + 13: ["BEJ", 8, 0, 39, 55, "N", 116, 25, "E", "Beijing, China"], + 14: ["BER", 1, 4, 52, 30, "N", 13, 25, "E", "Berlin, Germany"], + 15: ["BHM", -6, 14, 33, 30, "N", 86, 50, "W", "Birmingham, Ala."], + 16: ["BHX", 0, 4, 52, 25, "N", 1, 55, "W", "Birmingham, England"], + 17: ["BKK", 7, 0, 13, 45, "N", 100, 30, "E", "Bangkok, Thailand"], + 18: ["BNA", -6, 14, 36, 10, "N", 86, 47, "W", "Nashville, Tenn."], + 19: ["BNE", 10, 0, 27, 29, "S", 153, 8, "E", "Brisbane, Australia"], + 20: ["BOD", 1, 4, 44, 50, "N", 0, 31, "W", "Bordeaux, France"], + 21: ["BOG", -5, 0, 4, 32, "N", 74, 15, "W", "Bogota, Colombia"], + 22: ["BOS", -5, 14, 42, 21, "N", 71, 5, "W", "Boston, Mass."], + 23: ["BRE", 1, 4, 53, 5, "N", 8, 49, "E", "Bremen, Germany"], + 24: ["BRU", 1, 4, 50, 52, "N", 4, 22, "E", "Brussels, Belgium"], + 25: ["BUA", -3, 0, 34, 35, "S", 58, 22, "W", "Buenos Aires, Argentina"], + 26: ["BUD", 1, 4, 47, 30, "N", 19, 5, "E", "Budapest, Hungary"], + 27: ["BWI", -5, 14, 39, 18, "N", 76, 38, "W", "Baltimore, Md."], + 28: ["CAI", 2, 5, 30, 2, "N", 31, 21, "E", "Cairo, Egypt"], + 29: ["CCS", -4, 0, 10, 28, "N", 67, 2, "W", "Caracas, Venezuela"], + 30: ["CCU", 5.5, 0, 22, 34, "N", 88, 24, "E", "Calcutta, India (as Kolkata)"], + 31: ["CGX", -6, 14, 41, 50, "N", 87, 37, "W", "Chicago, IL"], + 32: ["CLE", -5, 14, 41, 28, "N", 81, 37, "W", "Cleveland, Ohio"], + 33: ["CMH", -5, 14, 40, 0, "N", 83, 1, "W", "Columbus, Ohio"], + 34: ["COR", -3, 0, 31, 28, "S", 64, 10, "W", "Cordoba, Argentina"], + 35: ["CPH", 1, 4, 55, 40, "N", 12, 34, "E", "Copenhagen, Denmark"], + 36: ["CPT", 2, 0, 33, 55, "S", 18, 22, "E", "Cape Town, South Africa"], + 37: ["CUU", -6, 14, 28, 37, "N", 106, 5, "W", "Chihuahua, Mexico"], + 38: ["CVG", -5, 14, 39, 8, "N", 84, 30, "W", "Cincinnati, Ohio"], + 39: ["DAL", -6, 14, 32, 46, "N", 96, 46, "W", "Dallas, Tex."], + 40: ["DCA", -5, 14, 38, 53, "N", 77, 2, "W", "Washington, D.C."], + 41: ["DEL", 5.5, 0, 28, 35, "N", 77, 12, "E", "New Delhi, India"], + 42: ["DEN", -7, 14, 39, 45, "N", 105, 0, "W", "Denver, Colo."], + 43: ["DKR", 0, 0, 14, 40, "N", 17, 28, "W", "Dakar, Senegal"], + 44: ["DTW", -5, 14, 42, 20, "N", 83, 3, "W", "Detroit, Mich."], + 45: ["DUB", 0, 4, 53, 20, "N", 6, 15, "W", "Dublin, Ireland"], + 46: ["DUR", 2, 0, 29, 53, "S", 30, 53, "E", "Durban, South Africa"], + 47: ["ELP", -7, 14, 31, 46, "N", 106, 29, "W", "El Paso, Tex."], + 48: ["FIH", 1, 0, 4, 18, "S", 15, 17, "E", "Kinshasa, Congo"], + 49: ["FRA", 1, 4, 50, 7, "N", 8, 41, "E", "Frankfurt, Germany"], + 50: ["GLA", 0, 4, 55, 50, "N", 4, 15, "W", "Glasgow, Scotland"], + 51: ["GUA", -6, 0, 14, 37, "N", 90, 31, "W", "Guatemala City, Guatemala"], + 52: ["HAM", 1, 4, 53, 33, "N", 10, 2, "E", "Hamburg, Germany"], + 53: ["HAV", -5, 6, 23, 8, "N", 82, 23, "W", "Havana, Cuba"], + 54: ["HEL", 2, 4, 60, 10, "N", 25, 0, "E", "Helsinki, Finland"], + 55: ["HKG", 8, 0, 22, 20, "N", 114, 11, "E", "Hong Kong, China"], + 56: ["HOU", -6, 14, 29, 45, "N", 95, 21, "W", "Houston, Tex."], + 57: ["IKT", 8, 8, 52, 30, "N", 104, 20, "E", "Irkutsk, Russia"], + 58: ["IND", -5, 0, 39, 46, "N", 86, 10, "W", "Indianapolis, Ind."], + 59: ["JAX", -5, 14, 30, 22, "N", 81, 40, "W", "Jacksonville, Fla."], + 60: ["JKT", 7, 0, 6, 16, "S", 106, 48, "E", "Jakarta, Indonesia"], + 61: ["JNB", 2, 0, 26, 12, "S", 28, 4, "E", "Johannesburg, South Africa"], + 62: ["KIN", -5, 0, 17, 59, "N", 76, 49, "W", "Kingston, Jamaica"], + 63: ["KIX", 9, 0, 34, 32, "N", 135, 30, "E", "Osaka, Japan"], + 64: ["KUL", 8, 0, 3, 8, "N", 101, 42, "E", "Kuala Lumpur, Malaysia"], + 65: ["LAS", -8, 14, 36, 10, "N", 115, 12, "W", "Las Vegas, Nev."], + 66: ["LAX", -8, 14, 34, 3, "N", 118, 15, "W", "Los Angeles, Calif."], + 67: ["LIM", -5, 0, 12, 0, "S", 77, 2, "W", "Lima, Peru"], + 68: ["LIS", 0, 4, 38, 44, "N", 9, 9, "W", "Lisbon, Portugal"], + 69: ["LON", 0, 4, 51, 32, "N", 0, 5, "W", "London, England"], + 70: ["LPB", -4, 0, 16, 27, "S", 68, 22, "W", "La Paz, Bolivia"], + 71: ["LPL", 0, 4, 53, 25, "N", 3, 0, "W", "Liverpool, England"], + 72: ["LYO", 1, 4, 45, 45, "N", 4, 50, "E", "Lyon, France"], + 73: ["MAD", 1, 4, 40, 26, "N", 3, 42, "W", "Madrid, Spain"], + 74: ["MEL", 10, 1, 37, 47, "S", 144, 58, "E", "Melbourne, Australia"], + 75: ["MEM", -6, 14, 35, 9, "N", 90, 3, "W", "Memphis, Tenn."], + 76: ["MEX", -6, 14, 19, 26, "N", 99, 7, "W", "Mexico City, Mexico"], + 77: ["MIA", -5, 14, 25, 46, "N", 80, 12, "W", "Miami, Fla."], + 78: ["MIL", 1, 4, 45, 27, "N", 9, 10, "E", "Milan, Italy"], + 79: ["MKE", -6, 14, 43, 2, "N", 87, 55, "W", "Milwaukee, Wis."], + 80: ["MNL", 8, 0, 14, 35, "N", 120, 57, "E", "Manila, Philippines"], + 81: ["MOW", 3, 8, 55, 45, "N", 37, 36, "E", "Moscow, Russia"], + 82: ["MRS", 1, 4, 43, 20, "N", 5, 20, "E", "Marseille, France"], + 83: ["MSP", -6, 14, 44, 59, "N", 93, 14, "W", "Minneapolis, Minn."], + 84: ["MSY", -6, 14, 29, 57, "N", 90, 4, "W", "New Orleans, La."], + 85: ["MUC", 1, 4, 48, 8, "N", 11, 35, "E", "Munich, Germany"], + 86: ["MVD", -3, 9, 34, 53, "S", 56, 10, "W", "Montevideo, Uruguay"], + 87: ["NAP", 1, 4, 40, 50, "N", 14, 15, "E", "Naples, Italy"], + 88: ["NBO", 3, 0, 1, 25, "S", 36, 55, "E", "Nairobi, Kenya"], + 89: ["NKG", 8, 0, 32, 3, "N", 118, 53, "E", "Nanjing (Nanking), China"], + 90: ["NYC", -5, 14, 40, 47, "N", 73, 58, "W", "New York, N.Y."], + 91: ["ODS", 2, 4, 46, 27, "N", 30, 48, "E", "Odessa, Ukraine"], + 92: ["OKC", -6, 14, 35, 26, "N", 97, 28, "W", "Oklahoma City, Okla."], + 93: ["OMA", -6, 14, 41, 15, "N", 95, 56, "W", "Omaha, Neb."], + 94: ["OSL", 1, 4, 59, 57, "N", 10, 42, "E", "Oslo, Norway"], + 95: ["PAR", 1, 4, 48, 48, "N", 2, 20, "E", "Paris, France"], + 96: ["PDX", -8, 14, 45, 31, "N", 122, 41, "W", "Portland, Ore."], + 97: ["PER", 8, 0, 31, 57, "S", 115, 52, "E", "Perth, Australia"], + 98: ["PHL", -5, 14, 39, 57, "N", 75, 10, "W", "Philadelphia, Pa."], + 99: ["PHX", -7, 0, 33, 29, "N", 112, 4, "W", "Phoenix, Ariz."], + 100: ["PIT", -5, 14, 40, 27, "N", 79, 57, "W", "Pittsburgh, Pa."], + 101: ["PRG", 1, 4, 50, 5, "N", 14, 26, "E", "Prague, Czech Republic"], + 102: ["PTY", -5, 0, 8, 58, "N", 79, 32, "W", "Panama City, Panama"], + 103: ["RGN", 6.5, 0, 16, 50, "N", 96, 0, "E", "Rangoon, Myanmar"], + 104: ["RIO", -3, 2, 22, 57, "S", 43, 12, "W", "Rio de Janeiro, Brazil"], + 105: ["RKV", 0, 0, 64, 4, "N", 21, 58, "W", "Reykjavik, Iceland"], + 106: ["ROM", 1, 4, 41, 54, "N", 12, 27, "E", "Rome, Italy"], + 107: ["SAN", -8, 14, 32, 42, "N", 117, 10, "W", "San Diego, Calif."], + 108: ["SAT", -6, 14, 29, 23, "N", 98, 33, "W", "San Antonio, Tex."], + 109: ["SCL", -4, 3, 33, 28, "S", 70, 45, "W", "Santiago, Chile"], + 110: ["SEA", -8, 14, 47, 37, "N", 122, 20, "W", "Seattle, Wash."], + 111: ["SFO", -8, 14, 37, 47, "N", 122, 26, "W", "San Francisco, Calif."], + 112: ["SHA", 8, 0, 31, 10, "N", 121, 28, "E", "Shanghai, China"], + 113: ["SIN", 8, 0, 1, 14, "N", 103, 55, "E", "Singapore, Singapore"], + 114: ["SJC", -8, 14, 37, 20, "N", 121, 53, "W", "San Jose, Calif."], + 115: ["SOF", 2, 4, 42, 40, "N", 23, 20, "E", "Sofia, Bulgaria"], + 116: ["SPL", -3, 2, 23, 31, "S", 46, 31, "W", "Sao Paulo, Brazil"], + 117: ["SSA", -3, 0, 12, 56, "S", 38, 27, "W", "Salvador, Brazil"], + 118: ["STL", -6, 14, 38, 35, "N", 90, 12, "W", "St. Louis, Mo."], + 119: ["SYD", 10, 1, 34, 0, "S", 151, 0, "E", "Sydney, Australia"], + 120: ["TKO", 9, 0, 35, 40, "N", 139, 45, "E", "Tokyo, Japan"], + 121: ["TPA", -5, 14, 27, 57, "N", 82, 27, "W", "Tampa, Fla."], + 122: ["TRP", 2, 0, 32, 57, "N", 13, 12, "E", "Tripoli, Libya"], + 123: ["USR", 0, 0, 0, 0, "N", 0, 0, "W", "User defined city"], + 124: ["VAC", -8, 14, 49, 16, "N", 123, 7, "W", "Vancouver, Canada"], + 125: ["VIE", 1, 4, 48, 14, "N", 16, 20, "E", "Vienna, Austria"], + 126: ["WAW", 1, 4, 52, 14, "N", 21, 0, "E", "Warsaw, Poland"], + 127: ["YMX", -5, 14, 45, 30, "N", 73, 35, "W", "Montreal, Que., Can."], + 128: ["YOW", -5, 14, 45, 24, "N", 75, 43, "W", "Ottawa, Ont., Can."], + 129: ["YTZ", -5, 14, 43, 40, "N", 79, 24, "W", "Toronto, Ont., Can."], + 130: ["YVR", -8, 14, 49, 13, "N", 123, 6, "W", "Vancouver, B.C., Can."], + 131: ["YYC", -7, 14, 51, 1, "N", 114, 1, "W", "Calgary, Alba., Can."], + 132: ["ZRH", 1, 4, 47, 21, "N", 8, 31, "E", "Zurich, Switzerland"] + } + + @property + def version(self): + return DRIVER_VERSION + + def add_options(self, parser): + super().add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N history records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display history records since N minutes ago") + parser.add_option("--minmax", dest="minmax", action="store_true", + help="display historical min/max data") + parser.add_option("--get-date", dest="getdate", action="store_true", + help="display station date") + parser.add_option("--set-date", dest="setdate", + type=str, metavar="YEAR,MONTH,DAY", + help="set station date") + parser.add_option("--sync-date", dest="syncdate", action="store_true", + help="set station date using system clock") + parser.add_option("--get-location-local", dest="loc_local", + action="store_true", + help="display local location and timezone") + parser.add_option("--set-location-local", dest="setloc_local", + type=str, metavar=self.LOCSTR, + help="set local location and timezone") + parser.add_option("--get-location-alt", dest="loc_alt", + action="store_true", + help="display alternate location and timezone") + parser.add_option("--set-location-alt", dest="setloc_alt", + type=str, metavar=self.LOCSTR, + help="set alternate location and timezone") + parser.add_option("--get-altitude", dest="getalt", action="store_true", + help="display altitude") + parser.add_option("--set-altitude", dest="setalt", type=int, + metavar="ALT", help="set altitude (meters)") + parser.add_option("--get-alarms", dest="getalarms", + action="store_true", help="display alarms") + parser.add_option("--set-alarms", dest="setalarms", type=str, + metavar=self.ALMSTR, help="set alarm state") + parser.add_option("--get-interval", dest="getinterval", + action="store_true", help="display archive interval") + parser.add_option("--set-interval", dest="setinterval", + type=str, metavar="INTERVAL", + help="set archive interval (minutes)") + parser.add_option("--format", dest="format", + type=str, metavar="FORMAT", default='table', + help="formats include: table, dict") + + def do_options(self, options, parser, config_dict, prompt): # @UnusedVariable + if (options.format.lower() != 'table' and + options.format.lower() != 'dict'): + parser.error("Unknown format '%s'. Known formats include 'table' and 'dict'." % options.format) + + with TE923Station() as station: + if options.info is not None: + self.show_info(station, fmt=options.format) + elif options.current is not None: + self.show_current(station, fmt=options.format) + elif options.nrecords is not None: + self.show_history(station, count=options.nrecords, + fmt=options.format) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(station, ts=ts, fmt=options.format) + elif options.minmax is not None: + self.show_minmax(station) + elif options.getdate is not None: + self.show_date(station) + elif options.setdate is not None: + self.set_date(station, options.setdate) + elif options.syncdate: + self.set_date(station, None) + elif options.loc_local is not None: + self.show_location(station, 0) + elif options.setloc_local is not None: + self.set_location(station, 0, options.setloc_local) + elif options.loc_alt is not None: + self.show_location(station, 1) + elif options.setloc_alt is not None: + self.set_location(station, 1, options.setloc_alt) + elif options.getalt is not None: + self.show_altitude(station) + elif options.setalt is not None: + self.set_altitude(station, options.setalt) + elif options.getalarms is not None: + self.show_alarms(station) + elif options.setalarms is not None: + self.set_alarms(station, options.setalarms) + elif options.getinterval is not None: + self.show_interval(station) + elif options.setinterval is not None: + self.set_interval(station, options.setinterval) + + @staticmethod + def show_info(station, fmt='dict'): + print('Querying the station for the configuration...') + data = station.get_config() + TE923Configurator.print_data(data, fmt) + + @staticmethod + def show_current(station, fmt='dict'): + print('Querying the station for current weather data...') + data = station.get_readings() + TE923Configurator.print_data(data, fmt) + + @staticmethod + def show_history(station, ts=0, count=None, fmt='dict'): + print("Querying the station for historical records...") + for r in station.gen_records(ts, count): + TE923Configurator.print_data(r, fmt) + + @staticmethod + def show_minmax(station): + print("Querying the station for historical min/max data") + data = station.get_minmax() + print("Console Temperature Min : %s" % data['t_in_min']) + print("Console Temperature Max : %s" % data['t_in_max']) + print("Console Humidity Min : %s" % data['h_in_min']) + print("Console Humidity Max : %s" % data['h_in_max']) + for i in range(1, 6): + print("Channel %d Temperature Min : %s" % (i, data['t_%d_min' % i])) + print("Channel %d Temperature Max : %s" % (i, data['t_%d_max' % i])) + print("Channel %d Humidity Min : %s" % (i, data['h_%d_min' % i])) + print("Channel %d Humidity Max : %s" % (i, data['h_%d_max' % i])) + print("Wind speed max since midnight : %s" % data['windspeed_max']) + print("Wind gust max since midnight : %s" % data['windgust_max']) + print("Rain yesterday : %s" % data['rain_yesterday']) + print("Rain this week : %s" % data['rain_week']) + print("Rain this month : %s" % data['rain_month']) + print("Last Barometer reading : %s" % time.strftime( + "%Y %b %d %H:%M", time.localtime(data['barometer_ts']))) + for i in range(25): + print(" T-%02d Hours : %.1f" % (i, data['barometer_%d' % i])) + + @staticmethod + def show_date(station): + ts = station.get_date() + tt = time.localtime(ts) + print("Date: %02d/%02d/%d" % (tt[2], tt[1], tt[0])) + TE923Configurator.print_alignment() + + @staticmethod + def set_date(station, datestr): + if datestr is not None: + date_list = datestr.split(',') + if len(date_list) != 3: + print("Bad date '%s', format is YEAR,MONTH,DAY" % datestr) + return + if int(date_list[0]) < 2000 or int(date_list[0]) > 2099: + print("Year must be between 2000 and 2099 inclusive") + return + if int(date_list[1]) < 1 or int(date_list[1]) > 12: + print("Month must be between 1 and 12 inclusive") + return + if int(date_list[2]) < 1 or int(date_list[2]) > 31: + print("Day must be between 1 and 31 inclusive") + return + tt = time.localtime() + offset = 1 if tt[3] < 12 else 0 + ts = time.mktime((int(date_list[0]), int(date_list[1]), int(date_list[2]) - offset, 0, 0, 0, 0, 0, 0)) + else: + ts = time.time() + station.set_date(ts) + TE923Configurator.print_alignment() + + def show_location(self, station, loc_type): + data = station.get_loc(loc_type) + print("City : %s (%s)" % (self.city_dict[data['city_time']][9], + self.city_dict[data['city_time']][0])) + degree_sign= u'\N{DEGREE SIGN}'.encode('iso-8859-1') + print("Location : %03d%s%02d'%s %02d%s%02d'%s" % ( + data['long_deg'], degree_sign, data['long_min'], data['long_dir'], + data['lat_deg'], degree_sign, data['lat_min'], data['lat_dir'])) + if data['dst_always_on']: + print("DST : Always on") + else: + print("DST : %s (%s)" % (self.dst_dict[data['dst']][1], + self.dst_dict[data['dst']][0])) + + def set_location(self, station, loc_type, location): + dst_on = 1 + dst_index = 0 + location_list = location.split(',') + if len(location_list) == 1 and location_list[0] != "USR": + city_index = None + for idx in range(len(self.city_dict)): + if self.city_dict[idx][0] == location_list[0]: + city_index = idx + break + if city_index is None: + print("City code '%s' not recognized - consult station manual for valid city codes" % location_list[0]) + return + long_deg = self.city_dict[city_index][6] + long_min = self.city_dict[city_index][7] + long_dir = self.city_dict[city_index][8] + lat_deg = self.city_dict[city_index][3] + lat_min = self.city_dict[city_index][4] + lat_dir = self.city_dict[city_index][5] + tz_hr = int(self.city_dict[city_index][1]) + tz_min = 0 if self.city_dict[city_index][1] == int(self.city_dict[city_index][1]) else 30 + dst_on = 0 + dst_index = self.city_dict[city_index][2] + elif len(location_list) == 9 and location_list[0] == "USR": + if int(location_list[1]) < 0 or int(location_list[1]) > 180: + print("Longitude degrees must be between 0 and 180 inclusive") + return + if int(location_list[2]) < 0 or int(location_list[2]) > 180: + print("Longitude minutes must be between 0 and 59 inclusive") + return + if location_list[3] != "E" and location_list[3] != "W": + print("Longitude direction must be E or W") + return + if int(location_list[4]) < 0 or int(location_list[4]) > 180: + print("Latitude degrees must be between 0 and 90 inclusive") + return + if int(location_list[5]) < 0 or int(location_list[5]) > 180: + print("Latitude minutes must be between 0 and 59 inclusive") + return + if location_list[6] != "N" and location_list[6] != "S": + print("Longitude direction must be N or S") + return + tz_list = location_list[7].split(':') + if len(tz_list) != 2: + print("Bad timezone '%s', format is HOUR:MINUTE" % location_list[7]) + return + if int(tz_list[0]) < -12 or int(tz_list[0]) > 12: + print("Timezone hour must be between -12 and 12 inclusive") + return + if int(tz_list[1]) != 0 and int(tz_list[1]) != 30: + print("Timezone minute must be 0 or 30") + return + if location_list[8].lower() != 'on': + dst_on = 0 + dst_index = None + for idx in range(16): + if self.dst_dict[idx][0] == location_list[8]: + dst_index = idx + break + if dst_index is None: + print("DST code '%s' not recognized - consult station manual for valid DST codes" % location_list[8]) + return + else: + dst_on = 1 + dst_index = 0 + city_index = 123 # user-defined city + long_deg = int(location_list[1]) + long_min = int(location_list[2]) + long_dir = location_list[3] + lat_deg = int(location_list[4]) + lat_min = int(location_list[5]) + lat_dir = location_list[6] + tz_hr = int(tz_list[0]) + tz_min = int(tz_list[1]) + else: + print("Bad location '%s'" % location) + print("Location format is: %s" % self.LOCSTR) + return + station.set_loc(loc_type, city_index, dst_on, dst_index, tz_hr, tz_min, + lat_deg, lat_min, lat_dir, + long_deg, long_min, long_dir) + + @staticmethod + def show_altitude(station): + altitude = station.get_alt() + print("Altitude: %d meters" % altitude) + + @staticmethod + def set_altitude(station, altitude): + if altitude < -200 or altitude > 5000: + print("Altitude must be between -200 and 5000 inclusive") + return + station.set_alt(altitude) + + @staticmethod + def show_alarms(station): + data = station.get_alarms() + print("Weekday alarm : %02d:%02d (%s)" % ( + data['weekday_hour'], data['weekday_min'], data['weekday_active'])) + print("Single alarm : %02d:%02d (%s)" % ( + data['single_hour'], data['single_min'], data['single_active'])) + print("Pre-alarm : %s (%s)" % ( + data['prealarm_period'], data['prealarm_active'])) + if data['snooze'] > 0: + print("Snooze : %d mins" % data['snooze']) + else: + print("Snooze : Invalid") + print("Max Temperature Alarm : %s" % data['max_temp']) + print("Min Temperature Alarm : %s" % data['min_temp']) + print("Rain Alarm : %d mm (%s)" % ( + data['rain'], data['rain_active'])) + print("Wind Speed Alarm : %s (%s)" % ( + data['windspeed'], data['windspeed_active'])) + print("Wind Gust Alarm : %s (%s)" % ( + data['windgust'], data['windgust_active'])) + + @staticmethod + def set_alarms(station, alarm): + alarm_list = alarm.split(',') + if len(alarm_list) != 9: + print("Bad alarm '%s'" % alarm) + print("Alarm format is: %s" % TE923Configurator.ALMSTR) + return + weekday = alarm_list[0] + if weekday.lower() != 'off': + weekday_list = weekday.split(':') + if len(weekday_list) != 2: + print("Bad alarm '%s', expected HOUR:MINUTE or OFF" % weekday) + return + if int(weekday_list[0]) < 0 or int(weekday_list[0]) > 23: + print("Alarm hours must be between 0 and 23 inclusive") + return + if int(weekday_list[1]) < 0 or int(weekday_list[1]) > 59: + print("Alarm minutes must be between 0 and 59 inclusive") + return + single = alarm_list[1] + if single.lower() != 'off': + single_list = single.split(':') + if len(single_list) != 2: + print("Bad alarm '%s', expected HOUR:MINUTE or OFF" % single) + return + if int(single_list[0]) < 0 or int(single_list[0]) > 23: + print("Alarm hours must be between 0 and 23 inclusive") + return + if int(single_list[1]) < 0 or int(single_list[1]) > 59: + print("Alarm minutes must be between 0 and 59 inclusive") + return + if alarm_list[2].lower() != 'off' and alarm_list[2] not in ['15', '30', '45', '60', '90']: + print("Prealarm must be 15, 30, 45, 60, 90 or OFF") + return + if int(alarm_list[3]) < 1 or int(alarm_list[3]) > 15: + print("Snooze must be between 1 and 15 inclusive") + return + if float(alarm_list[4]) < -50 or float(alarm_list[4]) > 70: + print("Temperature alarm must be between -50 and 70 inclusive") + return + if float(alarm_list[5]) < -50 or float(alarm_list[5]) > 70: + print("Temperature alarm must be between -50 and 70 inclusive") + return + if alarm_list[6].lower() != 'off' and (int(alarm_list[6]) < 1 or int(alarm_list[6]) > 9999): + print("Rain alarm must be between 1 and 999 inclusive or OFF") + return + if alarm_list[7].lower() != 'off' and (float(alarm_list[7]) < 1 or float(alarm_list[7]) > 199): + print("Wind alarm must be between 1 and 199 inclusive or OFF") + return + if alarm_list[8].lower() != 'off' and (float(alarm_list[8]) < 1 or float(alarm_list[8]) > 199): + print("Wind alarm must be between 1 and 199 inclusive or OFF") + return + station.set_alarms(alarm_list[0], alarm_list[1], alarm_list[2], + alarm_list[3], alarm_list[4], alarm_list[5], + alarm_list[6], alarm_list[7], alarm_list[8]) + print("Temperature alarms can only be modified via station controls") + + @staticmethod + def show_interval(station): + idx = station.get_interval() + print("Interval: %s" % TE923Configurator.idx_to_interval.get(idx, 'unknown')) + + @staticmethod + def set_interval(station, interval): + """accept 30s|2h|1d format or raw minutes, but only known intervals""" + idx = TE923Configurator.interval_to_idx.get(interval) + if idx is None: + try: + ival = int(interval * 60) + for i in TE923Station.idx_to_interval_sec: + if ival == TE923Station.idx_to_interval_sec[i]: + idx = i + except ValueError: + pass + if idx is None: + print("Bad interval '%s'" % interval) + print("Valid intervals are %s" % ','.join(list(TE923Configurator.interval_to_idx.keys()))) + return + station.set_interval(idx) + + @staticmethod + def print_data(data, fmt): + if fmt.lower() == 'table': + TE923Configurator.print_table(data) + else: + print(data) + + @staticmethod + def print_table(data): + for key in sorted(data): + print("%s: %s" % (key.rjust(16), data[key])) + + @staticmethod + def print_alignment(): + print(" If computer time is not aligned to station time then date") + print(" may be incorrect by 1 day") + + +class TE923Driver(weewx.drivers.AbstractDevice): + """Driver for Hideki TE923 stations.""" + + def __init__(self, **stn_dict): + """Initialize the station object. + + polling_interval: How often to poll the station, in seconds. + [Optional. Default is 10] + + model: Which station model is this? + [Optional. Default is 'TE923'] + """ + log.info('driver version is %s' % DRIVER_VERSION) + + global DEBUG_READ + DEBUG_READ = int(stn_dict.get('debug_read', DEBUG_READ)) + global DEBUG_WRITE + DEBUG_WRITE = int(stn_dict.get('debug_write', DEBUG_WRITE)) + global DEBUG_DECODE + DEBUG_DECODE = int(stn_dict.get('debug_decode', DEBUG_DECODE)) + + self._last_rain_loop = None + self._last_rain_archive = None + self._last_ts = None + + self.model = stn_dict.get('model', 'TE923') + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.retry_wait = int(stn_dict.get('retry_wait', 3)) + self.read_timeout = int(stn_dict.get('read_timeout', 10)) + self.polling_interval = int(stn_dict.get('polling_interval', 10)) + log.info('polling interval is %s' % str(self.polling_interval)) + self.sensor_map = dict(DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('sensor map is %s' % self.sensor_map) + + self.station = TE923Station(max_tries=self.max_tries, + retry_wait=self.retry_wait, + read_timeout=self.read_timeout) + self.station.open() + log.info('logger capacity %s records' % self.station.get_memory_size()) + ts = self.station.get_date() + now = int(time.time()) + log.info('station time is %s, computer time is %s' % (ts, now)) + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + +# @property +# def archive_interval(self): +# return self.station.get_interval_seconds() + + def genLoopPackets(self): + while True: + data = self.station.get_readings() + status = self.station.get_status() + packet = self.data_to_packet(data, status=status, + last_rain=self._last_rain_loop, + sensor_map=self.sensor_map) + self._last_rain_loop = packet['rainTotal'] + yield packet + time.sleep(self.polling_interval) + + # same as genStartupRecords, but insert battery status on the last record. + # when record_generation is hardware, this results in a full suit of sensor + # data, but with the archive interval calculations done by the hardware. +# def genArchiveRecords(self, since_ts=0): +# for data in self.station.gen_records(since_ts): +# # FIXME: insert battery status on the last record +# packet = self.data_to_packet(data, status=None, +# last_rain=self._last_rain_archive, +# sensor_map=self.sensor_map) +# self._last_rain_archive = packet['rainTotal'] +# if self._last_ts: +# packet['interval'] = (packet['dateTime'] - self._last_ts) / 60 +# yield packet +# self._last_ts = packet['dateTime'] + + # there is no battery status for historical records. + def genStartupRecords(self, since_ts=0): + log.info("reading records from logger since %s" % since_ts) + cnt = 0 + for data in self.station.gen_records(since_ts): + packet = self.data_to_packet(data, status=None, + last_rain=self._last_rain_archive, + sensor_map=self.sensor_map) + self._last_rain_archive = packet['rainTotal'] + if self._last_ts: + packet['interval'] = (packet['dateTime'] - self._last_ts) // 60 + if packet['interval'] > 0: + cnt += 1 + yield packet + else: + log.info("skip packet with duplidate timestamp: %s" % packet) + self._last_ts = packet['dateTime'] + if cnt % 50 == 0: + log.info("read %s records from logger" % cnt) + log.info("read %s records from logger" % cnt) + + @staticmethod + def data_to_packet(data, status, last_rain, sensor_map): + """convert raw data to format and units required by weewx + + station weewx (metric) + temperature degree C degree C + humidity percent percent + uv index unitless unitless + slp mbar mbar + wind speed mile/h km/h + wind gust mile/h km/h + wind dir degree degree + rain mm cm + rain rate cm/h + """ + + packet = dict() + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = data['dateTime'] + + # include the link status - 0 indicates ok, 1 indicates no link + data['link_wind'] = 0 if data['windspeed_state'] == STATE_OK else 1 + data['link_rain'] = 0 if data['rain_state'] == STATE_OK else 1 + data['link_uv'] = 0 if data['uv_state'] == STATE_OK else 1 + data['link_1'] = 0 if data['t_1_state'] == STATE_OK else 1 + data['link_2'] = 0 if data['t_2_state'] == STATE_OK else 1 + data['link_3'] = 0 if data['t_3_state'] == STATE_OK else 1 + data['link_4'] = 0 if data['t_4_state'] == STATE_OK else 1 + data['link_5'] = 0 if data['t_5_state'] == STATE_OK else 1 + + # map extensible sensors to database fields + for label in sensor_map: + if sensor_map[label] in data: + packet[label] = data[sensor_map[label]] + elif status is not None and sensor_map[label] in status: + packet[label] = int(status[sensor_map[label]]) + + # handle unit converstions + packet['windSpeed'] = data.get('windspeed') + if packet['windSpeed'] is not None: + packet['windSpeed'] *= 1.60934 # speed is mph; weewx wants km/h + packet['windDir'] = data.get('winddir') + if packet['windDir'] is not None: + packet['windDir'] *= 22.5 # weewx wants degrees + + packet['windGust'] = data.get('windgust') + if packet['windGust'] is not None: + packet['windGust'] *= 1.60934 # speed is mph; weewx wants km/h + + packet['rainTotal'] = data['rain'] + if packet['rainTotal'] is not None: + packet['rainTotal'] *= 0.0705555556 # weewx wants cm (1/36 inch) + packet['rain'] = weewx.wxformulas.calculate_rain( + packet['rainTotal'], last_rain) + + # some stations report uv + packet['UV'] = data['uv'] + + # station calculates windchill + packet['windchill'] = data['windchill'] + + # station reports baromter (SLP) + packet['barometer'] = data['slp'] + + # forecast and storm fields use the station's algorithms + packet['forecast'] = data['forecast'] + packet['storm'] = data['storm'] + + return packet + + +STATE_OK = 'ok' +STATE_INVALID = 'invalid' +STATE_NO_LINK = 'no_link' + +def _fmt(buf): + if buf: + return ' '.join(["%02x" % x for x in buf]) + return '' + +def bcd2int(bcd): + return int(((bcd & 0xf0) >> 4) * 10) + int(bcd & 0x0f) + +def rev_bcd2int(bcd): + return int((bcd & 0xf0) >> 4) + int((bcd & 0x0f) * 10) + +def int2bcd(num): + return int(num / 10) * 0x10 + (num % 10) + +def rev_int2bcd(num): + return (num % 10) * 0x10 + int(num / 10) + +def decode(buf): + data = dict() + for i in range(6): # console plus 5 remote channels + data.update(decode_th(buf, i)) + data.update(decode_uv(buf)) + data.update(decode_pressure(buf)) + data.update(decode_forecast(buf)) + data.update(decode_windchill(buf)) + data.update(decode_wind(buf)) + data.update(decode_rain(buf)) + return data + +def decode_th(buf, i): + if i == 0: + tlabel = 't_in' + hlabel = 'h_in' + else: + tlabel = 't_%d' % i + hlabel = 'h_%d' % i + tstate = '%s_state' % tlabel + hstate = '%s_state' % hlabel + offset = i * 3 + + if DEBUG_DECODE: + log.debug("TH%d BUF[%02d]=%02x BUF[%02d]=%02x BUF[%02d]=%02x" % + (i, 0 + offset, buf[0 + offset], 1 + offset, buf[1 + offset], + 2 + offset, buf[2 + offset])) + data = dict() + data[tlabel], data[tstate] = decode_temp(buf[0 + offset], buf[1 + offset], + i != 0) + data[hlabel], data[hstate] = decode_humid(buf[2 + offset]) + if DEBUG_DECODE: + log.debug("TH%d %s %s %s %s" % (i, data[tlabel], data[tstate], + data[hlabel], data[hstate])) + return data + +def decode_temp(byte1, byte2, remote): + """decode temperature. result is degree C.""" + if bcd2int(byte1 & 0x0f) > 9: + if byte1 & 0x0f == 0x0a: + return None, STATE_NO_LINK + else: + return None, STATE_INVALID + if byte2 & 0x40 != 0x40 and remote: + return None, STATE_INVALID + value = bcd2int(byte1) / 10.0 + bcd2int(byte2 & 0x0f) * 10.0 + if byte2 & 0x20 == 0x20: + value += 0.05 + if byte2 & 0x80 != 0x80: + value *= -1 + return value, STATE_OK + +def decode_humid(byte): + """decode humidity. result is percentage.""" + if bcd2int(byte & 0x0f) > 9: + if byte & 0x0f == 0x0a: + return None, STATE_NO_LINK + else: + return None, STATE_INVALID + return bcd2int(byte), STATE_OK + +# NB: te923tool does not include the 4-bit shift +def decode_uv(buf): + """decode data from uv sensor""" + data = dict() + if DEBUG_DECODE: + log.debug("UVX BUF[18]=%02x BUF[19]=%02x" % (buf[18], buf[19])) + if ((buf[18] == 0xaa and buf[19] == 0x0a) or + (buf[18] == 0xff and buf[19] == 0xff)): + data['uv_state'] = STATE_NO_LINK + data['uv'] = None + elif bcd2int(buf[18]) > 99 or bcd2int(buf[19]) > 99: + data['uv_state'] = STATE_INVALID + data['uv'] = None + else: + data['uv_state'] = STATE_OK + data['uv'] = bcd2int(buf[18] & 0x0f) / 10.0 \ + + bcd2int((buf[18] & 0xf0) >> 4) \ + + bcd2int(buf[19] & 0x0f) * 10.0 + if DEBUG_DECODE: + log.debug("UVX %s %s" % (data['uv'], data['uv_state'])) + return data + +def decode_pressure(buf): + """decode pressure data""" + data = dict() + if DEBUG_DECODE: + log.debug("PRS BUF[20]=%02x BUF[21]=%02x" % (buf[20], buf[21])) + if buf[21] & 0xf0 == 0xf0: + data['slp_state'] = STATE_INVALID + data['slp'] = None + else: + data['slp_state'] = STATE_OK + data['slp'] = int(buf[21] * 0x100 + buf[20]) * 0.0625 + if DEBUG_DECODE: + log.debug("PRS %s %s" % (data['slp'], data['slp_state'])) + return data + +# NB: te923tool divides speed/gust by 2.23694 (1 meter/sec = 2.23694 mile/hour) +# NB: wview does not divide speed/gust +# NB: wview multiplies winddir by 22.5, te923tool does not +def decode_wind(buf): + """decode wind speed, gust, and direction""" + data = dict() + if DEBUG_DECODE: + log.debug("WGS BUF[25]=%02x BUF[26]=%02x" % (buf[25], buf[26])) + data['windgust'], data['windgust_state'] = decode_ws(buf[25], buf[26]) + if DEBUG_DECODE: + log.debug("WGS %s %s" % (data['windgust'], data['windgust_state'])) + + if DEBUG_DECODE: + log.debug("WSP BUF[27]=%02x BUF[28]=%02x" % (buf[27], buf[28])) + data['windspeed'], data['windspeed_state'] = decode_ws(buf[27], buf[28]) + if DEBUG_DECODE: + log.debug("WSP %s %s" % (data['windspeed'], data['windspeed_state'])) + + if DEBUG_DECODE: + log.debug("WDR BUF[29]=%02x" % buf[29]) + data['winddir_state'] = data['windspeed_state'] + data['winddir'] = int(buf[29] & 0x0f) + if DEBUG_DECODE: + log.debug("WDR %s %s" % (data['winddir'], data['winddir_state'])) + + return data + +def decode_ws(byte1, byte2): + """decode wind speed, result is mph""" + if bcd2int(byte1 & 0xf0) > 90 or bcd2int(byte1 & 0x0f) > 9: + if ((byte1 == 0xee and byte2 == 0x8e) or + (byte1 == 0xff and byte2 == 0xff)): + return None, STATE_NO_LINK + else: + return None, STATE_INVALID + offset = 100 if byte2 & 0x10 == 0x10 else 0 + value = bcd2int(byte1) / 10.0 + bcd2int(byte2 & 0x0f) * 10.0 + offset + return value, STATE_OK + +# the rain counter is in the station, not the rain bucket. so if the link +# between rain bucket and station is lost, the station will miss rainfall and +# there is no way to know about it. +# FIXME: figure out how to detect link status between station and rain bucket +# NB: wview treats the raw rain count as millimeters +def decode_rain(buf): + """rain counter is number of bucket tips, each tip is about 0.03 inches""" + data = dict() + if DEBUG_DECODE: + log.debug("RAIN BUF[30]=%02x BUF[31]=%02x" % (buf[30], buf[31])) + data['rain_state'] = STATE_OK + data['rain'] = int(buf[31] * 0x100 + buf[30]) + if DEBUG_DECODE: + log.debug("RAIN %s %s" % (data['rain'], data['rain_state'])) + return data + +def decode_windchill(buf): + data = dict() + if DEBUG_DECODE: + log.debug("WCL BUF[23]=%02x BUF[24]=%02x" % (buf[23], buf[24])) + if bcd2int(buf[23] & 0xf0) > 90 or bcd2int(buf[23] & 0x0f) > 9: + if ((buf[23] == 0xee and buf[24] == 0x8e) or + (buf[23] == 0xff and buf[24] == 0xff)): + data['windchill_state'] = STATE_NO_LINK + else: + data['windchill_state'] = STATE_INVALID + data['windchill'] = None + elif buf[24] & 0x40 != 0x40: + data['windchill_state'] = STATE_INVALID + data['windchill'] = None + else: + data['windchill_state'] = STATE_OK + data['windchill'] = bcd2int(buf[23]) / 10.0 \ + + bcd2int(buf[24] & 0x0f) * 10.0 + if buf[24] & 0x20 == 0x20: + data['windchill'] += 0.05 + if buf[24] & 0x80 != 0x80: + data['windchill'] *= -1 + if DEBUG_DECODE: + log.debug("WCL %s %s" % (data['windchill'], data['windchill_state'])) + return data + +def decode_forecast(buf): + data = dict() + if DEBUG_DECODE: + log.debug("STT BUF[22]=%02x" % buf[22]) + if buf[22] & 0x0f == 0x0f: + data['storm'] = None + data['forecast'] = None + else: + data['storm'] = 1 if buf[22] & 0x08 == 0x08 else 0 + data['forecast'] = int(buf[22] & 0x07) + if DEBUG_DECODE: + log.debug("STT %s %s" % (data['storm'], data['forecast'])) + return data + + +class BadRead(weewx.WeeWxIOError): + """Bogus data length, CRC, header block, or other read failure""" + +class BadWrite(weewx.WeeWxIOError): + """Bogus data length, header block, or other write failure""" + +class BadHeader(weewx.WeeWxIOError): + """Bad header byte""" + +class TE923Station(object): + ENDPOINT_IN = 0x81 + READ_LENGTH = 0x8 + TIMEOUT = 1200 + START_ADDRESS = 0x101 + RECORD_SIZE = 0x26 + + idx_to_interval_sec = { + 1: 300, 2: 600, 3: 1200, 4: 1800, 5: 3600, 6: 5400, 7: 7200, + 8: 10800, 9: 14400, 10: 21600, 11: 86400} + + def __init__(self, vendor_id=0x1130, product_id=0x6801, + max_tries=10, retry_wait=5, read_timeout=5): + self.vendor_id = vendor_id + self.product_id = product_id + self.devh = None + self.max_tries = max_tries + self.retry_wait = retry_wait + self.read_timeout = read_timeout + + self._num_rec = None + self._num_blk = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, type_, value, traceback): # @UnusedVariable + self.close() + + def open(self, interface=0): + dev = self._find_dev(self.vendor_id, self.product_id) + if not dev: + log.critical("Cannot find USB device with VendorID=0x%04x ProductID=0x%04x" % (self.vendor_id, self.product_id)) + raise weewx.WeeWxIOError('Unable to find station on USB') + + self.devh = dev.open() + if not self.devh: + raise weewx.WeeWxIOError('Open USB device failed') + + # be sure kernel does not claim the interface + try: + self.devh.detachKernelDriver(interface) + except (AttributeError, usb.USBError): + pass + + # attempt to claim the interface + try: + self.devh.claimInterface(interface) + self.devh.setAltInterface(interface) + except usb.USBError as e: + self.close() + log.critical("Unable to claim USB interface %s: %s" % (interface, e)) + raise weewx.WeeWxIOError(e) + +# doing a reset seems to cause problems more often than it eliminates them +# self.devh.reset() + + # figure out which type of memory this station has + self.read_memory_size() + + def close(self): + try: + self.devh.releaseInterface() + except (ValueError, usb.USBError) as e: + log.error("release interface failed: %s" % e) + self.devh = None + + @staticmethod + def _find_dev(vendor_id, product_id): + """Find the vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vendor_id and dev.idProduct == product_id: + log.info('Found device on USB bus=%s device=%s' % + (bus.dirname, dev.filename)) + return dev + return None + + def _raw_read(self, addr): + reqbuf = [0x05, 0xAF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + reqbuf[4] = addr // 0x10000 + reqbuf[3] = (addr - (reqbuf[4] * 0x10000)) // 0x100 + reqbuf[2] = addr - (reqbuf[4] * 0x10000) - (reqbuf[3] * 0x100) + reqbuf[5] = (reqbuf[1] ^ reqbuf[2] ^ reqbuf[3] ^ reqbuf[4]) + ret = self.devh.controlMsg(requestType=0x21, + request=usb.REQ_SET_CONFIGURATION, + value=0x0200, + index=0x0000, + buffer=reqbuf, + timeout=self.TIMEOUT) + if ret != 8: + raise BadRead('Unexpected response to data request: %s != 8' % ret) + +# sleeping does not seem to have any effect on the reads +# time.sleep(0.1) # te923tool is 0.3 + start_ts = time.time() + rbuf = [] + while time.time() - start_ts < self.read_timeout: + try: + buf = self.devh.interruptRead( + self.ENDPOINT_IN, self.READ_LENGTH, self.TIMEOUT) + if buf: + nbytes = buf[0] + if nbytes > 7 or nbytes > len(buf) - 1: + raise BadRead("Bogus length during read: %d" % nbytes) + rbuf.extend(buf[1:1 + nbytes]) + if len(rbuf) >= 34: + break + except usb.USBError as e: + errmsg = repr(e) + if not ('No data available' in errmsg or 'No error' in errmsg): + raise +# sleeping seems to have no effect on the reads +# time.sleep(0.009) # te923tool is 0.15 + else: + log.debug("timeout while reading: ignoring bytes: %s" % _fmt(rbuf)) + raise BadRead("Timeout after %d bytes" % len(rbuf)) + + # Send acknowledgement whether it was a good read + reqbuf = [0x24, 0xAF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + reqbuf[4] = addr // 0x10000 + reqbuf[3] = (addr - (reqbuf[4] * 0x10000)) // 0x100 + reqbuf[2] = addr - (reqbuf[4] * 0x10000) - (reqbuf[3] * 0x100) + reqbuf[5] = (reqbuf[1] ^ reqbuf[2] ^ reqbuf[3] ^ reqbuf[4]) + ret = self.devh.controlMsg(requestType=0x21, + request=usb.REQ_SET_CONFIGURATION, + value=0x0200, + index=0x0000, + buffer=reqbuf, + timeout=self.TIMEOUT) + + # now check what we got + if len(rbuf) < 34: + raise BadRead("Not enough bytes: %d < 34" % len(rbuf)) + # there must be a header byte... + if rbuf[0] != 0x5a: + raise BadHeader("Bad header byte: %02x != %02x" % (rbuf[0], 0x5a)) + # ...and the last byte must be a valid crc + crc = 0x00 + for x in rbuf[:33]: + crc = crc ^ x + if crc != rbuf[33]: + raise BadRead("Bad crc: %02x != %02x" % (crc, rbuf[33])) + + # early versions of this driver used to get long reads, but these + # might not happen anymore. log it then try to use the data anyway. + if len(rbuf) != 34: + log.info("read: wrong number of bytes: %d != 34" % len(rbuf)) + + return rbuf + + def _raw_write(self, addr, buf): + wbuf = [0] * 38 + wbuf[0] = 0xAE + wbuf[3] = addr // 0x10000 + wbuf[2] = (addr - (wbuf[3] * 0x10000)) // 0x100 + wbuf[1] = addr - (wbuf[3] * 0x10000) - (wbuf[2] * 0x100) + crc = wbuf[0] ^ wbuf[1] ^ wbuf[2] ^ wbuf[3] + for i in range(32): + wbuf[i + 4] = buf[i] + crc = crc ^ buf[i] + wbuf[36] = crc + for i in range(6): + if i == 5: + reqbuf = [0x2, + wbuf[i * 7], wbuf[1 + i * 7], + 0x00, 0x00, 0x00, 0x00, 0x00] + else: + reqbuf = [0x7, + wbuf[i * 7], wbuf[1 + i * 7], wbuf[2 + i * 7], + wbuf[3 + i * 7], wbuf[4 + i * 7], wbuf[5 + i * 7], + wbuf[6 + i * 7]] + if DEBUG_WRITE: + log.debug("write: %s" % _fmt(reqbuf)) + ret = self.devh.controlMsg(requestType=0x21, + request=usb.REQ_SET_CONFIGURATION, + value=0x0200, + index=0x0000, + buffer=reqbuf, + timeout=self.TIMEOUT) + if ret != 8: + raise BadWrite('Unexpected response: %s != 8' % ret) + + # Wait for acknowledgement + time.sleep(0.1) + start_ts = time.time() + rbuf = [] + while time.time() - start_ts < 5: + try: + tmpbuf = self.devh.interruptRead( + self.ENDPOINT_IN, self.READ_LENGTH, self.TIMEOUT) + if tmpbuf: + nbytes = tmpbuf[0] + if nbytes > 7 or nbytes > len(tmpbuf) - 1: + raise BadRead("Bogus length during read: %d" % nbytes) + rbuf.extend(tmpbuf[1:1 + nbytes]) + if len(rbuf) >= 1: + break + except usb.USBError as e: + errmsg = repr(e) + if not ('No data available' in errmsg or 'No error' in errmsg): + raise + time.sleep(0.009) + else: + raise BadWrite("Timeout after %d bytes" % len(rbuf)) + + if len(rbuf) != 1: + log.info("write: ack got wrong number of bytes: %d != 1" % len(rbuf)) + if len(rbuf) == 0: + raise BadWrite("Bad ack: zero length response") + elif rbuf[0] != 0x5a: + raise BadHeader("Bad header byte: %02x != %02x" % (rbuf[0], 0x5a)) + + def _read(self, addr): + """raw_read returns the entire 34-byte chunk, i.e., one header byte, + 32 data bytes, one checksum byte. this function simply returns it.""" + # FIXME: strip the header and checksum so that we return only the + # 32 bytes of data. this will require shifting every index + # pretty much everywhere else in this code. + if DEBUG_READ: + log.debug("read: address 0x%06x" % addr) + for cnt in range(self.max_tries): + try: + buf = self._raw_read(addr) + if DEBUG_READ: + log.debug("read: %s" % _fmt(buf)) + return buf + except (BadRead, BadHeader, usb.USBError) as e: + log.error("Failed attempt %d of %d to read data: %s" % + (cnt + 1, self.max_tries, e)) + log.debug("Waiting %d seconds before retry" % self.retry_wait) + time.sleep(self.retry_wait) + else: + raise weewx.RetriesExceeded("Read failed after %d tries" % + self.max_tries) + + def _write(self, addr, buf): + if DEBUG_WRITE: + log.debug("write: address 0x%06x: %s" % (addr, _fmt(buf))) + for cnt in range(self.max_tries): + try: + self._raw_write(addr, buf) + return + except (BadWrite, BadHeader, usb.USBError) as e: + log.error("Failed attempt %d of %d to write data: %s" % + (cnt + 1, self.max_tries, e)) + log.debug("Waiting %d seconds before retry" % self.retry_wait) + time.sleep(self.retry_wait) + else: + raise weewx.RetriesExceeded("Write failed after %d tries" % + self.max_tries) + + def read_memory_size(self): + buf = self._read(0xfc) + if DEBUG_DECODE: + log.debug("MEM BUF[1]=%s" % buf[1]) + if buf[1] == 0: + self._num_rec = 208 + self._num_blk = 256 + log.debug("detected small memory size") + elif buf[1] == 2: + self._num_rec = 3442 + self._num_blk = 4096 + log.debug("detected large memory size") + else: + msg = "Unrecognised memory size '%s'" % buf[1] + log.error(msg) + raise weewx.WeeWxIOError(msg) + + def get_memory_size(self): + return self._num_rec + + def gen_blocks(self, count=None): + """generator that returns consecutive blocks of station memory""" + if not count: + count = self._num_blk + for x in range(0, count * 32, 32): + buf = self._read(x) + yield x, buf + + def dump_memory(self): + for i in range(8): + buf = self._read(i * 32) + for j in range(4): + log.info("%02x : %02x %02x %02x %02x %02x %02x %02x %02x" % + (i * 32 + j * 8, buf[1 + j * 8], buf[2 + j * 8], + buf[3 + j * 8], buf[4 + j * 8], buf[5 + j * 8], + buf[6 + j * 8], buf[7 + j * 8], buf[8 + j * 8])) + + def get_config(self): + data = dict() + data.update(self.get_versions()) + data.update(self.get_status()) + data['latitude'], data['longitude'] = self.get_location() + data['altitude'] = self.get_altitude() + return data + + def get_versions(self): + data = dict() + buf = self._read(0x98) + if DEBUG_DECODE: + log.debug("VER BUF[1]=%s BUF[2]=%s BUF[3]=%s BUF[4]=%s BUF[5]=%s" % + (buf[1], buf[2], buf[3], buf[4], buf[5])) + data['version_bar'] = buf[1] + data['version_uv'] = buf[2] + data['version_rcc'] = buf[3] + data['version_wind'] = buf[4] + data['version_sys'] = buf[5] + if DEBUG_DECODE: + log.debug("VER bar=%s uv=%s rcc=%s wind=%s sys=%s" % + (data['version_bar'], data['version_uv'], + data['version_rcc'], data['version_wind'], + data['version_sys'])) + return data + + def get_status(self): + # map the battery status flags. 0 indicates ok, 1 indicates failure. + # FIXME: i get 1 for uv even when no uv link + # FIXME: i get 0 for th3, th4, th5 even when no link + status = dict() + buf = self._read(0x4c) + if DEBUG_DECODE: + log.debug("BAT BUF[1]=%02x" % buf[1]) + status['bat_rain'] = 0 if buf[1] & 0x80 == 0x80 else 1 + status['bat_wind'] = 0 if buf[1] & 0x40 == 0x40 else 1 + status['bat_uv'] = 0 if buf[1] & 0x20 == 0x20 else 1 + status['bat_5'] = 0 if buf[1] & 0x10 == 0x10 else 1 + status['bat_4'] = 0 if buf[1] & 0x08 == 0x08 else 1 + status['bat_3'] = 0 if buf[1] & 0x04 == 0x04 else 1 + status['bat_2'] = 0 if buf[1] & 0x02 == 0x02 else 1 + status['bat_1'] = 0 if buf[1] & 0x01 == 0x01 else 1 + if DEBUG_DECODE: + log.debug("BAT rain=%s wind=%s uv=%s th5=%s th4=%s th3=%s th2=%s th1=%s" % + (status['bat_rain'], status['bat_wind'], status['bat_uv'], + status['bat_5'], status['bat_4'], status['bat_3'], + status['bat_2'], status['bat_1'])) + return status + + # FIXME: is this any different than get_alt? + def get_altitude(self): + buf = self._read(0x5a) + if DEBUG_DECODE: + log.debug("ALT BUF[1]=%02x BUF[2]=%02x BUF[3]=%02x" % + (buf[1], buf[2], buf[3])) + altitude = buf[2] * 0x100 + buf[1] + if buf[3] & 0x8 == 0x8: + altitude *= -1 + if DEBUG_DECODE: + log.debug("ALT %s" % altitude) + return altitude + + # FIXME: is this any different than get_loc? + def get_location(self): + buf = self._read(0x06) + if DEBUG_DECODE: + log.debug("LOC BUF[1]=%02x BUF[2]=%02x BUF[3]=%02x BUF[4]=%02x BUF[5]=%02x BUF[6]=%02x" % (buf[1], buf[2], buf[3], buf[4], buf[5], buf[6])) + latitude = float(rev_bcd2int(buf[1])) + (float(rev_bcd2int(buf[2])) / 60) + if buf[5] & 0x80 == 0x80: + latitude *= -1 + longitude = float((buf[6] & 0xf0) // 0x10 * 100) + float(rev_bcd2int(buf[3])) + (float(rev_bcd2int(buf[4])) / 60) + if buf[5] & 0x40 == 0x00: + longitude *= -1 + if DEBUG_DECODE: + log.debug("LOC %s %s" % (latitude, longitude)) + return latitude, longitude + + def get_readings(self): + """get sensor readings from the station, return as dictionary""" + buf = self._read(0x020001) + data = decode(buf[1:]) + data['dateTime'] = int(time.time() + 0.5) + return data + + def _get_next_index(self): + """get the index of the next history record""" + buf = self._read(0xfb) + if DEBUG_DECODE: + log.debug("HIS BUF[3]=%02x BUF[5]=%02x" % (buf[3], buf[5])) + record_index = buf[3] * 0x100 + buf[5] + log.debug("record_index=%s" % record_index) + if record_index > self._num_rec: + msg = "record index of %d exceeds memory size of %d records" % ( + record_index, self._num_rec) + log.error(msg) + raise weewx.WeeWxIOError(msg) + return record_index + + def _get_starting_addr(self, requested): + """calculate the oldest and latest addresses""" + count = requested + if count is None: + count = self._num_rec + elif count > self._num_rec: + count = self._num_rec + log.info("too many records requested (%d), using %d instead" % + (requested, count)) + idx = self._get_next_index() + if idx < 1: + idx += self._num_rec + latest_addr = self.START_ADDRESS + (idx - 1) * self.RECORD_SIZE + oldest_addr = latest_addr - (count - 1) * self.RECORD_SIZE + log.debug("count=%s oldest_addr=0x%06x latest_addr=0x%06x" % + (count, oldest_addr, latest_addr)) + return oldest_addr, count + + def gen_records(self, since_ts=0, requested=None): + """return requested records from station from oldest to newest. If + since_ts is specified, then all records since that time. If requested + is specified, then at most that many most recent records. If both + are specified then at most requested records newer than the timestamp. + + Each historical record is 38 bytes (0x26) long. Records start at + memory address 0x101 (257). The index of the record after the latest + is at address 0xfc:0xff (253:255), indicating the offset from the + starting address. + + On small memory stations, the last 32 bytes of memory are never used. + On large memory stations, the last 20 bytes of memory are never used. + """ + + log.debug("gen_records: since_ts=%s requested=%s" % (since_ts, requested)) + # we need the current year and month since station does not track year + start_ts = time.time() + tt = time.localtime(start_ts) + # get the archive interval for use in calculations later + arcint = self.get_interval_seconds() + # if nothing specified, get everything since time began + if since_ts is None: + since_ts = 0 + # if no count specified, use interval to estimate number of records + if requested is None: + requested = int((start_ts - since_ts) / arcint) + requested += 1 # safety margin + # get the starting address for what we want to read, plus actual count + oldest_addr, count = self._get_starting_addr(requested) + # inner loop reads records, outer loop catches any added while reading + more_records = True + while more_records: + n = 0 + while n < count: + addr = oldest_addr + n * self.RECORD_SIZE + if addr < self.START_ADDRESS: + addr += self._num_rec * self.RECORD_SIZE + record = self.get_record(addr, tt.tm_year, tt.tm_mon) + n += 1 + msg = "record %d of %d addr=0x%06x" % (n, count, addr) + if record and record['dateTime'] > since_ts: + msg += " %s" % timestamp_to_string(record['dateTime']) + log.debug("gen_records: yield %s" % msg) + yield record + else: + if record: + msg += " since_ts=%d %s" % ( + since_ts, timestamp_to_string(record['dateTime'])) + log.debug("gen_records: skip %s" % msg) + # insert a sleep to simulate slow reads +# time.sleep(5) + + # see if reading has taken so much time that more records have + # arrived. read whatever records have come in since the read began. + now = time.time() + if now - start_ts > arcint: + newreq = int((now - start_ts) / arcint) + newreq += 1 # safety margin + log.debug("gen_records: reading %d more records" % newreq) + oldest_addr, count = self._get_starting_addr(newreq) + start_ts = now + else: + more_records = False + + def get_record(self, addr, now_year, now_month): + """Return a single record from station.""" + + log.debug("get_record at address 0x%06x (year=%s month=%s)" % + (addr, now_year, now_month)) + buf = self._read(addr) + if DEBUG_DECODE: + log.debug("REC %02x %02x %02x %02x" % + (buf[1], buf[2], buf[3], buf[4])) + if buf[1] == 0xff: + log.debug("get_record: no data at address 0x%06x" % addr) + return None + + year = now_year + month = buf[1] & 0x0f + if month > now_month: + year -= 1 + day = bcd2int(buf[2]) + hour = bcd2int(buf[3]) + minute = bcd2int(buf[4]) + ts = time.mktime((year, month, day, hour, minute, 0, 0, 0, -1)) + if DEBUG_DECODE: + log.debug("REC %d/%02d/%02d %02d:%02d = %d" % + (year, month, day, hour, minute, ts)) + + tmpbuf = buf[5:16] + buf = self._read(addr + 0x10) + tmpbuf.extend(buf[1:22]) + + data = decode(tmpbuf) + data['dateTime'] = int(ts) + log.debug("get_record: found record %s" % data) + return data + + def _read_minmax(self): + buf = self._read(0x24) + tmpbuf = self._read(0x40) + buf[28:37] = tmpbuf[1:10] + tmpbuf = self._read(0xaa) + buf[37:47] = tmpbuf[1:11] + tmpbuf = self._read(0x60) + buf[47:74] = tmpbuf[1:28] + tmpbuf = self._read(0x7c) + buf[74:101] = tmpbuf[1:28] + return buf + + def get_minmax(self): + buf = self._read_minmax() + data = dict() + data['t_in_min'], _ = decode_temp(buf[1], buf[2], 0) + data['t_in_max'], _ = decode_temp(buf[3], buf[4], 0) + data['h_in_min'], _ = decode_humid(buf[5]) + data['h_in_max'], _ = decode_humid(buf[6]) + for i in range(5): + label = 't_%d_%%s' % (i + 1) + data[label % 'min'], _ = decode_temp(buf[7+i*6], buf[8 +i*6], 1) + data[label % 'max'], _ = decode_temp(buf[9+i*6], buf[10+i*6], 1) + label = 'h_%d_%%s' % (i + 1) + data[label % 'min'], _ = decode_humid(buf[11+i*6]) + data[label % 'max'], _ = decode_humid(buf[12+i*6]) + data['windspeed_max'], _ = decode_ws(buf[37], buf[38]) + data['windgust_max'], _ = decode_ws(buf[39], buf[40]) + # not sure if this is the correct units here... + data['rain_yesterday'] = (buf[42] * 0x100 + buf[41]) * 0.705555556 + data['rain_week'] = (buf[44] * 0x100 + buf[43]) * 0.705555556 + data['rain_month'] = (buf[46] * 0x100 + buf[45]) * 0.705555556 + tt = time.localtime() + offset = 1 if tt[3] < 12 else 0 + month = bcd2int(buf[47] & 0xf) + day = bcd2int(buf[48]) + hour = bcd2int(buf[49]) + minute = bcd2int(buf[50]) + year = tt.tm_year + if month > tt.tm_mon: + year -= 1 + ts = time.mktime((year, month, day - offset, hour, minute, 0, 0, 0, 0)) + data['barometer_ts'] = ts + for i in range(25): + data['barometer_%d' % i] = (buf[52+i*2]*0x100 + buf[51+i*2])*0.0625 + return data + + def _read_date(self): + buf = self._read(0x0) + return buf[1:33] + + def _write_date(self, buf): + self._write(0x0, buf) + + def get_date(self): + tt = time.localtime() + offset = 1 if tt[3] < 12 else 0 + buf = self._read_date() + day = rev_bcd2int(buf[2]) + month = (buf[5] & 0xF0) // 0x10 + year = rev_bcd2int(buf[4]) + 2000 + ts = time.mktime((year, month, day + offset, 0, 0, 0, 0, 0, 0)) + return ts + + def set_date(self, ts): + tt = time.localtime(ts) + buf = self._read_date() + buf[2] = rev_int2bcd(tt[2]) + buf[4] = rev_int2bcd(tt[0] - 2000) + buf[5] = tt[1] * 0x10 + (tt[6] + 1) * 2 + (buf[5] & 1) + buf[15] = self._checksum(buf[0:15]) + self._write_date(buf) + + def _read_loc(self, loc_type): + addr = 0x0 if loc_type == 0 else 0x16 + buf = self._read(addr) + return buf[1:33] + + def _write_loc(self, loc_type, buf): + addr = 0x0 if loc_type == 0 else 0x16 + self._write(addr, buf) + + def get_loc(self, loc_type): + buf = self._read_loc(loc_type) + offset = 6 if loc_type == 0 else 0 + data = dict() + data['city_time'] = (buf[6 + offset] & 0xF0) + (buf[7 + offset] & 0xF) + data['lat_deg'] = rev_bcd2int(buf[0 + offset]) + data['lat_min'] = rev_bcd2int(buf[1 + offset]) + data['lat_dir'] = "S" if buf[4 + offset] & 0x80 == 0x80 else "N" + data['long_deg'] = (buf[5 + offset] & 0xF0) // 0x10 * 100 + rev_bcd2int(buf[2 + offset]) + data['long_min'] = rev_bcd2int(buf[3 + offset]) + data['long_dir'] = "E" if buf[4 + offset] & 0x40 == 0x40 else "W" + data['tz_hr'] = (buf[7 + offset] & 0xF0) // 0x10 + if buf[4 + offset] & 0x8 == 0x8: + data['tz_hr'] *= -1 + data['tz_min'] = 30 if buf[4 + offset] & 0x3 == 0x3 else 0 + if buf[4 + offset] & 0x10 == 0x10: + data['dst_always_on'] = True + else: + data['dst_always_on'] = False + data['dst'] = buf[5 + offset] & 0xf + return data + + def set_loc(self, loc_type, city_index, dst_on, dst_index, tz_hr, tz_min, + lat_deg, lat_min, lat_dir, long_deg, long_min, long_dir): + buf = self._read_loc(loc_type) + offset = 6 if loc_type == 0 else 0 + buf[0 + offset] = rev_int2bcd(lat_deg) + buf[1 + offset] = rev_int2bcd(lat_min) + buf[2 + offset] = rev_int2bcd(long_deg % 100) + buf[3 + offset] = rev_int2bcd(long_min) + buf[4 + offset] = (lat_dir == "S") * 0x80 + (long_dir == "E") * 0x40 + (tz_hr < 0) + dst_on * 0x10 * 0x8 + (tz_min == 30) * 3 + buf[5 + offset] = (long_deg > 99) * 0x10 + dst_index + buf[6 + offset] = (buf[28] & 0x0F) + int(city_index / 0x10) * 0x10 + buf[7 + offset] = city_index % 0x10 + abs(tz_hr) * 0x10 + if loc_type == 0: + buf[15] = self._checksum(buf[0:15]) + else: + buf[8] = self._checksum(buf[0:8]) + self._write_loc(loc_type, buf) + + def _read_alt(self): + buf = self._read(0x5a) + return buf[1:33] + + def _write_alt(self, buf): + self._write(0x5a, buf) + + def get_alt(self): + buf = self._read_alt() + altitude = buf[1] * 0x100 + buf[0] + if buf[3] & 0x8 == 0x8: + altitude *= -1 + return altitude + + def set_alt(self, altitude): + buf = self._read_alt() + buf[0] = abs(altitude) & 0xff + buf[1] = abs(altitude) // 0x100 + buf[2] = buf[2] & 0x7 + (altitude < 0) * 0x8 + buf[3] = self._checksum(buf[0:3]) + self._write_alt(buf) + + def _read_alarms(self): + buf = self._read(0x10) + tmpbuf = self._read(0x1F) + buf[33:65] = tmpbuf[1:33] + tmpbuf = self._read(0xA0) + buf[65:97] = tmpbuf[1:33] + return buf[1:97] + + def _write_alarms(self, buf): + self._write(0x10, buf[0:32]) + self._write(0x1F, buf[32:64]) + self._write(0xA0, buf[64:96]) + + def get_alarms(self): + buf = self._read_alarms() + data = dict() + data['weekday_active'] = buf[0] & 0x4 == 0x4 + data['single_active'] = buf[0] & 0x8 == 0x8 + data['prealarm_active'] = buf[2] & 0x8 == 0x8 + data['weekday_hour'] = rev_bcd2int(buf[0] & 0xF1) + data['weekday_min'] = rev_bcd2int(buf[1]) + data['single_hour'] = rev_bcd2int(buf[2] & 0xF1) + data['single_min'] = rev_bcd2int(buf[3]) + data['prealarm_period'] = (buf[4] & 0xF0) // 0x10 + data['snooze'] = buf[4] & 0xF + data['max_temp'], _ = decode_temp(buf[32], buf[33], 0) + data['min_temp'], _ = decode_temp(buf[34], buf[35], 0) + data['rain_active'] = buf[64] & 0x4 == 0x4 + data['windspeed_active'] = buf[64] & 0x2 == 0x2 + data['windgust_active'] = buf[64] & 0x1 == 0x1 + data['rain'] = bcd2int(buf[66]) * 100 + bcd2int(buf[65]) + data['windspeed'], _ = decode_ws(buf[68], buf[69]) + data['windgust'], _ = decode_ws(buf[71], buf[72]) + return data + + def set_alarms(self, weekday, single, prealarm, snooze, + maxtemp, mintemp, rain, wind, gust): + buf = self._read_alarms() + if weekday.lower() != 'off': + weekday_list = weekday.split(':') + buf[0] = rev_int2bcd(int(weekday_list[0])) | 0x4 + buf[1] = rev_int2bcd(int(weekday_list[1])) + else: + buf[0] &= 0xFB + if single.lower() != 'off': + single_list = single.split(':') + buf[2] = rev_int2bcd(int(single_list[0])) + buf[3] = rev_int2bcd(int(single_list[1])) + buf[0] |= 0x8 + else: + buf[0] &= 0xF7 + if (prealarm.lower() != 'off' and + (weekday.lower() != 'off' or single.lower() != 'off')): + if int(prealarm) == 15: + buf[4] = 0x10 + elif int(prealarm) == 30: + buf[4] = 0x20 + elif int(prealarm) == 45: + buf[4] = 0x30 + elif int(prealarm) == 60: + buf[4] = 0x40 + elif int(prealarm) == 90: + buf[4] = 0x50 + buf[2] |= 0x8 + else: + buf[2] &= 0xF7 + buf[4] = (buf[4] & 0xF0) + int(snooze) + buf[5] = self._checksum(buf[0:5]) + + buf[32] = int2bcd(int(abs(float(maxtemp)) * 10) % 100) + buf[33] = int2bcd(int(abs(float(maxtemp)) / 10)) + if float(maxtemp) >= 0: + buf[33] |= 0x80 + if (abs(float(maxtemp)) * 100) % 10 == 5: + buf[33] |= 0x20 + buf[34] = int2bcd(int(abs(float(mintemp)) * 10) % 100) + buf[35] = int2bcd(int(abs(float(mintemp)) / 10)) + if float(mintemp) >= 0: + buf[35] |= 0x80 + if (abs(float(mintemp)) * 100) % 10 == 5: + buf[35] |= 0x20 + buf[36] = self._checksum(buf[32:36]) + + if rain.lower() != 'off': + buf[65] = int2bcd(int(rain) % 100) + buf[66] = int2bcd(int(int(rain) / 100)) + buf[64] |= 0x4 + else: + buf[64] = buf[64] & 0xFB + if wind.lower() != 'off': + buf[68] = int2bcd(int(float(wind) * 10) % 100) + buf[69] = int2bcd(int(float(wind) / 10)) + buf[64] |= 0x2 + else: + buf[64] = buf[64] & 0xFD + if gust.lower() != 'off': + buf[71] = int2bcd(int(float(gust) * 10) % 100) + buf[72] = int2bcd(int(float(gust) / 10)) + buf[64] |= 0x1 + else: + buf[64] |= 0xFE + buf[73] = self._checksum(buf[64:73]) + self._write_alarms(buf) + + def get_interval(self): + buf = self._read(0xFE) + return buf[1] + + def get_interval_seconds(self): + idx = self.get_interval() + interval = self.idx_to_interval_sec.get(idx) + if interval is None: + msg = "Unrecognized archive interval '%s'" % idx + log.error(msg) + raise weewx.WeeWxIOError(msg) + return interval + + def set_interval(self, idx): + buf = self._read(0xFE) + buf = buf[1:33] + buf[0] = idx + self._write(0xFE, buf) + + @staticmethod + def _checksum(buf): + crc = 0x100 + for i in range(len(buf)): + crc -= buf[i] + if crc < 0: + crc += 0x100 + return crc + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/te923.py +# +# by default, output matches that of te923tool +# te923con display current weather readings +# te923con -d dump 208 memory records +# te923con -s display station status +# +# date; PYTHONPATH=bin python bin/user/te923.py --records 0 > c; date +# 91s +# Thu Dec 10 00:12:59 EST 2015 +# Thu Dec 10 00:14:30 EST 2015 +# date; PYTHONPATH=bin python bin/weewx/drivers/te923.py --records 0 > b; date +# 531s +# Tue Nov 26 10:37:36 EST 2013 +# Tue Nov 26 10:46:27 EST 2013 +# date; /home/mwall/src/te923tool-0.6.1/te923con -d > a; date +# 53s +# Tue Nov 26 10:46:52 EST 2013 +# Tue Nov 26 10:47:45 EST 2013 + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + FMT_TE923TOOL = 'te923tool' + FMT_DICT = 'dict' + FMT_TABLE = 'table' + + usage = """%prog [options] [--debug] [--help]""" + + def main(): + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='display diagnostic information while running') + parser.add_option('--status', dest='status', action='store_true', + help='display station status') + parser.add_option('--readings', dest='readings', action='store_true', + help='display sensor readings') + parser.add_option("--records", dest="records", type=int, metavar="N", + help="display N station records, oldest to newest") + parser.add_option('--blocks', dest='blocks', type=int, metavar="N", + help='display N 32-byte blocks of station memory') + parser.add_option("--format", dest="format", type=str,metavar="FORMAT", + default=FMT_TE923TOOL, + help="format for output: te923tool, table, or dict") + (options, _) = parser.parse_args() + + if options.version: + print("te923 driver version %s" % DRIVER_VERSION) + exit(1) + + if options.debug: + weewx.debug = 1 + + weeutil.logger.setup('wee_te923') + + if (options.format.lower() != FMT_TE923TOOL and + options.format.lower() != FMT_TABLE and + options.format.lower() != FMT_DICT): + print("Unknown format '%s'. Known formats include: %s" % ( + options.format, ','.join([FMT_TE923TOOL, FMT_TABLE, FMT_DICT]))) + exit(1) + + with TE923Station() as station: + if options.status: + data = station.get_versions() + data.update(station.get_status()) + if options.format.lower() == FMT_TE923TOOL: + print_status(data) + else: + print_data(data, options.format) + if options.readings: + data = station.get_readings() + if options.format.lower() == FMT_TE923TOOL: + print_readings(data) + else: + print_data(data, options.format) + if options.records is not None: + for data in station.gen_records(requested=options.records): + if options.format.lower() == FMT_TE923TOOL: + print_readings(data) + else: + print_data(data, options.format) + if options.blocks is not None: + for ptr, block in station.gen_blocks(count=options.blocks): + print_hex(ptr, block) + + def print_data(data, fmt): + if fmt.lower() == FMT_TABLE: + print_table(data) + else: + print(data) + + def print_hex(ptr, data): + print("0x%06x %s" % (ptr, _fmt(data))) + + def print_table(data): + """output entire dictionary contents in two columns""" + for key in sorted(data): + print("%s: %s" % (key.rjust(16), data[key])) + + def print_status(data): + """output status fields in te923tool format""" + print("0x%x:0x%x:0x%x:0x%x:0x%x:%d:%d:%d:%d:%d:%d:%d:%d" % ( + data['version_sys'], data['version_bar'], data['version_uv'], + data['version_rcc'], data['version_wind'], + data['bat_rain'], data['bat_uv'], data['bat_wind'], data['bat_5'], + data['bat_4'], data['bat_3'], data['bat_2'], data['bat_1'])) + + def print_readings(data): + """output sensor readings in te923tool format""" + output = [str(data['dateTime'])] + output.append(getvalue(data, 't_in', '%0.2f')) + output.append(getvalue(data, 'h_in', '%d')) + for i in range(1, 6): + output.append(getvalue(data, 't_%d' % i, '%0.2f')) + output.append(getvalue(data, 'h_%d' % i, '%d')) + output.append(getvalue(data, 'slp', '%0.1f')) + output.append(getvalue(data, 'uv', '%0.1f')) + output.append(getvalue(data, 'forecast', '%d')) + output.append(getvalue(data, 'storm', '%d')) + output.append(getvalue(data, 'winddir', '%d')) + output.append(getvalue(data, 'windspeed', '%0.1f')) + output.append(getvalue(data, 'windgust', '%0.1f')) + output.append(getvalue(data, 'windchill', '%0.1f')) + output.append(getvalue(data, 'rain', '%d')) + print(':'.join(output)) + + def getvalue(data, label, fmt): + if label + '_state' in data: + if data[label + '_state'] == STATE_OK: + return fmt % data[label] + else: + return data[label + '_state'] + else: + if data[label] is None: + return 'x' + else: + return fmt % data[label] + +if __name__ == '__main__': + main() diff --git a/dist/weewx-5.0.2/src/weewx/drivers/tests/__init__.py b/dist/weewx-5.0.2/src/weewx/drivers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weewx/drivers/tests/test_ultimeter.py b/dist/weewx-5.0.2/src/weewx/drivers/tests/test_ultimeter.py new file mode 100644 index 0000000..921a5b8 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/tests/test_ultimeter.py @@ -0,0 +1,88 @@ +# +# Copyright (c) 2021-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the ultimeter driver using mock""" +import struct +import unittest +from unittest.mock import patch + +import weewx.drivers.ultimeter + + +class StopTest(Exception): + """Raised when it's time to stop the test""" + + +int2byte = struct.Struct(">B").pack + + +def gen_bytes(): + """Return bytes one-by-one + + Returns: + byte: A byte string containing a single byte. + """ + # This includes some leading nonsense bytes (b'01212'): + for c in b'01212!!005100BE02EB0064277002A8023A023A0025005800000000\r\n': + # Under Python 3, c is an int. Convert to type bytes + yield int2byte(c) + # Because genLoopPackets() is intended to run forever, we need something to break the loop + # and stop the test. + raise StopTest + + +# Midnight, 1-Jan-2021 PST +KNOWN_TIME = 1609488000 + +expected_packet = { + 'daily_rain': 0.0, 'barometer': 29.81, 'wind_average': 0.0, + 'outHumidity': 57.0, 'day_of_year': 37, 'rain': None, 'dateTime': KNOWN_TIME, + 'windDir': 268.24, 'outTemp': 74.7, 'windSpeed': 5.03, 'inHumidity': 57.0, + 'inTemp': 68.0, 'minute_of_day': 88, 'rain_total': 1.0, 'usUnits': 1 +} + + +class UltimeterTest(unittest.TestCase): + + def test_decode(self): + self.assertEqual(weewx.drivers.ultimeter.decode(bytes(b'0123')), 291) + self.assertIsNone(weewx.drivers.ultimeter.decode(bytes(b'----'))) + self.assertEqual(weewx.drivers.ultimeter.decode(bytes(b'FF85'), neg=True), -123) + + @patch('weewx.drivers.ultimeter.time') + @patch('weewx.drivers.ultimeter.serial.Serial') + def test_get_readings(self, mock_serial, mock_time): + + # This is so time.time() returns a known value. + mock_time.time.return_value = KNOWN_TIME + + # Get a new UltimeterDriver(). It will have a mocked serial port. + # That is, station_driver.serial_port will be a Mock object. + station_driver = weewx.drivers.ultimeter.UltimeterDriver() + with self.assertRaises(StopTest): + + # Serial port read() should return the values from gen_bytes() + station_driver.station.serial_port.read.side_effect = gen_bytes() + + packets = 0 + # Get packets from the fake serial port. + for packet in station_driver.genLoopPackets(): + packets += 1 + # Check to make sure all the observation types match + for obs_type in packet: + self.assertAlmostEqual(packet[obs_type], expected_packet[obs_type], 2, + "%s %s != %s within 2 decimal places" + % (obs_type, + packet[obs_type], + expected_packet[obs_type])) + self.assertEqual(packets, 1, "Function genLoopPackets() called %d times, " + "instead of once and only once" % packets) + + station_driver.closePort() + self.assertIsNone(station_driver.station) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/drivers/ultimeter.py b/dist/weewx-5.0.2/src/weewx/drivers/ultimeter.py new file mode 100644 index 0000000..5dab026 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/ultimeter.py @@ -0,0 +1,455 @@ +# Copyright 2014-2024 Matthew Wall +# Copyright 2014 Nate Bargmann +# See the file LICENSE.txt for your rights. +# +# Credit to and contributions from: +# Jay Nugent (WB8TKL) and KRK6 for weather-2.kr6k-V2.1 +# http://server1.nuge.com/~weather/ +# Steve (sesykes71) for testing the first implementations of this driver +# Garret Power for improved decoding and proper handling of negative values +# Chris Thompstone for testing the fast-read implementation +# +# Thanks to PeetBros for publishing the communication protocols and details +# about each model they manufacture. + +"""Driver for Peet Bros Ultimeter weather stations except the Ultimeter II + +This driver assumes the Ultimeter is emitting data in Peet Bros Data Logger +mode format. This driver will set the mode automatically on stations +manufactured after 2004. Stations manufactured before 2004 must be set to +data logger mode using the buttons on the console. + +Resources for the Ultimeter stations + +Ultimeter Models 2100, 2000, 800, & 100 serial specifications: + http://www.peetbros.com/shop/custom.aspx?recid=29 + +Ultimeter 2000 Pinouts and Parsers: + http://www.webaugur.com/ham-radio/52-ultimeter-2000-pinouts-and-parsers.html + +Ultimeter II + not supported by this driver + +All models communicate over an RS-232 compatible serial port using three +wires--RXD, TXD, and Ground (except Ultimeter II which omits TXD). Port +parameters are 2400, 8N1, with no flow control. + +The Ultimeter hardware supports several "modes" for providing station data +to the serial port. This driver utilizes the "modem mode" to set the date +and time of the Ultimeter upon initialization and then sets it into Data +Logger mode for continuous updates. + +Modem Mode commands used by the driver + >Addddmmmm Set Date and Time (decimal digits dddd = day of year, + mmmm = minute of day; Jan 1 = 0000, Midnight = 0000) + + >I Set output mode to Data Logger Mode (continuous output) + +""" + +import logging +import time + +import serial + +import weewx.drivers +import weewx.wxformulas +from weewx.units import INHG_PER_MBAR, MILE_PER_KM +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'Ultimeter' +DRIVER_VERSION = '0.6' + + +def loader(config_dict, _): + return UltimeterDriver(**config_dict[DRIVER_NAME]) + + +def confeditor_loader(): + return UltimeterConfEditor() + + +def _fmt(x): + return ' '.join(["%0.2X" % c for c in x]) + + +class UltimeterDriver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with a Peet Bros Ultimeter station""" + + def __init__(self, **stn_dict): + """ + Args: + model (str): station model, e.g., 'Ultimeter 2000' or 'Ultimeter 100' + Optional. Default is 'Ultimeter' + + port(str): Serial port. + Required. Default is '/dev/ttyUSB0' + + max_tries(int): How often to retry serial communication before giving up + Optional. Default is 5 + + retry_wait (float): How long to wait before retrying an I/O operation. + Optional. Default is 3.0 + + debug_serial (int): Greater than one for additional debug information. + Optional. Default is 0 + """ + self.model = stn_dict.get('model', 'Ultimeter') + self.port = stn_dict.get('port', Station.DEFAULT_PORT) + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.retry_wait = float(stn_dict.get('retry_wait', 3.0)) + debug_serial = int(stn_dict.get('debug_serial', 0)) + self.last_rain = None + + log.info('Driver version is %s', DRIVER_VERSION) + log.info('Using serial port %s', self.port) + self.station = Station(self.port, debug_serial=debug_serial) + self.station.open() + + def closePort(self): + if self.station: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + + def DISABLED_getTime(self): + return self.station.get_time() + + def DISABLED_setTime(self): + self.station.set_time(int(time.time())) + + def genLoopPackets(self): + self.station.set_logger_mode() + while True: + packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + readings = self.station.get_readings_with_retry(self.max_tries, + self.retry_wait) + data = parse_readings(readings) + packet.update(data) + self._augment_packet(packet) + yield packet + + def _augment_packet(self, packet): + packet['rain'] = weewx.wxformulas.calculate_rain(packet['rain_total'], self.last_rain) + self.last_rain = packet['rain_total'] + + +class Station(object): + DEFAULT_PORT = '/dev/ttyUSB0' + + def __init__(self, port, debug_serial=0): + self.port = port + self._debug_serial = debug_serial + self.baudrate = 2400 + self.timeout = 3 # seconds + self.serial_port = None + # setting the year works only for models 2004 and later + self.can_set_year = True + # modem mode is available only on models 2004 and later + # not available on pre-2004 models 50/100/500/700/800 + self.has_modem_mode = True + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self): + log.debug("Open serial port %s", self.port) + self.serial_port = serial.Serial(self.port, self.baudrate, timeout=self.timeout) + self.serial_port.flushInput() + + def close(self): + if self.serial_port: + log.debug("Close serial port %s", self.port) + self.serial_port.close() + self.serial_port = None + + def get_time(self): + try: + self.set_logger_mode() + buf = self.get_readings_with_retry() + data = parse_readings(buf) + d = data['day_of_year'] # seems to start at 0 + m = data['minute_of_day'] # 0 is midnight before start of day + tt = time.localtime() + y = tt.tm_year + s = tt.tm_sec + ts = time.mktime((y, 1, 1, 0, 0, s, 0, 0, -1)) + d * 86400 + m * 60 + log.debug("Station time: day:%s min:%s (%s)", d, m, timestamp_to_string(ts)) + return ts + except (serial.SerialException, weewx.WeeWxIOError) as e: + log.error("get_time failed: %s", e) + return int(time.time()) + + def set_time(self, ts): + # go to modem mode, so we do not get logger chatter + self.set_modem_mode() + + # set time should work on all models + tt = time.localtime(ts) + cmd = b">A%04d%04d" % (tt.tm_yday - 1, tt.tm_min + tt.tm_hour * 60) + log.debug("Set station time to %s (%s)", timestamp_to_string(ts), cmd) + self.serial_port.write(b"%s\r" % cmd) + + # year works only for models 2004 and later + if self.can_set_year: + cmd = b">U%s" % tt.tm_year + log.debug("Set station year to %s (%s)", tt.tm_year, cmd) + self.serial_port.write(b"%s\r" % cmd) + + def set_logger_mode(self): + # in logger mode, station sends logger mode records continuously + if self._debug_serial: + log.debug("Set station to logger mode") + self.serial_port.write(b">I\r") + + def set_modem_mode(self): + # setting to modem mode should stop data logger output + if self.has_modem_mode: + if self._debug_serial: + log.debug("Set station to modem mode") + self.serial_port.write(b">\r") + + def get_readings_with_retry(self, max_tries, retry_wait): + for ntries in range(max_tries): + try: + buf = get_readings(self.serial_port, self._debug_serial) + validate_string(buf) + return buf + except (serial.SerialException, weewx.WeeWxIOError) as e: + log.info("Failed attempt %d of %d to get readings: %s", + ntries + 1, max_tries, e) + time.sleep(retry_wait) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + +# ##############################################################33 +# Utilities +# ##############################################################33 + +def get_readings(serial_port, debug_serial): + """Read an Ultimeter sentence from a serial port. + + Args: + serial_port (serial.Serial): An open port + debug_serial (int): Set to greater than zero for extra debug information. + + Returns: + bytearray: A bytearray containing the sentence. + """ + + # Search for the character '!', which marks the beginning of a "sentence": + while True: + c = serial_port.read(1) + if c == b'!': + break + # Save the first '!' ... + buf = bytearray(c) + # ... then read until we get to a '\r' or '\n' + while True: + c = serial_port.read(1) + if c == b'\n' or c == b'\r': + # We found a carriage return or newline, so we have the complete sentence. + # NB: Because the Ultimeter terminates a sentence with a '\r\n', this will + # leave a newline in the buffer. We don't care: it will get skipped over when + # we search for the next sentence. + break + buf += c + if debug_serial: + log.debug("Station said: %s", _fmt(buf)) + return buf + + +def validate_string(buf, choices=None): + """Validate a data buffer. + + Args: + buf (bytes): The raw data + choices (list of int): The possible valid lengths of the buffer. + + Raises: + weewx.WeeWXIOError: A string of unexpected length, or that does not start with b'!!', + raises this error. + """ + choices = choices or [42, 46, 50] + + if len(buf) not in choices: + raise weewx.WeeWxIOError("Unexpected buffer length %d" % len(buf)) + if buf[0:2] != b'!!': + raise weewx.WeeWxIOError("Unexpected header bytes '%s'" % buf[0:2]) + + +def parse_readings(raw): + """Ultimeter stations emit data in PeetBros format. + + http://www.peetbros.com/shop/custom.aspx?recid=29 + + Each line has 52 characters - 2 header bytes, 48 data bytes, and a carriage return and line + feed (new line): + + !!000000BE02EB000027700000023A023A0025005800000000\r\n + SSSSXXDDTTTTLLLLPPPPttttHHHHhhhhddddmmmmRRRRWWWW + + SSSS - wind speed (0.1 kph) + XX - wind direction calibration + DD - wind direction (0-255) + TTTT - outdoor temperature (0.1 F) + LLLL - long term rain (0.01 in) + PPPP - pressure (0.1 mbar) + tttt - indoor temperature (0.1 F) + HHHH - outdoor humidity (0.1 %) + hhhh - indoor humidity (0.1 %) + dddd - date (day of year) + mmmm - time (minute of day) + RRRR - daily rain (0.01 in) + WWWW - one- minute wind average (0.1 kph) + + "pressure" reported by the Ultimeter 2000 is correlated to the local + official barometer reading as part of the setup of the station + console so this value is assigned to the 'barometer' key and + the pressure and altimeter values are calculated from it. + + Some stations may omit daily_rain or wind_average, so check for those. + + Args: + raw (bytearray): A bytearray containing the sentence. + + Returns + dict: A dictionary containing the data. + """ + # Convert from bytearray to bytes + buf = bytes(raw[2:]) + data = { + 'windSpeed': decode(buf[0:4], 0.1 * MILE_PER_KM), # mph + 'windDir': decode(buf[6:8], 1.411764), # compass deg + 'outTemp': decode(buf[8:12], 0.1, neg=True), # degree_F + 'rain_total': decode(buf[12:16], 0.01), # inch + 'barometer': decode(buf[16:20], 0.1 * INHG_PER_MBAR), # inHg + 'inTemp': decode(buf[20:24], 0.1, neg=True), # degree_F + 'outHumidity': decode(buf[24:28], 0.1), # percent + 'inHumidity': decode(buf[28:32], 0.1), # percent + 'day_of_year': decode(buf[32:36]), + 'minute_of_day': decode(buf[36:40]) + } + if len(buf) > 40: + data['daily_rain'] = decode(buf[40:44], 0.01) # inch + if len(buf) > 44: + data['wind_average'] = decode(buf[44:48], 0.1 * MILE_PER_KM) # mph + return data + + +def decode(s, multiplier=None, neg=False): + """Decode a byte string. + + Ultimeter puts dashes in the string when a sensor is not installed. When we get a dashes, + or any other non-hex character, return None. Negative values are represented in twos + complement format. Only do the check for negative values if requested, since some + parameters use the full set of bits (e.g., wind direction) and some do not (e.g., + temperature). + + Args: + s (bytes): Encoded value as hexadecimal digits. + multiplier (float): Multiply the results by this value. + neg (bool): If True, calculate the twos-complement. + + Returns: + float: The decoded value. + """ + + # First check for all dashes. + if s == len(s) * b'-': + # All bytes are dash values, meaning a non-existent or broken sensor. Return None. + return None + + # Decode the hexadecimal number + try: + v = int(s, 16) + except ValueError as e: + log.debug("Decode failed for '%s': %s", s, e) + return None + + # If requested, calculate the twos-complement + if neg: + bits = 4 * len(s) + if v & (1 << (bits - 1)) != 0: + v -= (1 << bits) + + # If requested, scale the number + if multiplier is not None: + v *= multiplier + + return v + + +class UltimeterConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[Ultimeter] + # This section is for the PeetBros Ultimeter series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cua0 + port = %s + + # The station model, e.g., Ultimeter 2000, Ultimeter 100 + model = Ultimeter + + # The driver to use: + driver = weewx.drivers.ultimeter +""" % Station.DEFAULT_PORT + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example: /dev/ttyUSB0 or /dev/ttyS0 or /dev/cua0.") + port = self._prompt('port', Station.DEFAULT_PORT) + return {'port': port} + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/ultimeter.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='provide additional debug output in log') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected', + default=Station.DEFAULT_PORT) + (options, args) = parser.parse_args() + + if options.version: + print("ultimeter driver version %s" % DRIVER_VERSION) + exit(0) + + if options.debug: + weewx.debug = 1 + + weeutil.logger.setup('wee_ultimeter') + + with Station(options.port, debug_serial=options.debug) as station: + station.set_logger_mode() + while True: + print(time.time(), _fmt(station.get_readings())) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/vantage.py b/dist/weewx-5.0.2/src/weewx/drivers/vantage.py new file mode 100644 index 0000000..4537b95 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/vantage.py @@ -0,0 +1,3079 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes and functions for interfacing with a Davis VantagePro, VantagePro2, +or VantageVue weather station""" + +import datetime +import logging +import struct +import sys +import time + +import weeutil.weeutil +import weewx.drivers +import weewx.engine +import weewx.units +from weeutil.weeutil import to_int, to_sorted_string +from weewx.crc16 import crc16 + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'Vantage' +DRIVER_VERSION = '3.6.2' + +int2byte = struct.Struct(">B").pack + + +def loader(config_dict, engine): + return VantageService(engine, config_dict) + + +def configurator_loader(config_dict): # @UnusedVariable + return VantageConfigurator() + + +def confeditor_loader(): + return VantageConfEditor() + + +# A few handy constants: +_ack = b'\x06' +_resend = b'\x15' # NB: The Davis documentation gives this code as 0x21, but it's actually decimal 21 + + +# =============================================================================== +# class BaseWrapper +# =============================================================================== + +class BaseWrapper(object): + """Base class for (Serial|Ethernet)Wrapper""" + + def __init__(self, wait_before_retry, command_delay): + + self.wait_before_retry = wait_before_retry + self.command_delay = command_delay + + def read(self, nbytes=1): + raise NotImplementedError + + def write(self, buf): + raise NotImplementedError + + def flush_input(self): + raise NotImplementedError + + # =============================================================================== + # Primitives for working with the Davis Console + # =============================================================================== + + def wakeup_console(self, max_tries=3): + """Wake up a Davis Vantage console. + + This call has three purposes: + 1. Wake up a sleeping console; + 2. Cancel pending LOOP data (if any); + 3. Flush the input buffer + Note: a flushed buffer is important before sending a command; we want to make sure + the next received character is the expected ACK. + + If unsuccessful, an exception of type weewx.WakeupError is thrown""" + + for count in range(1, max_tries + 1): + try: + # Clear out any pending input or output characters: + self.flush_output() + self.flush_input() + # It can be hard to get the console's attention, particularly + # when in the middle of a LOOP command. Send a bunch of line feeds, + # then flush everything, then look for the \n\r acknowledgment + self.write(b'\n\n\n') + time.sleep(0.5) + self.flush_input() + self.write(b'\n') + _resp = self.read(2) + if _resp == b'\n\r': # LF, CR = 0x0a, 0x0d + # We're done; the console accepted our cancel LOOP command. + log.debug("Successfully woke up Vantage console") + return + else: + log.debug("Bad wake-up response from Vantage console: %s", _resp) + except weewx.WeeWxIOError as e: + log.debug("Wake up try %d failed. Exception: %s", count, e) + + log.debug("Retry #%d unable to wake up console... sleeping", count) + print("Unable to wake up console... sleeping") + time.sleep(self.wait_before_retry) + print("Unable to wake up console... retrying") + + log.error("Unable to wake up Vantage console") + raise weewx.WakeupError("Unable to wake up Vantage console") + + def send_data(self, data): + """Send data to the Davis console, waiting for an acknowledging + + Args: + data(bytes): The data to send, as bytes. + + Raises: + weewx.WeeWxIOError: If no is received from the console. No retry is attempted. + """ + + self.write(data) + + # Look for the acknowledging ACK character + _resp = self.read() + if _resp != _ack: + log.error("send_data: no received from Vantage console") + raise weewx.WeeWxIOError("No received from Vantage console") + + def send_data_with_crc16(self, data, max_tries=3): + """Send data to the Davis console along with a CRC check, waiting for an + acknowledging . If none received, resend up to max_tries times. + + Args: + data(bytearray | bytes): The data to send, as a bytearray + max_tries(int): The maximum number of times to try before raising an error. + + Raises: + weewx.CRCError: The send failed to pass a CRC check. + """ + + # Calculate the crc for the data: + _crc = crc16(data) + + # ...and pack that on to the end of the data in big-endian order: + _data_with_crc = data + struct.pack(">H", _crc) + + # Retry up to max_tries times: + for count in range(1, max_tries + 1): + try: + self.write(_data_with_crc) + # Look for the acknowledgment. + _resp = self.read() + if _resp == _ack: + return + else: + log.debug("send_data_with_crc16 try #%d bad : %s", count, _resp) + except weewx.WeeWxIOError as e: + log.debug("send_data_with_crc16 try #%d exception: %s", count, e) + + log.error("Unable to pass CRC16 check while sending data to Vantage console") + raise weewx.CRCError("Unable to pass CRC16 check while sending data to Vantage console") + + def send_command(self, command, max_tries=3): + """Send a command to the console, then look for the byte string 'OK' in the response. + + Any response from the console is split on \n\r characters and returned as a list.""" + + for count in range(1, max_tries + 1): + try: + self.wakeup_console(max_tries=max_tries) + + self.write(command) + # Takes some time for the Vantage to react and fill up the buffer. Sleep for a bit: + time.sleep(self.command_delay) + # Can't use function serial.readline() because the VP responds with \n\r, + # not just \n. So, instead find how many bytes are waiting and fetch them all + nc = self.queued_bytes() + _buffer = self.read(nc) + # Split the buffer on the newlines + _buffer_list = _buffer.strip().split(b'\n\r') + # The first member should be the 'OK' in the VP response + if _buffer_list[0] == b'OK': + # Return the rest: + return _buffer_list[1:] + else: + log.debug("send_command; try #%d failed. Response: %s", count, _buffer_list[0]) + except weewx.WeeWxIOError as e: + # Caught an error. Log, then keep trying... + log.debug("send_command; try #%d failed. Exception: %s", count, e) + + msg = "Max retries exceeded while sending command %s" % command + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def get_data_with_crc16(self, nbytes, prompt=None, max_tries=3): + """Get a packet of data and do a CRC16 check on it, asking for retransmit if necessary. + + It is guaranteed that the length of the returned data will be of the requested length. + An exception of type CRCError will be raised if the data cannot pass the CRC test + in the requested number of retries. + + Args: + + nbytes(int): The number of bytes (including the 2 byte CRC) to get. + prompt(bytes|None): Any byte string to be sent before requesting the data. Default=None + max_tries(int): Number of tries before giving up. Default=3 + + Returns: + bytes: The packet data as a bytes array. The last 2 bytes will be the CRC + + Raises: + weewx.CRCError: Fail to pass a CRC check while getting data + weewx.WeeWxIOError: Other I/O error, such as a timeout + """ + if prompt: + self.write(prompt) + + first_time = True + _buffer = None + + for count in range(1, max_tries + 1): + try: + if not first_time: + self.write(_resend) + _buffer = self.read(nbytes) + if crc16(_buffer) == 0: + return _buffer + log.debug("Get_data_with_crc16; try #%d failed. CRC error", count) + except weewx.WeeWxIOError as e: + log.debug("Get_data_with_crc16; try #%d failed: %s", count, e) + first_time = False + + if _buffer: + log.error("Unable to pass CRC16 check while getting data") + raise weewx.CRCError("Unable to pass CRC16 check while getting data") + else: + log.debug("Timeout in get_data_with_crc16") + raise weewx.WeeWxIOError("Timeout in get_data_with_crc16") + + +# =============================================================================== +# class Serial Wrapper +# =============================================================================== + +def guard_termios(fn): + """Decorator function that converts termios exceptions into weewx exceptions.""" + # Some functions in the module 'serial' can raise undocumented termios + # exceptions. This catches them and converts them to weewx exceptions. + try: + import termios + def guarded_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except termios.error as e: + raise weewx.WeeWxIOError(e) + except ImportError: + def guarded_fn(*args, **kwargs): + return fn(*args, **kwargs) + return guarded_fn + + +class SerialWrapper(BaseWrapper): + """Wraps a serial connection returned from package serial""" + + def __init__(self, port, baudrate, timeout, wait_before_retry, command_delay): + super().__init__(wait_before_retry=wait_before_retry, + command_delay=command_delay) + self.port = port + self.baudrate = baudrate + self.timeout = timeout + + @guard_termios + def flush_input(self): + self.serial_port.flushInput() + + @guard_termios + def flush_output(self): + self.serial_port.flushOutput() + + @guard_termios + def queued_bytes(self): + return self.serial_port.inWaiting() + + def read(self, chars=1): + import serial + try: + _buffer = self.serial_port.read(chars) + except serial.serialutil.SerialException as e: + log.error("SerialException on read.") + log.error(" **** %s", e) + log.error(" **** Is there a competing process running??") + # Reraise as a Weewx error I/O error: + raise weewx.WeeWxIOError(e) + N = len(_buffer) + if N != chars: + raise weewx.WeeWxIOError("Expected to read %d chars; got %d instead" % (chars, N)) + return _buffer + + def write(self, data): + import serial + try: + N = self.serial_port.write(data) + except serial.serialutil.SerialException as e: + log.error("SerialException on write.") + log.error(" **** %s", e) + # Reraise as a Weewx error I/O error: + raise weewx.WeeWxIOError(e) from e + if N is not None and N != len(data): + raise weewx.WeeWxIOError("Expected to write %d chars; sent %d instead" + % (len(data), N)) + + def openPort(self): + import serial + # Open up the port and store it + self.serial_port = serial.Serial(self.port, self.baudrate, timeout=self.timeout) + log.debug("Opened up serial port %s; baud %d; timeout %.2f", + self.port, self.baudrate, self.timeout) + + def closePort(self): + try: + # This will cancel any pending loop: + self.write(b'\n') + except weewx.WeeWxIOError: + pass + self.serial_port.close() + + +# =============================================================================== +# class EthernetWrapper +# =============================================================================== + +class EthernetWrapper(BaseWrapper): + """Wrap a socket""" + + def __init__(self, host, port, timeout, tcp_send_delay, wait_before_retry, command_delay): + + super().__init__(wait_before_retry=wait_before_retry, + command_delay=command_delay) + + self.host = host + self.port = port + self.timeout = timeout + self.tcp_send_delay = tcp_send_delay + + def openPort(self): + import socket + try: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(self.timeout) + self.socket.connect((self.host, self.port)) + except (socket.error, socket.timeout, socket.herror) as ex: + log.error("Socket error while opening port %d to ethernet host %s.", self.port, + self.host) + # Reraise as a weewx I/O error: + raise weewx.WeeWxIOError(ex) + except: + log.error("Unable to connect to ethernet host %s on port %d.", self.host, self.port) + raise + log.debug("Opened up ethernet host %s on port %d. timeout=%s, tcp_send_delay=%s", + self.host, self.port, self.timeout, self.tcp_send_delay) + + def closePort(self): + import socket + try: + # This will cancel any pending loop: + self.write(b'\n') + except: + pass + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + + def flush_input(self): + """Flush the input buffer from WeatherLinkIP""" + import socket + try: + # This is a bit of a hack, but there is no analogue to pyserial's flushInput() + # Set socket timeout to 0 to get immediate result + self.socket.settimeout(0) + self.socket.recv(4096) + except (socket.timeout, socket.error): + pass + finally: + # set socket timeout back to original value + self.socket.settimeout(self.timeout) + + def flush_output(self): + """Flush the output buffer to WeatherLinkIP + + This function does nothing as there should never be anything left in + the buffer when using socket.sendall()""" + pass + + def queued_bytes(self): + """Determine how many bytes are in the buffer""" + import socket + length = 0 + try: + self.socket.settimeout(0) + length = len(self.socket.recv(8192, socket.MSG_PEEK)) + except socket.error: + pass + finally: + self.socket.settimeout(self.timeout) + return length + + def read(self, chars=1): + """Read bytes from WeatherLinkIP""" + import socket + _buffer = b'' + _remaining = chars + while _remaining: + _N = min(4096, _remaining) + try: + _recv = self.socket.recv(_N) + except (socket.timeout, socket.error) as ex: + log.error("ip-read error: %s", ex) + # Reraise as a weewx I/O error: + raise weewx.WeeWxIOError(ex) + _nread = len(_recv) + if _nread == 0: + raise weewx.WeeWxIOError("Expected %d characters; got zero instead" % (_N,)) + _buffer += _recv + _remaining -= _nread + return _buffer + + def write(self, data): + """Write to a WeatherLinkIP""" + import socket + try: + self.socket.sendall(data) + # A delay of 0.0 gives socket write error; 0.01 gives no ack error; + # 0.05 is OK for weewx program. + # Note: a delay of 0.5 s is required for weectl device --logger=logger_info + time.sleep(self.tcp_send_delay) + except (socket.timeout, socket.error) as ex: + log.error("ip-write error: %s", ex) + # Reraise as a weewx I/O error: + raise weewx.WeeWxIOError(ex) + + +# =============================================================================== +# class Vantage +# =============================================================================== + +class Vantage(weewx.drivers.AbstractDevice): + """Class that represents a connection to a Davis Vantage console. + + The connection to the console will be open after initialization""" + + # Various codes used internally by the VP2: + barometer_unit_dict = {0:'inHg', 1:'mmHg', 2:'hPa', 3:'mbar'} + temperature_unit_dict = {0:'degree_F', 1:'degree_10F', 2:'degree_C', 3:'degree_10C'} + altitude_unit_dict = {0:'foot', 1:'meter'} + rain_unit_dict = {0:'inch', 1:'mm'} + wind_unit_dict = {0:'mile_per_hour', 1:'meter_per_second', 2:'km_per_hour', 3:'knot'} + wind_cup_dict = {0:'small', 1:'large'} + rain_bucket_dict = {0:'0.01 inches', 1:'0.2 mm', 2:'0.1 mm'} + transmitter_type_dict = {0:'iss', 1:'temp', 2:'hum', 3:'temp_hum', 4:'wind', + 5:'rain', 6:'leaf', 7:'soil', 8:'leaf_soil', + 9:'sensorlink', 10:'none'} + repeater_dict = {0:'none', 8:'A', 9:'B', 10:'C', 11:'D', + 12:'E', 13:'F', 14:'G', 15:'H'} + listen_dict = {0:'inactive', 1:'active'} + + def __init__(self, **vp_dict): + """Initialize an object of type Vantage. + + Args: + + connection_type: The type of connection (serial|ethernet) [Required] + + port: The serial port of the VP. [Required if serial/USB + communication] + + host: The Vantage network host [Required if Ethernet communication] + + baudrate: Baudrate of the port. [Optional. Default 19200] + + tcp_port: TCP port to connect to [Optional. Default 22222] + + tcp_send_delay: Block after sending data to WeatherLinkIP to allow it + to process the command [Optional. Default is 0.5] + + timeout: How long to wait before giving up on a response from the + serial port. [Optional. Default is 4] + + wait_before_retry: How long to wait before retrying. [Optional. + Default is 1.2 seconds] + + command_delay: How long to wait after sending a command before looking + for acknowledgement. [Optional. Default is 0.5 seconds] + + max_tries: How many times to try again before giving up. [Optional. + Default is 4] + + iss_id: The station number of the ISS [Optional. Default is 1] + + model_type: Vantage Pro model type. 1=Vantage Pro; 2=Vantage Pro2 + [Optional. Default is 2] + + loop_request: Requested packet type. 1=LOOP; 2=LOOP2; 3=both. + + loop_batch: How many LOOP packets to get in a single batch. + [Optional. Default is 200] + + max_batch_errors: How many errors to allow in a batch before a restart. + [Optional. Default is 3] + """ + + log.debug('Driver version is %s', DRIVER_VERSION) + + self.hardware_type = None + + # These come from the configuration dictionary: + self.max_tries = to_int(vp_dict.get('max_tries', 4)) + self.iss_id = to_int(vp_dict.get('iss_id')) + self.model_type = to_int(vp_dict.get('model_type', 2)) + if self.model_type not in (1, 2): + raise weewx.UnsupportedFeature("Unknown model_type (%d)" % self.model_type) + self.loop_request = to_int(vp_dict.get('loop_request', 1)) + log.debug("Option loop_request=%d", self.loop_request) + self.loop_batch = to_int(vp_dict.get('loop_batch', 200)) + self.max_batch_errors = to_int(vp_dict.get('max_batch_errors', 3)) + + self.save_day_rain = None + self.max_dst_jump = 7200 + + # Get an appropriate port, depending on the connection type: + self.port = Vantage._port_factory(vp_dict) + + # Open it up: + self.port.openPort() + + # Read the EEPROM and fill in properties in this instance + self._setup() + log.debug("Hardware name: %s", self.hardware_name) + + def openPort(self): + """Open up the connection to the console""" + self.port.openPort() + + def closePort(self): + """Close the connection to the console. """ + self.port.closePort() + + def genLoopPackets(self): + """Generator function that returns loop packets""" + + while True: + # Get LOOP packets in big batches This is necessary because there is + # an undocumented limit to how many LOOP records you can request + # on the VP (somewhere around 220). + for _loop_packet in self.genDavisLoopPackets(self.loop_batch): + yield _loop_packet + + def genDavisLoopPackets(self, N=1): + """Generator function to return N loop packets from a Vantage console + + Args: + N(int): The number of packets to generate. Default is 1. + + Yields: + dict: up to N loop packets (could be less in the event of a + read or CRC error). + """ + + log.debug("Requesting %d LOOP packets.", N) + + attempt = 1 + while attempt <= self.max_batch_errors: + try: + self.port.wakeup_console(self.max_tries) + if self.loop_request == 1: + # If asking for old-fashioned LOOP1 data, send the older command in case the + # station does not support the LPS command: + self.port.send_data(b"LOOP %d\n" % N) + else: + # Request N packets of type "loop_request": + self.port.send_data(b"LPS %d %d\n" % (self.loop_request, N)) + + for loop in range(N): + loop_packet = self._get_packet() + yield loop_packet + return + + except weewx.WeeWxIOError as e: + log.error("LOOP batch try #%d; error: %s", attempt, e) + attempt += 1 + else: + msg = "LOOP max batch errors (%d) exceeded." % self.max_batch_errors + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def _get_packet(self): + """Get a single LOOP packet + + Returns: + dict: A single LOOP packet + """ + # Fetch a packet... + _buffer = self.port.read(99) + # ... see if it passes the CRC test ... + crc = crc16(_buffer) + if crc: + if weewx.debug > 1: + log.error("LOOP buffer failed CRC check. Calculated CRC=%d", crc) + log.error("Buffer: %s", _buffer) + raise weewx.CRCError("LOOP buffer failed CRC check") + # ... decode it ... + loop_packet = self._unpackLoopPacket(_buffer[:95]) + # ... then return it + return loop_packet + + def genArchiveRecords(self, since_ts): + """A generator function to return archive packets from a Davis Vantage station. + + Args: + since_ts(float|None): A timestamp. All data since (but not including) this time + will be returned. Pass in None for all data + + Yields: + dict: a sequence of dictionaries containing the data + """ + + count = 1 + while count <= self.max_tries: + try: + for _record in self.genDavisArchiveRecords(since_ts): + # Successfully retrieved record. Set count back to one. + count = 1 + since_ts = _record['dateTime'] + yield _record + # The generator loop exited. We're done. + return + except weewx.WeeWxIOError as e: + # Problem. Log, then increment count + log.error("DMPAFT try #%d; error: %s", count, e) + count += 1 + + log.error("DMPAFT max tries (%d) exceeded.", self.max_tries) + raise weewx.RetriesExceeded("Max tries exceeded while getting archive data.") + + def genDavisArchiveRecords(self, since_ts): + """A generator function to return archive records from a Davis Vantage station. + + This version does not catch any exceptions.""" + + if since_ts: + since_tt = time.localtime(since_ts) + # NB: note that some of the Davis documentation gives the year offset as 1900. + # From experimentation, 2000 seems to be right, at least for the newer models: + _vantageDateStamp = since_tt[2] + (since_tt[1] << 5) + ((since_tt[0] - 2000) << 9) + _vantageTimeStamp = since_tt[3] * 100 + since_tt[4] + log.debug('Getting archive packets since %s', + weeutil.weeutil.timestamp_to_string(since_ts)) + else: + _vantageDateStamp = _vantageTimeStamp = 0 + log.debug('Getting all archive packets') + + # Pack the date and time into a string, little-endian order + _datestr = struct.pack("> 9 # year + mo = (0x01e0 & datestamp) >> 5 # month + d = (0x001f & datestamp) # day + h = timestamp // 100 # hour + mn = timestamp % 100 # minute + yield (_ipage, _index, y, mo, d, h, mn, time_ts) + log.debug("Vantage: Finished logger summary.") + + def getTime(self): + """Get the current time from the console, returning it as timestamp""" + + time_dt = self.getConsoleTime() + return time.mktime(time_dt.timetuple()) + + def getConsoleTime(self): + """Return the raw time on the console, uncorrected for DST or timezone.""" + + # Try up to max_tries times: + for unused_count in range(self.max_tries): + try: + # Wake up the console... + self.port.wakeup_console(max_tries=self.max_tries) + # ... request the time... + self.port.send_data(b'GETTIME\n') + # ... get the binary data. No prompt, only one try: + _buffer = self.port.get_data_with_crc16(8, max_tries=1) + (sec, minute, hr, day, mon, yr, unused_crc) = struct.unpack("B', usetx_bits), max_tries=1) + # Then call NEWSETUP to get it all to stick: + self.port.send_data(b"NEWSETUP\n") + + self._setup() + + def setRetransmit(self, channel): + """Set console retransmit channel. + + Args: + channel (int): Zero to turn off all retransmitting. Otherwise, a channel number [1-8] + to turn on retransmitting. + """ + if channel: + # Get the old retransmit data + retransmit_data = self._getEEPROM_value(0x18)[0] + # Turn on the appropriate channel by ORing in the new channel + retransmit_data |= 1 << channel - 1 + else: + # Turn off all channels + retransmit_data = 0 + # Tell the console to put one byte in hex location 0x18 + self.port.send_data(b"EEBWR 18 01\n") + # Follow it up with the data: + self.port.send_data_with_crc16(int2byte(retransmit_data), max_tries=1) + # Then call NEWSETUP to get it to stick: + self.port.send_data(b"NEWSETUP\n") + + self._setup() + if channel: + log.info("Retransmit set to 'ON' at channel: %d", channel) + else: + log.info("Retransmit set to 'OFF'") + + def setTempLogging(self, new_tempLogging='AVERAGE'): + """Set console temperature logging to 'AVERAGE' or 'LAST'.""" + try: + _setting = {'LAST': 1, 'AVERAGE': 0}[new_tempLogging.upper()] + except KeyError: + raise ValueError("Unknown console temperature logging setting '%s'" + % new_tempLogging.upper()) + + # Tell the console to put one byte in hex location 0x2B + self.port.send_data(b"EEBWR FFC 01\n") + # Follow it up with the data: + self.port.send_data_with_crc16(int2byte(_setting), max_tries=1) + # Then call NEWSETUP to get it to stick: + self.port.send_data(b"NEWSETUP\n") + + log.info("Console temperature logging set to '%s'", new_tempLogging.upper()) + + def setCalibrationWindDir(self, offset): + """Set the on-board wind direction calibration.""" + if not -359 <= offset <= 359: + raise weewx.ViolatedPrecondition("Offset %d out of range [-359, 359]." % offset) + # Tell the console to put two bytes in hex location 0x4D + self.port.send_data(b"EEBWR 4D 02\n") + # Follow it up with the data: + self.port.send_data_with_crc16(struct.pack("> 4 + # Convert the number to the repeater channel letter, or None. + repeater_id = chr(repeater_no - 8 + ord('A')) if repeater_no else None + # The least significant bit of use_tx will be whether to listen to the current channel. + use_flag = use_tx & 0x01 + # Shift use_tx over by one bit to get it ready for the next channel. + use_tx >>= 1 + # The least significant bit of retransmit_data will be whether to retransmit + # the current channel. + retransmit = retransmit_data & 0x01 + # Shift retransmit_data by one bit to get it ready for the next channel. + retransmit_data >>= 1 + transmitter_dict = { + 'transmitter_type': Vantage.transmitter_type_dict[transmitter_type], + 'repeater': repeater_id, + 'listen': Vantage.listen_dict[use_flag], + 'retransmit' : 'Y' if retransmit else 'N' + } + if transmitter_dict['transmitter_type'] in ['temp', 'temp_hum']: + # Extra temperature is origin 1. + transmitter_dict['temp'] = (upper_byte & 0xF) + 1 + if transmitter_dict['transmitter_type'] in ['hum', 'temp_hum']: + # Extra humidity is origin 0. + transmitter_dict['hum'] = upper_byte >> 4 + + transmitters.append(transmitter_dict) + + return transmitters + + def getStnCalibration(self): + """ Get the temperature/humidity/wind calibrations built into the console. """ + (inTemp, inTempComp, outTemp, + extraTemp1, extraTemp2, extraTemp3, extraTemp4, extraTemp5, extraTemp6, extraTemp7, + soilTemp1, soilTemp2, soilTemp3, soilTemp4, leafTemp1, leafTemp2, leafTemp3, leafTemp4, + inHumid, outHumid, extraHumid1, extraHumid2, extraHumid3, extraHumid4, extraHumid5, + extraHumid6, extraHumid7, wind) = self._getEEPROM_value(0x32, "<27bh") + # inTempComp is 1's complement of inTemp. + if inTemp + inTempComp != -1: + log.error("Inconsistent EEPROM calibration values") + return None + # Temperatures are in tenths of a degree F; Humidity in 1 percent. + return { + "inTemp": inTemp / 10.0, + "outTemp": outTemp / 10.0, + "extraTemp1": extraTemp1 / 10.0, + "extraTemp2": extraTemp2 / 10.0, + "extraTemp3": extraTemp3 / 10.0, + "extraTemp4": extraTemp4 / 10.0, + "extraTemp5": extraTemp5 / 10.0, + "extraTemp6": extraTemp6 / 10.0, + "extraTemp7": extraTemp7 / 10.0, + "soilTemp1": soilTemp1 / 10.0, + "soilTemp2": soilTemp2 / 10.0, + "soilTemp3": soilTemp3 / 10.0, + "soilTemp4": soilTemp4 / 10.0, + "leafTemp1": leafTemp1 / 10.0, + "leafTemp2": leafTemp2 / 10.0, + "leafTemp3": leafTemp3 / 10.0, + "leafTemp4": leafTemp4 / 10.0, + "inHumid": inHumid, + "outHumid": outHumid, + "extraHumid1": extraHumid1, + "extraHumid2": extraHumid2, + "extraHumid3": extraHumid3, + "extraHumid4": extraHumid4, + "extraHumid5": extraHumid5, + "extraHumid6": extraHumid6, + "extraHumid7": extraHumid7, + "wind": wind + } + + def startLogger(self): + self.port.send_command(b"START\n") + + def stopLogger(self): + self.port.send_command(b'STOP\n') + + # =========================================================================== + # Davis Vantage utility functions + # =========================================================================== + + @property + def hardware_name(self): + if self.hardware_type == 16: + if self.model_type == 1: + return "Vantage Pro" + else: + return "Vantage Pro2" + elif self.hardware_type == 17: + return "Vantage Vue" + else: + raise weewx.UnsupportedFeature("Unknown hardware type %d" % self.hardware_type) + + @property + def archive_interval(self): + return self.archive_interval_ + + def _determine_hardware(self): + # Determine the type of hardware: + for count in range(self.max_tries): + try: + self.port.send_data(b"WRD\x12\x4d\n") + self.hardware_type = self.port.read()[0] + log.debug("Hardware type is %d", self.hardware_type) + # 16 = Pro, Pro2, 17 = Vue + return self.hardware_type + except weewx.WeeWxIOError as e: + log.error("_determine_hardware; retry #%d: '%s'", count, e) + + log.error("Unable to read hardware type; raise WeeWxIOError") + raise weewx.WeeWxIOError("Unable to read hardware type") + + def _setup(self): + """Retrieve the EEPROM data block from a VP2 and use it to set various properties""" + + self.port.wakeup_console(max_tries=self.max_tries) + + # Get hardware type, if not done yet. + if self.hardware_type is None: + self.hardware_type = self._determine_hardware() + # Overwrite model_type if we have Vantage Vue. + if self.hardware_type == 17: + self.model_type = 2 + + unit_bits = self._getEEPROM_value(0x29)[0] + setup_bits = self._getEEPROM_value(0x2B)[0] + self.rain_year_start = self._getEEPROM_value(0x2C)[0] + self.archive_interval_ = self._getEEPROM_value(0x2D)[0] * 60 + self.altitude = self._getEEPROM_value(0x0F, "> 2 + altitude_unit_code = (unit_bits & 0x10) >> 4 + rain_unit_code = (unit_bits & 0x20) >> 5 + wind_unit_code = (unit_bits & 0xC0) >> 6 + + self.wind_cup_type = (setup_bits & 0x08) >> 3 + self.rain_bucket_type = (setup_bits & 0x30) >> 4 + + self.barometer_unit = Vantage.barometer_unit_dict[barometer_unit_code] + self.temperature_unit = Vantage.temperature_unit_dict[temperature_unit_code] + self.altitude_unit = Vantage.altitude_unit_dict[altitude_unit_code] + self.rain_unit = Vantage.rain_unit_dict[rain_unit_code] + self.wind_unit = Vantage.wind_unit_dict[wind_unit_code] + self.wind_cup_size = Vantage.wind_cup_dict[self.wind_cup_type] + self.rain_bucket_size = Vantage.rain_bucket_dict[self.rain_bucket_type] + + # Try to guess the ISS ID for gauging reception strength. + if self.iss_id is None: + stations = self.getStnTransmitters() + # Wind retransmitter is the best candidate. + for station_id in range(0, 8): + if stations[station_id]['transmitter_type'] == 'wind': + self.iss_id = station_id + 1 # Origin 1. + break + else: + # ISS is next best candidate. + for station_id in range(0, 8): + if stations[station_id]['transmitter_type'] == 'iss': + self.iss_id = station_id + 1 # Origin 1. + break + else: + # On Vue, can use VP2 ISS, which reports as "rain" + for station_id in range(0, 8): + if stations[station_id]['transmitter_type'] == 'rain': + self.iss_id = station_id + 1 # Origin 1. + break + else: + self.iss_id = 1 # Pick a reasonable default. + + log.debug("ISS ID is %s", self.iss_id) + + def _getEEPROM_value(self, offset, v_format="B"): + """Return a list of values from the EEPROM starting at a specified offset, using a + specified format""" + + nbytes = struct.calcsize(v_format) + # Don't bother waking up the console for the first try. It's probably + # already awake from opening the port. However, if we fail, then do a + # wake-up. + firsttime = True + + command = b"EEBRD %X %X\n" % (offset, nbytes) + for unused_count in range(self.max_tries): + try: + if not firsttime: + self.port.wakeup_console(max_tries=self.max_tries) + firsttime = False + self.port.send_data(command) + _buffer = self.port.get_data_with_crc16(nbytes + 2, max_tries=1) + _value = struct.unpack(v_format, _buffer[:-2]) + return _value + except weewx.WeeWxIOError: + continue + + msg = "While getting EEPROM data value at address 0x%X" % offset + log.error(msg) + raise weewx.RetriesExceeded(msg) + + @staticmethod + def _port_factory(vp_dict): + """Produce a serial or ethernet port object""" + + timeout = float(vp_dict.get('timeout', 4.0)) + wait_before_retry = float(vp_dict.get('wait_before_retry', 1.2)) + command_delay = float(vp_dict.get('command_delay', 0.5)) + + # Get the connection type. If it is not specified, assume 'serial': + connection_type = vp_dict.get('type', 'serial').lower() + + if connection_type == "serial": + port = vp_dict['port'] + baudrate = int(vp_dict.get('baudrate', 19200)) + return SerialWrapper(port, baudrate, timeout, + wait_before_retry, command_delay) + elif connection_type == "ethernet": + hostname = vp_dict['host'] + tcp_port = int(vp_dict.get('tcp_port', 22222)) + tcp_send_delay = float(vp_dict.get('tcp_send_delay', 0.5)) + return EthernetWrapper(hostname, tcp_port, timeout, tcp_send_delay, + wait_before_retry, command_delay) + raise weewx.UnsupportedFeature(vp_dict['type']) + + def _unpackLoopPacket(self, raw_loop_buffer): + """Decode a raw Davis LOOP packet, returning the results as a dictionary in physical units. + + Args: + raw_loop_buffer(bytearray): The loop packet data buffer, passed in as a byte array + + Returns: + dict: The key will be an observation type, the value will be the observation + in physical units. + """ + + # Get the packet type. It's in byte 4. + packet_type = raw_loop_buffer[4] + if packet_type == 0: + loop_struct = loop1_struct + loop_types = loop1_types + elif packet_type == 1: + loop_struct = loop2_struct + loop_types = loop2_types + else: + raise weewx.WeeWxIOError("Unknown LOOP packet type %s" % packet_type) + + # Unpack the data, using the appropriate compiled stuct.Struct buffer. + # The result will be a long tuple with just the raw values from the console. + data_tuple = loop_struct.unpack(raw_loop_buffer) + + # Combine it with the data types. The result will be a long iterable of 2-way + # tuples: (type, raw-value) + raw_loop_tuples = zip(loop_types, data_tuple) + + # Convert to a dictionary: + raw_loop_packet = dict(raw_loop_tuples) + # Add the bucket type. It's needed to decode rain bucket tips. + raw_loop_packet['bucket_type'] = self.rain_bucket_type + + loop_packet = { + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US + } + # Now we need to map the raw values to physical units. + for _type in raw_loop_packet: + if _type in extra_sensors and self.hardware_type == 17: + # Vantage Vues do not support extra sensors. Skip them. + continue + # Get the mapping function for this type. If there is + # no such function, supply a lambda function that returns None + func = _loop_map.get(_type, lambda p, k: None) + # Apply the function + val = func(raw_loop_packet, _type) + # Ignore None values: + if val is not None: + loop_packet[_type] = val + + # Adjust sunrise and sunset: + start_of_day = weeutil.weeutil.startOfDay(loop_packet['dateTime']) + if 'sunrise' in loop_packet: + loop_packet['sunrise'] += start_of_day + if 'sunset' in loop_packet: + loop_packet['sunset'] += start_of_day + + # Because the Davis stations do not offer bucket tips in LOOP data, we + # must calculate it by looking for changes in rain totals. This won't + # work for the very first rain packet. + if self.save_day_rain is None: + delta = None + else: + delta = loop_packet['dayRain'] - self.save_day_rain + # If the difference is negative, we're at the beginning of a month. + if delta < 0: delta = None + loop_packet['rain'] = delta + self.save_day_rain = loop_packet['dayRain'] + + return loop_packet + + def _unpackArchivePacket(self, raw_archive_buffer): + """Decode a Davis archive packet, returning the results as a dictionary. + + raw_archive_buffer: The archive record data buffer, passed in as + a string (Python 2), or a byte array (Python 3). + + returns: + + A dictionary. The key will be an observation type, the value will be + the observation in physical units.""" + + # Get the record type. It's in byte 42. + record_type = raw_archive_buffer[42] + + if record_type == 0xff: + # Rev A packet type: + rec_struct = rec_A_struct + rec_types = rec_types_A + elif record_type == 0x00: + # Rev B packet type: + rec_struct = rec_B_struct + rec_types = rec_types_B + else: + raise weewx.UnknownArchiveType("Unknown archive type = 0x%x" % (record_type,)) + + data_tuple = rec_struct.unpack(raw_archive_buffer) + + raw_archive_record = dict(zip(rec_types, data_tuple)) + raw_archive_record['bucket_type'] = self.rain_bucket_type + + archive_record = { + 'dateTime': _archive_datetime(raw_archive_record['date_stamp'], + raw_archive_record['time_stamp']), + 'usUnits': weewx.US, + # Divide archive interval by 60 to keep consistent with wview + 'interval': int(self.archive_interval // 60), + } + + archive_record['rxCheckPercent'] = _rxcheck(self.model_type, + archive_record['interval'], + self.iss_id, + raw_archive_record['wind_samples']) + + for _type in raw_archive_record: + if _type in extra_sensors and self.hardware_type == 17: + # VantageVues do not support extra sensors. Skip them. + continue + # Get the mapping function for this type. If there is no such + # function, supply a lambda function that will just return None + func = _archive_map.get(_type, lambda p, k: None) + # Call the function: + val = func(raw_archive_record, _type) + # Skip all null values + if val is not None: + archive_record[_type] = val + + return archive_record + + +# =============================================================================== +# LOOP packet +# =============================================================================== + + +# A list of all the types held in a Vantage LOOP packet in their native order. +loop1_schema = [ + ('loop', '3s'), ('rev_type', 'b'), ('packet_type', 'B'), + ('next_record', 'H'), ('barometer', 'H'), ('inTemp', 'h'), + ('inHumidity', 'B'), ('outTemp', 'h'), ('windSpeed', 'B'), + ('windSpeed10', 'B'), ('windDir', 'H'), ('extraTemp1', 'B'), + ('extraTemp2', 'B'), ('extraTemp3', 'B'), ('extraTemp4', 'B'), + ('extraTemp5', 'B'), ('extraTemp6', 'B'), ('extraTemp7', 'B'), + ('soilTemp1', 'B'), ('soilTemp2', 'B'), ('soilTemp3', 'B'), + ('soilTemp4', 'B'), ('leafTemp1', 'B'), ('leafTemp2', 'B'), + ('leafTemp3', 'B'), ('leafTemp4', 'B'), ('outHumidity', 'B'), + ('extraHumid1', 'B'), ('extraHumid2', 'B'), ('extraHumid3', 'B'), + ('extraHumid4', 'B'), ('extraHumid5', 'B'), ('extraHumid6', 'B'), + ('extraHumid7', 'B'), ('rainRate', 'H'), ('UV', 'B'), + ('radiation', 'H'), ('stormRain', 'H'), ('stormStart', 'H'), + ('dayRain', 'H'), ('monthRain', 'H'), ('yearRain', 'H'), + ('dayET', 'H'), ('monthET', 'H'), ('yearET', 'H'), + ('soilMoist1', 'B'), ('soilMoist2', 'B'), ('soilMoist3', 'B'), + ('soilMoist4', 'B'), ('leafWet1', 'B'), ('leafWet2', 'B'), + ('leafWet3', 'B'), ('leafWet4', 'B'), ('insideAlarm', 'B'), + ('rainAlarm', 'B'), ('outsideAlarm1', 'B'), ('outsideAlarm2', 'B'), + ('extraAlarm1', 'B'), ('extraAlarm2', 'B'), ('extraAlarm3', 'B'), + ('extraAlarm4', 'B'), ('extraAlarm5', 'B'), ('extraAlarm6', 'B'), + ('extraAlarm7', 'B'), ('extraAlarm8', 'B'), ('soilLeafAlarm1', 'B'), + ('soilLeafAlarm2', 'B'), ('soilLeafAlarm3', 'B'), ('soilLeafAlarm4', 'B'), + ('txBatteryStatus', 'B'), ('consBatteryVoltage', 'H'), ('forecastIcon', 'B'), + ('forecastRule', 'B'), ('sunrise', 'H'), ('sunset', 'H') +] + +loop2_schema = [ + ('loop', '3s'), ('trendIcon', 'b'), ('packet_type', 'B'), + ('_unused', 'H'), ('barometer', 'H'), ('inTemp', 'h'), + ('inHumidity', 'B'), ('outTemp', 'h'), ('windSpeed', 'B'), + ('_unused', 'B'), ('windDir', 'H'), ('windSpeed10', 'H'), + ('windSpeed2', 'H'), ('windGust10', 'H'), ('windGustDir10', 'H'), + ('_unused', 'H'), ('_unused', 'H'), ('dewpoint', 'h'), + ('_unused', 'B'), ('outHumidity', 'B'), ('_unused', 'B'), + ('heatindex', 'h'), ('windchill', 'h'), ('THSW', 'h'), + ('rainRate', 'H'), ('UV', 'B'), ('radiation', 'H'), + ('stormRain', 'H'), ('stormStart', 'H'), ('dayRain', 'H'), + ('rain15', 'H'), ('hourRain', 'H'), ('dayET', 'H'), + ('rain24', 'H'), ('bar_reduction', 'B'), ('bar_offset', 'h'), + ('bar_calibration', 'h'), ('pressure_raw', 'H'), ('pressure', 'H'), + ('altimeter', 'H'), ('_unused', 'B'), ('_unused', 'B'), + ('_unused_graph', 'B'), ('_unused_graph', 'B'), ('_unused_graph', 'B'), + ('_unused_graph', 'B'), ('_unused_graph', 'B'), ('_unused_graph', 'B'), + ('_unused_graph', 'B'), ('_unused_graph', 'B'), ('_unused_graph', 'B'), + ('_unused_graph', 'B'), ('_unused', 'H'), ('_unused', 'H'), + ('_unused', 'H'), ('_unused', 'H'), ('_unused', 'H'), + ('_unused', 'H') +] + +# Extract the types and struct.Struct formats for the two types of LOOP packets +loop1_types, loop1_code = zip(*loop1_schema) +loop1_struct = struct.Struct('<' + ''.join(loop1_code)) +loop2_types, loop2_code = zip(*loop2_schema) +loop2_struct = struct.Struct('<' + ''.join(loop2_code)) + +# =============================================================================== +# archive packet +# =============================================================================== + +rec_A_schema =[ + ('date_stamp', 'H'), ('time_stamp', 'H'), ('outTemp', 'h'), + ('highOutTemp', 'h'), ('lowOutTemp', 'h'), ('rain', 'H'), + ('rainRate', 'H'), ('barometer', 'H'), ('radiation', 'H'), + ('wind_samples', 'H'), ('inTemp', 'h'), ('inHumidity', 'B'), + ('outHumidity', 'B'), ('windSpeed', 'B'), ('windGust', 'B'), + ('windGustDir', 'B'), ('windDir', 'B'), ('UV', 'B'), + ('ET', 'B'), ('invalid_data', 'B'), ('soilMoist1', 'B'), + ('soilMoist2', 'B'), ('soilMoist3', 'B'), ('soilMoist4', 'B'), + ('soilTemp1', 'B'), ('soilTemp2', 'B'), ('soilTemp3', 'B'), + ('soilTemp4', 'B'), ('leafWet1', 'B'), ('leafWet2', 'B'), + ('leafWet3', 'B'), ('leafWet4', 'B'), ('extraTemp1', 'B'), + ('extraTemp2', 'B'), ('extraHumid1', 'B'), ('extraHumid2','B'), + ('readClosed', 'H'), ('readOpened', 'H'), ('unused', 'B') +] + +rec_B_schema = [ + ('date_stamp', 'H'), ('time_stamp', 'H'), ('outTemp', 'h'), + ('highOutTemp', 'h'), ('lowOutTemp', 'h'), ('rain', 'H'), + ('rainRate', 'H'), ('barometer', 'H'), ('radiation', 'H'), + ('wind_samples', 'H'), ('inTemp', 'h'), ('inHumidity', 'B'), + ('outHumidity', 'B'), ('windSpeed', 'B'), ('windGust', 'B'), + ('windGustDir', 'B'), ('windDir', 'B'), ('UV', 'B'), + ('ET', 'B'), ('highRadiation', 'H'), ('highUV', 'B'), + ('forecastRule', 'B'), ('leafTemp1', 'B'), ('leafTemp2', 'B'), + ('leafWet1', 'B'), ('leafWet2', 'B'), ('soilTemp1', 'B'), + ('soilTemp2', 'B'), ('soilTemp3', 'B'), ('soilTemp4', 'B'), + ('download_record_type', 'B'), ('extraHumid1', 'B'), ('extraHumid2','B'), + ('extraTemp1', 'B'), ('extraTemp2', 'B'), ('extraTemp3', 'B'), + ('soilMoist1', 'B'), ('soilMoist2', 'B'), ('soilMoist3', 'B'), + ('soilMoist4', 'B') +] + +# Extract the types and struct.Struct formats for the two types of archive packets: +rec_types_A, fmt_A = zip(*rec_A_schema) +rec_types_B, fmt_B = zip(*rec_B_schema) +rec_A_struct = struct.Struct('<' + ''.join(fmt_A)) +rec_B_struct = struct.Struct('<' + ''.join(fmt_B)) + +# These are extra sensors, not found on the Vues. +extra_sensors = { + 'leafTemp1', 'leafTemp2', 'leafWet1', 'leafWet2', + 'soilTemp1', 'soilTemp2', 'soilTemp3', 'soilTemp4', + 'extraHumid1', 'extraHumid2', 'extraTemp1', 'extraTemp2', 'extraTemp3', + 'soilMoist1', 'soilMoist2', 'soildMoist3', 'soilMoist4' +} + + +def _rxcheck(model_type, interval, iss_id, number_of_wind_samples): + """Gives an estimate of the fraction of packets received. + + Ref: Vantage Serial Protocol doc, V2.1.0, released 25-Jan-05; p42""" + # The formula for the expected # of packets varies with model number. + if model_type == 1: + _expected_packets = float(interval * 60) / (2.5 + (iss_id - 1) / 16.0) - \ + float(interval * 60) / (50.0 + (iss_id - 1) * 1.25) + elif model_type == 2: + _expected_packets = 960.0 * interval / float(41 + iss_id - 1) + else: + return None + _frac = number_of_wind_samples * 100.0 / _expected_packets + if _frac > 100.0: + _frac = 100.0 + return _frac + + +# =============================================================================== +# Decoding routines +# =============================================================================== + + +def _archive_datetime(datestamp, timestamp): + """Returns the epoch time of the archive packet.""" + try: + # Construct a time tuple from Davis time. Unfortunately, as timestamps come + # off the Vantage logger, there is no way of telling whether DST is + # in effect. So, have the operating system guess by using a '-1' in the last + # position of the time tuple. It's the best we can do... + time_tuple = (((0xfe00 & datestamp) >> 9) + 2000, # year + (0x01e0 & datestamp) >> 5, # month + (0x001f & datestamp), # day + timestamp // 100, # hour + timestamp % 100, # minute + 0, # second + 0, 0, -1) # have OS guess DST + # Convert to epoch time: + ts = int(time.mktime(time_tuple)) + except (OverflowError, ValueError, TypeError): + ts = None + return ts + + +def _loop_date(p, k): + """Returns the epoch time stamp of a time encoded in the LOOP packet, + which, for some reason, uses a different encoding scheme than the archive packet. + Also, the Davis documentation isn't clear whether "bit 0" refers to the least-significant + bit, or the most-significant bit. I'm assuming the former, which is the usual + in little-endian machines.""" + v = p[k] + if v == 0xffff: + return None + time_tuple = ((0x007f & v) + 2000, # year + (0xf000 & v) >> 12, # month + (0x0f80 & v) >> 7, # day + 0, 0, 0, # h, m, s + 0, 0, -1) + # Convert to epoch time: + try: + ts = int(time.mktime(time_tuple)) + except (OverflowError, ValueError): + ts = None + return ts + + +def _decode_rain(p, k): + # The Davis documentation makes no mention that rain can have a "dash" value, + # but we've seen them. Detect them. + if p[k] == 0xFFFF: + return None + elif p['bucket_type'] == 0: + # 0.01 inch bucket + return p[k] / 100.0 + elif p['bucket_type'] == 1: + # 0.2 mm bucket + return p[k] * 0.0078740157 + elif p['bucket_type'] == 2: + # 0.1 mm bucket + return p[k] * 0.00393700787 + else: + log.warning("Unknown bucket type %s" % p['bucket_type']) + + +def _decode_windSpeed_H(p, k): + """Decode 10-min average wind speed. It is encoded slightly + differently between type 0 and type 1 LOOP packets.""" + if p['packet_type'] == 0: + return float(p[k]) if p[k] != 0xff else None + elif p['packet_type'] == 1: + return float(p[k]) / 10.0 if p[k] != 0xffff else None + else: + log.warning("Unknown LOOP packet type %s" % p['packet_type']) + + +# This dictionary maps a type key to a function. The function should be able to +# decode a sensor value held in the LOOP packet in the internal, Davis form into US +# units and return it. +# NB: 5/28/2022. In a private email with Davis support, they say that leafWet3 and leafWet4 should +# always be ignored. They are not supported. +_loop_map = { + 'altimeter' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'bar_calibration' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'bar_offset' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'bar_reduction' : lambda p, k: p[k], + 'barometer' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'consBatteryVoltage': lambda p, k: float((p[k] * 300) >> 9) / 100.0, + 'dayET' : lambda p, k: float(p[k]) / 1000.0, + 'dayRain' : _decode_rain, + 'dewpoint' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'extraAlarm1' : lambda p, k: p[k], + 'extraAlarm2' : lambda p, k: p[k], + 'extraAlarm3' : lambda p, k: p[k], + 'extraAlarm4' : lambda p, k: p[k], + 'extraAlarm5' : lambda p, k: p[k], + 'extraAlarm6' : lambda p, k: p[k], + 'extraAlarm7' : lambda p, k: p[k], + 'extraAlarm8' : lambda p, k: p[k], + 'extraHumid1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid5' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid6' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid7' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp5' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp6' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp7' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'forecastIcon' : lambda p, k: p[k], + 'forecastRule' : lambda p, k: p[k], + 'heatindex' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'hourRain' : _decode_rain, + 'inHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'insideAlarm' : lambda p, k: p[k], + 'inTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'leafTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafWet1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet3' : lambda p, k: None, # Vantage supports only 2 leaf wetness sensors + 'leafWet4' : lambda p, k: None, + 'monthET' : lambda p, k: float(p[k]) / 100.0, + 'monthRain' : _decode_rain, + 'outHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'outsideAlarm1' : lambda p, k: p[k], + 'outsideAlarm2' : lambda p, k: p[k], + 'outTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'pressure' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'pressure_raw' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'radiation' : lambda p, k: float(p[k]) if p[k] != 0x7fff else None, + 'rain15' : _decode_rain, + 'rain24' : _decode_rain, + 'rainAlarm' : lambda p, k: p[k], + 'rainRate' : _decode_rain, + 'soilLeafAlarm1' : lambda p, k: p[k], + 'soilLeafAlarm2' : lambda p, k: p[k], + 'soilLeafAlarm3' : lambda p, k: p[k], + 'soilLeafAlarm4' : lambda p, k: p[k], + 'soilMoist1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'stormRain' : _decode_rain, + 'stormStart' : _loop_date, + 'sunrise' : lambda p, k: 3600 * (p[k] // 100) + 60 * (p[k] % 100), + 'sunset' : lambda p, k: 3600 * (p[k] // 100) + 60 * (p[k] % 100), + 'THSW' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'trendIcon' : lambda p, k: p[k], + 'txBatteryStatus' : lambda p, k: int(p[k]), + 'UV' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0xff else None, + 'windchill' : lambda p, k: float(p[k]) if p[k] & 0xff != 0xff else None, + 'windDir' : lambda p, k: (float(p[k]) if p[k] != 360 else 0) if p[k] and p[k] != 0x7fff else None, + 'windGust10' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'windGustDir10' : lambda p, k: (float(p[k]) if p[k] != 360 else 0) if p[k] and p[k] != 0x7fff else None, + 'windSpeed' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'windSpeed10' : _decode_windSpeed_H, + 'windSpeed2' : _decode_windSpeed_H, + 'yearET' : lambda p, k: float(p[k]) / 100.0, + 'yearRain' : _decode_rain, +} + +# This dictionary maps a type key to a function. The function should be able to +# decode a sensor value held in the archive packet in the internal, Davis form into US +# units and return it. +_archive_map = { + 'barometer' : lambda p, k: float(p[k]) / 1000.0 if p[k] else None, + 'ET' : lambda p, k: float(p[k]) / 1000.0, + 'extraHumid1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraHumid2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'extraTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'extraTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'forecastRule' : lambda p, k: p[k] if p[k] != 193 else None, + 'highOutTemp' : lambda p, k: float(p[k] / 10.0) if p[k] != -32768 else None, + 'highRadiation' : lambda p, k: float(p[k]) if p[k] != 0x7fff else None, + 'highUV' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0xff else None, + 'inHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'inTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'leafTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'leafWet1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'leafWet4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'lowOutTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'outHumidity' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'outTemp' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0x7fff else None, + 'radiation' : lambda p, k: float(p[k]) if p[k] != 0x7fff else None, + 'rain' : _decode_rain, + 'rainRate' : _decode_rain, + 'readClosed' : lambda p, k: p[k], + 'readOpened' : lambda p, k: p[k], + 'soilMoist1' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist2' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist3' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilMoist4' : lambda p, k: float(p[k]) if p[k] != 0xff else None, + 'soilTemp1' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp2' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp3' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'soilTemp4' : lambda p, k: float(p[k] - 90) if p[k] != 0xff else None, + 'UV' : lambda p, k: float(p[k]) / 10.0 if p[k] != 0xff else None, + 'wind_samples' : lambda p, k: float(p[k]) if p[k] else None, + 'windDir' : lambda p, k: float(p[k]) * 22.5 if p[k] != 0xff else None, + 'windGust' : lambda p, k: float(p[k]), + 'windGustDir' : lambda p, k: float(p[k]) * 22.5 if p[k] != 0xff else None, + 'windSpeed' : lambda p, k: float(p[k]) if p[k] != 0xff else None, +} + + +# =============================================================================== +# class VantageService +# =============================================================================== + +# This class uses multiple inheritance: + +class VantageService(Vantage, weewx.engine.StdService): + """Weewx service for the Vantage weather stations.""" + + def __init__(self, engine, config_dict): + Vantage.__init__(self, **config_dict[DRIVER_NAME]) + weewx.engine.StdService.__init__(self, engine, config_dict) + + self.max_loop_gust = 0.0 + self.max_loop_gustdir = None + + self.bind(weewx.STARTUP, self.startup) + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.END_ARCHIVE_PERIOD, self.end_archive_period) + + def startup(self, event): # @UnusedVariable + self.max_loop_gust = 0.0 + self.max_loop_gustdir = None + + def closePort(self): + # Now close my superclass's port: + Vantage.closePort(self) + + def new_loop_packet(self, event): + """Calculate the max gust seen since the last archive record.""" + + # Calculate the max gust seen since the start of this archive record + # and put it in the packet. + windSpeed = event.packet.get('windSpeed') + windDir = event.packet.get('windDir') + if windSpeed is not None and windSpeed > self.max_loop_gust: + self.max_loop_gust = windSpeed + self.max_loop_gustdir = windDir + event.packet['windGust'] = self.max_loop_gust + event.packet['windGustDir'] = self.max_loop_gustdir + + def end_archive_period(self, event): + """Zero out the max gust seen since the start of the record""" + self.max_loop_gust = 0.0 + self.max_loop_gustdir = None + + +# =============================================================================== +# Class VantageConfigurator +# =============================================================================== + +class VantageConfigurator(weewx.drivers.AbstractConfigurator): + """Configures the Davis Vantage weather station.""" + + # Comments about retransmitting and repeaters + # + # Retransmitting + # Any console can retransmit (not to be confused with acting as a repeater). + # This is done through either the setup screen, or by using weectl device. For example, + # weectl device --set-retransmit=on,4 + # would tell the console to retransmit data from the ISS on channel 4. + # Another console can then be configured to receive information from this channel, + # rather than directly from the ISS: + # weectl device --set-transmitter-type=4,0 + # This says listen for an ISS on channel 4. Note that no repeater is involved. + + # Repeaters + # A repeater is a specialized bit of Davis hardware that retransmits signals of any kind + # (not just the ISS). If you want to use a repeater, you need to tell the console not only + # which channel to listen to, but also what sensor will be on that channel. + # The setting up of a repeater is covered in the VP2 Console Manual, Appendix C (last page). + # You must set both the channel, and the repeater ID. For example, a temperature/humidity + # station on channel 5, which uses repeater B, would be set with the following: + # weectl device --set-transmitter-type=5,3,2,4,B + # This says to look for the station on channel 5. It will be of type temp_hum (3). The + # temperature will appear as extraTemp2, the humidity as extraHumid4. It will use + # repeater "B". + + @property + def description(self): + return "Configures the Davis Vantage weather station." + + @property + def usage(self): + return """%prog --help + %prog --info [FILENAME|--config=FILENAME] + %prog --current [FILENAME|--config=FILENAME] + %prog --clear-memory [FILENAME|--config=FILENAME] [-y] + %prog --set-interval=MINUTES [FILENAME|--config=FILENAME] [-y] + %prog --set-latitude=DEGREE [FILENAME|--config=FILENAME] [-y] + %prog --set-longitude=DEGREE [FILENAME|--config=FILENAME] [-y] + %prog --set-altitude=FEET [FILENAME|--config=FILENAME] [-y] + %prog --set-barometer=inHg [FILENAME|--config=FILENAME] [-y] + %prog --set-wind-cup=CODE [FILENAME|--config=FILENAME] [-y] + %prog --set-bucket=CODE [FILENAME|--config=FILENAME] [-y] + %prog --set-rain-year-start=MM [FILENAME|--config=FILENAME] [-y] + %prog --set-offset=VARIABLE,OFFSET [FILENAME|--config=FILENAME] [-y] + %prog --set-transmitter-type=CHANNEL,TYPE,TEMP,HUM,REPEATER_ID [FILENAME|--config=FILENAME] [-y] + %prog --set-retransmit=[OFF|ON|ON,CHANNEL] [FILENAME|--config=FILENAME] [-y] + %prog --set-temperature-logging=[LAST|AVERAGE] [FILENAME|--config=FILENAME] [-y] + %prog --set-time [FILENAME|--config=FILENAME] [-y] + %prog --set-dst=[AUTO|ON|OFF] [FILENAME|--config=FILENAME] [-y] + %prog --set-tz-code=TZCODE [FILENAME|--config=FILENAME] [-y] + %prog --set-tz-offset=HHMM [FILENAME|--config=FILENAME] [-y] + %prog --set-lamp=[ON|OFF] [FILENAME|--config=FILENAME] + %prog --dump [--batch-size=BATCH_SIZE] [FILENAME|--config=FILENAME] [-y] + %prog --logger-summary=FILE [FILENAME|--config=FILENAME] [-y] + %prog [--start | --stop] [FILENAME|--config=FILENAME]""" + + def add_options(self, parser): + super(VantageConfigurator, self).add_options(parser) + parser.add_option("--info", action="store_true", dest="info", + help="To print configuration, reception, and barometer " + "calibration information about your weather station.") + parser.add_option("--current", action="store_true", + help="To print current LOOP information.") + parser.add_option("--clear-memory", action="store_true", dest="clear_memory", + help="To clear the memory of your weather station.") + parser.add_option("--set-interval", type=int, dest="set_interval", + metavar="MINUTES", + help="Sets the archive interval to the specified number of minutes. " + "Valid values are 1, 5, 10, 15, 30, 60, or 120.") + parser.add_option("--set-latitude", type=float, dest="set_latitude", + metavar="DEGREE", + help="Sets the latitude of the station to the specified number of tenth degree.") + parser.add_option("--set-longitude", type=float, dest="set_longitude", + metavar="DEGREE", + help="Sets the longitude of the station to the specified number of tenth degree.") + parser.add_option("--set-altitude", type=float, dest="set_altitude", + metavar="FEET", + help="Sets the altitude of the station to the specified number of feet.") + parser.add_option("--set-barometer", type=float, dest="set_barometer", + metavar="inHg", + help="Sets the barometer reading of the station to a known correct " + "value in inches of mercury. Specify 0 (zero) to have the console " + "pick a sensible value.") + parser.add_option("--set-wind-cup", type=int, dest="set_wind_cup", + metavar="CODE", + help="Set the type of wind cup. Specify '0' for small size; '1' for large size") + parser.add_option("--set-bucket", type=int, dest="set_bucket", + metavar="CODE", + help="Set the type of rain bucket. Specify '0' for 0.01 inches; " + "'1' for 0.2 mm; '2' for 0.1 mm") + parser.add_option("--set-rain-year-start", type=int, + dest="set_rain_year_start", metavar="MM", + help="Set the rain year start (1=Jan, 2=Feb, etc.).") + parser.add_option("--set-offset", type=str, + dest="set_offset", metavar="VARIABLE,OFFSET", + help="Set the onboard offset for VARIABLE inTemp, outTemp, extraTemp[1-7], " + "inHumid, outHumid, extraHumid[1-7], soilTemp[1-4], leafTemp[1-4], windDir) " + "to OFFSET (Fahrenheit, %, degrees)") + parser.add_option("--set-transmitter-type", type=str, + dest="set_transmitter_type", + metavar="CHANNEL,TYPE,TEMP,HUM,REPEATER_ID", + help="Set the transmitter type for CHANNEL (1-8), TYPE (0=iss, 1=temp, " + "2=hum, 3=temp_hum, 4=wind, 5=rain, 6=leaf, 7=soil, 8=leaf_soil, " + "9=sensorlink, 10=none), as extra TEMP station and extra HUM " + "station (both 1-7, if applicable), REPEATER_ID (A-H, or 0=OFF)") + parser.add_option("--set-retransmit", type=str, dest="set_retransmit", + metavar="OFF|ON|ON,CHANNEL", + help="Turn ISS retransmit 'ON' or 'OFF', using optional CHANNEL.") + parser.add_option("--set-temperature-logging", dest="set_temp_logging", + metavar="LAST|AVERAGE", + help="Set console temperature logging to either 'LAST' or 'AVERAGE'.") + parser.add_option("--set-time", action="store_true", dest="set_time", + help="Set the onboard clock to the current time.") + parser.add_option("--set-dst", dest="set_dst", + metavar="AUTO|ON|OFF", + help="Set DST to 'ON', 'OFF', or 'AUTO'") + parser.add_option("--set-tz-code", type=int, dest="set_tz_code", + metavar="TZCODE", + help="Set timezone code to TZCODE. See your Vantage manual for " + "valid codes.") + parser.add_option("--set-tz-offset", dest="set_tz_offset", + help="Set timezone offset to HHMM. E.g. '-0800' for U.S. Pacific Time.", + metavar="HHMM") + parser.add_option("--set-lamp", dest="set_lamp", + metavar="ON|OFF", + help="Turn the console lamp 'ON' or 'OFF'.") + parser.add_option("--dump", action="store_true", + help="Dump all data to the archive. " + "NB: This may result in many duplicate primary key errors.") + parser.add_option("--batch-size", type=int, default=1, metavar="BATCH_SIZE", + help="Use with option --dump. Pages are read off the console in batches " + "of BATCH_SIZE. A BATCH_SIZE of zero means dump all data first, " + "then put it in the database. This can improve performance in " + "high-latency environments, but requires sufficient memory to " + "hold all station data. Default is 1 (one).") + parser.add_option("--logger-summary", type="string", + dest="logger_summary", metavar="FILE", + help="Save diagnostic summary to FILE (for debugging the logger).") + parser.add_option("--start", action="store_true", + help="Start the logger.") + parser.add_option("--stop", action="store_true", + help="Stop the logger.") + + def do_options(self, options, parser, config_dict, prompt): + if options.start and options.stop: + parser.error("Cannot specify both --start and --stop") + if options.set_tz_code and options.set_tz_offset: + parser.error("Cannot specify both --set-tz-code and --set-tz-offset") + + station = Vantage(**config_dict[DRIVER_NAME]) + if options.info: + self.show_info(station) + if options.current: + self.current(station) + if options.set_interval is not None: + self.set_interval(station, options.set_interval, options.noprompt) + if options.set_latitude is not None: + self.set_latitude(station, options.set_latitude, options.noprompt) + if options.set_longitude is not None: + self.set_longitude(station, options.set_longitude, options.noprompt) + if options.set_altitude is not None: + self.set_altitude(station, options.set_altitude, options.noprompt) + if options.set_barometer is not None: + self.set_barometer(station, options.set_barometer, options.noprompt) + if options.clear_memory: + self.clear_memory(station, options.noprompt) + if options.set_wind_cup is not None: + self.set_wind_cup(station, options.set_wind_cup, options.noprompt) + if options.set_bucket is not None: + self.set_bucket(station, options.set_bucket, options.noprompt) + if options.set_rain_year_start is not None: + self.set_rain_year_start(station, options.set_rain_year_start, options.noprompt) + if options.set_offset is not None: + self.set_offset(station, options.set_offset, options.noprompt) + if options.set_transmitter_type is not None: + self.set_transmitter_type(station, options.set_transmitter_type, options.noprompt) + if options.set_retransmit is not None: + self.set_retransmit(station, options.set_retransmit, options.noprompt) + if options.set_temp_logging is not None: + self.set_temp_logging(station, options.set_temp_logging, options.noprompt) + if options.set_time: + self.set_time(station) + if options.set_dst: + self.set_dst(station, options.set_dst) + if options.set_tz_code: + self.set_tz_code(station, options.set_tz_code) + if options.set_tz_offset: + self.set_tz_offset(station, options.set_tz_offset) + if options.set_lamp: + self.set_lamp(station, options.set_lamp) + if options.dump: + self.dump_logger(station, config_dict, options.noprompt, options.batch_size) + if options.logger_summary: + self.logger_summary(station, options.logger_summary) + if options.start: + self.start_logger(station) + if options.stop: + self.stop_logger(station) + + @staticmethod + def show_info(station, dest=sys.stdout): + """Query the configuration of the Vantage, printing out status + information""" + + print("Querying...") + try: + _firmware_date = station.getFirmwareDate().decode('ascii') + except weewx.RetriesExceeded: + _firmware_date = "" + try: + _firmware_version = station.getFirmwareVersion().decode('ascii') + except weewx.RetriesExceeded: + _firmware_version = '' + + console_time = station.getConsoleTime() + altitude_converted = weewx.units.convert(station.altitude_vt, station.altitude_unit)[0] + + print("""Davis Vantage EEPROM settings: + + CONSOLE TYPE: %s + + CONSOLE FIRMWARE: + Date: %s + Version: %s + + CONSOLE SETTINGS: + Archive interval: %d (seconds) + Altitude: %d (%s) + Wind cup type: %s + Rain bucket type: %s + Rain year start: %d + Onboard time: %s + + CONSOLE DISPLAY UNITS: + Barometer: %s + Temperature: %s + Rain: %s + Wind: %s + """ % (station.hardware_name, _firmware_date, _firmware_version, + station.archive_interval, + altitude_converted, station.altitude_unit, + station.wind_cup_size, station.rain_bucket_size, + station.rain_year_start, console_time, + station.barometer_unit, station.temperature_unit, + station.rain_unit, station.wind_unit), file=dest) + + try: + stnlat, stnlon, man_or_auto, dst, gmt_or_zone, \ + zone_code, gmt_offset, tempLogging = station.getStnInfo() + if man_or_auto == 'AUTO': + dst = 'N/A' + if gmt_or_zone == 'ZONE_CODE': + gmt_offset_str = 'N/A' + else: + gmt_offset_str = "%+.1f hours" % gmt_offset + zone_code = 'N/A' + print(""" CONSOLE STATION INFO: + Latitude (onboard): %+0.1f + Longitude (onboard): %+0.1f + Use manual or auto DST? %s + DST setting: %s + Use GMT offset or zone code? %s + Time zone code: %s + GMT offset: %s + Temperature logging: %s + """ % (stnlat, stnlon, man_or_auto, dst, gmt_or_zone, zone_code, gmt_offset_str, + tempLogging), file=dest) + except weewx.RetriesExceeded: + pass + + # Add transmitter types for each channel, if we can: + try: + transmitter_list = station.getStnTransmitters() + except weewx.RetriesExceeded: + transmitter_list = None + else: + print(" TRANSMITTERS: ", file=dest) + print(" Channel Receive Retransmit Repeater Type", file=dest) + for tx_id in range(8): + comment = "" + transmitter_type = transmitter_list[tx_id]["transmitter_type"] + retransmit = transmitter_list[tx_id]["retransmit"] + repeater = transmitter_list[tx_id]["repeater"] + repeater_str = repeater if repeater else "NONE" + listen = transmitter_list[tx_id]["listen"] + if transmitter_type == 'temp_hum': + comment = "(as extra temperature %d and extra humidity %d)" \ + % (transmitter_list[tx_id]["temp"], + transmitter_list[tx_id]["hum"]) + elif transmitter_type == 'temp': + comment = "(as extra temperature %d)" \ + % transmitter_list[tx_id]["temp"] + elif transmitter_type == 'hum': + comment = "(as extra humidity %d)" \ + % transmitter_list[tx_id]["hum"] + elif transmitter_type == 'none': + transmitter_type = "(N/A)" + print(" %d %-8s %-10s%-5s %s %s" + % (tx_id + 1, listen, retransmit, repeater_str, transmitter_type, comment), file=dest) + print("", file=dest) + + # Add reception statistics if we can: + try: + _rx_list = station.getRX() + print(""" RECEPTION STATS: + Total packets received: %d + Total packets missed: %d + Number of resynchronizations: %d + Longest good stretch: %d + Number of CRC errors: %d + """ % _rx_list, file=dest) + except: + pass + + # Add barometer calibration data if we can. + try: + _bar_list = station.getBarData() + print(""" BAROMETER CALIBRATION DATA: + Current barometer reading: %.3f inHg + Altitude: %.0f feet + Dew point: %.0f F + Virtual temperature: %.0f F + Humidity correction factor: %.1f + Correction ratio: %.3f + Correction constant: %+.3f inHg + Gain: %.3f + Offset: %.3f + """ % _bar_list, file=dest) + except weewx.RetriesExceeded: + pass + + # Add temperature/humidity/wind calibration if we can. + calibration_dict = station.getStnCalibration() + print(""" OFFSETS: + Wind direction: %(wind)+.0f deg + Inside Temperature: %(inTemp)+.1f F + Inside Humidity: %(inHumid)+.0f %% + Outside Temperature: %(outTemp)+.1f F + Outside Humidity: %(outHumid)+.0f %%""" % calibration_dict, file=dest) + if transmitter_list is not None: + # Only print the calibrations for channels that we are + # listening to. + for extraTemp in range(1, 8): + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['temp', 'temp_hum'] and \ + extraTemp == transmitter_list[t_id]["temp"]: + print(" Extra Temperature %d: %+.1f F" + % (extraTemp, calibration_dict["extraTemp%d" % extraTemp]), + file=dest) + for extraHumid in range(1, 8): + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['hum', 'temp_hum'] and \ + extraHumid == transmitter_list[t_id]["hum"]: + print(" Extra Humidity %d: %+.1f F" + % (extraHumid, calibration_dict["extraHumid%d" % extraHumid]), + file=dest) + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['soil', 'leaf_soil']: + for soil in range(1, 5): + print(" Soil Temperature %d: %+.1f F" + % (soil, calibration_dict["soilTemp%d" % soil]), file=dest) + for t_id in range(0, 8): + t_type = transmitter_list[t_id]["transmitter_type"] + if t_type in ['leaf', 'leaf_soil']: + for leaf in range(1, 5): + print(" Leaf Temperature %d: %+.1f F" + % (leaf, calibration_dict["leafTemp%d" % leaf]), file=dest) + print("", file=dest) + + @staticmethod + def current(station): + """Print a single, current LOOP packet.""" + print('Querying the station for current weather data...') + for pack in station.genDavisLoopPackets(1): + print(weeutil.weeutil.timestamp_to_string(pack['dateTime']), + to_sorted_string(pack)) + + @staticmethod + def set_interval(station, new_interval_minutes, noprompt): + """Set the console archive interval.""" + + old_interval_minutes = station.archive_interval // 60 + print("Old archive interval is %d minutes, new one will be %d minutes." + % (station.archive_interval // 60, new_interval_minutes)) + if old_interval_minutes == new_interval_minutes: + print("Old and new archive intervals are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the archive interval " + "as well as erase all old archive records.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setArchiveInterval(new_interval_minutes * 60) + print("Archive interval now set to %d seconds." % (station.archive_interval,)) + # The Davis documentation implies that the log is + # cleared after changing the archive interval, but that + # doesn't seem to be the case. Clear it explicitly: + station.clearLog() + print("Archive records erased.") + else: + print("Nothing done.") + + @staticmethod + def set_latitude(station, latitude_dg, noprompt): + """Set the console station latitude""" + + ans = weeutil.weeutil.y_or_n("Proceeding will set the latitude value to %.1f degree.\n" + "Are you sure you wish to proceed (y/n)? " % latitude_dg, + noprompt) + if ans == 'y': + station.setLatitude(latitude_dg) + print("Station latitude set to %.1f degree." % latitude_dg) + else: + print("Nothing done.") + + @staticmethod + def set_longitude(station, longitude_dg, noprompt): + """Set the console station longitude""" + + ans = weeutil.weeutil.y_or_n("Proceeding will set the longitude value to %.1f degree.\n" + "Are you sure you wish to proceed (y/n)? " % longitude_dg, + noprompt) + if ans == 'y': + station.setLongitude(longitude_dg) + print("Station longitude set to %.1f degree." % longitude_dg) + else: + print("Nothing done.") + + @staticmethod + def set_altitude(station, altitude_ft, noprompt): + """Set the console station altitude""" + ans = weeutil.weeutil.y_or_n("Proceeding will set the station altitude to %.0f feet.\n" + "Are you sure you wish to proceed (y/n)? " % altitude_ft, + noprompt) + if ans == 'y': + # Hit the console to get the current barometer calibration data and preserve it: + _bardata = station.getBarData() + _barcal = _bardata[6] + # Set new altitude to station and clear previous _barcal value + station.setBarData(0.0, altitude_ft) + if _barcal != 0.0: + # Hit the console again to get the new barometer data: + _bardata = station.getBarData() + # Set previous _barcal value + station.setBarData(_bardata[0] + _barcal, altitude_ft) + else: + print("Nothing done.") + + @staticmethod + def set_barometer(station, barometer_inHg, noprompt): + """Set the barometer reading to a known correct value.""" + # Hit the console to get the current barometer calibration data: + _bardata = station.getBarData() + + if barometer_inHg: + msg = "Proceeding will set the barometer value to %.3f and " \ + "the station altitude to %.0f feet.\n" % (barometer_inHg, _bardata[1]) + else: + msg = "Proceeding will have the console pick a sensible barometer " \ + "calibration and set the station altitude to %.0f feet.\n" % (_bardata[1],) + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you wish to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setBarData(barometer_inHg, _bardata[1]) + else: + print("Nothing done.") + + @staticmethod + def clear_memory(station, noprompt): + """Clear the archive memory of a VantagePro""" + + ans = weeutil.weeutil.y_or_n("Proceeding will erase all archive records.\n" + "Are you sure you wish to proceed (y/n)? ", + noprompt) + if ans == 'y': + print("Erasing all archive records ...") + station.clearLog() + print("Archive records erased.") + else: + print("Nothing done.") + + @staticmethod + def set_wind_cup(station, new_wind_cup_type, noprompt): + """Set the wind cup type on the console.""" + + if station.hardware_type != 16: + print("Unable to set new wind cup type.") + print("Reason: command only valid with Vantage Pro or Vantage Pro2 station.", + file=sys.stderr) + return + + print("Old rain wind cup type is %d (%s), new one is %d (%s)." + % (station.wind_cup_type, + station.wind_cup_size, + new_wind_cup_type, + Vantage.wind_cup_dict[new_wind_cup_type])) + + if station.wind_cup_type == new_wind_cup_type: + print("Old and new wind cup types are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the wind cup type.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setWindCupType(new_wind_cup_type) + print("Wind cup type set to %d (%s)." + % (station.wind_cup_type, station.wind_cup_size)) + else: + print("Nothing done.") + + @staticmethod + def set_bucket(station, new_bucket_type, noprompt): + """Set the bucket type on the console.""" + + print("Old rain bucket type is %d (%s), new one is %d (%s)." + % (station.rain_bucket_type, + station.rain_bucket_size, + new_bucket_type, + Vantage.rain_bucket_dict[new_bucket_type])) + + if station.rain_bucket_type == new_bucket_type: + print("Old and new bucket types are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the rain bucket type.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setBucketType(new_bucket_type) + print("Bucket type now set to %d." % (station.rain_bucket_type,)) + else: + print("Nothing done.") + + @staticmethod + def set_rain_year_start(station, rain_year_start, noprompt): + + print("Old rain season start is %d, new one is %d." + % (station.rain_year_start, rain_year_start)) + + if station.rain_year_start == rain_year_start: + print("Old and new rain season starts are the same. Nothing done.") + else: + ans = weeutil.weeutil.y_or_n("Proceeding will change the rain season start.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setRainYearStart(rain_year_start) + print("Rain year start now set to %d." % (station.rain_year_start,)) + else: + print("Nothing done.") + + @staticmethod + def set_offset(station, offset_list, noprompt): + """Set the on-board offset for a temperature, humidity or wind direction variable.""" + (variable, offset_str) = offset_list.split(',') + # These variables may be calibrated. + temp_variables = ['inTemp', 'outTemp'] + \ + ['extraTemp%d' % i for i in range(1, 8)] + \ + ['soilTemp%d' % i for i in range(1, 5)] + \ + ['leafTemp%d' % i for i in range(1, 5)] + + humid_variables = ['inHumid', 'outHumid'] + \ + ['extraHumid%d' % i for i in range(1, 8)] + + # Wind direction can also be calibrated. + if variable == "windDir": + offset = int(offset_str) + if not -359 <= offset <= 359: + print("Wind direction offset %d is out of range." % offset, file=sys.stderr) + else: + ans = weeutil.weeutil.y_or_n( + "Proceeding will set offset for wind direction to %+d.\n" % offset + + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setCalibrationWindDir(offset) + print("Wind direction offset now set to %+d." % offset) + else: + print("Nothing done.") + elif variable in temp_variables: + offset = float(offset_str) + if not -12.8 <= offset <= 12.7: + print("Temperature offset %+.1f is out of range." % (offset), file=sys.stderr) + else: + ans = weeutil.weeutil.y_or_n("Proceeding will set offset for " + "temperature %s to %+.1f.\n" % (variable, offset) + + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setCalibrationTemp(variable, offset) + print("Temperature offset %s now set to %+.1f." % (variable, offset)) + else: + print("Nothing done.") + elif variable in humid_variables: + offset = int(offset_str) + if not 0 <= offset <= 100: + print("Humidity offset %+d is out of range." % (offset), file=sys.stderr) + else: + ans = weeutil.weeutil.y_or_n("Proceeding will set offset for " + "humidity %s to %+d.\n" % (variable, offset) + + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setCalibrationHumid(variable, offset) + print("Humidity offset %s now set to %+d." % (variable, offset)) + else: + print("Nothing done.") + else: + print("Unknown variable %s" % variable, file=sys.stderr) + + @staticmethod + def set_transmitter_type(station, transmitter_str, noprompt): + """Set the transmitter type for one of the eight channels.""" + + transmitter_list = transmitter_str.split(',') + + channel = int(transmitter_list[0]) + + # Check new channel against retransmit channel. + # Warn and stop if new channel is used as retransmit channel. + retransmit_channel = station._getEEPROM_value(0x18)[0] + if retransmit_channel == channel: + print(f"Channel {channel} is used as a retransmit channel.") + print("Please turn off retransmit function or choose another channel.") + return + + transmitter_type = int(transmitter_list[1]) + extra_temp = to_int(transmitter_list[2]) if len(transmitter_list) > 2 else None + extra_hum = to_int(transmitter_list[3]) if len(transmitter_list) > 3 else None + repeater_id = transmitter_list[4].upper() if len(transmitter_list) > 4 else None + try: + # Is it the digit zero? + if repeater_id is not None and int(repeater_id) == 0: + # Yes. Set to None + repeater_id = None + except ValueError: + # Cannot be converted to integer. Must be an ID. + pass + usetx = 0 if transmitter_type == 10 else 1 + + try: + transmitter_type_name = Vantage.transmitter_type_dict[transmitter_type] + except KeyError: + print(f'Unknown transmitter type ("{transmitter_type}")') + return + + # Check the temperature station ID + if transmitter_type_name in ['temp', 'temp_hum']: + if extra_temp is None: + print(f'A transmitter of type "{transmitter_type_name}" requires a ' + f"TEMP station ID") + return + + # Check the humidity station ID + if transmitter_type_name in ['hum', 'temp_hum']: + if extra_hum is None: + print(f'A transmitter of type "{transmitter_type_name}" requires a ' + f"HUM station ID") + return + + msg = f"Proceeding will set channel {channel} " \ + f"to type {transmitter_type:d} " \ + f"({transmitter_type_name}, {Vantage.listen_dict[usetx]}), " \ + f"repeater: {'OFF' if not repeater_id else repeater_id}\n" + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you want to proceed (y/n)? ", noprompt) + if ans == 'y': + station.setTransmitterType(channel, transmitter_type, extra_temp, extra_hum, + repeater_id) + msg = f"Set channel {channel} " \ + f"to type {transmitter_type:d} " \ + f"({transmitter_type_name}, {Vantage.listen_dict[usetx]}), " \ + f"repeater: {'OFF' if not repeater_id else repeater_id}\n" + print(msg) + log.info(msg) + else: + print("Nothing done.") + + @staticmethod + def set_retransmit(station, channel_on_off, noprompt): + """Set console retransmit channel. + + Args: + station(Vantage): An instance of class Vantage + channel_on_off (str): A comma separated string. First element is 'ON' or 'OFF'. + Second element is which channel + noprompt (bool): True to ask whether the user is sure. Otherwise, don't. + """ + + channel = None + channel_on_off_list = channel_on_off.strip().upper().split(',') + on_off = channel_on_off_list[0] + if on_off == 'OFF': + channel = 0 + elif on_off == "ON": + transmitter_list = station.getStnTransmitters() + if len(channel_on_off_list) > 1: + channel = int(channel_on_off_list[1]) + if not 1 <= channel <= 8: + print("Channel out of range 1..8.") + print("Nothing done.") + return + if transmitter_list[channel - 1]["listen"] == "active": + print("Channel %d in use. Please select another channel." % channel) + print("Nothing done.") + return + else: + # Pick one for the user + for i in range(8): + if transmitter_list[i]["listen"] == "inactive": + channel = i + 1 + break + if channel is None: + print("All Channels in use. Retransmit can't be enabled.") + print("Nothing done.") + return + else: + print(f"Unrecognized command '{on_off}'. Must be 'ON' or 'OFF'.") + print("Nothing done.") + return + + if channel: + msg = "Proceeding will set retransmit to 'ON' on channel: %d.\n" % channel + else: + msg = "Proceeding will set retransmit to 'OFF'.\n" + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setRetransmit(channel) + if channel: + print("Retransmit set to 'ON' at channel: %d." % channel) + else: + print("Retransmit set to 'OFF'.") + else: + print("Nothing done.") + + @staticmethod + def set_temp_logging(station, tempLogging, noprompt): + """Set console temperature logging to 'LAST' or 'AVERAGE'.""" + + msg = "Proceeding will change the console temperature logging to '%s'.\n" % tempLogging.upper() + ans = weeutil.weeutil.y_or_n(msg + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + station.setTempLogging(tempLogging) + print("Console temperature logging set to '%s'." % (tempLogging.upper())) + else: + print("Nothing done.") + + @staticmethod + def set_time(station): + print("Setting time on console...") + station.setTime() + newtime_ts = station.getTime() + print("Current console time is %s" % weeutil.weeutil.timestamp_to_string(newtime_ts)) + + @staticmethod + def set_dst(station, dst): + station.setDST(dst) + print("Set DST on console to '%s'" % dst) + + @staticmethod + def set_tz_code(station, tz_code): + print("Setting time zone code to %d..." % tz_code) + station.setTZcode(tz_code) + new_tz_code = station.getStnInfo()[5] + print("Set time zone code to %s" % new_tz_code) + + @staticmethod + def set_tz_offset(station, tz_offset): + offset_int = int(tz_offset) + h = abs(offset_int) // 100 + m = abs(offset_int) % 100 + if h > 12 or m >= 60: + raise ValueError("Invalid time zone offset: %s" % tz_offset) + offset = h * 100 + (100 * m // 60) + if offset_int < 0: + offset = -offset + station.setTZoffset(offset) + new_offset = station.getStnInfo()[6] + print("Set time zone offset to %+.1f hours" % new_offset) + + @staticmethod + def set_lamp(station, onoff): + print("Setting lamp on console...") + station.setLamp(onoff) + + @staticmethod + def start_logger(station): + print("Starting logger ...") + station.startLogger() + print("Logger started") + + @staticmethod + def stop_logger(station): + print("Stopping logger ...") + station.stopLogger() + print("Logger stopped") + + @staticmethod + def dump_logger(station, config_dict, noprompt, batch_size=1): + import weewx.manager + ans = weeutil.weeutil.y_or_n("Proceeding will dump all data in the logger.\n" + "Are you sure you want to proceed (y/n)? ", + noprompt) + if ans == 'y': + with weewx.manager.open_manager_with_config(config_dict, 'wx_binding', + initialize=True) as archive: + nrecs = 0 + # Determine whether to use something to show our progress: + progress_fn = print_page if batch_size == 0 else None + + # Wrap the Vantage generator function in a converter, which will convert the units + # to the same units used by the database: + converted_generator = weewx.units.GenWithConvert( + station.genArchiveDump(progress_fn=progress_fn), + archive.std_unit_system) + + # Wrap it again, to dump in the requested batch size + converted_generator = weeutil.weeutil.GenByBatch(converted_generator, batch_size) + + print("Starting dump ...") + + for record in converted_generator: + archive.addRecord(record) + nrecs += 1 + print("Records processed: %d; Timestamp: %s\r" + % (nrecs, weeutil.weeutil.timestamp_to_string(record['dateTime'])), + end=' ', + file=sys.stdout) + sys.stdout.flush() + print("\nFinished dump. %d records added" % (nrecs,)) + else: + print("Nothing done.") + + @staticmethod + def logger_summary(station, dest_path): + + with open(dest_path, mode="w") as dest: + + VantageConfigurator.show_info(station, dest) + + print("Starting download of logger summary...") + + nrecs = 0 + for (page, index, y, mo, d, h, mn, time_ts) in station.genLoggerSummary(): + if time_ts: + print("%4d %4d %4d | %4d-%02d-%02d %02d:%02d | %s" + % (nrecs, page, index, y + 2000, mo, d, h, mn, + weeutil.weeutil.timestamp_to_string(time_ts)), file=dest) + else: + print("%4d %4d %4d [*** Unused index ***]" + % (nrecs, page, index), file=dest) + nrecs += 1 + if nrecs % 10 == 0: + print("Records processed: %d; Timestamp: %s\r" + % (nrecs, weeutil.weeutil.timestamp_to_string(time_ts)), end=' ', + file=sys.stdout) + sys.stdout.flush() + print("\nFinished download of logger summary to file '%s'. %d records processed." % ( + dest_path, nrecs)) + + +# ============================================================================= +# Class VantageConfEditor +# ============================================================================= + +class VantageConfEditor(weewx.drivers.AbstractConfEditor): + """Provide a default stanza, and the opportunity to change it.""" + + @property + def default_stanza(self): + return """ +[Vantage] + # This section is for the Davis Vantage series of weather stations. + + # Connection type: serial or ethernet + # serial (the classic VantagePro) + # ethernet (the WeatherLinkIP or Serial-Ethernet bridge) + type = serial + + # If the connection type is serial, a port must be specified: + # Debian, Ubuntu, Redhat, Fedora, and SuSE: + # /dev/ttyUSB0 is a common USB port name + # /dev/ttyS0 is a common serial port name + # BSD: + # /dev/cuaU0 is a common serial port name + port = /dev/ttyUSB0 + + # If the connection type is ethernet, an IP Address/hostname is required: + host = 1.2.3.4 + + ###################################################### + # The rest of this section rarely needs any attention. + # You can safely leave it "as is." + ###################################################### + + # Serial baud rate (usually 19200) + baudrate = 19200 + + # TCP port (when using the WeatherLinkIP) + tcp_port = 22222 + + # TCP send delay (when using the WeatherLinkIP): + tcp_send_delay = 0.5 + + # The type of LOOP packet to request: 1 = LOOP1; 2 = LOOP2; 3 = both + loop_request = 1 + + # The id of your ISS station (usually 1). If you use a wind meter connected + # to a anemometer transmitter kit, use its id + iss_id = 1 + + # How long to wait for a response from the station before giving up (in + # seconds; must be greater than 2) + timeout = 4 + + # How long to wait before trying again (in seconds) + wait_before_retry = 1.2 + + # How many times to try before giving up: + max_tries = 4 + + # Vantage model Type: 1 = Vantage Pro; 2 = Vantage Pro2 + model_type = 2 + + # The driver to use: + driver = weewx.drivers.vantage +""" + + def prompt_for_settings(self): + settings = dict() + print("Specify the hardware interface, either 'serial' or 'ethernet'.") + print("If the station is connected by serial, USB, or serial-to-USB") + print("adapter, specify serial. Specify ethernet for stations with") + print("WeatherLinkIP interface.") + settings['type'] = self._prompt('type', 'serial', ['serial', 'ethernet']) + if settings['type'] == 'serial': + print("Specify a port for stations with a serial interface, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + settings['port'] = self._prompt('port', '/dev/ttyUSB0') + else: + print("Specify the IP address (e.g., 192.168.0.10) or hostname") + print("(e.g., console or console.example.com) for stations with") + print("an ethernet interface.") + settings['host'] = self._prompt('host') + return settings + + +def print_page(ipage): + print("Requesting page %d/512\r" % ipage, end=' ', file=sys.stdout) + sys.stdout.flush() + + +# Define a main entry point for basic testing of the station without weewx +# engine and service overhead. Invoke this as follows from the weewx root directory: +# +# PYTHONPATH=bin python -m weewx.drivers.vantage + + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('wee_vantage') + + usage = """Usage: python -m weewx.drivers.vantage --help + python -m weewx.drivers.vantage --version + python -m weewx.drivers.vantage [--port=PORT]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', action='store_true', + help='Display driver version') + parser.add_option('--port', default='/dev/ttyUSB0', + help='Serial port to use. Default is "/dev/ttyUSB0"', + metavar="PORT") + (options, args) = parser.parse_args() + + if options.version: + print("Vantage driver version %s" % DRIVER_VERSION) + exit(0) + + vantage = Vantage(connection_type='serial', port=options.port) + + for packet in vantage.genLoopPackets(): + print(packet) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/wmr100.py b/dist/weewx-5.0.2/src/weewx/drivers/wmr100.py new file mode 100644 index 0000000..3d88017 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/wmr100.py @@ -0,0 +1,441 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classees and functions for interfacing with an Oregon Scientific WMR100 +station. The WMRS200 reportedly works with this driver (NOT the WMR200, which +is a different beast). + +The wind sensor reports wind speed, wind direction, and wind gust. It does +not report wind gust direction. + +WMR89: + - data logger + - up to 3 channels + - protocol 3 sensors + - THGN800, PRCR800, WTG800 + +WMR86: + - no data logger + - protocol 3 sensors + - THGR800, WGR800, PCR800, UVN800 + +The following references were useful for figuring out the WMR protocol: + +From Per Ejeklint: + https://github.com/ejeklint/WLoggerDaemon/blob/master/Station_protocol.md + +From Rainer Finkeldeh: + http://www.bashewa.com/wmr200-protocol.php + +The WMR driver for the wfrog weather system: + http://code.google.com/p/wfrog/source/browse/trunk/wfdriver/station/wmrs200.py + +Unfortunately, there is no documentation for PyUSB v0.4, so you have to back +it out of the source code, available at: + https://pyusb.svn.sourceforge.net/svnroot/pyusb/branches/0.4/pyusb.c +""" + +import logging +import time +import operator +from functools import reduce + +import usb + +import weewx.drivers +import weewx.wxformulas +import weeutil.weeutil + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WMR100' +DRIVER_VERSION = "3.5.0" + +def loader(config_dict, engine): # @UnusedVariable + return WMR100(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WMR100ConfEditor() + + +class WMR100(weewx.drivers.AbstractDevice): + """Driver for the WMR100 station.""" + + DEFAULT_MAP = { + 'pressure': 'pressure', + 'windSpeed': 'wind_speed', + 'windDir': 'wind_dir', + 'windGust': 'wind_gust', + 'windBatteryStatus': 'battery_status_wind', + 'inTemp': 'temperature_0', + 'outTemp': 'temperature_1', + 'extraTemp1': 'temperature_2', + 'extraTemp2': 'temperature_3', + 'extraTemp3': 'temperature_4', + 'extraTemp4': 'temperature_5', + 'extraTemp5': 'temperature_6', + 'extraTemp6': 'temperature_7', + 'extraTemp7': 'temperature_8', + 'inHumidity': 'humidity_0', + 'outHumidity': 'humidity_1', + 'extraHumid1': 'humidity_2', + 'extraHumid2': 'humidity_3', + 'extraHumid3': 'humidity_4', + 'extraHumid4': 'humidity_5', + 'extraHumid5': 'humidity_6', + 'extraHumid6': 'humidity_7', + 'extraHumid7': 'humidity_8', + 'inTempBatteryStatus': 'battery_status_0', + 'outTempBatteryStatus': 'battery_status_1', + 'extraBatteryStatus1': 'battery_status_2', + 'extraBatteryStatus2': 'battery_status_3', + 'extraBatteryStatus3': 'battery_status_4', + 'extraBatteryStatus4': 'battery_status_5', + 'extraBatteryStatus5': 'battery_status_6', + 'extraBatteryStatus6': 'battery_status_7', + 'extraBatteryStatus7': 'battery_status_8', + 'rain': 'rain', + 'rainTotal': 'rain_total', + 'rainRate': 'rain_rate', + 'hourRain': 'rain_hour', + 'rain24': 'rain_24', + 'rainBatteryStatus': 'battery_status_rain', + 'UV': 'uv', + 'uvBatteryStatus': 'battery_status_uv'} + + def __init__(self, **stn_dict): + """Initialize an object of type WMR100. + + NAMED ARGUMENTS: + + model: Which station model is this? + [Optional. Default is 'WMR100'] + + timeout: How long to wait, in seconds, before giving up on a response + from the USB port. + [Optional. Default is 15 seconds] + + wait_before_retry: How long to wait before retrying. + [Optional. Default is 5 seconds] + + max_tries: How many times to try before giving up. + [Optional. Default is 3] + + vendor_id: The USB vendor ID for the WMR + [Optional. Default is 0xfde] + + product_id: The USB product ID for the WM + [Optional. Default is 0xca01] + + interface: The USB interface + [Optional. Default is 0] + + IN_endpoint: The IN USB endpoint used by the WMR. + [Optional. Default is usb.ENDPOINT_IN + 1] + """ + + log.info('Driver version is %s' % DRIVER_VERSION) + self.model = stn_dict.get('model', 'WMR100') + # TODO: Consider putting these in the driver loader instead: + self.record_generation = stn_dict.get('record_generation', 'software') + self.timeout = float(stn_dict.get('timeout', 15.0)) + self.wait_before_retry = float(stn_dict.get('wait_before_retry', 5.0)) + self.max_tries = int(stn_dict.get('max_tries', 3)) + self.vendor_id = int(stn_dict.get('vendor_id', '0x0fde'), 0) + self.product_id = int(stn_dict.get('product_id', '0xca01'), 0) + self.interface = int(stn_dict.get('interface', 0)) + self.IN_endpoint = int(stn_dict.get('IN_endpoint', usb.ENDPOINT_IN + 1)) + self.sensor_map = dict(self.DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('Sensor map is %s' % self.sensor_map) + self.last_rain_total = None + self.devh = None + self.openPort() + + def openPort(self): + dev = self._findDevice() + if not dev: + log.error("Unable to find USB device (0x%04x, 0x%04x)" + % (self.vendor_id, self.product_id)) + raise weewx.WeeWxIOError("Unable to find USB device") + self.devh = dev.open() + # Detach any old claimed interfaces + try: + self.devh.detachKernelDriver(self.interface) + except usb.USBError: + pass + try: + self.devh.claimInterface(self.interface) + except usb.USBError as e: + self.closePort() + log.error("Unable to claim USB interface: %s" % e) + raise weewx.WeeWxIOError(e) + + def closePort(self): + try: + self.devh.releaseInterface() + except usb.USBError: + pass + try: + self.devh.detachKernelDriver(self.interface) + except usb.USBError: + pass + + def genLoopPackets(self): + """Generator function that continuously returns loop packets""" + + # Get a stream of raw packets, then convert them, depending on the + # observation type. + + for _packet in self.genPackets(): + try: + _packet_type = _packet[1] + if _packet_type in WMR100._dispatch_dict: + # get the observations from the packet + _raw = WMR100._dispatch_dict[_packet_type](self, _packet) + if _raw: + # map the packet labels to schema fields + _record = dict() + for k in self.sensor_map: + if self.sensor_map[k] in _raw: + _record[k] = _raw[self.sensor_map[k]] + # if there are any observations, add time and units + if _record: + for k in ['dateTime', 'usUnits']: + _record[k] = _raw[k] + yield _record + except IndexError: + log.error("Malformed packet: %s" % _packet) + + def genPackets(self): + """Generate measurement packets. These are 8 to 17 byte long packets containing + the raw measurement data. + + For a pretty good summary of what's in these packets see + https://github.com/ejeklint/WLoggerDaemon/blob/master/Station_protocol.md + """ + + # Wrap the byte generator function in GenWithPeek so we + # can peek at the next byte in the stream. The result, the variable + # genBytes, will be a generator function. + genBytes = weeutil.weeutil.GenWithPeek(self._genBytes_raw()) + + # Start by throwing away any partial packets: + for ibyte in genBytes: + if genBytes.peek() != 0xff: + break + + buff = [] + # March through the bytes generated by the generator function genBytes: + for ibyte in genBytes: + # If both this byte and the next one are 0xff, then we are at the end of a record + if ibyte == 0xff and genBytes.peek() == 0xff: + # We are at the end of a packet. + # Compute its checksum. This can throw an exception if the packet is empty. + try: + computed_checksum = reduce(operator.iadd, buff[:-2]) + except TypeError as e: + log.debug("Exception while calculating checksum: %s" % e) + else: + actual_checksum = (buff[-1] << 8) + buff[-2] + if computed_checksum == actual_checksum: + # Looks good. Yield the packet + yield buff + else: + log.debug("Bad checksum on buffer of length %d" % len(buff)) + # Throw away the next character (which will be 0xff): + next(genBytes) + # Start with a fresh buffer + buff = [] + else: + buff.append(ibyte) + + @property + def hardware_name(self): + return self.model + + #=============================================================================== + # USB functions + #=============================================================================== + + def _findDevice(self): + """Find the given vendor and product IDs on the USB bus""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == self.vendor_id and dev.idProduct == self.product_id: + return dev + + def _genBytes_raw(self): + """Generates a sequence of bytes from the WMR USB reports.""" + + try: + # Only need to be sent after a reset or power failure of the station: + self.devh.controlMsg(usb.TYPE_CLASS + usb.RECIP_INTERFACE, # requestType + 0x0000009, # request + [0x20,0x00,0x08,0x01,0x00,0x00,0x00,0x00], # buffer + 0x0000200, # value + 0x0000000, # index + 1000) # timeout + except usb.USBError as e: + log.error("Unable to send USB control message: %s" % e) + # Convert to a Weewx error: + raise weewx.WakeupError(e) + + nerrors = 0 + while True: + try: + # Continually loop, retrieving "USB reports". They are 8 bytes long each. + report = self.devh.interruptRead(self.IN_endpoint, + 8, # bytes to read + int(self.timeout * 1000)) + # While the report is 8 bytes long, only a smaller, variable portion of it + # has measurement data. This amount is given by byte zero. Return each + # byte, starting with byte one: + for i in range(1, report[0] + 1): + yield report[i] + nerrors = 0 + except (IndexError, usb.USBError) as e: + log.debug("Bad USB report received: %s" % e) + nerrors += 1 + if nerrors > self.max_tries: + log.error("Max retries exceeded while fetching USB reports") + raise weewx.RetriesExceeded("Max retries exceeded while fetching USB reports") + time.sleep(self.wait_before_retry) + + # ========================================================================= + # LOOP packet decoding functions + #========================================================================== + + def _rain_packet(self, packet): + + # NB: in my experiments with the WMR100, it registers in increments of + # 0.04 inches. Per Ejeklint's notes have you divide the packet values + # by 10, but this would result in an 0.4 inch bucket --- too big. So, + # I'm dividing by 100. + _record = { + 'rain_rate' : ((packet[3] << 8) + packet[2]) / 100.0, + 'rain_hour' : ((packet[5] << 8) + packet[4]) / 100.0, + 'rain_24' : ((packet[7] << 8) + packet[6]) / 100.0, + 'rain_total' : ((packet[9] << 8) + packet[8]) / 100.0, + 'battery_status_rain': packet[0] >> 4, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + + # Because the WMR does not offer anything like bucket tips, we must + # calculate it by looking for the change in total rain. Of course, this + # won't work for the very first rain packet. + _record['rain'] = weewx.wxformulas.calculate_rain( + _record['rain_total'], self.last_rain_total) + self.last_rain_total = _record['rain_total'] + return _record + + def _temperature_packet(self, packet): + _record = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + # Per Ejeklint's notes don't mention what to do if temperature is + # negative. I think the following is correct. Also, from experience, we + # know that the WMR has problems measuring dewpoint at temperatures + # below about 20F. So ignore dewpoint and let weewx calculate it. + T = (((packet[4] & 0x7f) << 8) + packet[3]) / 10.0 + if packet[4] & 0x80: + T = -T + R = float(packet[5]) + channel = packet[2] & 0x0f + _record['temperature_%d' % channel] = T + _record['humidity_%d' % channel] = R + _record['battery_status_%d' % channel] = (packet[0] & 0x40) >> 6 + return _record + + def _temperatureonly_packet(self, packet): + # function added by fstuyk to manage temperature-only sensor THWR800 + _record = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + # Per Ejeklint's notes don't mention what to do if temperature is + # negative. I think the following is correct. + T = (((packet[4] & 0x7f) << 8) + packet[3])/10.0 + if packet[4] & 0x80: + T = -T + channel = packet[2] & 0x0f + _record['temperature_%d' % channel] = T + _record['battery_status_%d' % channel] = (packet[0] & 0x40) >> 6 + return _record + + def _pressure_packet(self, packet): + # Although the WMR100 emits SLP, not all consoles in the series + # (notably, the WMRS200) allow the user to set altitude. So we + # record only the station pressure (raw gauge pressure). + SP = float(((packet[3] & 0x0f) << 8) + packet[2]) + _record = {'pressure': SP, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + return _record + + def _uv_packet(self, packet): + _record = {'uv': float(packet[3]), + 'battery_status_uv': packet[0] >> 4, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC} + return _record + + def _wind_packet(self, packet): + """Decode a wind packet. Wind speed will be in kph""" + + _record = { + 'wind_speed': ((packet[6] << 4) + ((packet[5]) >> 4)) / 10.0, + 'wind_gust': (((packet[5] & 0x0f) << 8) + packet[4]) / 10.0, + 'wind_dir': (packet[2] & 0x0f) * 360.0 / 16.0, + 'battery_status_wind': (packet[0] >> 4), + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRICWX} + + # Sometimes the station emits a wind gust that is less than the + # average wind. If this happens, ignore it. + if _record['wind_gust'] < _record['wind_speed']: + _record['wind_gust'] = None + + return _record + + def _clock_packet(self, packet): + """The clock packet is not used by weewx. However, the last time is + saved in case getTime() is called.""" + tt = (2000 + packet[8], packet[7], packet[6], packet[5], packet[4], 0, 0, 0, -1) + try: + self.last_time = time.mktime(tt) + except OverflowError: + log.error("Bad clock packet: %s", packet) + log.error("**** ignored.") + return None + + # Dictionary that maps a measurement code, to a function that can decode it + _dispatch_dict = {0x41: _rain_packet, + 0x42: _temperature_packet, + 0x46: _pressure_packet, + 0x47: _uv_packet, + 0x48: _wind_packet, + 0x60: _clock_packet, + 0x44: _temperatureonly_packet} + + +class WMR100ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WMR100] + # This section is for the Oregon Scientific WMR100 + + # The driver to use + driver = weewx.drivers.wmr100 + + # The station model, e.g., WMR100, WMR100N, WMRS200 + model = WMR100 +""" + + def modify_config(self, config_dict): + print(""" +Setting rainRate calculation to hardware.""") + config_dict.setdefault('StdWXCalculate', {}) + config_dict['StdWXCalculate'].setdefault('Calculations', {}) + config_dict['StdWXCalculate']['Calculations']['rainRate'] = 'hardware' diff --git a/dist/weewx-5.0.2/src/weewx/drivers/wmr300.py b/dist/weewx-5.0.2/src/weewx/drivers/wmr300.py new file mode 100644 index 0000000..689c74d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/wmr300.py @@ -0,0 +1,2027 @@ +# Copyright 2015-2024 Matthew Wall +# See the file LICENSE.txt for your rights. +# +# Credits: +# Thanks to Cameron for diving deep into USB timing issues +# +# Thanks to Benji for the identification and decoding of 7 packet types +# +# Thanks to Eric G for posting USB captures and providing hardware for testing +# https://groups.google.com/forum/#!topic/weewx-development/5R1ahy2NFsk +# +# Thanks to Zahlii +# https://bsweather.myworkbook.de/category/weather-software/ +# +# No thanks to oregon scientific - repeated requests for hardware and/or +# specifications resulted in no response at all. + +# TODO: figure out battery level for each sensor +# TODO: figure out signal strength for each sensor + +# FIXME: These all seem to offer low value return and unlikely to be done. +# In decreasing order of usefulness: +# 1. warn if altitude in pressure packet does not match weewx altitude +# 2. If rain is being accumulated when weewx is stopped, and we need to retrieve +# the history record then the current code returns None for the rain in +# the earliest new time interval and the rain increment for that time will +# get added to and reported at the next interval. +# 3. figure out unknown bytes in history packet (probably nothing useful) +# 4. decode the 0xdb (forecast) packets + +"""Driver for Oregon Scientific WMR300 weather stations. + +Sensor data transmission frequencies: + wind: 2.5 to 3 seconds + TH: 10 to 12 seconds + rain: 20 to 24 seconds + +The station supports 1 wind, 1 rain, 1 UV, and up to 8 temperature/humidity +sensors. + +The station ships with "Weather OS PRO" software for windows. This was used +for the USB sniffing. + +Sniffing USB traffic shows all communication is interrupt. The endpoint +descriptors for the device show this as well. Response timing is 1. + +It appears that communication must be initiated with an interrupted write. +After that, the station will spew data. Sending commands to the station is +not reliable - like other Oregon Scientific hardware, there is a certain +amount of "send a command, wait to see what happens, then maybe send it again" +in order to communicate with the hardware. Once the station does start sending +data, the driver basically just processes it based on the message type. It +must also send "heartbeat" messages to keep the data flowing from the hardware. + +Communication is confounded somewhat by the various libusb implementations. +Some communication "fails" with a "No data available" usb error. But in other +libusb versions, this error does not appear. USB timeouts are also tricky. +Since the WMR protocol seems to expect timeouts, it is necessary for the +driver to ignore them at some times, but not at others. Since not every libusb +version includes a code/class to indicate that a USB error is a timeout, the +driver must do more work to figure it out. + +The driver ignores USB timeouts. It would seem that a timeout just means that +the station is not ready to communicate; it does not indicate a communication +failure. + +Internal observation names use the convention name_with_specifier. These are +mapped to the wview or other schema as needed with a configuration setting. +For example, for the wview schema, wind_speed maps to windSpeed, temperature_0 +maps to inTemp, and humidity_1 maps to outHumidity. + +Maximum value for rain counter is 400 in (10160 mm) (40000 = 0x9c 0x40). The +counter does not wrap; it must be reset when it hits maximum value otherwise +rain data will not be recorded. + + +Message types ----------------------------------------------------------------- + +packet types from station: +57 - station type/model; history count + other status +41 - ACK +D2 - history; 128 bytes +D3 - temperature/humidity/dewpoint/heatindex; 61 bytes +D4 - wind/windchill; 54 bytes +D5 - rain; 40 bytes +D6 - pressure; 46 bytes +DB - forecast; 32 bytes +DC - temperature/humidity ranges; 62 bytes + +packet types from host: +A6 - heartbeat - response is 57 (usually) +41 - ACK +65 - do not delete history when it is reported. each of these is ack-ed by + the station +b3 - delete history after you give it to me. +cd - start history request. last two bytes are one after most recent read +35 - finish history request. last two bytes are latest record index that + was read. +73 - some sort of initialisation packet +72 - ? on rare occasions will be used in place of 73, in both observed cases + the console was already free-running transmitting data + +WOP sends A6 message every 20 seconds +WOP requests history at startup, then again every 120 minutes +each A6 is followed by a 57 from the station, except the one initiating history +each data packet D* from the station is followed by an ack packet 41 from host +D2 (history) records are recorded every minute +D6 (pressure) packets seem to come every 15 minutes (900 seconds) +4,5 of 7x match 12,13 of 57 + +examples of 72/73 initialization packets: + 73 e5 0a 26 0e c1 + 73 e5 0a 26 88 8b + 72 a9 c1 60 52 00 + +---- cameron's extra notes: + +Station will free-run transmitting data for about 100s without seeing an ACK. +a6 will always be followed by 91 ca 45 52 but final byte may be 0, 20, 32, 67, +8b, d6, df, or... + 0 - when first packet after connection or program startup. + It looks like the final byte is just the last character that was previously + written to a static output buffer. and hence it is meaningless. +41 - ack in - 2 types +41 - ack out - numerous types. combinations of packet type, channel, last byte. + For a6, it looks like the last byte is just uncleared residue. +b3 59 0a 17 01 - when you give me history, delete afterwards + - final 2 bytes are probably ignored + response: ACK b3 59 0a 17 +65 19 e5 04 52 - when you give me history, do not delete afterwards + - final 2 bytes are probably ignored + response: ACK 65 19 e5 04 +cd 18 30 62 nn mm - start history, starting at record 0xnnmm + response - if preceeded by 65, the ACK is the same string: ACK 65 19 e5 04 + - if preceeded by b3 then there is NO ACK. + +Initialisation: +out: a6 91 ca 45 52 + - note: null 6th byte. +in: 57: WMR300,A004,\0\0,,,, + - where numbered bytes are unknown content +then either... +out: 73 e5 0a 26 + - b5 is set to b13 of pkt 57, b6 <= b14 +in: 41 43 4b 73 e5 0a 26 + - this is the full packet 73 prefixed by "ACK" +or... +out: 72 a9 c1 60 52 + - occurs when console is already free-running (but how does WOsP know?) +NO ACK + +Message field decodings ------------------------------------------------------- + +Values are stored in 1 to 3 bytes in big endian order. Negative numbers are +stored as Two's Complement (if the first byte starts with F it is a negative +number). Count values are unsigned. + +no data: + 7f ff + +values for channel number: +0 - console sensor +1 - sensor 1 +2 - sensor 2 +... +8 - sensor 8 + +values for trend: +0 - steady +1 - rising +2 - falling +3 - no sensor data + +bitwise transformation for compass direction: +1000 0000 0000 0000 = NNW +0100 0000 0000 0000 = NW +0010 0000 0000 0000 = WNW +0001 0000 0000 0000 = W +0000 1000 0000 0000 = WSW +0000 0100 0000 0000 = SW +0000 0010 0000 0000 = SSW +0000 0001 0000 0000 = S +0000 0000 1000 0000 = SSE +0000 0000 0100 0000 = SE +0000 0000 0010 0000 = ESE +0000 0000 0001 0000 = E +0000 0000 0000 1000 = ENE +0000 0000 0000 0100 = NE +0000 0000 0000 0010 = NNE +0000 0000 0000 0001 = N + +values for forecast: +0x08 - cloudy +0x0c - rainy +0x1e - partly cloudy +0x0e - partly cloudy at night +0x70 - sunny +0x00 - clear night + + +Message decodings ------------------------------------------------------------- + +message: ACK +byte hex dec description decoded value + 0 41 A acknowledgement ACK + 1 43 C + 2 4b K + 3 73 command sent from PC + 4 e5 + 5 0a + 6 26 + 7 0e + 8 c1 + +examples: + 41 43 4b 73 e5 0a 26 0e c1 last 2 bytes differ + 41 43 4b 65 19 e5 04 always same + + +message: station info +byte hex dec description decoded value + 0 57 W station type WMR300 + 1 4d M + 2 52 R + 3 33 3 + 4 30 0 + 5 30 0 + 6 2c , + 7 41 A station model A002 + 8 30 0 + 9 30 0 +10 32 2 or 0x34 +11 2c , +12 0e (3777 dec) or mine always 88 8b (34955) +13 c1 +14 00 always? +15 00 +16 2c , +17 67 next history record 26391 (0x67*256 0x17) (0x7fe0 (32736) is full) + The value at this index has not been used yet. +18 17 +19 2c , +20 4b usually 'K' (0x4b). occasionally was 0x43 when history is set to + or 43 delete after downloading. This is a 1-bit change (1<<3) + NB: Does not return to 4b when latest history record is reset to + 0x20 after history is deleted. +21 2c , +22 52 0x52 (82, 'R'), occasionally 0x49(73, 'G') + 0b 0101 0010 (0x52) vs + 0b 0100 1001 (0x49) lots of bits flipped! + or 49 this maybe has some link with one or other battery, but does not + make sense +23 2c , + +examples: + 57 4d 52 33 30 30 2c 41 30 30 32 2c 0e c1 00 00 2c 67 17 2c 4b 2c 52 2c + 57 4d 52 33 30 30 2c 41 30 30 32 2c 88 8b 00 00 2c 2f b5 2c 4b 2c 52 2c + 57 4d 52 33 30 30 2c 41 30 30 34 2c 0e c1 00 00 2c 7f e0 2c 4b 2c 49 2c + 57 4d 52 33 30 30 2c 41 30 30 34 2c 88 8b 00 00 2c 7f e0 2c 4b 2c 49 2c + + +message: history +byte hex dec description decoded value + 0 d2 packet type + 1 80 128 packet length + 2 31 count (hi) 12694 - index number of this packet + 3 96 count (lo) + 4 0f 15 year ee if not set + 5 08 8 month ee if not set + 6 0a 10 day ee if not set + 7 06 6 hour + 8 02 2 minute + 9 00 temperature 0 21.7 C +10 d9 +11 00 temperature 1 25.4 C +12 fe +13 7f temperature 2 +14 ff +15 7f temperature 3 +16 ff +17 7f temperature 4 +18 ff +19 7f temperature 5 +20 ff +21 7f temperature 6 +22 ff +23 7f temperature 7 +24 ff +25 7f temperature 8 +26 ff (a*256 + b)/10 +27 26 humidity 0 38 % +28 49 humidity 1 73 % +29 7f humidity 2 +30 7f humidity 3 +31 7f humidity 4 +32 7f humidity 5 +33 7f humidity 6 +34 7f humidity 7 +35 7f humidity 8 +36 00 dewpoint 1 20.0 C +37 c8 (a*256 + b)/10 +38 7f dewpoint 2 +39 ff +40 7f dewpoint 3 +41 ff +42 7f dewpoint 4 +43 ff +44 7f dewpoint 5 +45 ff +46 7f dewpoint 6 +47 ff +48 7f dewpoint 7 +49 ff +50 7f dewpoint 8 +51 ff +52 7f heat index 1 C +53 fd (a*256 + b)/10 +54 7f heat index 2 +55 ff +56 7f heat index 3 +57 ff +58 7f heat index 4 +59 ff +60 7f heat index 5 +61 ff +62 7f heat index 6 +63 ff +64 7f heat index 7 +65 ff +66 7f heat index 8 +67 ff +68 7f wind chill C +69 fd (a*256 + b)/10 +70 7f ? +71 ff ? +72 00 wind gust speed 0.0 m/s +73 00 (a*256 + b)/10 +74 00 wind average speed 0.0 m/s +75 00 (a*256 + b)/10 +76 01 wind gust direction 283 degrees +77 1b (a*256 + b) +78 01 wind average direction 283 degrees +78 1b (a*256 + b) +80 30 forecast +81 00 ? +82 00 ? +83 00 hourly rain hundredths_of_inch +84 00 (a*256 + b) +85 00 ? +86 00 accumulated rain hundredths_of_inch +87 03 (a*256 + b) +88 0f accumulated rain start year +89 07 accumulated rain start month +90 09 accumulated rain start day +91 13 accumulated rain start hour +92 09 accumulated rain start minute +93 00 rain rate hundredths_of_inch/hour +94 00 (a*256 + b) +95 26 pressure mbar +96 ab (a*256 + b)/10 +97 01 pressure trend +98 7f ? +99 ff ? +100 7f ? +101 ff ? +102 7f ? +103 ff ? +104 7f ? +105 ff ? +106 7f ? +107 7f ? +108 7f ? +109 7f ? +110 7f ? +111 7f ? +112 7f ? +113 7f ? +114 ff ? +115 7f ? +116 ff ? +117 7f ? +118 ff ? +119 00 ? +120 00 ? +121 00 ? +122 00 ? +123 00 ? +124 00 ? +125 00 ? +126 f8 checksum +127 3b + + +message: temperature/humidity/dewpoint +byte hex dec description decoded value + 0 D3 packet type + 1 3D 61 packet length + 2 0E 14 year + 3 05 5 month + 4 09 9 day + 5 12 12 hour + 6 14 20 minute + 7 01 1 channel number + 8 00 temperature 19.5 C + 9 C3 +10 2D humidity 45 % +11 00 dewpoint 7.0 C +12 46 +13 7F heat index N/A +14 FD +15 00 temperature trend +16 00 humidity trend? not sure - never saw a falling value +17 0E 14 max_dewpoint_last_day year +18 05 5 month +19 09 9 day +20 0A 10 hour +21 24 36 minute +22 00 max_dewpoint_last_day 13.0 C +23 82 +24 0E 14 min_dewpoint_last_day year +25 05 5 month +26 09 9 day +27 10 16 hour +28 1F 31 minute +29 00 min_dewpoint_last_day 6.0 C +30 3C +31 0E 14 max_dewpoint_last_month year +32 05 5 month +33 01 1 day +34 0F 15 hour +35 1B 27 minute +36 00 max_dewpoint_last_month 13.0 C +37 82 +38 0E 14 min_dewpoint_last_month year +39 05 5 month +40 04 4 day +41 0B 11 hour +42 08 8 minute +43 FF min_dewpoint_last_month -1.0 C +44 F6 +45 0E 14 max_heat_index year +46 05 5 month +47 09 9 day +48 00 0 hour +49 00 0 minute +50 7F max_heat_index N/A +51 FF +52 0E 14 min_heat_index year +53 05 5 month +54 01 1 day +55 00 0 hour +56 00 0 minute +57 7F min_heat_index N/A +58 FF +59 0B checksum +60 63 + + 0 41 ACK + 1 43 + 2 4B + 3 D3 packet type + 4 01 channel number + 5 8B sometimes DF and others + +examples: + 41 43 4b d3 00 20 - for last byte: 32, 67, 8b, d6 + 41 43 4b d3 01 20 - for last byte: same + 20, df + for unused temps, last byte always 8b (or is it byte 14 of pkt 57?) + + +message: wind +byte hex dec description decoded value + 0 D4 packet type + 1 36 54 packet length + 2 0E 14 year + 3 05 5 month + 4 09 9 day + 5 12 18 hour + 6 14 20 minute + 7 01 1 channel number + 8 00 gust speed 1.4 m/s + 9 0E +10 00 gust direction 168 degrees +11 A8 +12 00 average speed 2.9 m/s +13 1D +14 00 average direction 13 degrees +15 0D +16 00 compass direction 3 N/NNE +17 03 +18 7F windchill 32765 N/A +19 FD +20 0E 14 gust today year +21 05 5 month +22 09 9 day +23 10 16 hour +24 3B 59 minute +25 00 gust today 10 m/s +26 64 +27 00 gust direction today 39 degree +28 27 +29 0E 14 gust this month year +30 05 5 month +31 09 9 day +32 10 16 hour +33 3B 59 minute +34 00 gust this month 10 m/s +35 64 +36 00 gust direction this month 39 degree +37 27 +38 0E 14 wind chill today year +39 05 5 month +40 09 9 day +41 00 0 hour +42 00 0 minute +43 7F windchill today N/A +44 FF +45 0E 14 windchill this month year +46 05 5 month +47 03 3 day +48 09 9 hour +49 04 4 minute +50 00 windchill this month 2.9 C +51 1D +52 07 checksum +53 6A + + 0 41 ACK + 1 43 + 2 4B + 3 D4 packet type + 4 01 channel number + 5 8B variable + +examples: + 41 43 4b d4 01 20 - last byte: 20, 32, 67, 8b, d6, df + 41 43 4b d4 01 16 + + +message: rain +byte hex dec description decoded value + 0 D5 packet type + 1 28 40 packet length + 2 0E 14 year + 3 05 5 month + 4 09 9 day + 5 12 18 hour + 6 15 21 minute + 7 01 1 channel number + 8 00 + 9 00 rainfall this hour 0 inch +10 00 +11 00 +12 00 rainfall last 24 hours 0.12 inch +13 0C 12 +14 00 +15 00 rainfall accumulated 1.61 inch +16 A1 161 +17 00 rainfall rate 0 inch/hr +18 00 +19 0E 14 accumulated start year +20 04 4 month +21 1D 29 day +22 12 18 hour +23 00 0 minute +24 0E 14 max rate last 24 hours year +25 05 5 month +26 09 9 day +27 01 1 hour +28 0C 12 minute +29 00 0 max rate last 24 hours 0.11 inch/hr ((0x00<<8)+0x0b)/100.0 +30 0B 11 +31 0E 14 max rate last month year +32 05 5 month +33 02 2 day +34 04 4 hour +35 0C 12 minute +36 00 0 max rate last month 1.46 inch/hr ((0x00<<8)+0x92)/100.0 +37 92 146 +38 03 checksum 794 = (0x03<<8) + 0x1a +39 1A + + 0 41 ACK + 1 43 + 2 4B + 3 D5 packet type + 4 01 channel number + 5 8B + +examples: + 41 43 4b d5 01 20 - last byte: 20, 32, 67, 8b, d6, df + 41 43 4b d5 01 16 + + +message: pressure +byte hex dec description decoded value + 0 D6 packet type + 1 2E 46 packet length + 2 0E 14 year + 3 05 5 month + 4 0D 13 day + 5 0E 14 hour + 6 30 48 minute + 7 00 1 channel number + 8 26 station pressure 981.7 mbar ((0x26<<8)+0x59)/10.0 + 9 59 +10 27 sea level pressure 1015.3 mbar ((0x27<<8)+0xa9)/10.0 +11 A9 +12 01 altitude meter 300 m (0x01<<8)+0x2c +13 2C +14 03 barometric trend have seen 0,1,2, and 3 +15 00 only ever observed 0 or 2. is this battery? +16 0E 14 max pressure today year +17 05 5 max pressure today month +18 0D 13 max pressure today day +19 0C 12 max pressure today hour +20 33 51 max pressure today minute +21 27 max pressure today 1015.7 mbar +22 AD +23 0E 14 min pressure today year +24 05 5 min pressure today month +25 0D 13 min pressure today day +26 00 0 min pressure today hour +27 06 6 min pressure today minute +28 27 min pressure today 1014.1 mbar +29 9D +30 0E 14 max pressure month year +31 05 5 max pressure month month +32 04 4 max pressure month day +33 01 1 max pressure month hour +34 15 21 max pressure month minute +35 27 max pressure month 1022.5 mbar +36 F1 +37 0E 14 min pressure month year +38 05 5 min pressure month month +39 0B 11 min pressure month day +40 00 0 min pressure month hour +41 06 6 min pressure month minute +42 27 min pressure month 1007.8 mbar +43 5E +44 06 checksum +45 EC + + 0 41 ACK + 1 43 + 2 4B + 3 D6 packet type + 4 00 channel number + 5 8B + +examples: + 41 43 4b d6 00 20 - last byte: 32, 67, 8b + + +message: forecast +byte hex dec description decoded value + 0 DB + 1 20 pkt length + 2 0F 15 year + 3 07 7 month + 4 09 9 day + 5 12 18 hour + 6 23 35 minute + 7 00 below are alternate observations - little overlap + 8 FA 0a + 9 79 02, 22, 82, a2 +10 FC 05 +11 40 f9 +12 01 fe +13 4A fc +14 06 variable +15 17 variable +16 14 variable +17 23 variable +18 06 00 to 07 (no 01) +19 01 +20 00 00 or 01 +21 00 remainder same +22 01 +23 01 +24 01 +25 00 +26 00 +27 00 +28 FE +29 00 +30 05 checksum (hi) +31 A5 checksum (lo) + + 0 41 ACK + 1 43 + 2 4B + 3 D6 packet type + 4 00 channel number + 5 20 + +examples: + 41 43 4b db 00 20 - last byte: 32, 67, 8b, d6 + + +message: temperature/humidity ranges +byte hex dec description decoded value + 0 DC packet type + 1 3E 62 packet length + 2 0E 14 year + 3 05 5 month + 4 0D 13 day + 5 0E 14 hour + 6 30 48 minute + 7 00 0 channel number + 8 0E 14 max temp today year + 9 05 5 month +10 0D 13 day +11 00 0 hour +12 00 0 minute +13 00 max temp today 20.8 C +14 D0 +15 0E 14 min temp today year +16 05 5 month +17 0D 13 day +18 0B 11 hour +19 34 52 minute +20 00 min temp today 19.0 C +21 BE +22 0E 14 max temp month year +23 05 5 month +24 0A 10 day +25 0D 13 hour +26 19 25 minute +27 00 max temp month 21.4 C +28 D6 +29 0E 14 min temp month year +30 05 5 month +31 04 4 day +32 03 3 hour +33 2A 42 minute +34 00 min temp month 18.1 C +35 B5 +36 0E 14 max humidity today year +37 05 5 month +38 0D 13 day +39 05 5 hour +40 04 4 minute +41 45 max humidity today 69 % +42 0E 14 min numidity today year +43 05 5 month +44 0D 13 day +45 0B 11 hour +46 32 50 minute +47 41 min humidity today 65 % +48 0E 14 max humidity month year +49 05 5 month +50 0C 12 day +51 13 19 hour +52 32 50 minute +53 46 max humidity month 70 % +54 0E 14 min humidity month year +55 05 5 month +56 04 4 day +57 14 20 hour +58 0E 14 minute +59 39 min humidity month 57 % +60 07 checksum +61 BF + + 0 41 ACK + 1 43 + 2 4B + 3 DC packet type + 4 00 0 channel number + 5 8B + +examples: + 41 43 4b dc 00 20 - last byte: 32, 67, 8b, d6 + 41 43 4b dc 01 20 - last byte: 20, 32, 67, 8b, d6, df + 41 43 4b dc 00 16 + 41 43 4b dc 01 16 + +""" + +import logging +import time +import usb + +import weewx.drivers +import weewx.wxformulas +from weeutil.weeutil import timestamp_to_string + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WMR300' +DRIVER_VERSION = '0.33' + +DEBUG_COMM = 0 +DEBUG_PACKET = 0 +DEBUG_COUNTS = 0 +DEBUG_DECODE = 0 +DEBUG_HISTORY = 1 +DEBUG_RAIN = 0 +DEBUG_TIMING = 1 + + +def loader(config_dict, _): + return WMR300Driver(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WMR300ConfEditor() + + +def _fmt_bytes(data): + return ' '.join(['%02x' % x for x in data]) + +def _lo(x): + return x - 256 * (x >> 8) + +def _hi(x): + return x >> 8 + +# pyusb 0.4.x does not provide an errno or strerror with the usb errors that +# it wraps into USBError. so we have to compare strings to figure out exactly +# what type of USBError we are dealing with. unfortunately, those strings are +# localized, so we must compare in every language. +USB_NOERR_MESSAGES = [ + 'No data available', 'No error', + 'Nessun dato disponibile', 'Nessun errore', + 'Keine Daten verf', + 'No hay datos disponibles', + 'Pas de donn', + 'Ingen data er tilgjengelige'] + +# these are the usb 'errors' that should be ignored +def is_noerr(e): + errmsg = repr(e) + for msg in USB_NOERR_MESSAGES: + if msg in errmsg: + return True + return False + +# strings for the timeout error +USB_TIMEOUT_MESSAGES = [ + 'Connection timed out', + 'Operation timed out'] + +# detect usb timeout error (errno 110) +def is_timeout(e): + if hasattr(e, 'errno') and e.errno == 110: + return True + errmsg = repr(e) + for msg in USB_TIMEOUT_MESSAGES: + if msg in errmsg: + return True + return False + +def get_usb_info(): + pyusb_version = '0.4.x' + try: + pyusb_version = usb.__version__ + except AttributeError: + pass + return "pyusb_version=%s" % pyusb_version + + +class WMR300Driver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with a WMR300 weather station.""" + + # map sensor values to the database schema fields + # the default map is for the wview schema + DEFAULT_MAP = { + 'pressure': 'pressure', + 'barometer': 'barometer', + 'windSpeed': 'wind_avg', + 'windDir': 'wind_dir', + 'windGust': 'wind_gust', + 'windGustDir': 'wind_gust_dir', + 'inTemp': 'temperature_0', + 'outTemp': 'temperature_1', + 'extraTemp1': 'temperature_2', + 'extraTemp2': 'temperature_3', + 'extraTemp3': 'temperature_4', + 'extraTemp4': 'temperature_5', + 'extraTemp5': 'temperature_6', + 'extraTemp6': 'temperature_7', + 'extraTemp7': 'temperature_8', + 'inHumidity': 'humidity_0', + 'outHumidity': 'humidity_1', + 'extraHumid1': 'humidity_2', + 'extraHumid2': 'humidity_3', + 'extraHumid3': 'humidity_4', + 'extraHumid4': 'humidity_5', + 'extraHumid5': 'humidity_6', + 'extraHumid6': 'humidity_7', + 'extraHumid7': 'humidity_8', + 'dewpoint': 'dewpoint_1', + 'extraDewpoint1': 'dewpoint_2', + 'extraDewpoint2': 'dewpoint_3', + 'extraDewpoint3': 'dewpoint_4', + 'extraDewpoint4': 'dewpoint_5', + 'extraDewpoint5': 'dewpoint_6', + 'extraDewpoint6': 'dewpoint_7', + 'extraDewpoint7': 'dewpoint_8', + 'heatindex': 'heatindex_1', + 'extraHeatindex1': 'heatindex_2', + 'extraHeatindex2': 'heatindex_3', + 'extraHeatindex3': 'heatindex_4', + 'extraHeatindex4': 'heatindex_5', + 'extraHeatindex5': 'heatindex_6', + 'extraHeatindex6': 'heatindex_7', + 'extraHeatindex7': 'heatindex_8', + 'windchill': 'windchill', + 'rainRate': 'rain_rate'} + + # threshold at which the history will be cleared, specified as an integer + # between 5 and 95, inclusive. + DEFAULT_HIST_LIMIT = 20 + + # threshold at which warning will be emitted. if the rain counter exceeds + # this percentage, then a warning will be emitted to remind user to reset + # the rain counter. + DEFAULT_RAIN_WARNING = 90 + + # if DEBUG_COUNTS is set then this defines how many seconds elapse between each print/reset of the counters + COUNT_SUMMARY_INTERVAL = 20 + + def __init__(self, **stn_dict): + log.info('driver version is %s' % DRIVER_VERSION) + log.info('usb info: %s' % get_usb_info()) + self.model = stn_dict.get('model', DRIVER_NAME) + self.sensor_map = dict(self.DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('sensor map is %s' % self.sensor_map) + hlimit = int(stn_dict.get('history_limit', self.DEFAULT_HIST_LIMIT)) + # clear history seems to fail if tried from below 5% + # 1 day at 1 min intervals is ~4.5% + if hlimit < 5: + hlimit = 5 + if hlimit > 95: + hlimit = 95 + self.history_limit_index = Station.get_history_pct_as_index(hlimit) + log.info('history limit is %d%% at index %d' % (hlimit, self.history_limit_index) ) + frac = int(stn_dict.get('rain_warning', self.DEFAULT_RAIN_WARNING)) + self.rain_warn = frac / 100.0 + + global DEBUG_COMM + DEBUG_COMM = int(stn_dict.get('debug_comm', DEBUG_COMM)) + global DEBUG_PACKET + DEBUG_PACKET = int(stn_dict.get('debug_packet', DEBUG_PACKET)) + global DEBUG_COUNTS + DEBUG_COUNTS = int(stn_dict.get('debug_counts', DEBUG_COUNTS)) + global DEBUG_DECODE + DEBUG_DECODE = int(stn_dict.get('debug_decode', DEBUG_DECODE)) + global DEBUG_HISTORY + DEBUG_HISTORY = int(stn_dict.get('debug_history', DEBUG_HISTORY)) + global DEBUG_TIMING + DEBUG_TIMING = int(stn_dict.get('debug_timing', DEBUG_TIMING)) + global DEBUG_RAIN + DEBUG_RAIN = int(stn_dict.get('debug_rain', DEBUG_RAIN)) + + self.logged_rain_counter = 0 + self.logged_history_usage = 0 + self.log_interval = 24 * 3600 # how often to log station status, in seconds + + self.heartbeat = 20 # how often to send a6 messages, in seconds + self.history_retry = 60 # how often to retry history, in seconds + self.last_rain = None # last rain total + self.last_a6 = 0 # timestamp of last 0xa6 message + self.data_since_heartbeat = 0 # packets of loop data seen + self.last_65 = 0 # timestamp of last 0x65 message + self.last_7x = 0 # timestamp of last 0x7x message + self.last_record = Station.HISTORY_START_REC - 1 + self.pressure_cache = dict() # FIXME: make the cache values age + self.station = Station() + self.station.open() + pkt = self.init_comm() + log.info("communication established: %s" % pkt) + self.history_end_index = pkt['history_end_index'] + self.magic0 = pkt['magic0'] + self.magic1 = pkt['magic1'] + self.mystery0 = pkt['mystery0'] + self.mystery1 = pkt['mystery1'] + if DEBUG_COUNTS: + self.last_countsummary = time.time() + + def closePort(self): + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + + def init_comm(self, max_tries=3, max_read_tries=10): + """initiate communication with the station: + 1 send a special a6 packet + 2 read the packet 57 + 3 send a type 73 packet + 4 read the ack + """ + + cnt = 0 + while cnt < max_tries: + cnt += 1 + try: + buf = None + self.station.flush_read_buffer() + if DEBUG_COMM: + log.info("init_comm: send initial heartbeat 0xa6") + self.send_heartbeat() + if DEBUG_COMM: + log.info("init_comm: try to read 0x57") + read_cnt = 0 + while read_cnt < max_read_tries: + buf = self.station.read() + read_cnt += 1 + if buf and buf[0] == 0x57: + break + if not buf or buf[0] != 0x57: + raise ProtocolError("failed to read pkt 0x57") + pkt = Station._decode_57(buf) + if DEBUG_COMM: + log.info("init_comm: send initialization 0x73") + cmd = [0x73, 0xe5, 0x0a, 0x26, pkt['magic0'], pkt['magic1']] + self.station.write(cmd) + self.last_7x = time.time() + if DEBUG_COMM: + log.info("init_comm: try to read 0x41") + read_cnt = 0 + while read_cnt < max_read_tries: + buf = self.station.read() + read_cnt += 1 + if buf and buf[0] == 0x41: + break + if not buf or buf[0] != 0x41: + raise ProtocolError("failed to read ack 0x41 for pkt 0x73") + if DEBUG_COMM: + log.info("initialization completed in %s tries" % cnt) + return pkt + except ProtocolError as e: + if DEBUG_COMM: + log.info("init_comm: failed attempt %d of %d: %s" % + (cnt, max_tries, e)) + time.sleep(0.1) + raise ProtocolError("Init comm failed after %d tries" % max_tries) + + def init_history(self, clear_logger=False, max_tries=5, max_read_tries=10): + """initiate streaming of history records from the station: + + 1 if clear logger: + 1a send 0xb3 packet + 1 if not clear logger: + 1a send special 0xa6 (like other 0xa6 packets, but no 0x57 reply) + 1b send 0x65 packet + 2 read the ACK + 3 send a 0xcd packet + 4 do not wait for ACK - it might not come + + then return to reading packets + """ + + cnt = 0 + while cnt < max_tries: + cnt += 1 + try: + if DEBUG_HISTORY: + log.info("init history attempt %d of %d" % (cnt, max_tries)) + # eliminate anything that might be in the buffer + self.station.flush_read_buffer() + # send the sequence for initiating history packets + if clear_logger: + cmd = [0xb3, 0x59, 0x0a, 0x17, 0x01, 0xeb] + self.station.write(cmd) + # a partial read is sufficient. No need to read stuff we are not going to save + start_rec = Station.clip_index( self.history_end_index - 1200 ) + else: + self.send_heartbeat() + cmd = [0x65, 0x19, 0xe5, 0x04, 0x52, 0x8b] + self.station.write(cmd) + self.last_65 = time.time() + start_rec = self.last_record + + # read the ACK. there might be regular packets here, so be + # ready to read a few - just ignore them. + read_cnt = 0 + while read_cnt < max_read_tries: + buf = self.station.read() + read_cnt += 1 + if buf and buf[0] == 0x41 and buf[3] == cmd[0]: + break + if not buf or buf[0] != 0x41: + raise ProtocolError("failed to read ack to %02x" % cmd[0]) + + # send the request to start history packets + nxt = Station.clip_index(start_rec) + if DEBUG_HISTORY: + log.info("init history cmd=0x%02x rec=%d" % (cmd[0], nxt)) + cmd = [0xcd, 0x18, 0x30, 0x62, _hi(nxt), _lo(nxt)] + self.station.write(cmd) + + # do NOT wait for an ACK. the console should start spewing + # history packets, and any ACK or 0x57 packets will be out of + # sequence. so just drop into the normal reading loop and + # process whatever comes. + if DEBUG_HISTORY: + log.info("init history completed after attempt %d of %d" % + (cnt, max_tries)) + return + except ProtocolError as e: + if DEBUG_HISTORY: + log.info("init_history: failed attempt %d of %d: %s" % + (cnt, max_tries, e)) + time.sleep(0.1) + raise ProtocolError("Init history failed after %d tries" % max_tries) + + def finish_history(self, max_tries=3): + """conclude reading of history records. + 1 final 0xa6 has been sent and 0x57 has been seen + 2 send 0x35 packet + 3 no ACK, but sometimes another 57:? - ignore it + """ + + cnt = 0 + while cnt < max_tries: + cnt += 1 + try: + if DEBUG_HISTORY: + log.info("finish history attempt %d of %d" % (cnt, max_tries)) + # eliminate anything that might be in the buffer + self.station.flush_read_buffer() + # send packet 0x35 + cmd = [0x35, 0x0b, 0x1a, 0x87, + _hi(self.last_record), _lo(self.last_record)] + self.station.write(cmd) + # do NOT wait for an ACK + if DEBUG_HISTORY: + log.info("finish history completed after attempt %d of %d" % + (cnt, max_tries)) + return + except ProtocolError as e: + if DEBUG_HISTORY: + log.info("finish history failed attempt %d of %d: %s" % + (cnt, max_tries, e)) + time.sleep(0.1) + raise ProtocolError("Finish history failed after %d tries" % max_tries) + + def dump_history(self): + log.info("dump history") + if DEBUG_HISTORY: + reread_start_time = time.time() + for rec in self.get_history(time.time(), clear_logger=True): + pass + if DEBUG_HISTORY: + reread_duration = time.time() - reread_start_time + log.info( "History clear completed in %.1f sec" % reread_duration ) + + def get_history(self, since_ts, clear_logger=False): + if self.history_end_index is None: + log.info("read history skipped: index has not been set") + return + if self.history_end_index < 1: + # this should never happen. if it does, then either no 0x57 packet + # was received or the index provided by the station was bogus. + log.error("read history failed: bad index: %s" % self.history_end_index) + return + + log.info("%s records since %s (last_index=%s history_end_index=%s)" % + ("Clearing" if clear_logger else "Reading", + timestamp_to_string(since_ts) if since_ts is not None else "the start", + self.last_record, self.history_end_index)) + self.init_history(clear_logger) + half_buf = None + last_ts = None + processed = 0 + loop_ignored = 0 + state = "reading history" + # there is sometimes a series of bogus history records reported + # these are to keep a track of them + bogus_count = 0 + bogus_first = 0 + bogus_last = 0 + # since_ts of None implies DB just created, so read all... + if since_ts is None: + since_ts = 0 + + while True: + try: + buf = self.station.read() + if buf: + # The message length is 64 bytes, but historical records + # are 128 bytes. So we have to assemble the two 64-byte + # parts of each historical record into a single 128-byte + # message for processing. We have to assume we do not get any + # non-historical records interspersed between parts. + # This code will fail if any user has 7 or 8 + # temperature sensors installed, as it is relying on seeing + # 0x7f in byte 64, which just means "no data" + if buf[0] == 0xd2: + pktlength = buf[1] + if pktlength != 128: + raise WMR300Error("History record unexpected length: assumed 128, found %d" % pktlength ) + half_buf = buf + buf = None + # jump immediately to read the 2nd half of the packet + # we don't want other possibilities intervening + continue + elif buf[0] == 0x7f and half_buf is not None: + buf = half_buf + buf + half_buf = None + if buf[0] == 0xd2: + next_record = Station.get_record_index(buf) + if last_ts is not None and next_record != self.last_record + 1: + log.info("missing record: skipped from %d to %d" % + (self.last_record, next_record)) + self.last_record = next_record + ts = Station._extract_ts(buf[4:9]) + if ts is None: + if bogus_count == 0 : + bogus_first = next_record + bogus_count += 1 + bogus_last = next_record + if DEBUG_HISTORY: + log.info("Bogus historical record index: %d " % (next_record)) + #log.info(" content: %s" % _fmt_bytes(buf)) + else: + if ts > since_ts: + pkt = Station.decode(buf) + packet = self.convert_historical(pkt, ts, last_ts) + last_ts = ts + if 'interval' in packet: + if DEBUG_HISTORY: + log.info("New historical record for %s: %s: %s" % + (timestamp_to_string(ts), pkt['index'], packet)) + processed += 1 + yield packet + else: + last_ts = ts + + elif buf[0] == 0x57: + self.history_end_index = Station.get_history_end_index(buf) + msg = " count=%s updated; last_index rcvd=%s; final_index=%s; state = %s" % ( + processed, self.last_record, self.history_end_index, state) + if state == "wait57": + log.info("History read completed: %s" % msg) + break + else: + log.info("History read in progress: %s" % msg) + + elif buf[0] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]: + # ignore any packets other than history records. this + # means there will be no current data while the history + # is being read. + loop_ignored += 1 + if DEBUG_HISTORY: + log.info("ignored packet type 0x%2x" % buf[0]) + # do not ACK data packets. the PC software does send ACKs + # here, but they are ignored anyway. so we just ignore. + else: + log.error("get_history: unexpected packet, content: %s" % _fmt_bytes(buf)) + + if self.last_record + 1 >= self.history_end_index : + if state == "reading history": + if DEBUG_HISTORY: + msg = "count=%s kept, last_received=%s final=%s" % ( + processed, self.last_record, self.history_end_index ) + log.info("History read nearly complete: %s; state=%s" % (msg, state) ) + state = "finishing" + + if (state == "finishing") or (time.time() - self.last_a6 > self.heartbeat): + if DEBUG_HISTORY: + log.info("request station status at index: %s; state: %s" % + (self.last_record, state ) ) + self.send_heartbeat() + if state == "finishing" : + # It is possible that another history packet has been created between the + # most recent 0x57 status and now. So, we have to stay in the loop + # to read the possible next packet. + # Evidence suggests that such history packet will arrive before the + # 0x57 reply to this request, so presumably it was already in some output queue. + state = "wait57" + + + except usb.USBError as e: + raise weewx.WeeWxIOError(e) + except DecodeError as e: + log.info("get_history: %s" % e) + time.sleep(0.001) + if loop_ignored > 0 : + log.info( "During history read, %d loop data packets were ignored" % loop_ignored ) + if bogus_count > 0 : + log.info( "During history read, %d bogus entries found from %d to %d" % + (bogus_count, bogus_first, bogus_last)) + self.finish_history() + + def genLoopPackets(self): + + while True: + try: + read_enter_time = time.time() + buf = self.station.read() + read_return_delta = time.time() - read_enter_time + if buf: + if buf[0] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]: + # compose ack for most data packets + # cmd = [0x41, 0x43, 0x4b, buf[0], buf[7]] + # do not bother to send the ACK - console does not care + #self.station.write(cmd) + # we only care about packets with loop data + if buf[0] in [0xd3, 0xd4, 0xd5, 0xd6]: + pkt = Station.decode(buf) + self.data_since_heartbeat += 1 + packet = self.convert_loop(pkt) + if DEBUG_COUNTS: + if "Loop" in self.station.recv_counts: + self.station.recv_counts["Loop"] += 1 + else: + self.station.recv_counts["Loop"] = 1 + if DEBUG_TIMING: + yield_rtn_time = time.time() + yield packet + if DEBUG_TIMING: + yield_return_delta = time.time() - yield_rtn_time + if yield_return_delta > 5: + log.info( "Yield delayed for = %d s" % yield_return_delta ) + elif buf[0] == 0x57: + self.history_end_index = Station.get_history_end_index(buf) + if time.time() - self.logged_history_usage > self.log_interval: + pct = Station.get_history_usage_pct(self.history_end_index) + log.info("history buffer at %.2f%% (%d)" % (pct, self.history_end_index)) + self.logged_history_usage = time.time() + if DEBUG_TIMING and read_return_delta > 5: + log.info( "USB Read delayed for = %d s" % read_return_delta ) + + if DEBUG_COUNTS: + now = time.time() + # we just print a summary each chosen interval + if (now - self.last_countsummary) > self.COUNT_SUMMARY_INTERVAL: + Station.print_count( "read", self.station.recv_counts ) + self.station.recv_counts.clear() + Station.print_count( "write", self.station.send_counts ) + self.station.send_counts.clear() + self.last_countsummary = now + + time_since_heartbeat = time.time() - self.last_a6 + if time_since_heartbeat > self.heartbeat: + if DEBUG_TIMING and self.data_since_heartbeat < 10 : + log.info( "Loop data packets in heartbeat interval = %d" % self.data_since_heartbeat ) + needs_restart = False + if time_since_heartbeat > 60: + log.error( "Excessive heartbeat delay: %ds, restarting" % (time_since_heartbeat) ) + needs_restart = True + if self.data_since_heartbeat <= 0 : + log.error( "No loop data in heartbeat interval, restarting" ) + needs_restart = True + + if needs_restart: + # I think the 0x73 starts the data transmission, but not sure if the + # a6 / 73 order is important. + cmd = [0x73, 0xe5, 0x0a, 0x26, self.magic0, self.magic1 ] + self.station.write(cmd) + + + self.send_heartbeat() + if self.history_end_index is not None: + if self.history_end_index >= self.history_limit_index: + # if the logger usage exceeds the limit, clear it + self.dump_history() + self.history_end_index = None + except usb.USBError as e: + raise weewx.WeeWxIOError(e) + except (DecodeError, ProtocolError) as e: + log.info("genLoopPackets: %s" % e) + time.sleep(0.001) + + def genStartupRecords(self, since_ts): + for rec in self.get_history(since_ts): + yield rec + + def convert(self, pkt, ts): + # if debugging packets, log everything we got + if DEBUG_PACKET: + log.info("raw packet: %s" % pkt) + # timestamp and unit system are the same no matter what + p = {'dateTime': ts, 'usUnits': weewx.METRICWX} + # map hardware names to the requested database schema names + for label in self.sensor_map: + if self.sensor_map[label] in pkt: + p[label] = pkt[self.sensor_map[label]] + # single variable to track last_rain assumes that any historical reads + # will happen before any loop reads, and no historical reads will + # happen after any loop reads. Such double-counting of rain + # events is avoided by deliberately ignoring all loop packets during history read. + if 'rain_total' in pkt: + p['rain'] = self.calculate_rain(pkt['rain_total'], self.last_rain) + if DEBUG_RAIN and pkt['rain_total'] != self.last_rain: + log.info("rain=%s rain_total=%s last_rain=%s" % + (p['rain'], pkt['rain_total'], self.last_rain)) + self.last_rain = pkt['rain_total'] + if pkt['rain_total'] == Station.MAX_RAIN_MM: + if time.time() - self.logged_rain_counter > self.log_interval: + log.info("rain counter at maximum, reset required") + self.logged_rain_counter = time.time() + if pkt['rain_total'] >= Station.MAX_RAIN_MM * self.rain_warn: + if time.time() - self.logged_rain_counter > self.log_interval: + log.info("rain counter is above warning level, reset recommended") + self.logged_rain_counter = time.time() + if DEBUG_PACKET: + log.info("converted packet: %s" % p) + return p + + def send_heartbeat( self ): + cmd = [0xa6, 0x91, 0xca, 0x45, 0x52] + self.station.write(cmd) + self.last_a6 = time.time() + self.data_since_heartbeat = 0 + + def convert_historical(self, pkt, ts, last_ts): + p = self.convert(pkt, ts) + if last_ts is not None: + x = (ts - last_ts) / 60 # interval is in minutes + if x > 0: + p['interval'] = x + else: + log.info("ignoring record: bad interval %s (%s)" % (x, p)) + return p + + def convert_loop(self, pkt): + p = self.convert(pkt, int(time.time() + 0.5)) + if DEBUG_HISTORY and self.history_end_index is not None: + p['rxCheckPercent'] = float(self.history_end_index) # fake value as easiest way to return it. + if 'pressure' in p: + # cache any pressure-related values + for x in ['pressure', 'barometer']: + self.pressure_cache[x] = p[x] + else: + # apply any cached pressure-related values + p.update(self.pressure_cache) + return p + + @staticmethod + def calculate_rain(newtotal, oldtotal): + """Calculate the rain difference given two cumulative measurements.""" + if newtotal is not None and oldtotal is not None: + if newtotal >= oldtotal: + delta = newtotal - oldtotal + else: + log.info("rain counter decrement detected: new=%s old=%s" % (newtotal, oldtotal)) + delta = None + else: + log.info("possible missed rain event: new=%s old=%s" % (newtotal, oldtotal)) + delta = None + return delta + + +class WMR300Error(weewx.WeeWxIOError): + """map station errors to weewx io errors""" + +class ProtocolError(WMR300Error): + """communication protocol error""" + +class DecodeError(WMR300Error): + """decoding error""" + +class WrongLength(DecodeError): + """bad packet length""" + +class BadChecksum(DecodeError): + """bogus checksum""" + +class BadTimestamp(DecodeError): + """bogus timestamp""" + +class BadBuffer(DecodeError): + """bogus buffer""" + +class UnknownPacketType(DecodeError): + """unknown packet type""" + +class Station(object): + # these identify the weather station on the USB + VENDOR_ID = 0x0FDE + PRODUCT_ID = 0xCA08 + # standard USB endpoint identifiers + EP_IN = 0x81 + EP_OUT = 0x01 + # all USB messages for this device have the same length + MESSAGE_LENGTH = 64 + + HISTORY_START_REC = 0x20 # index to first history record + HISTORY_MAX_REC = 0x7fe0 # index to history record when full + HISTORY_N_RECORDS = 32704 # maximum number of records (MAX_REC - START_REC) + MAX_RAIN_MM = 10160 # maximum value of rain counter, in mm + + def __init__(self, vend_id=VENDOR_ID, prod_id=PRODUCT_ID): + self.vendor_id = vend_id + self.product_id = prod_id + self.handle = None + self.timeout = 500 + self.interface = 0 # device has only the one interface + self.recv_counts = dict() + self.send_counts = dict() + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): # @UnusedVariable + self.close() + + def open(self): + dev = self._find_dev(self.vendor_id, self.product_id) + if not dev: + raise WMR300Error("Unable to find station on USB: " + "cannot find device with " + "VendorID=0x%04x ProductID=0x%04x" % + (self.vendor_id, self.product_id)) + + self.handle = dev.open() + if not self.handle: + raise WMR300Error('Open USB device failed') + + # FIXME: reset is actually a no-op for some versions of libusb/pyusb? + self.handle.reset() + + # for HID devices on linux, be sure kernel does not claim the interface + try: + self.handle.detachKernelDriver(self.interface) + except (AttributeError, usb.USBError): + pass + + # attempt to claim the interface + try: + self.handle.claimInterface(self.interface) + except usb.USBError as e: + self.close() + raise WMR300Error("Unable to claim interface %s: %s" % + (self.interface, e)) + + def close(self): + if self.handle is not None: + try: + self.handle.releaseInterface() + except (ValueError, usb.USBError) as e: + log.debug("Release interface failed: %s" % e) + self.handle = None + + def reset(self): + self.handle.reset() + + def _read(self, count=True, timeout=None): + if timeout is None: + timeout = self.timeout + buf = self.handle.interruptRead( + Station.EP_IN, self.MESSAGE_LENGTH, timeout) + if DEBUG_COMM: + log.info("read: %s" % _fmt_bytes(buf)) + if DEBUG_COUNTS and count: + self.update_count(buf, self.recv_counts) + return buf + + def read(self, count=True, timeout=None, ignore_non_errors=True, ignore_timeouts=True): + try: + return self._read(count, timeout) + except usb.USBError as e: + if DEBUG_COMM: + log.info("read: e.errno=%s e.strerror=%s e.message=%s repr=%s" % + (e.errno, e.strerror, e.message, repr(e))) + if ignore_timeouts and is_timeout(e): + return [] + if ignore_non_errors and is_noerr(e): + return [] + raise + + def _write(self, buf): + if DEBUG_COMM: + log.info("write: %s" % _fmt_bytes(buf)) + # pad with zeros up to the standard message length + while len(buf) < self.MESSAGE_LENGTH: + buf.append(0x00) + sent = self.handle.interruptWrite(Station.EP_OUT, buf, self.timeout) + if DEBUG_COUNTS: + self.update_count(buf, self.send_counts) + return sent + + def write(self, buf, ignore_non_errors=True, ignore_timeouts=True): + try: + return self._write(buf) + except usb.USBError as e: + if ignore_timeouts and is_timeout(e): + return 0 + if ignore_non_errors and is_noerr(e): + return 0 + raise + + def flush_read_buffer(self): + """discard anything read from the device""" + if DEBUG_COMM: + log.info("flush buffer") + cnt = 0 + buf = self.read(False, 100) + while buf is not None and len(buf) > 0: + cnt += len(buf) + buf = self.read(False, 100) + if DEBUG_COMM: + log.info("flush: discarded %d bytes" % cnt) + return cnt + + # keep track of the message types for debugging purposes + @staticmethod + def update_count(buf, count_dict): + label = 'empty' + if buf and len(buf) > 0: + #if buf[0] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]: + # message type and channel for data packets + #label = '%02x_%d' % (buf[0], buf[7]) + if buf[0] == 0xd3: + # message type and channel for data packets + label = 'TH_%d' % (buf[7]) + elif buf[0] == 0xdc: + label = 'THrng_%d' % (buf[7]) + # ignore this for the moment... + return + elif buf[0] == 0xd4: + label = 'wind' + elif buf[0] == 0xd5: + label = 'rain' + elif buf[0] == 0xd6: + label = 'barom' + elif buf[0] == 0xdb: + label = 'forecast' + elif (buf[0] in [0x41] and + buf[3] in [0xd3, 0xd4, 0xd5, 0xd6, 0xdb, 0xdc]): + # message type and channel for data ack packets + # these are no longer sent. + label = '%02x_%02x_%d' % (buf[0], buf[3], buf[4]) + else: + # otherwise just track the message type + # prefix with x to place at end + label = 'x%02x' % buf[0] + if label in count_dict: + count_dict[label] += 1 + else: + count_dict[label] = 1 + #Station.print_count( "unknown", count_dict) + + # print the count type summary for debugging + @staticmethod + def print_count( direction, count_dict): + cstr = [] + for k in sorted(count_dict): + cstr.append('%s: %s' % (k, count_dict[k])) + log.info('%s counts; %s' % ( direction, '; '.join(cstr))) + + @staticmethod + def _find_dev(vendor_id, product_id): + """Find the first device with vendor and product ID on the USB.""" + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vendor_id and dev.idProduct == product_id: + log.debug('Found station at bus=%s device=%s' % + (bus.dirname, dev.filename)) + return dev + return None + + @staticmethod + def _verify_length(label, length, buf): + if buf[1] != length: + raise WrongLength("%s: wrong length: expected %02x, got %02x" % + (label, length, buf[1])) + + @staticmethod + def _verify_checksum(label, buf, msb_first=True): + """Calculate and compare checksum""" + try: + cs1 = Station._calc_checksum(buf) + cs2 = Station._extract_checksum(buf, msb_first) + if cs1 != cs2: + raise BadChecksum("%s: bad checksum: %04x != %04x" % + (label, cs1, cs2)) + except IndexError as e: + raise BadChecksum("%s: not enough bytes for checksum: %s" % + (label, e)) + + @staticmethod + def _calc_checksum(buf): + cs = 0 + for x in buf[:-2]: + cs += x + return cs + + @staticmethod + def _extract_checksum(buf, msb_first): + if msb_first: + return (buf[-2] << 8) | buf[-1] + return (buf[-1] << 8) | buf[-2] + + @staticmethod + def _extract_ts(buf): + if buf[0] == 0xee and buf[1] == 0xee and buf[2] == 0xee: + # year, month, and day are 0xee when timestamp is unset + return None + try: + year = int(buf[0]) + 2000 + month = int(buf[1]) + day = int(buf[2]) + hour = int(buf[3]) + minute = int(buf[4]) + return time.mktime((year, month, day, hour, minute, 0, -1, -1, -1)) + except IndexError: + raise BadTimestamp("buffer too short for timestamp") + except (OverflowError, ValueError) as e: + raise BadTimestamp( + "cannot create timestamp from y:%s m:%s d:%s H:%s M:%s: %s" % + (buf[0], buf[1], buf[2], buf[3], buf[4], e)) + + @staticmethod + def _extract_signed(hi, lo, m): + if hi == 0x7f: + return None + s = 0 + if hi & 0xf0 == 0xf0: + s = 0x10000 + return ((hi << 8) + lo - s) * m + + @staticmethod + def _extract_value(buf, m): + if buf[0] == 0x7f: + return None + if len(buf) == 2: + return ((buf[0] << 8) + buf[1]) * m + return buf[0] * m + + @staticmethod + def get_history_end_index(buf): + """ get the index value reported in the 0x57 packet. + It is the index of the first free history record, + and so is one more than the most recent history record stored in the console + """ + if buf[0] != 0x57: + return None + idx = (buf[17] << 8) + buf[18] + #if idx < Station.HISTORY_START_REC: + # raise WMR300Error("History index: %d below limit of %d" % (idx, Station.HISTORY_START_REC) ) + #elif idx > Station.HISTORY_MAX_REC: + # raise WMR300Error("History index: %d above limit of %d" % (idx, Station.HISTORY_MAX_REC) ) + #self.history_pct=Station.get_history_usage( idx ) + return Station.clip_index(idx) + + @staticmethod + def get_next_index(n): + ## this code is currently UNUSED + # return the index of the record after indicated index + if n == 0: + return 0x20 + if n + 1 > Station.MAX_RECORDS: + return 0x20 # FIXME: verify the wraparound + return n + 1 + + @staticmethod + def clip_index(n): + # given a history record index, clip it to a valid value + # the HISTORY_MAX_REC value is what it returned in packet 0x57 when the + # buffer is full. You cannot ask for it, only the one before. + if n < Station.HISTORY_START_REC: + return Station.HISTORY_START_REC + if n >= Station.HISTORY_MAX_REC - 1: + return Station.HISTORY_MAX_REC - 1 # wraparound never happens + return n + + @staticmethod + def get_record_index(buf): + # extract the index from the history record + if buf[0] != 0xd2: + return None + return (buf[2] << 8) + buf[3] + + @staticmethod + def get_history_pct_as_index( pct ): + # return history buffer index corresponding to a given percentage + if pct is None: + return Station.HISTORY_START_REC + return int(pct * 0.01 * Station.HISTORY_N_RECORDS + Station.HISTORY_START_REC) + + @staticmethod + def get_history_usage_pct(index): + """ return the console's history buffer use corresponding to the given index expressed as a percent + index = index value in console's history buffer + normally the next free history location as returned in 0x57 status packet + """ + if index is None: + return -1.0 + return 100.0 * float(index - Station.HISTORY_START_REC) / Station.HISTORY_N_RECORDS + + @staticmethod + def decode(buf): + try: + pkt = getattr(Station, '_decode_%02x' % buf[0])(buf) + if DEBUG_DECODE: + log.info('decode: %s %s' % (_fmt_bytes(buf), pkt)) + return pkt + except IndexError as e: + raise BadBuffer("cannot decode buffer: %s" % e) + except AttributeError: + raise UnknownPacketType("unknown packet type %02x: %s" % + (buf[0], _fmt_bytes(buf))) + + @staticmethod + def _decode_57(buf): + """57 packet contains station information""" + pkt = dict() + pkt['packet_type'] = 0x57 + pkt['station_type'] = ''.join("%s" % chr(x) for x in buf[0:6]) + pkt['station_model'] = ''.join("%s" % chr(x) for x in buf[7:11]) + pkt['magic0'] = buf[12] + pkt['magic1'] = buf[13] + pkt['history_cleared'] = (buf[20] == 0x43) # FIXME: verify this + pkt['mystery0'] = buf[22] + pkt['mystery1'] = buf[23] + pkt['history_end_index'] = Station.get_history_end_index( buf ) + if DEBUG_HISTORY: + log.info("history index: %s" % pkt['history_end_index']) + return pkt + + @staticmethod + def _decode_41(_): + """41 43 4b is ACK""" + pkt = dict() + pkt['packet_type'] = 0x41 + return pkt + + @staticmethod + def _decode_d2(buf): + """D2 packet contains history data""" + Station._verify_length("D2", 0x80, buf) + Station._verify_checksum("D2", buf[:0x80], msb_first=False) + pkt = dict() + pkt['packet_type'] = 0xd2 + pkt['index'] = Station.get_record_index(buf) + pkt['ts'] = Station._extract_ts(buf[4:9]) + for i in range(9): + pkt['temperature_%d' % i] = Station._extract_signed( + buf[9 + 2 * i], buf[10 + 2 * i], 0.1) # C + pkt['humidity_%d' % i] = Station._extract_value( + buf[27 + i:28 + i], 1.0) # % + for i in range(1, 9): + pkt['dewpoint_%d' % i] = Station._extract_signed( + buf[36 + 2 * i], buf[37 + 2 * i], 0.1) # C + pkt['heatindex_%d' % i] = Station._extract_signed( + buf[52 + 2 * i], buf[53 + 2 * i], 0.1) # C + pkt['windchill'] = Station._extract_signed(buf[68], buf[69], 0.1) # C + pkt['wind_gust'] = Station._extract_value(buf[72:74], 0.1) # m/s + pkt['wind_avg'] = Station._extract_value(buf[74:76], 0.1) # m/s + pkt['wind_gust_dir'] = Station._extract_value(buf[76:78], 1.0) # degree + pkt['wind_dir'] = Station._extract_value(buf[78:80], 1.0) # degree + pkt['forecast'] = Station._extract_value(buf[80:81], 1.0) + pkt['rain_hour'] = Station._extract_value(buf[83:85], 0.254) # mm + pkt['rain_total'] = Station._extract_value(buf[86:88], 0.254) # mm + pkt['rain_start_dateTime'] = Station._extract_ts(buf[88:93]) + pkt['rain_rate'] = Station._extract_value(buf[93:95], 0.254) # mm/hour + pkt['barometer'] = Station._extract_value(buf[95:97], 0.1) # mbar + pkt['pressure_trend'] = Station._extract_value(buf[97:98], 1.0) + return pkt + + @staticmethod + def _decode_d3(buf): + """D3 packet contains temperature/humidity data""" + Station._verify_length("D3", 0x3d, buf) + Station._verify_checksum("D3", buf[:0x3d]) + pkt = dict() + pkt['packet_type'] = 0xd3 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['temperature_%d' % pkt['channel']] = Station._extract_signed( + buf[8], buf[9], 0.1) # C + pkt['humidity_%d' % pkt['channel']] = Station._extract_value( + buf[10:11], 1.0) # % + pkt['dewpoint_%d' % pkt['channel']] = Station._extract_signed( + buf[11], buf[12], 0.1) # C + pkt['heatindex_%d' % pkt['channel']] = Station._extract_signed( + buf[13], buf[14], 0.1) # C + return pkt + + @staticmethod + def _decode_d4(buf): + """D4 packet contains wind data""" + Station._verify_length("D4", 0x36, buf) + Station._verify_checksum("D4", buf[:0x36]) + pkt = dict() + pkt['packet_type'] = 0xd4 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['wind_gust'] = Station._extract_value(buf[8:10], 0.1) # m/s + pkt['wind_gust_dir'] = Station._extract_value(buf[10:12], 1.0) # degree + pkt['wind_avg'] = Station._extract_value(buf[12:14], 0.1) # m/s + pkt['wind_dir'] = Station._extract_value(buf[14:16], 1.0) # degree + pkt['windchill'] = Station._extract_signed(buf[18], buf[19], 0.1) # C + return pkt + + @staticmethod + def _decode_d5(buf): + """D5 packet contains rain data""" + Station._verify_length("D5", 0x28, buf) + Station._verify_checksum("D5", buf[:0x28]) + pkt = dict() + pkt['packet_type'] = 0xd5 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['rain_hour'] = Station._extract_value(buf[9:11], 0.254) # mm + pkt['rain_24_hour'] = Station._extract_value(buf[12:14], 0.254) # mm + pkt['rain_total'] = Station._extract_value(buf[15:17], 0.254) # mm + pkt['rain_rate'] = Station._extract_value(buf[17:19], 0.254) # mm/hour + pkt['rain_start_dateTime'] = Station._extract_ts(buf[19:24]) + return pkt + + @staticmethod + def _decode_d6(buf): + """D6 packet contains pressure data""" + Station._verify_length("D6", 0x2e, buf) + Station._verify_checksum("D6", buf[:0x2e]) + pkt = dict() + pkt['packet_type'] = 0xd6 + pkt['ts'] = Station._extract_ts(buf[2:7]) + pkt['channel'] = buf[7] + pkt['pressure'] = Station._extract_value(buf[8:10], 0.1) # mbar + pkt['barometer'] = Station._extract_value(buf[10:12], 0.1) # mbar + pkt['altitude'] = Station._extract_value(buf[12:14], 1.0) # meter + return pkt + + @staticmethod + def _decode_dc(buf): + """DC packet contains temperature/humidity range data""" + Station._verify_length("DC", 0x3e, buf) + Station._verify_checksum("DC", buf[:0x3e]) + pkt = dict() + pkt['packet_type'] = 0xdc + pkt['ts'] = Station._extract_ts(buf[2:7]) + return pkt + + @staticmethod + def _decode_db(buf): + """DB packet is forecast""" + Station._verify_length("DB", 0x20, buf) + Station._verify_checksum("DB", buf[:0x20]) + pkt = dict() + pkt['packet_type'] = 0xdb + return pkt + + +class WMR300ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WMR300] + # This section is for WMR300 weather stations. + + # The station model, e.g., WMR300A + model = WMR300 + + # The driver to use: + driver = weewx.drivers.wmr300 + + # The console history buffer will be emptied each + # time it gets to this percent full + # Allowed range 5 to 95. + history_limit = 10 + +""" + + def modify_config(self, config_dict): + print(""" +Setting rainRate, windchill, heatindex calculations to hardware. +Dewpoint from hardware is truncated to integer so use software""") + config_dict.setdefault('StdWXCalculate', {}) + config_dict['StdWXCalculate'].setdefault('Calculations', {}) + config_dict['StdWXCalculate']['Calculations']['rainRate'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['windchill'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['heatindex'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['dewpoint'] = 'software' + + +# define a main entry point for basic testing of the station. +# invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/user/wmr300.py + +if __name__ == '__main__': + import optparse + + from weeutil.weeutil import to_sorted_string + import weewx + import weeutil.logger + + weewx.debug = 1 + + weeutil.logger.setup('wee_wmr300') + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', action='store_true', + help='display driver version') + parser.add_option('--get-current', action='store_true', + help='get current packets') + parser.add_option('--get-history', action='store_true', + help='get history records from station') + (options, args) = parser.parse_args() + + if options.version: + print("%s driver version %s" % (DRIVER_NAME, DRIVER_VERSION)) + exit(0) + + driver_dict = { + 'debug_comm': 0, + 'debug_packet': 0, + 'debug_counts': 0, + 'debug_decode': 0, + 'debug_history': 1, + 'debug_timing': 0, + 'debug_rain': 0} + stn = WMR300Driver(**driver_dict) + + if options.get_history: + ts = time.time() - 3600 # get last hour of data + for pkt in stn.genStartupRecords(ts): + print(to_sorted_string(pkt).encode('utf-8')) + + if options.get_current: + for packet in stn.genLoopPackets(): + print(to_sorted_string(packet).encode('utf-8')) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/wmr9x8.py b/dist/weewx-5.0.2/src/weewx/drivers/wmr9x8.py new file mode 100644 index 0000000..082b3ea --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/wmr9x8.py @@ -0,0 +1,754 @@ +# Copyright (c) 2012 Will Page +# See the file LICENSE.txt for your full rights. +# +# Derivative of vantage.py and wmr100.py, credit to Tom Keffer + +"""Classes and functions for interfacing with Oregon Scientific WM-918, WMR9x8, +and WMR-968 weather stations + +See + http://wx200.planetfall.com/wx200.txt + http://www.qsl.net/zl1vfo/wx200/wx200.txt + http://ed.toton.org/projects/weather/station-protocol.txt +for documentation on the WM-918 / WX-200 serial protocol + +See + http://www.netsky.org/WMR/Protocol.htm +for documentation on the WMR9x8 serial protocol, and + http://code.google.com/p/wmr968/source/browse/trunk/src/edu/washington/apl/weather/packet/ +for sample (java) code. +""" + +import logging +import operator +import time +from functools import reduce + +import serial + +import weewx.drivers + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WMR9x8' +DRIVER_VERSION = "3.5" +DEFAULT_PORT = '/dev/ttyS0' + +def loader(config_dict, engine): # @UnusedVariable + return WMR9x8(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WMR9x8ConfEditor() + + +class WMR9x8ProtocolError(weewx.WeeWxIOError): + """Used to signal a protocol error condition""" + +def channel_decoder(chan): + if 1 <= chan <= 2: + outchan = chan + elif chan == 4: + outchan = 3 + else: + raise WMR9x8ProtocolError("Bad channel number %d" % chan) + return outchan + +# Dictionary that maps a measurement code, to a function that can decode it: +# packet_type_decoder_map and packet_type_size_map are filled out using the @_registerpackettype +# decorator below +wmr9x8_packet_type_decoder_map = {} +wmr9x8_packet_type_size_map = {} + +wm918_packet_type_decoder_map = {} +wm918_packet_type_size_map = {} + +def wmr9x8_registerpackettype(typecode, size): + """ Function decorator that registers the function as a handler + for a particular packet type. Parameters to the decorator + are typecode and size (in bytes). """ + def wrap(dispatcher): + wmr9x8_packet_type_decoder_map[typecode] = dispatcher + wmr9x8_packet_type_size_map[typecode] = size + return wrap + +def wm918_registerpackettype(typecode, size): + """ Function decorator that registers the function as a handler + for a particular packet type. Parameters to the decorator + are typecode and size (in bytes). """ + def wrap(dispatcher): + wm918_packet_type_decoder_map[typecode] = dispatcher + wm918_packet_type_size_map[typecode] = size + return wrap + + +class SerialWrapper(object): + """Wraps a serial connection returned from package serial""" + + def __init__(self, port): + self.port = port + # WMR9x8 specific settings + self.serialconfig = { + "bytesize": serial.EIGHTBITS, + "parity": serial.PARITY_NONE, + "stopbits": serial.STOPBITS_ONE, + "timeout": None, + "rtscts": 1 + } + + def flush_input(self): + self.serial_port.flushInput() + + def queued_bytes(self): + return self.serial_port.inWaiting() + + def read(self, chars=1): + _buffer = self.serial_port.read(chars) + N = len(_buffer) + if N != chars: + raise weewx.WeeWxIOError("Expected to read %d chars; got %d instead" % (chars, N)) + return _buffer + + def openPort(self): + # Open up the port and store it + self.serial_port = serial.Serial(self.port, **self.serialconfig) + log.debug("Opened up serial port %s" % self.port) + + def closePort(self): + self.serial_port.close() + +#============================================================================== +# Class WMR9x8 +#============================================================================== + +class WMR9x8(weewx.drivers.AbstractDevice): + """Driver for the Oregon Scientific WMR9x8 console. + + The connection to the console will be open after initialization""" + + DEFAULT_MAP = { + 'barometer': 'barometer', + 'pressure': 'pressure', + 'windSpeed': 'wind_speed', + 'windDir': 'wind_dir', + 'windGust': 'wind_gust', + 'windGustDir': 'wind_gust_dir', + 'windBatteryStatus': 'battery_status_wind', + 'inTemp': 'temperature_in', + 'outTemp': 'temperature_out', + 'extraTemp1': 'temperature_1', + 'extraTemp2': 'temperature_2', + 'extraTemp3': 'temperature_3', + 'extraTemp4': 'temperature_4', + 'extraTemp5': 'temperature_5', + 'extraTemp6': 'temperature_6', + 'extraTemp7': 'temperature_7', + 'extraTemp8': 'temperature_8', + 'inHumidity': 'humidity_in', + 'outHumidity': 'humidity_out', + 'extraHumid1': 'humidity_1', + 'extraHumid2': 'humidity_2', + 'extraHumid3': 'humidity_3', + 'extraHumid4': 'humidity_4', + 'extraHumid5': 'humidity_5', + 'extraHumid6': 'humidity_6', + 'extraHumid7': 'humidity_7', + 'extraHumid8': 'humidity_8', + 'inTempBatteryStatus': 'battery_status_in', + 'outTempBatteryStatus': 'battery_status_out', + 'extraBatteryStatus1': 'battery_status_1', # was batteryStatusTHx + 'extraBatteryStatus2': 'battery_status_2', # or batteryStatusTx + 'extraBatteryStatus3': 'battery_status_3', + 'extraBatteryStatus4': 'battery_status_4', + 'extraBatteryStatus5': 'battery_status_5', + 'extraBatteryStatus6': 'battery_status_6', + 'extraBatteryStatus7': 'battery_status_7', + 'extraBatteryStatus8': 'battery_status_8', + 'inDewpoint': 'dewpoint_in', + 'dewpoint': 'dewpoint_out', + 'dewpoint0': 'dewpoint_0', + 'dewpoint1': 'dewpoint_1', + 'dewpoint2': 'dewpoint_2', + 'dewpoint3': 'dewpoint_3', + 'dewpoint4': 'dewpoint_4', + 'dewpoint5': 'dewpoint_5', + 'dewpoint6': 'dewpoint_6', + 'dewpoint7': 'dewpoint_7', + 'dewpoint8': 'dewpoint_8', + 'rain': 'rain', + 'rainTotal': 'rain_total', + 'rainRate': 'rain_rate', + 'hourRain': 'rain_hour', + 'rain24': 'rain_24', + 'yesterdayRain': 'rain_yesterday', + 'rainBatteryStatus': 'battery_status_rain', + 'windchill': 'windchill'} + + def __init__(self, **stn_dict): + """Initialize an object of type WMR9x8. + + NAMED ARGUMENTS: + + model: Which station model is this? + [Optional. Default is 'WMR968'] + + port: The serial port of the WM918/WMR918/WMR968. + [Required if serial communication] + + baudrate: Baudrate of the port. + [Optional. Default 9600] + + timeout: How long to wait before giving up on a response from the + serial port. + [Optional. Default is 5] + """ + + log.info('driver version is %s' % DRIVER_VERSION) + self.model = stn_dict.get('model', 'WMR968') + self.sensor_map = dict(self.DEFAULT_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + log.info('sensor map is %s' % self.sensor_map) + self.last_rain_total = None + + # Create the specified port + self.port = WMR9x8._port_factory(stn_dict) + + # Open it up: + self.port.openPort() + + @property + def hardware_name(self): + return self.model + + def openPort(self): + """Open up the connection to the console""" + self.port.openPort() + + def closePort(self): + """Close the connection to the console. """ + self.port.closePort() + + def genLoopPackets(self): + """Generator function that continuously returns loop packets""" + buf = [] + # We keep a buffer the size of the largest supported packet + wmr9x8max = max(list(wmr9x8_packet_type_size_map.items()), key=operator.itemgetter(1))[1] + wm918max = max(list(wm918_packet_type_size_map.items()), key=operator.itemgetter(1))[1] + preBufferSize = max(wmr9x8max, wm918max) + while True: + buf.extend(bytearray(self.port.read(preBufferSize - len(buf)))) + # WMR-9x8/968 packets are framed by 0xFF characters + if buf[0] == 0xFF and buf[1] == 0xFF and buf[2] in wmr9x8_packet_type_size_map: + # Look up packet type, the expected size of this packet type + ptype = buf[2] + psize = wmr9x8_packet_type_size_map[ptype] + # Capture only the data belonging to this packet + pdata = buf[0:psize] + if weewx.debug >= 2: + self.log_packet(pdata) + # Validate the checksum + sent_checksum = pdata[-1] + calc_checksum = reduce(operator.add, pdata[0:-1]) & 0xFF + if sent_checksum == calc_checksum: + log.debug("Received WMR9x8 data packet.") + payload = pdata[2:-1] + _record = wmr9x8_packet_type_decoder_map[ptype](self, payload) + _record = self._sensors_to_fields(_record, self.sensor_map) + if _record is not None: + yield _record + # Eliminate all packet data from the buffer + buf = buf[psize:] + else: + log.debug("Invalid data packet (%s)." % pdata) + # Drop the first byte of the buffer and start scanning again + buf.pop(0) + # WM-918 packets have no framing + elif buf[0] in wm918_packet_type_size_map: + # Look up packet type, the expected size of this packet type + ptype = buf[0] + psize = wm918_packet_type_size_map[ptype] + # Capture only the data belonging to this packet + pdata = buf[0:psize] + # Validate the checksum + sent_checksum = pdata[-1] + calc_checksum = reduce(operator.add, pdata[0:-1]) & 0xFF + if sent_checksum == calc_checksum: + log.debug("Received WM-918 data packet.") + payload = pdata[0:-1] # send all of packet but crc + _record = wm918_packet_type_decoder_map[ptype](self, payload) + _record = self._sensors_to_fields(_record, self.sensor_map) + if _record is not None: + yield _record + # Eliminate all packet data from the buffer + buf = buf[psize:] + else: + log.debug("Invalid data packet (%s)." % pdata) + # Drop the first byte of the buffer and start scanning again + buf.pop(0) + else: + log.debug("Advancing buffer by one for the next potential packet") + buf.pop(0) + + @staticmethod + def _sensors_to_fields(oldrec, sensor_map): + # map a record with observation names to a record with db field names + if oldrec: + newrec = dict() + for k in sensor_map: + if sensor_map[k] in oldrec: + newrec[k] = oldrec[sensor_map[k]] + if newrec: + newrec['dateTime'] = oldrec['dateTime'] + newrec['usUnits'] = oldrec['usUnits'] + return newrec + return None + + #========================================================================== + # Oregon Scientific WMR9x8 utility functions + #========================================================================== + + @staticmethod + def _port_factory(stn_dict): + """Produce a serial port object""" + + # Get the connection type. If it is not specified, assume 'serial': + connection_type = stn_dict.get('type', 'serial').lower() + + if connection_type == "serial": + port = stn_dict['port'] + return SerialWrapper(port) + raise weewx.UnsupportedFeature(stn_dict['type']) + + @staticmethod + def _get_nibble_data(packet): + nibbles = bytearray() + for byte in packet: + nibbles.extend([(byte & 0x0F), (byte & 0xF0) >> 4]) + return nibbles + + def log_packet(self, packet): + packet_str = ','.join(["x%x" % v for v in packet]) + print("%d, %s, %s" % (int(time.time() + 0.5), time.asctime(), packet_str)) + + @wmr9x8_registerpackettype(typecode=0x00, size=11) + def _wmr9x8_wind_packet(self, packet): + """Decode a wind packet. Wind speed will be in kph""" + null, status, dir1, dir10, dir100, gust10th, gust1, gust10, avg10th, avg1, avg10, chillstatus, chill1, chill10 = self._get_nibble_data(packet[1:]) # @UnusedVariable + + battery = (status & 0x04) >> 2 + + # The console returns wind speeds in m/s. Our metric system requires + # kph, so the result needs to be multiplied by 3.6 + _record = { + 'battery_status_wind': battery, + 'wind_speed': ((avg10th / 10.0) + avg1 + (avg10 * 10)) * 3.6, + 'wind_dir': dir1 + (dir10 * 10) + (dir100 * 100), + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Sometimes the station emits a wind gust that is less than the + # average wind. Ignore it if this is the case. + windGustSpeed = ((gust10th / 10.0) + gust1 + (gust10 * 10)) * 3.6 + if windGustSpeed >= _record['wind_speed']: + _record['wind_gust'] = windGustSpeed + + # Bit 1 of chillstatus is on if there is no wind chill data; + # Bit 2 is on if it has overflowed. Check them both: + if chillstatus & 0x6 == 0: + chill = chill1 + (10 * chill10) + if chillstatus & 0x8: + chill = -chill + _record['windchill'] = chill + else: + _record['windchill'] = None + + return _record + + @wmr9x8_registerpackettype(typecode=0x01, size=16) + def _wmr9x8_rain_packet(self, packet): + null, status, cur1, cur10, cur100, tot10th, tot1, tot10, tot100, tot1000, yest1, yest10, yest100, yest1000, totstartmin1, totstartmin10, totstarthr1, totstarthr10, totstartday1, totstartday10, totstartmonth1, totstartmonth10, totstartyear1, totstartyear10 = self._get_nibble_data(packet[1:]) # @UnusedVariable + battery = (status & 0x04) >> 2 + + # station units are mm and mm/hr while the internal metric units are + # cm and cm/hr. It is reported that total rainfall is biased by +0.5 mm + _record = { + 'battery_status_rain': battery, + 'rain_rate': (cur1 + (cur10 * 10) + (cur100 * 100)) / 10.0, + 'rain_yesterday': (yest1 + (yest10 * 10) + (yest100 * 100) + (yest1000 * 1000)) / 10.0, + 'rain_total': (tot10th / 10.0 + tot1 + 10.0 * tot10 + 100.0 * tot100 + 1000.0 * tot1000) / 10.0, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Because the WMR does not offer anything like bucket tips, we must + # calculate it by looking for the change in total rain. Of course, + # this won't work for the very first rain packet. + _record['rain'] = (_record['rain_total'] - self.last_rain_total) if self.last_rain_total is not None else None + self.last_rain_total = _record['rain_total'] + return _record + + @wmr9x8_registerpackettype(typecode=0x02, size=9) + def _wmr9x8_thermohygro_packet(self, packet): + chan, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10 = self._get_nibble_data(packet[1:]) + + chan = channel_decoder(chan) + + battery = (status & 0x04) >> 2 + _record = { + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC, + 'battery_status_%d' % chan :battery + } + + _record['humidity_%d' % chan] = hum1 + (hum10 * 10) + + tempoverunder = temp100etc & 0x04 + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + _record['temperature_%d' % chan] = temp + else: + _record['temperature_%d' % chan] = None + + dewunder = bool(status & 0x01) + # If dew point is valid, save it. + if not dewunder: + _record['dewpoint_%d' % chan] = dew1 + (dew10 * 10) + + return _record + + @wmr9x8_registerpackettype(typecode=0x03, size=9) + def _wmr9x8_mushroom_packet(self, packet): + _, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10 = self._get_nibble_data(packet[1:]) + + battery = (status & 0x04) >> 2 + _record = { + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC, + 'battery_status_out': battery, + 'humidity_out': hum1 + (hum10 * 10) + } + + tempoverunder = temp100etc & 0x04 + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + _record['temperature_out'] = temp + else: + _record['temperature_out'] = None + + dewunder = bool(status & 0x01) + # If dew point is valid, save it. + if not dewunder: + _record['dewpoint_out'] = dew1 + (dew10 * 10) + + return _record + + @wmr9x8_registerpackettype(typecode=0x04, size=7) + def _wmr9x8_therm_packet(self, packet): + chan, status, temp10th, temp1, temp10, temp100etc = self._get_nibble_data(packet[1:]) + + chan = channel_decoder(chan) + battery = (status & 0x04) >> 2 + + _record = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC, + 'battery_status_%d' % chan: battery} + + temp = temp10th / 10.0 + temp1 + 10.0 * temp10 + 100.0 * (temp100etc & 0x03) + if temp100etc & 0x08: + temp = -temp + tempoverunder = temp100etc & 0x04 + _record['temperature_%d' % chan] = temp if not tempoverunder else None + + return _record + + @wmr9x8_registerpackettype(typecode=0x05, size=13) + def _wmr9x8_in_thermohygrobaro_packet(self, packet): + null, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10, baro1, baro10, wstatus, null2, slpoff10th, slpoff1, slpoff10, slpoff100 = self._get_nibble_data(packet[1:]) # @UnusedVariable + + battery = (status & 0x04) >> 2 + hum = hum1 + (hum10 * 10) + + tempoverunder = bool(temp100etc & 0x04) + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + else: + temp = None + + dewunder = bool(status & 0x01) + if not dewunder: + dew = dew1 + (dew10 * 10) + else: + dew = None + + rawsp = ((baro10 & 0xF) << 4) | baro1 + sp = rawsp + 795 + pre_slpoff = (slpoff10th / 10.0) + slpoff1 + (slpoff10 * 10) + (slpoff100 * 100) + slpoff = (1000 + pre_slpoff) if pre_slpoff < 400.0 else pre_slpoff + + _record = { + 'battery_status_in': battery, + 'humidity_in': hum, + 'temperature_in': temp, + 'dewpoint_in': dew, + 'barometer': rawsp + slpoff, + 'pressure': sp, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + @wmr9x8_registerpackettype(typecode=0x06, size=14) + def _wmr9x8_in_ext_thermohygrobaro_packet(self, packet): + null, status, temp10th, temp1, temp10, temp100etc, hum1, hum10, dew1, dew10, baro1, baro10, baro100, wstatus, null2, slpoff10th, slpoff1, slpoff10, slpoff100, slpoff1000 = self._get_nibble_data(packet[1:]) # @UnusedVariable + + battery = (status & 0x04) >> 2 + hum = hum1 + (hum10 * 10) + + tempoverunder = bool(temp100etc & 0x04) + if not tempoverunder: + temp = (temp10th / 10.0) + temp1 + (temp10 * 10) + ((temp100etc & 0x03) * 100) + if temp100etc & 0x08: + temp = -temp + else: + temp = None + + dewunder = bool(status & 0x01) + if not dewunder: + dew = dew1 + (dew10 * 10) + else: + dew = None + + rawsp = ((baro100 & 0x01) << 8) | ((baro10 & 0xF) << 4) | baro1 + sp = rawsp + 600 + slpoff = (slpoff10th / 10.0) + slpoff1 + (slpoff10 * 10) + (slpoff100 * 100) + (slpoff1000 * 1000) + + _record = { + 'battery_status_in': battery, + 'humidity_in': hum, + 'temperature_in': temp, + 'dewpoint_in': dew, + 'barometer': rawsp + slpoff, + 'pressure': sp, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + @wmr9x8_registerpackettype(typecode=0x0e, size=5) + def _wmr9x8_time_packet(self, packet): + """The (partial) time packet is not used by weewx. + However, the last time is saved in case getTime() is called.""" + min1, min10 = self._get_nibble_data(packet[1:]) + minutes = min1 + ((min10 & 0x07) * 10) + + cur = time.gmtime() + self.last_time = time.mktime( + (cur.tm_year, cur.tm_mon, cur.tm_mday, + cur.tm_hour, minutes, 0, + cur.tm_wday, cur.tm_yday, cur.tm_isdst)) + return None + + @wmr9x8_registerpackettype(typecode=0x0f, size=9) + def _wmr9x8_clock_packet(self, packet): + """The clock packet is not used by weewx. + However, the last time is saved in case getTime() is called.""" + min1, min10, hour1, hour10, day1, day10, month1, month10, year1, year10 = self._get_nibble_data(packet[1:]) + year = year1 + (year10 * 10) + # The station initializes itself to "1999" as the first year + # Thus 99 = 1999, 00 = 2000, 01 = 2001, etc. + year += 1900 if year == 99 else 2000 + month = month1 + (month10 * 10) + day = day1 + (day10 * 10) + hour = hour1 + (hour10 * 10) + minutes = min1 + ((min10 & 0x07) * 10) + cur = time.gmtime() + # TODO: not sure if using tm_isdst is correct here + self.last_time = time.mktime( + (year, month, day, + hour, minutes, 0, + cur.tm_wday, cur.tm_yday, cur.tm_isdst)) + return None + + @wm918_registerpackettype(typecode=0xcf, size=27) + def _wm918_wind_packet(self, packet): + """Decode a wind packet. Wind speed will be in m/s""" + gust10th, gust1, gust10, dir1, dir10, dir100, avg10th, avg1, avg10, avgdir1, avgdir10, avgdir100 = self._get_nibble_data(packet[1:7]) + _chill10, _chill1 = self._get_nibble_data(packet[16:17]) + + # The console returns wind speeds in m/s. Our metric system requires + # kph, so the result needs to be multiplied by 3.6 + _record = { + 'wind_speed': ((avg10th / 10.0) + avg1 + (avg10 * 10)) * 3.6, + 'wind_dir': avgdir1 + (avgdir10 * 10) + (avgdir100 * 100), + 'wind_gust': ((gust10th / 10.0) + gust1 + (gust10 * 10)) * 3.6, + 'wind_gust_dir': dir1 + (dir10 * 10) + (dir100 * 100), + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Sometimes the station emits a wind gust that is less than the + # average wind. Ignore it if this is the case. + if _record['wind_gust'] < _record['wind_speed']: + _record['wind_gust'] = _record['wind_speed'] + return _record + + @wm918_registerpackettype(typecode=0xbf, size=14) + def _wm918_rain_packet(self, packet): + cur1, cur10, cur100, _stat, yest1, yest10, yest100, yest1000, tot1, tot10, tot100, tot1000 = self._get_nibble_data(packet[1:7]) + + # It is reported that total rainfall is biased by +0.5 mm + _record = { + 'rain_rate': (cur1 + (cur10 * 10) + (cur100 * 100)) / 10.0, + 'rain_yesterday': (yest1 + (yest10 * 10) + (yest100 * 100) + (yest1000 * 1000)) / 10.0, + 'rain_total': (tot1 + (tot10 * 10) + (tot100 * 100) + (tot1000 * 1000)) / 10.0, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + # Because the WM does not offer anything like bucket tips, we must + # calculate it by looking for the change in total rain. Of course, this + # won't work for the very first rain packet. + # the WM reports rain rate as rain_rate, rain yesterday (updated by + # wm at midnight) and total rain since last reset + # weewx needs rain since last packet we need to divide by 10 to mimic + # Vantage reading + _record['rain'] = (_record['rain_total'] - self.last_rain_total) if self.last_rain_total is not None else None + self.last_rain_total = _record['rain_total'] + return _record + + @wm918_registerpackettype(typecode=0x8f, size=35) + def _wm918_humidity_packet(self, packet): + hum1, hum10 = self._get_nibble_data(packet[8:9]) + humout1, humout10 = self._get_nibble_data(packet[20:21]) + + hum = hum1 + (hum10 * 10) + humout = humout1 + (humout10 * 10) + _record = { + 'humidity_out': humout, + 'humidity_in': hum, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + return _record + + @wm918_registerpackettype(typecode=0x9f, size=34) + def _wm918_therm_packet(self, packet): + temp10th, temp1, temp10, null = self._get_nibble_data(packet[1:3]) # @UnusedVariable + tempout10th, tempout1, tempout10, null = self._get_nibble_data(packet[16:18]) # @UnusedVariable + + temp = (temp10th / 10.0) + temp1 + ((temp10 & 0x7) * 10) + temp *= -1 if (temp10 & 0x08) else 1 + tempout = (tempout10th / 10.0) + tempout1 + ((tempout10 & 0x7) * 10) + tempout *= -1 if (tempout10 & 0x08) else 1 + _record = { + 'temperature_in': temp, + 'temperature_out': tempout, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + @wm918_registerpackettype(typecode=0xaf, size=31) + def _wm918_baro_dew_packet(self, packet): + baro1, baro10, baro100, baro1000, slp10th, slp1, slp10, slp100, slp1000, fmt, prediction, trend, dewin1, dewin10 = self._get_nibble_data(packet[1:8]) # @UnusedVariable + dewout1, dewout10 = self._get_nibble_data(packet[18:19]) # @UnusedVariable + + #dew = dewin1 + (dewin10 * 10) + #dewout = dewout1 + (dewout10 *10) + sp = baro1 + (baro10 * 10) + (baro100 * 100) + (baro1000 * 1000) + slp = (slp10th / 10.0) + slp1 + (slp10 * 10) + (slp100 * 100) + (slp1000 * 1000) + _record = { + 'barometer': slp, + 'pressure': sp, + #'inDewpoint': dew, + #'outDewpoint': dewout, + #'dewpoint': dewout, + 'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.METRIC + } + + return _record + + +class WMR9x8ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WMR9x8] + # This section is for the Oregon Scientific WMR918/968 + + # Connection type. For now, 'serial' is the only option. + type = serial + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., WMR918, Radio Shack 63-1016 + model = WMR968 + + # The driver to use: + driver = weewx.drivers.wmr9x8 +""" + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + port = self._prompt('port', '/dev/ttyUSB0') + return {'port': port} + + def modify_config(self, config_dict): + print(""" +Setting rainRate, windchill, and dewpoint calculations to hardware.""") + config_dict.setdefault('StdWXCalculate', {}) + config_dict['StdWXCalculate'].setdefault('Calculations', {}) + config_dict['StdWXCalculate']['Calculations']['rainRate'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['windchill'] = 'hardware' + config_dict['StdWXCalculate']['Calculations']['dewpoint'] = 'hardware' + +# Define a main entry point for basic testing without the weewx engine. +# Invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/wmr9x8.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + weewx.debug = 2 + + weeutil.logger.setup('wee_wmr9x8') + + usage = """Usage: %prog --help + %prog --version + %prog --gen-packets [--port=PORT]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='Display driver version') + parser.add_option('--port', dest='port', metavar='PORT', + help='The port to use. Default is %s' % DEFAULT_PORT, + default=DEFAULT_PORT) + parser.add_option('--gen-packets', dest='gen_packets', action='store_true', + help="Generate packets indefinitely") + + (options, args) = parser.parse_args() + + if options.version: + print("WMR9x8 driver version %s" % DRIVER_VERSION) + exit(0) + + if options.gen_packets: + log.debug("wmr9x8: Running genLoopPackets()") + stn_dict = {'port': options.port} + stn = WMR9x8(**stn_dict) + + for packet in stn.genLoopPackets(): + print(packet) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/ws1.py b/dist/weewx-5.0.2/src/weewx/drivers/ws1.py new file mode 100644 index 0000000..448ac2e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/ws1.py @@ -0,0 +1,522 @@ +# Copyright 2014-2024 Matthew Wall +# See the file LICENSE.txt for your rights. + +"""Driver for ADS WS1 weather stations. + +Thanks to Kevin and Paul Caccamo for adding the serial-to-tcp capability. + +Thanks to Steve (sesykes71) for the testing that made this driver possible. + +Thanks to Jay Nugent (WB8TKL) and KRK6 for weather-2.kr6k-V2.1 + http://server1.nuge.com/~weather/ +""" + +import logging +import time + +import weewx.drivers +from weewx.units import INHG_PER_MBAR, MILE_PER_KM +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WS1' +DRIVER_VERSION = '1.0' + + +def loader(config_dict, _): + return WS1Driver(**config_dict[DRIVER_NAME]) + +def confeditor_loader(): + return WS1ConfEditor() + + +DEFAULT_SER_PORT = '/dev/ttyS0' +DEFAULT_TCP_ADDR = '192.168.36.25' +DEFAULT_TCP_PORT = 3000 +PACKET_SIZE = 50 +DEBUG_READ = 0 + + +class WS1Driver(weewx.drivers.AbstractDevice): + """weewx driver that communicates with an ADS-WS1 station + + mode - Communication mode - TCP, UDP, or Serial. + [Required. Default is serial] + + port - Serial port or network address. + [Required. Default is /dev/ttyS0 for serial, + and 192.168.36.25:3000 for TCP/IP] + + max_tries - how often to retry serial communication before giving up. + [Optional. Default is 5] + + wait_before_retry - how long to wait, in seconds, before retrying after a failure. + [Optional. Default is 10] + + timeout - The amount of time, in seconds, before the connection fails if + there is no response. + [Optional. Default is 3] + + debug_read - The level of message logging. The higher this number, the more + information is logged. + [Optional. Default is 0] + """ + def __init__(self, **stn_dict): + log.info('driver version is %s' % DRIVER_VERSION) + + con_mode = stn_dict.get('mode', 'serial').lower() + if con_mode == 'tcp' or con_mode == 'udp': + port = stn_dict.get('port', '%s:%d' % (DEFAULT_TCP_ADDR, DEFAULT_TCP_PORT)) + elif con_mode == 'serial': + port = stn_dict.get('port', DEFAULT_SER_PORT) + else: + raise ValueError("Invalid driver connection mode %s" % con_mode) + + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.wait_before_retry = float(stn_dict.get('wait_before_retry', 10)) + timeout = int(stn_dict.get('timeout', 3)) + + self.last_rain = None + + log.info('using %s port %s' % (con_mode, port)) + + global DEBUG_READ + DEBUG_READ = int(stn_dict.get('debug_read', DEBUG_READ)) + + if con_mode == 'tcp' or con_mode == 'udp': + self.station = StationSocket(port, protocol=con_mode, + timeout=timeout, + max_tries=self.max_tries, + wait_before_retry=self.wait_before_retry) + else: + self.station = StationSerial(port, timeout=timeout) + self.station.open() + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return "WS1" + + def genLoopPackets(self): + while True: + packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + readings = self.station.get_readings_with_retry(self.max_tries, + self.wait_before_retry) + data = StationData.parse_readings(readings) + packet.update(data) + self._augment_packet(packet) + yield packet + + def _augment_packet(self, packet): + # calculate the rain delta from rain total + packet['rain'] = weewx.wxformulas.calculate_rain(packet.get('rain_total'), self.last_rain) + self.last_rain = packet.get('rain_total') + + +# =========================================================================== # +# Station data class - parses and validates data from the device # +# =========================================================================== # + + +class StationData(object): + def __init__(self): + pass + + @staticmethod + def validate_string(buf): + if len(buf) != PACKET_SIZE: + raise weewx.WeeWxIOError("Unexpected buffer length %d" % len(buf)) + if buf[0:2] != b'!!': + raise weewx.WeeWxIOError("Unexpected header bytes '%s'" % buf[0:2]) + return buf + + @staticmethod + def parse_readings(raw): + """WS1 station emits data in PeetBros format: + + http://www.peetbros.com/shop/custom.aspx?recid=29 + + Each line has 50 characters - 2 header bytes and 48 data bytes: + + !!000000BE02EB000027700000023A023A0025005800000000 + SSSSXXDDTTTTLLLLPPPPttttHHHHhhhhddddmmmmRRRRWWWW + + SSSS - wind speed (0.1 kph) + XX - wind direction calibration + DD - wind direction (0-255) + TTTT - outdoor temperature (0.1 F) + LLLL - long term rain (0.01 in) + PPPP - barometer (0.1 mbar) + tttt - indoor temperature (0.1 F) + HHHH - outdoor humidity (0.1 %) + hhhh - indoor humidity (0.1 %) + dddd - date (day of year) + mmmm - time (minute of day) + RRRR - daily rain (0.01 in) + WWWW - one minute wind average (0.1 kph) + """ + # FIXME: peetbros could be 40 bytes or 44 bytes, what about ws1? + # FIXME: peetbros uses two's complement for temp, what about ws1? + buf = raw[2:].decode('ascii') + data = dict() + data['windSpeed'] = StationData._decode(buf[0:4], 0.1 * MILE_PER_KM) # mph + data['windDir'] = StationData._decode(buf[6:8], 1.411764) # compass deg + data['outTemp'] = StationData._decode(buf[8:12], 0.1, True) # degree_F + data['rain_total'] = StationData._decode(buf[12:16], 0.01) # inch + data['barometer'] = StationData._decode(buf[16:20], 0.1 * INHG_PER_MBAR) # inHg + data['inTemp'] = StationData._decode(buf[20:24], 0.1, True) # degree_F + data['outHumidity'] = StationData._decode(buf[24:28], 0.1) # percent + data['inHumidity'] = StationData._decode(buf[28:32], 0.1) # percent + data['day_of_year'] = StationData._decode(buf[32:36]) + data['minute_of_day'] = StationData._decode(buf[36:40]) + data['daily_rain'] = StationData._decode(buf[40:44], 0.01) # inch + data['wind_average'] = StationData._decode(buf[44:48], 0.1 * MILE_PER_KM) # mph + return data + + @staticmethod + def _decode(s, multiplier=None, neg=False): + v = None + try: + v = int(s, 16) + if neg: + bits = 4 * len(s) + if v & (1 << (bits - 1)) != 0: + v -= (1 << bits) + if multiplier is not None: + v *= multiplier + except ValueError as e: + if s != '----': + log.debug("decode failed for '%s': %s" % (s, e)) + return v + + +# =========================================================================== # +# Station Serial class - Gets data through a serial port # +# =========================================================================== # + + +class StationSerial(object): + def __init__(self, port, timeout=3): + self.port = port + self.baudrate = 2400 + self.timeout = timeout + self.serial_port = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): # @UnusedVariable + self.close() + + def open(self): + import serial + log.debug("open serial port %s" % self.port) + self.serial_port = serial.Serial(self.port, self.baudrate, + timeout=self.timeout) + + def close(self): + if self.serial_port is not None: + log.debug("close serial port %s" % self.port) + self.serial_port.close() + self.serial_port = None + + # FIXME: use either CR or LF as line terminator. apparently some ws1 + # hardware occasionally ends a line with only CR instead of the standard + # CR-LF, resulting in a line that is too long. + def get_readings(self): + buf = self.serial_port.readline() + if DEBUG_READ >= 2: + log.debug("bytes: '%s'" % ' '.join(["%0.2X" % c for c in buf])) + buf = buf.strip() + return buf + + def get_readings_with_retry(self, max_tries=5, wait_before_retry=10): + import serial + for ntries in range(max_tries): + try: + buf = self.get_readings() + StationData.validate_string(buf) + return buf + except (serial.serialutil.SerialException, weewx.WeeWxIOError) as e: + log.info("Failed attempt %d of %d to get readings: %s" % + (ntries + 1, max_tries, e)) + time.sleep(wait_before_retry) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + +# =========================================================================== # +# Station TCP class - Gets data through a TCP/IP connection # +# For those users with a serial->TCP adapter # +# =========================================================================== # + + +class StationSocket(object): + def __init__(self, addr, protocol='tcp', timeout=3, max_tries=5, + wait_before_retry=10): + import socket + + self.max_tries = max_tries + self.wait_before_retry = wait_before_retry + + if addr.find(':') != -1: + self.conn_info = addr.split(':') + self.conn_info[1] = int(self.conn_info[1], 10) + self.conn_info = tuple(self.conn_info) + else: + self.conn_info = (addr, DEFAULT_TCP_PORT) + + try: + if protocol == 'tcp': + self.net_socket = socket.socket( + socket.AF_INET, socket.SOCK_STREAM) + elif protocol == 'udp': + self.net_socket = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM) + except (socket.error, socket.herror) as ex: + log.error("Cannot create socket for some reason: %s" % ex) + raise weewx.WeeWxIOError(ex) + + self.net_socket.settimeout(timeout) + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): # @UnusedVariable + self.close() + + def open(self): + import socket + + log.debug("Connecting to %s:%d." % (self.conn_info[0], self.conn_info[1])) + + for conn_attempt in range(self.max_tries): + try: + if conn_attempt > 1: + log.debug("Retrying connection...") + self.net_socket.connect(self.conn_info) + break + except (socket.error, socket.timeout, socket.herror) as ex: + log.error("Cannot connect to %s:%d for some reason: %s. %d tries left." % ( + self.conn_info[0], self.conn_info[1], ex, + self.max_tries - (conn_attempt + 1))) + log.debug("Will retry in %.2f seconds..." % self.wait_before_retry) + time.sleep(self.wait_before_retry) + else: + log.error("Max tries (%d) exceeded for connection." % self.max_tries) + raise weewx.RetriesExceeded("Max tries exceeding while attempting connection") + + def close(self): + import socket + + log.debug("Closing connection to %s:%d." % (self.conn_info[0], self.conn_info[1])) + try: + self.net_socket.close() + except (socket.error, socket.herror, socket.timeout) as ex: + log.error("Cannot close connection to %s:%d. Reason: %s" + % (self.conn_info[0], self.conn_info[1], ex)) + raise weewx.WeeWxIOError(ex) + + def get_data(self, num_bytes=8): + """Get data from the socket connection + Args: + num_bytes: The number of bytes to request. + Returns: + bytes: The data from the remote device. + """ + + import socket + try: + data = self.net_socket.recv(num_bytes, socket.MSG_WAITALL) + except Exception as ex: + raise weewx.WeeWxIOError(ex) + else: + if len(data) == 0: + raise weewx.WeeWxIOError("No data recieved") + + return data + + def find_record_start(self): + """Find the start of a data record by requesting data from the remote + device until we find it. + Returns: + bytes: The start of a data record from the remote device. + """ + if DEBUG_READ >= 2: + log.debug("Attempting to find record start..") + + buf = bytes("", "utf-8") + while True: + data = self.get_data() + + if DEBUG_READ >= 2: + log.debug("(searching...) buf: %s" % buf.decode('utf-8')) + # split on line breaks and take everything after the line break + data = data.splitlines()[-1] + if b"!!" in data: + # if it contains !!, take everything after the last occurance of !! (we sometimes see a whole bunch of !) + buf = data.rpartition(b"!!")[-1] + if len(buf) > 0: + # if there is anything left, add the !! back on and break + # we have effectively found everything between a line break and !! + buf = b"!!" + buf + if DEBUG_READ >= 2: + log.debug("Record start found!") + break + return buf + + + def fill_buffer(self, buf): + """Get the remainder of the data record from the remote device. + Args: + buf: The beginning of the data record. + Returns: + bytes: The data from the remote device. + """ + if DEBUG_READ >= 2: + log.debug("filling buffer with rest of record") + while True: + data = self.get_data() + + # split on line breaks and take everything before it + data = data.splitlines()[0] + buf = buf + data + if DEBUG_READ >= 2: + log.debug("buf is %s" % buf.decode('utf-8')) + if len(buf) == 50: + if DEBUG_READ >= 2: + log.debug("filled record %s" % buf.decode('utf-8')) + break + return buf + + def get_readings(self): + buf = self.find_record_start() + if DEBUG_READ >= 2: + log.debug("record start: %s" % buf.decode('utf-8')) + buf = self.fill_buffer(buf) + if DEBUG_READ >= 1: + log.debug("Got data record: %s" % buf.decode('utf-8')) + return buf + + def get_readings_with_retry(self, max_tries=5, wait_before_retry=10): + for _ in range(max_tries): + buf = bytes("", "utf-8") + try: + buf = self.get_readings() + StationData.validate_string(buf) + return buf + except (weewx.WeeWxIOError) as e: + log.debug("Failed to get data. Reason: %s" % e) + + # NOTE: WeeWx IO Errors may not always occur because of + # invalid data. These kinds of errors are also caused by socket + # errors and timeouts. + + if DEBUG_READ >= 1: + log.debug("buf: %s (%d bytes)" % (buf.decode('utf-8'), len(buf))) + + time.sleep(wait_before_retry) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + +class WS1ConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WS1] + # This section is for the ADS WS1 series of weather stations. + + # Driver mode - tcp, udp, or serial + mode = serial + + # If serial, specify the serial port device. (ex. /dev/ttyS0, /dev/ttyUSB0, + # or /dev/cuaU0) + # If TCP, specify the IP address and port number. (ex. 192.168.36.25:3000) + port = /dev/ttyUSB0 + + # The amount of time, in seconds, before the connection fails if there is + # no response + timeout = 3 + + # The driver to use: + driver = weewx.drivers.ws1 +""" + + def prompt_for_settings(self): + print("How is the station connected? tcp, udp, or serial.") + con_mode = self._prompt('mode', 'serial') + con_mode = con_mode.lower() + + if con_mode == 'serial': + print("Specify the serial port on which the station is connected, ") + "for example: /dev/ttyUSB0 or /dev/ttyS0." + port = self._prompt('port', '/dev/ttyUSB0') + elif con_mode == 'tcp' or con_mode == 'udp': + print("Specify the IP address and port of the station. For ") + "example: 192.168.36.40:3000." + port = self._prompt('port', '192.168.36.40:3000') + + print("Specify how long to wait for a response, in seconds.") + timeout = self._prompt('timeout', 3) + + return {'mode': con_mode, 'port': port, 'timeout': timeout} + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/ws1.py +# PYTHONPATH=/usr/share/weewx python3 /usr/share/weewx/weewx/drivers/ws1.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--help]""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='provide additional debug output in log') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected to use Serial mode', + default=DEFAULT_SER_PORT) + parser.add_option('--addr', dest='addr', metavar='ADDR', + help='ip address and port to use TCP mode', + default=DEFAULT_TCP_ADDR) + + (options, args) = parser.parse_args() + + if options.version: + print("ADS WS1 driver version %s" % DRIVER_VERSION) + exit(0) + + if options.debug: + weewx.debug = 2 + DEBUG_READ = 2 + + weeutil.logger.setup('wee_ws1') + + Station = StationSerial + if options.addr is not None: + Station = StationSocket + + with Station(options.addr) as s: + while True: + print(time.time(), s.get_readings().decode("utf-8")) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/ws23xx.py b/dist/weewx-5.0.2/src/weewx/drivers/ws23xx.py new file mode 100644 index 0000000..5a496b8 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/ws23xx.py @@ -0,0 +1,2125 @@ +# Copyright 2013 Matthew Wall +# See the file LICENSE.txt for your full rights. +# +# Thanks to Kenneth Lavrsen for the Open2300 implementation: +# http://www.lavrsen.dk/foswiki/bin/view/Open2300/WebHome +# description of the station communication interface: +# http://www.lavrsen.dk/foswiki/bin/view/Open2300/OpenWSAPI +# memory map: +# http://www.lavrsen.dk/foswiki/bin/view/Open2300/OpenWSMemoryMap +# +# Thanks to Russell Stuart for the ws2300 python implementation: +# http://ace-host.stuart.id.au/russell/files/ws2300/ +# and the map of the station memory: +# http://ace-host.stuart.id.au/russell/files/ws2300/memory_map_2300.txt +# +# This immplementation copies directly from Russell Stuart's implementation, +# but only the parts required to read from and write to the weather station. + +"""Classes and functions for interfacing with WS-23xx weather stations. + +LaCrosse made a number of stations in the 23xx series, including: + + WS-2300, WS-2308, WS-2310, WS-2315, WS-2317, WS-2357 + +The stations were also sold as the TFA Matrix and TechnoLine 2350. + +The WWVB receiver is located in the console. + +To synchronize the console and sensors, press and hold the PLUS key for 2 +seconds. When console is not synchronized no data will be received. + +To do a factory reset, press and hold PRESSURE and WIND for 5 seconds. + +A single bucket tip is 0.0204 in (0.518 mm). + +The station has 175 history records. That is just over 7 days of data with +the default history recording interval of 60 minutes. + +The station supports both wireless and wired communication between the +sensors and a station console. Wired connection updates data every 8 seconds. +Wireless connection updates data in 16 to 128 second intervals, depending on +wind speed and rain activity. + +The connection type can be one of 0=cable, 3=lost, 15=wireless + +sensor update frequency: + + 32 seconds when wind speed > 22.36 mph (wireless) + 128 seconds when wind speed < 22.36 mph (wireless) + 10 minutes (wireless after 5 failed attempts) + 8 seconds (wired) + +console update frequency: + + 15 seconds (pressure/temperature) + 20 seconds (humidity) + +It is possible to increase the rate of wireless updates: + + http://www.wxforum.net/index.php?topic=2196.0 + +Sensors are connected by unshielded phone cables. RF interference can cause +random spikes in data, with one symptom being values of 25.5 m/s or 91.8 km/h +for the wind speed. Unfortunately those values are within the sensor limits +of 0-113 mph (50.52 m/s or 181.9 km/h). To reduce the number of spikes in +data, replace with shielded cables: + + http://www.lavrsen.dk/sources/weather/windmod.htm + +The station records wind speed and direction, but has no notion of gust. + +The station calculates windchill and dewpoint. + +The station has a serial connection to the computer. + +This driver does not keep the serial port open for long periods. Instead, the +driver opens the serial port, reads data, then closes the port. + +This driver polls the station. Use the polling_interval parameter to specify +how often to poll for data. If not specified, the polling interval will adapt +based on connection type and status. + +USB-Serial Converters + +With a USB-serial converter one can connect the station to a computer with +only USB ports, but not every converter will work properly. Perhaps the two +most common converters are based on the Prolific and FTDI chipsets. Many +people report better luck with the FTDI-based converters. Some converters +that use the Prolific chipset (PL2303) will work, but not all of them. + +Known to work: ATEN UC-232A + +Bounds checking + + wind speed: 0-113 mph + wind direction: 0-360 + humidity: 0-100 + temperature: ok if not -22F and humidity is valid + dewpoint: ok if not -22F and humidity is valid + barometer: 25-35 inHg + rain rate: 0-10 in/hr + +Discrepancies Between Implementations + +As of December 2013, there are significant differences between the open2300, +wview, and ws2300 implementations. Current version numbers are as follows: + + open2300 1.11 + ws2300 1.8 + wview 5.20.2 + +History Interval + +The factory default is 60 minutes. The value stored in the console is one +less than the actual value (in minutes). So for the factory default of 60, +the console stores 59. The minimum interval is 1. + +ws2300.py reports the actual value from the console, e.g., 59 when the +interval is 60. open2300 reports the interval, e.g., 60 when the interval +is 60. wview ignores the interval. + +Detecting Bogus Sensor Values + +wview queries the station 3 times for each sensor then accepts the value only +if the three values were close to each other. + +open2300 sleeps 10 seconds if a wind measurement indicates invalid or overflow. + +The ws2300.py implementation includes overflow and validity flags for values +from the wind sensors. It does not retry based on invalid or overflow. + +Wind Speed + +There is disagreement about how to calculate wind speed and how to determine +whether the wind speed is valid. + +This driver introduces a WindConversion object that uses open2300/wview +decoding so that wind speeds match that of open2300/wview. ws2300 1.8 +incorrectly uses bcd2num instead of bin2num. This bug is fixed in this driver. + +The memory map indicates the following: + +addr smpl description +0x527 0 Wind overflow flag: 0 = normal +0x528 0 Wind minimum code: 0=min, 1=--.-, 2=OFL +0x529 0 Windspeed: binary nibble 0 [m/s * 10] +0x52A 0 Windspeed: binary nibble 1 [m/s * 10] +0x52B 0 Windspeed: binary nibble 2 [m/s * 10] +0x52C 8 Wind Direction = nibble * 22.5 degrees +0x52D 8 Wind Direction 1 measurement ago +0x52E 9 Wind Direction 2 measurement ago +0x52F 8 Wind Direction 3 measurement ago +0x530 7 Wind Direction 4 measurement ago +0x531 7 Wind Direction 5 measurement ago +0x532 0 + +wview 5.20.2 implementation (wview apparently copied from open2300): + +read 3 bytes starting at 0x527 + +0x527 x[0] +0x528 x[1] +0x529 x[2] + +if ((x[0] != 0x00) || + ((x[1] == 0xff) && (((x[2] & 0xf) == 0) || ((x[2] & 0xf) == 1)))) { + fail +} else { + dir = (x[2] >> 4) * 22.5 + speed = ((((x[2] & 0xf) << 8) + (x[1])) / 10.0 * 2.23693629) + maxdir = dir + maxspeed = speed +} + +open2300 1.10 implementation: + +read 6 bytes starting at 0x527 + +0x527 x[0] +0x528 x[1] +0x529 x[2] +0x52a x[3] +0x52b x[4] +0x52c x[5] + +if ((x[0] != 0x00) || + ((x[1] == 0xff) && (((x[2] & 0xf) == 0) || ((x[2] & 0xf) == 1)))) { + sleep 10 +} else { + dir = x[2] >> 4 + speed = ((((x[2] & 0xf) << 8) + (x[1])) / 10.0) + dir0 = (x[2] >> 4) * 22.5 + dir1 = (x[3] & 0xf) * 22.5 + dir2 = (x[3] >> 4) * 22.5 + dir3 = (x[4] & 0xf) * 22.5 + dir4 = (x[4] >> 4) * 22.5 + dir5 = (x[5] & 0xf) * 22.5 +} + +ws2300.py 1.8 implementation: + +read 1 nibble starting at 0x527 +read 1 nibble starting at 0x528 +read 4 nibble starting at 0x529 +read 3 nibble starting at 0x529 +read 1 nibble starting at 0x52c +read 1 nibble starting at 0x52d +read 1 nibble starting at 0x52e +read 1 nibble starting at 0x52f +read 1 nibble starting at 0x530 +read 1 nibble starting at 0x531 + +0x527 overflow +0x528 validity +0x529 speed[0] +0x52a speed[1] +0x52b speed[2] +0x52c dir[0] + +speed: ((x[2] * 100 + x[1] * 10 + x[0]) % 1000) / 10 +velocity: (x[2] * 100 + x[1] * 10 + x[0]) / 10 + +dir = data[0] * 22.5 +speed = (bcd2num(data) % 10**3 + 0) / 10**1 +velocity = (bcd2num(data[:3])/10.0, bin2num(data[3:4]) * 22.5) + +bcd2num([a,b,c]) -> c*100+b*10+a + +""" + +# TODO: use pyserial instead of LinuxSerialPort +# TODO: put the __enter__ and __exit__ scaffolding on serial port, not Station +# FIXME: unless we can get setTime to work, just ignore the console clock +# FIXME: detect bogus wind speed/direction +# i see these when the wind instrument is disconnected: +# ws 26.399999 +# wsh 21 +# w0 135 + +import logging +import time +import string +import fcntl +import os +import select +import struct +import termios +import tty +from functools import reduce + +import weeutil.weeutil +import weewx.drivers +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WS23xx' +DRIVER_VERSION = '0.5' + +int2byte = struct.Struct(">B").pack + +def loader(config_dict, _): + return WS23xxDriver(config_dict=config_dict, **config_dict[DRIVER_NAME]) + +def configurator_loader(_): + return WS23xxConfigurator() + +def confeditor_loader(): + return WS23xxConfEditor() + + +DEFAULT_PORT = '/dev/ttyUSB0' + + +class WS23xxConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super().add_options(parser) + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N history records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display history records since N minutes ago") + parser.add_option("--clear-memory", dest="clear", action="store_true", + help="clear station memory") + parser.add_option("--set-time", dest="settime", action="store_true", + help="set the station clock to the current time") + parser.add_option("--set-interval", dest="interval", + type=int, metavar="N", + help="set the station archive interval to N minutes") + + def do_options(self, options, parser, config_dict, prompt): + self.station = WS23xxDriver(**config_dict[DRIVER_NAME]) + if options.current: + self.show_current() + elif options.nrecords is not None: + self.show_history(count=options.nrecords) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(ts=ts) + elif options.settime: + self.set_clock(prompt) + elif options.interval is not None: + self.set_interval(options.interval, prompt) + elif options.clear: + self.clear_history(prompt) + else: + self.show_info() + self.station.closePort() + + def show_info(self): + """Query the station then display the settings.""" + print('Querying the station for the configuration...') + config = self.station.getConfig() + for key in sorted(config): + print('%s: %s' % (key, config[key])) + + def show_current(self): + """Get current weather observation.""" + print('Querying the station for current weather data...') + for packet in self.station.genLoopPackets(): + print(packet) + break + + def show_history(self, ts=None, count=0): + """Show the indicated number of records or records since timestamp""" + print("Querying the station for historical records...") + for i, r in enumerate(self.station.genArchiveRecords(since_ts=ts, + count=count)): + print(r) + if count and i > count: + break + + def set_clock(self, prompt): + """Set station clock to current time.""" + ans = None + while ans not in ['y', 'n']: + v = self.station.getTime() + vstr = weeutil.weeutil.timestamp_to_string(v) + print("Station clock is", vstr) + if prompt: + ans = input("Set station clock (y/n)? ") + else: + print("Setting station clock") + ans = 'y' + if ans == 'y': + self.station.setTime() + v = self.station.getTime() + vstr = weeutil.weeutil.timestamp_to_string(v) + print("Station clock is now", vstr) + elif ans == 'n': + print("Set clock cancelled.") + + def set_interval(self, interval, prompt): + print("Changing the interval will clear the station memory.") + v = self.station.getArchiveInterval() + ans = None + while ans not in ['y', 'n']: + print("Interval is", v) + if prompt: + ans = input("Set interval to %d minutes (y/n)? " % interval) + else: + print("Setting interval to %d minutes" % interval) + ans = 'y' + if ans == 'y': + self.station.setArchiveInterval(interval) + v = self.station.getArchiveInterval() + print("Interval is now", v) + elif ans == 'n': + print("Set interval cancelled.") + + def clear_history(self, prompt): + ans = None + while ans not in ['y', 'n']: + v = self.station.getRecordCount() + print("Records in memory:", v) + if prompt: + ans = input("Clear console memory (y/n)? ") + else: + print('Clearing console memory') + ans = 'y' + if ans == 'y': + self.station.clearHistory() + v = self.station.getRecordCount() + print("Records in memory:", v) + elif ans == 'n': + print("Clear memory cancelled.") + + +class WS23xxDriver(weewx.drivers.AbstractDevice): + """Driver for LaCrosse WS23xx stations.""" + + def __init__(self, **stn_dict): + """Initialize the station object. + + port: The serial port, e.g., /dev/ttyS0 or /dev/ttyUSB0 + [Required. Default is /dev/ttyS0] + + polling_interval: How often to poll the station, in seconds. + [Optional. Default is 8 (wired) or 30 (wireless)] + + model: Which station model is this? + [Optional. Default is 'LaCrosse WS23xx'] + """ + self._last_rain = None + self._last_cn = None + self._poll_wait = 60 + + self.model = stn_dict.get('model', 'LaCrosse WS23xx') + self.port = stn_dict.get('port', DEFAULT_PORT) + self.max_tries = int(stn_dict.get('max_tries', 5)) + self.retry_wait = int(stn_dict.get('retry_wait', 30)) + self.polling_interval = stn_dict.get('polling_interval', None) + if self.polling_interval is not None: + self.polling_interval = int(self.polling_interval) + self.enable_startup_records = stn_dict.get('enable_startup_records', + True) + self.enable_archive_records = stn_dict.get('enable_archive_records', + True) + self.mode = stn_dict.get('mode', 'single_open') + + log.info('driver version is %s' % DRIVER_VERSION) + log.info('serial port is %s' % self.port) + log.info('polling interval is %s' % self.polling_interval) + + if self.mode == 'single_open': + self.station = WS23xx(self.port) + else: + self.station = None + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return self.model + + # weewx wants the archive interval in seconds, but the console uses minutes + @property + def archive_interval(self): + if not self.enable_startup_records and not self.enable_archive_records: + raise NotImplementedError + return self.getArchiveInterval() * 60 + + def genLoopPackets(self): + ntries = 0 + while ntries < self.max_tries: + ntries += 1 + try: + if self.station: + data = self.station.get_raw_data(SENSOR_IDS) + else: + with WS23xx(self.port) as s: + data = s.get_raw_data(SENSOR_IDS) + packet = data_to_packet(data, int(time.time() + 0.5), + last_rain=self._last_rain) + self._last_rain = packet['rainTotal'] + ntries = 0 + yield packet + + if self.polling_interval is not None: + self._poll_wait = self.polling_interval + if data['cn'] != self._last_cn: + conn_info = get_conn_info(data['cn']) + log.info("connection changed from %s to %s" + % (get_conn_info(self._last_cn)[0], conn_info[0])) + self._last_cn = data['cn'] + if self.polling_interval is None: + log.info("using %s second polling interval for %s connection" + % (conn_info[1], conn_info[0])) + self._poll_wait = conn_info[1] + time.sleep(self._poll_wait) + except Ws2300.Ws2300Exception as e: + log.error("Failed attempt %d of %d to get LOOP data: %s" + % (ntries, self.max_tries, e)) + log.debug("Waiting %d seconds before retry" % self.retry_wait) + time.sleep(self.retry_wait) + else: + msg = "Max retries (%d) exceeded for LOOP data" % self.max_tries + log.error(msg) + raise weewx.RetriesExceeded(msg) + + def genStartupRecords(self, since_ts): + if not self.enable_startup_records: + raise NotImplementedError + if self.station: + return self.genRecords(self.station, since_ts) + else: + with WS23xx(self.port) as s: + return self.genRecords(s, since_ts) + + def genArchiveRecords(self, since_ts, count=0): + if not self.enable_archive_records: + raise NotImplementedError + if self.station: + return self.genRecords(self.station, since_ts, count) + else: + with WS23xx(self.port) as s: + return self.genRecords(s, since_ts, count) + + def genRecords(self, s, since_ts, count=0): + last_rain = None + for ts, data in s.gen_records(since_ts=since_ts, count=count): + record = data_to_packet(data, ts, last_rain=last_rain) + record['interval'] = data['interval'] + last_rain = record['rainTotal'] + yield record + +# def getTime(self) : +# with WS23xx(self.port) as s: +# return s.get_time() + +# def setTime(self): +# with WS23xx(self.port) as s: +# s.set_time() + + def getArchiveInterval(self): + if self.station: + return self.station.get_archive_interval() + else: + with WS23xx(self.port) as s: + return s.get_archive_interval() + + def setArchiveInterval(self, interval): + if self.station: + self.station.set_archive_interval(interval) + else: + with WS23xx(self.port) as s: + s.set_archive_interval(interval) + + def getConfig(self): + fdata = dict() + if self.station: + data = self.station.get_raw_data(list(Measure.IDS.keys())) + else: + with WS23xx(self.port) as s: + data = s.get_raw_data(list(Measure.IDS.keys())) + for key in data: + fdata[Measure.IDS[key].name] = data[key] + return fdata + + def getRecordCount(self): + if self.station: + return self.station.get_record_count() + else: + with WS23xx(self.port) as s: + return s.get_record_count() + + def clearHistory(self): + if self.station: + self.station.clear_memory() + else: + with WS23xx(self.port) as s: + s.clear_memory() + + +# ids for current weather conditions and connection type +SENSOR_IDS = ['it','ih','ot','oh','pa','wind','rh','rt','dp','wc','cn'] +# polling interval, in seconds, for various connection types +POLLING_INTERVAL = {0: ("cable", 8), 3: ("lost", 60), 15: ("wireless", 30)} + +def get_conn_info(conn_type): + return POLLING_INTERVAL.get(conn_type, ("unknown", 60)) + +def data_to_packet(data, ts, last_rain=None): + """Convert raw data to format and units required by weewx. + + station weewx (metric) + temperature degree C degree C + humidity percent percent + uv index unitless unitless + pressure mbar mbar + wind speed m/s km/h + wind dir degree degree + wind gust None + wind gust dir None + rain mm cm + rain rate cm/h + """ + + packet = dict() + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = ts + packet['inTemp'] = data['it'] + packet['inHumidity'] = data['ih'] + packet['outTemp'] = data['ot'] + packet['outHumidity'] = data['oh'] + packet['pressure'] = data['pa'] + + ws, wd, wso, wsv = data['wind'] + if wso == 0 and wsv == 0: + packet['windSpeed'] = ws + if packet['windSpeed'] is not None: + packet['windSpeed'] *= 3.6 # weewx wants km/h + packet['windDir'] = wd + else: + log.info('invalid wind reading: speed=%s dir=%s overflow=%s invalid=%s' + % (ws, wd, wso, wsv)) + packet['windSpeed'] = None + packet['windDir'] = None + + packet['windGust'] = None + packet['windGustDir'] = None + + packet['rainTotal'] = data['rt'] + if packet['rainTotal'] is not None: + packet['rainTotal'] /= 10 # weewx wants cm + packet['rain'] = weewx.wxformulas.calculate_rain( + packet['rainTotal'], last_rain) + + # station provides some derived variables + packet['rainRate'] = data['rh'] + if packet['rainRate'] is not None: + packet['rainRate'] /= 10 # weewx wants cm/hr + packet['dewpoint'] = data['dp'] + packet['windchill'] = data['wc'] + + return packet + + +class WS23xx(object): + """Wrap the Ws2300 object so we can easily open serial port, read/write, + close serial port without all of the try/except/finally scaffolding.""" + + def __init__(self, port): + log.debug('create LinuxSerialPort') + self.serial_port = LinuxSerialPort(port) + log.debug('create Ws2300') + self.ws = Ws2300(self.serial_port) + + def __enter__(self): + log.debug('station enter') + return self + + def __exit__(self, type_, value, traceback): + log.debug('station exit') + self.ws = None + self.close() + + def close(self): + log.debug('close LinuxSerialPort') + self.serial_port.close() + self.serial_port = None + + def set_time(self, ts): + """Set station time to indicated unix epoch.""" + log.debug('setting station clock to %s' + % weeutil.weeutil.timestamp_to_string(ts)) + for m in [Measure.IDS['sd'], Measure.IDS['st']]: + data = m.conv.value2binary(ts) + cmd = m.conv.write(data, None) + self.ws.write_safe(m.address, *cmd[1:]) + + def get_time(self): + """Return station time as unix epoch.""" + data = self.get_raw_data(['sw']) + ts = int(data['sw']) + log.debug('station clock is %s' % weeutil.weeutil.timestamp_to_string(ts)) + return ts + + def set_archive_interval(self, interval): + """Set the archive interval in minutes.""" + if int(interval) < 1: + raise ValueError('archive interval must be greater than zero') + log.debug('setting hardware archive interval to %s minutes' % interval) + interval -= 1 + for m,v in [(Measure.IDS['hi'],interval), # archive interval in minutes + (Measure.IDS['hc'],1), # time till next sample in minutes + (Measure.IDS['hn'],0)]: # number of valid records + data = m.conv.value2binary(v) + cmd = m.conv.write(data, None) + self.ws.write_safe(m.address, *cmd[1:]) + + def get_archive_interval(self): + """Return archive interval in minutes.""" + data = self.get_raw_data(['hi']) + x = 1 + int(data['hi']) + log.debug('station archive interval is %s minutes' % x) + return x + + def clear_memory(self): + """Clear station memory.""" + log.debug('clearing console memory') + for m,v in [(Measure.IDS['hn'],0)]: # number of valid records + data = m.conv.value2binary(v) + cmd = m.conv.write(data, None) + self.ws.write_safe(m.address, *cmd[1:]) + + def get_record_count(self): + data = self.get_raw_data(['hn']) + x = int(data['hn']) + log.debug('record count is %s' % x) + return x + + def gen_records(self, since_ts=None, count=None, use_computer_clock=True): + """Get latest count records from the station from oldest to newest. If + count is 0 or None, return all records. + + The station has a history interval, and it records when the last + history sample was saved. So as long as the interval does not change + between the first and last records, we are safe to infer timestamps + for each record. This assumes that if the station loses power then + the memory will be cleared. + + There is no timestamp associated with each record - we have to guess. + The station tells us the time until the next record and the epoch of + the latest record, based on the station's clock. So we can use that + or use the computer clock to guess the timestamp for each record. + + To ensure accurate data, the first record must be read within one + minute of the initial read and the remaining records must be read + within numrec * interval minutes. + """ + + log.debug("gen_records: since_ts=%s count=%s clock=%s" + % (since_ts, count, use_computer_clock)) + measures = [Measure.IDS['hi'], Measure.IDS['hw'], + Measure.IDS['hc'], Measure.IDS['hn']] + raw_data = read_measurements(self.ws, measures) + interval = 1 + int(measures[0].conv.binary2value(raw_data[0])) # minute + latest_ts = int(measures[1].conv.binary2value(raw_data[1])) # epoch + time_to_next = int(measures[2].conv.binary2value(raw_data[2])) # minute + numrec = int(measures[3].conv.binary2value(raw_data[3])) + + now = int(time.time()) + cstr = 'station' + if use_computer_clock: + latest_ts = now - (interval - time_to_next) * 60 + cstr = 'computer' + log.debug("using %s clock with latest_ts of %s" + % (cstr, weeutil.weeutil.timestamp_to_string(latest_ts))) + + if not count: + count = HistoryMeasure.MAX_HISTORY_RECORDS + if since_ts is not None: + count = int((now - since_ts) / (interval * 60)) + log.debug("count is %d to satisfy timestamp of %s" + % (count, weeutil.weeutil.timestamp_to_string(since_ts))) + if count == 0: + return + if count > numrec: + count = numrec + if count > HistoryMeasure.MAX_HISTORY_RECORDS: + count = HistoryMeasure.MAX_HISTORY_RECORDS + + # station is about to overwrite first record, so skip it + if time_to_next <= 1 and count == HistoryMeasure.MAX_HISTORY_RECORDS: + count -= 1 + + log.debug("downloading %d records from station" % count) + HistoryMeasure.set_constants(self.ws) + measures = [HistoryMeasure(n) for n in range(count-1, -1, -1)] + raw_data = read_measurements(self.ws, measures) + last_ts = latest_ts - (count-1) * interval * 60 + for measure, nybbles in zip(measures, raw_data): + value = measure.conv.binary2value(nybbles) + data_dict = { + 'interval': interval, + 'it': value.temp_indoor, + 'ih': value.humidity_indoor, + 'ot': value.temp_outdoor, + 'oh': value.humidity_outdoor, + 'pa': value.pressure_absolute, + 'rt': value.rain, + 'wind': (value.wind_speed/10, value.wind_direction, 0, 0), + 'rh': None, # no rain rate in history + 'dp': None, # no dewpoint in history + 'wc': None, # no windchill in history + } + yield last_ts, data_dict + last_ts += interval * 60 + + def get_raw_data(self, labels): + """Get raw data from the station, return as dictionary.""" + measures = [Measure.IDS[m] for m in labels] + raw_data = read_measurements(self.ws, measures) + data_dict = dict(list(zip(labels, [m.conv.binary2value(d) for m, d in zip(measures, raw_data)]))) + return data_dict + + +# ============================================================================= +# The following code was adapted from ws2300.py by Russell Stuart +# ============================================================================= + +VERSION = "1.8 2013-08-26" + +# +# Debug options. +# +DEBUG_SERIAL = False + +# +# A fatal error. +# +class FatalError(Exception): + source = None + message = None + cause = None + def __init__(self, source, message, cause=None): + self.source = source + self.message = message + self.cause = cause + Exception.__init__(self, message) + +# +# The serial port interface. We can talk to the Ws2300 over anything +# that implements this interface. +# +class SerialPort(object): + # + # Discard all characters waiting to be read. + # + def clear(self): raise NotImplementedError() + # + # Close the serial port. + # + def close(self): raise NotImplementedError() + # + # Wait for all characters to be sent. + # + def flush(self): raise NotImplementedError() + # + # Read a character, waiting for a most timeout seconds. Return the + # character read, or None if the timeout occurred. + # + def read_byte(self, timeout): raise NotImplementedError() + # + # Release the serial port. Closes it until it is used again, when + # it is automatically re-opened. It need not be implemented. + # + def release(self): pass + # + # Write characters to the serial port. + # + def write(self, data): raise NotImplementedError() + +# +# A Linux Serial port. Implements the Serial interface on Linux. +# +class LinuxSerialPort(SerialPort): + SERIAL_CSIZE = { + "7": tty.CS7, + "8": tty.CS8, } + SERIAL_PARITIES= { + "e": tty.PARENB, + "n": 0, + "o": tty.PARENB|tty.PARODD, } + SERIAL_SPEEDS = { + "300": tty.B300, + "600": tty.B600, + "1200": tty.B1200, + "2400": tty.B2400, + "4800": tty.B4800, + "9600": tty.B9600, + "19200": tty.B19200, + "38400": tty.B38400, + "57600": tty.B57600, + "115200": tty.B115200, } + SERIAL_SETTINGS = "2400,n,8,1" + device = None # string, the device name. + orig_settings = None # class, the original ports settings. + select_list = None # list, The serial ports + serial_port = None # int, OS handle to device. + settings = None # string, the settings on the command line. + # + # Initialise ourselves. + # + def __init__(self,device,settings=SERIAL_SETTINGS): + self.device = device + self.settings = settings.split(",") + self.settings.extend([None,None,None]) + self.settings[0] = self.__class__.SERIAL_SPEEDS.get(self.settings[0], None) + self.settings[1] = self.__class__.SERIAL_PARITIES.get(self.settings[1].lower(), None) + self.settings[2] = self.__class__.SERIAL_CSIZE.get(self.settings[2], None) + if len(self.settings) != 7 or None in self.settings[:3]: + raise FatalError(self.device, 'Bad serial settings "%s".' % settings) + self.settings = self.settings[:4] + # + # Open the port. + # + try: + self.serial_port = os.open(self.device, os.O_RDWR) + except EnvironmentError as e: + raise FatalError(self.device, "can't open tty device - %s." % str(e)) + try: + fcntl.flock(self.serial_port, fcntl.LOCK_EX) + self.orig_settings = tty.tcgetattr(self.serial_port) + setup = self.orig_settings[:] + setup[0] = tty.INPCK + setup[1] = 0 + setup[2] = tty.CREAD|tty.HUPCL|tty.CLOCAL|reduce(lambda x,y: x|y, self.settings[:3]) + setup[3] = 0 # tty.ICANON + setup[4] = self.settings[0] + setup[5] = self.settings[0] + setup[6] = [b'\000']*len(setup[6]) + setup[6][tty.VMIN] = 1 + setup[6][tty.VTIME] = 0 + tty.tcflush(self.serial_port, tty.TCIOFLUSH) + # + # Restart IO if stopped using software flow control (^S/^Q). This + # doesn't work on FreeBSD. + # + try: + tty.tcflow(self.serial_port, tty.TCOON|tty.TCION) + except termios.error: + pass + tty.tcsetattr(self.serial_port, tty.TCSAFLUSH, setup) + # + # Set DTR low and RTS high and leave other control lines untouched. + # + arg = struct.pack('I', 0) + arg = fcntl.ioctl(self.serial_port, tty.TIOCMGET, arg) + portstatus = struct.unpack('I', arg)[0] + portstatus = portstatus & ~tty.TIOCM_DTR | tty.TIOCM_RTS + arg = struct.pack('I', portstatus) + fcntl.ioctl(self.serial_port, tty.TIOCMSET, arg) + self.select_list = [self.serial_port] + except Exception: + os.close(self.serial_port) + raise + def close(self): + if self.orig_settings: + tty.tcsetattr(self.serial_port, tty.TCSANOW, self.orig_settings) + os.close(self.serial_port) + def read_byte(self, timeout): + ready = select.select(self.select_list, [], [], timeout) + if not ready[0]: + return None + return os.read(self.serial_port, 1) + # + # Write a string to the port. + # + def write(self, data): + os.write(self.serial_port, data) + # + # Flush the input buffer. + # + def clear(self): + tty.tcflush(self.serial_port, tty.TCIFLUSH) + # + # Flush the output buffer. + # + def flush(self): + tty.tcdrain(self.serial_port) + +# +# This class reads and writes bytes to a Ws2300. It is passed something +# that implements the Serial interface. The major routines are: +# +# Ws2300() - Create one of these objects that talks over the serial port. +# read_batch() - Reads data from the device using an scatter/gather interface. +# write_safe() - Writes data to the device. +# +class Ws2300(object): + # + # An exception for us. + # + class Ws2300Exception(weewx.WeeWxIOError): + def __init__(self, *args): + weewx.WeeWxIOError.__init__(self, *args) + # + # Constants we use. + # + MAXBLOCK = 30 + MAXRETRIES = 50 + MAXWINDRETRIES= 20 + WRITENIB = 0x42 + SETBIT = 0x12 + UNSETBIT = 0x32 + WRITEACK = 0x10 + SETACK = 0x04 + UNSETACK = 0x0C + RESET_MIN = 0x01 + RESET_MAX = 0x02 + MAX_RESETS = 100 + # + # Instance data. + # + log_buffer = None # list, action log + log_mode = None # string, Log mode + long_nest = None # int, Nesting of log actions + serial_port = None # string, SerialPort port to use + # + # Initialise ourselves. + # + def __init__(self, serial_port): + self.log_buffer = [] + self.log_nest = 0 + self.serial_port = serial_port + # + # Write data to the device. + # + def write_byte(self, data): + """Write a single-element byte string. + + data: In Python 2, type 'str'; in Python 3, either type 'bytes', or 'bytearray'. It should + hold only one element. + """ + if self.log_mode != 'w': + if self.log_mode != 'e': + self.log(' ') + self.log_mode = 'w' + self.log("%02x" % ord(data)) + self.serial_port.write(data) + # + # Read a byte from the device. + # + def read_byte(self, timeout=1.0): + if self.log_mode != 'r': + self.log_mode = 'r' + self.log(':') + result = self.serial_port.read_byte(timeout) + if not result: + self.log("--") + else: + self.log("%02x" % ord(result)) + time.sleep(0.01) # reduce chance of data spike by avoiding contention + return result + # + # Remove all pending incoming characters. + # + def clear_device(self): + if self.log_mode != 'e': + self.log(' ') + self.log_mode = 'c' + self.log("C") + self.serial_port.clear() + # + # Write a reset string and wait for a reply. + # + def reset_06(self): + self.log_enter("re") + try: + for _ in range(self.__class__.MAX_RESETS): + self.clear_device() + self.write_byte(b'\x06') + # + # Occasionally 0, then 2 is returned. If 0 comes back, + # continue reading as this is more efficient than sending + # an out-of sync reset and letting the data reads restore + # synchronization. Occasionally, multiple 2's are returned. + # Read with a fast timeout until all data is exhausted, if + # we got a 2 back at all, we consider it a success. + # + success = False + answer = self.read_byte() + while answer != None: + if answer == b'\x02': + success = True + answer = self.read_byte(0.05) + if success: + return + msg = "Reset failed, %d retries, no response" % self.__class__.MAX_RESETS + raise self.Ws2300Exception(msg) + finally: + self.log_exit() + # + # Encode the address. + # + def write_address(self,address): + for digit in range(4): + byte = int2byte((address >> (4 * (3-digit)) & 0xF) * 4 + 0x82) + self.write_byte(byte) + ack = int2byte(digit * 16 + (ord(byte) - 0x82) // 4) + answer = self.read_byte() + if ack != answer: + self.log("??") + return False + return True + # + # Write data, checking the reply. + # + def write_data(self,nybble_address,nybbles,encode_constant=None): + self.log_enter("wd") + try: + if not self.write_address(nybble_address): + return None + if encode_constant == None: + encode_constant = self.WRITENIB + encoded_data = b''.join([ + int2byte(nybbles[i]*4 + encode_constant) + for i in range(len(nybbles))]) + ack_constant = { + self.SETBIT: self.SETACK, + self.UNSETBIT: self.UNSETACK, + self.WRITENIB: self.WRITEACK + }[encode_constant] + self.log(",") + for i in range(len(encoded_data)): + self.write_byte(bytearray([encoded_data[i]])) + answer = self.read_byte() + if int2byte(nybbles[i] + ack_constant) != answer: + self.log("??") + return None + return True + finally: + self.log_exit() + # + # Reset the device and write a command, verifing it was written correctly. + # + def write_safe(self,nybble_address,nybbles,encode_constant=None): + self.log_enter("ws") + try: + for _ in range(self.MAXRETRIES): + self.reset_06() + command_data = self.write_data(nybble_address,nybbles,encode_constant) + if command_data != None: + return command_data + raise self.Ws2300Exception("write_safe failed, retries exceeded") + finally: + self.log_exit() + # + # A total kuldge this, but its the easiest way to force the 'computer + # time' to look like a normal ws2300 variable, which it most definitely + # isn't, of course. + # + def read_computer_time(self,nybble_address,nybble_count): + now = time.time() + tm = time.localtime(now) + tu = time.gmtime(now) + year2 = tm[0] % 100 + datetime_data = ( + tu[5]%10, tu[5]//10, tu[4]%10, tu[4]//10, tu[3]%10, tu[3]//10, + tm[5]%10, tm[5]//10, tm[4]%10, tm[4]//10, tm[3]%10, tm[3]//10, + tm[2]%10, tm[2]//10, tm[1]%10, tm[1]//10, year2%10, year2//10) + address = nybble_address+18 + return datetime_data[address:address+nybble_count] + # + # Read 'length' nybbles at address. Returns: (nybble_at_address, ...). + # Can't read more than MAXBLOCK nybbles at a time. + # + def read_data(self,nybble_address,nybble_count): + if nybble_address < 0: + return self.read_computer_time(nybble_address,nybble_count) + self.log_enter("rd") + try: + if nybble_count < 1 or nybble_count > self.MAXBLOCK: + Exception("Too many nybbles requested") + bytes_ = (nybble_count + 1) // 2 + if not self.write_address(nybble_address): + return None + # + # Write the number bytes we want to read. + # + encoded_data = int2byte(0xC2 + bytes_*4) + self.write_byte(encoded_data) + answer = self.read_byte() + check = int2byte(0x30 + bytes_) + if answer != check: + self.log("??") + return None + # + # Read the response. + # + self.log(", :") + response = b"" + for _ in range(bytes_): + answer = self.read_byte() + if answer is None: + return None + response += answer + # + # Read and verify checksum + # + answer = self.read_byte() + checksum = sum(b for b in response) % 256 + if int2byte(checksum) != answer: + self.log("??") + return None + r = () + for b in response: + r += (b % 16, b // 16) + return r[:nybble_count] + finally: + self.log_exit() + # + # Read a batch of blocks. Batches is a list of data to be read: + # [(address_of_first_nybble, length_in_nybbles), ...] + # returns: + # [(nybble_at_address, ...), ...] + # + def read_batch(self,batches): + self.log_enter("rb start") + self.log_exit() + try: + if [b for b in batches if b[0] >= 0]: + self.reset_06() + result = [] + for batch in batches: + address = batch[0] + data = () + for start_pos in range(0,batch[1],self.MAXBLOCK): + for _ in range(self.MAXRETRIES): + bytes_ = min(self.MAXBLOCK, batch[1]-start_pos) + response = self.read_data(address + start_pos, bytes_) + if response != None: + break + self.reset_06() + if response == None: + raise self.Ws2300Exception("read failed, retries exceeded") + data += response + result.append(data) + return result + finally: + self.log_enter("rb end") + self.log_exit() + # + # Reset the device, read a block of nybbles at the passed address. + # + def read_safe(self,nybble_address,nybble_count): + self.log_enter("rs") + try: + return self.read_batch([(nybble_address,nybble_count)])[0] + finally: + self.log_exit() + # + # Debug logging of serial IO. + # + def log(self, s): + if not DEBUG_SERIAL: + return + self.log_buffer[-1] = self.log_buffer[-1] + s + def log_enter(self, action): + if not DEBUG_SERIAL: + return + self.log_nest += 1 + if self.log_nest == 1: + if len(self.log_buffer) > 1000: + del self.log_buffer[0] + self.log_buffer.append("%5.2f %s " % (time.time() % 100, action)) + self.log_mode = 'e' + def log_exit(self): + if not DEBUG_SERIAL: + return + self.log_nest -= 1 + +# +# Print a data block. +# +def bcd2num(nybbles): + digits = list(nybbles)[:] + digits.reverse() + return reduce(lambda a,b: a*10 + b, digits, 0) + +def num2bcd(number, nybble_count): + result = [] + for _ in range(nybble_count): + result.append(int(number % 10)) + number //= 10 + return tuple(result) + +def bin2num(nybbles): + digits = list(nybbles) + digits.reverse() + return reduce(lambda a,b: a*16 + b, digits, 0) + +def num2bin(number, nybble_count): + result = [] + number = int(number) + for _ in range(nybble_count): + result.append(number % 16) + number //= 16 + return tuple(result) + +# +# A "Conversion" encapsulates a unit of measurement on the Ws2300. Eg +# temperature, or wind speed. +# +class Conversion(object): + description = None # Description of the units. + nybble_count = None # Number of nybbles used on the WS2300 + units = None # Units name (eg hPa). + # + # Initialise ourselves. + # units - text description of the units. + # nybble_count- Size of stored value on ws2300 in nybbles + # description - Description of the units + # + def __init__(self, units, nybble_count, description): + self.description = description + self.nybble_count = nybble_count + self.units = units + # + # Convert the nybbles read from the ws2300 to our internal value. + # + def binary2value(self, data): raise NotImplementedError() + # + # Convert our internal value to nybbles that can be written to the ws2300. + # + def value2binary(self, value): raise NotImplementedError() + # + # Print value. + # + def str(self, value): raise NotImplementedError() + # + # Convert the string produced by "str()" back to the value. + # + def parse(self, s): raise NotImplementedError() + # + # Transform data into something that can be written. Returns: + # (new_bytes, ws2300.write_safe_args, ...) + # This only becomes tricky when less than a nybble is written. + # + def write(self, data, nybble): + return (data, data) + # + # Test if the nybbles read from the Ws2300 is sensible. Sometimes a + # communications error will make it past the weak checksums the Ws2300 + # uses. This optional function implements another layer of checking - + # does the value returned make sense. Returns True if the value looks + # like garbage. + # + def garbage(self, data): + return False + +# +# For values stores as binary numbers. +# +class BinConversion(Conversion): + mult = None + scale = None + units = None + def __init__(self, units, nybble_count, scale, description, mult=1, check=None): + Conversion.__init__(self, units, nybble_count, description) + self.mult = mult + self.scale = scale + self.units = units + def binary2value(self, data): + return (bin2num(data) * self.mult) / 10.0**self.scale + def value2binary(self, value): + return num2bin(int(value * 10**self.scale) // self.mult, self.nybble_count) + def str(self, value): + return "%.*f" % (self.scale, value) + def parse(self, s): + return float(s) + +# +# For values stored as BCD numbers. +# +class BcdConversion(Conversion): + offset = None + scale = None + units = None + def __init__(self, units, nybble_count, scale, description, offset=0): + Conversion.__init__(self, units, nybble_count, description) + self.offset = offset + self.scale = scale + self.units = units + def binary2value(self, data): + num = bcd2num(data) % 10**self.nybble_count + self.offset + return float(num) / 10**self.scale + def value2binary(self, value): + return num2bcd(int(value * 10**self.scale) - self.offset, self.nybble_count) + def str(self, value): + return "%.*f" % (self.scale, value) + def parse(self, s): + return float(s) + +# +# For pressures. Add a garbage check. +# +class PressureConversion(BcdConversion): + def __init__(self): + BcdConversion.__init__(self, "hPa", 5, 1, "pressure") + def garbage(self, data): + value = self.binary2value(data) + return value < 900 or value > 1200 + +# +# For values the represent a date. +# +class ConversionDate(Conversion): + format = None + def __init__(self, nybble_count, format_): + description = format_ + for xlate in "%Y:yyyy,%m:mm,%d:dd,%H:hh,%M:mm,%S:ss".split(","): + description = description.replace(*xlate.split(":")) + Conversion.__init__(self, "", nybble_count, description) + self.format = format_ + def str(self, value): + return time.strftime(self.format, time.localtime(value)) + def parse(self, s): + return time.mktime(time.strptime(s, self.format)) + +class DateConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 6, "%Y-%m-%d") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[2] + tm[1] * 100 + (tm[0]-2000) * 10000 + return num2bcd(dt, self.nybble_count) + +class DatetimeConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 11, "%Y-%m-%d %H:%M") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x // 1000000000 % 100 + 2000, + x // 10000000 % 100, + x // 100000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dow = tm[6] + 1 + dt = tm[4]+(tm[3]+(dow+(tm[2]+(tm[1]+(tm[0]-2000)*100)*100)*10)*100)*100 + return num2bcd(dt, self.nybble_count) + +class UnixtimeConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 12, "%Y-%m-%d %H:%M:%S") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x //10000000000 % 100 + 2000, + x // 100000000 % 100, + x // 1000000 % 100, + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[5]+(tm[4]+(tm[3]+(tm[2]+(tm[1]+(tm[0]-2000)*100)*100)*100)*100)*100 + return num2bcd(dt, self.nybble_count) + +class TimestampConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 10, "%Y-%m-%d %H:%M") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + x // 100000000 % 100 + 2000, + x // 1000000 % 100, + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0, + 0)) + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[4] + (tm[3] + (tm[2] + (tm[1] + (tm[0]-2000)*100)*100)*100)*100 + return num2bcd(dt, self.nybble_count) + +class TimeConversion(ConversionDate): + def __init__(self): + ConversionDate.__init__(self, 6, "%H:%M:%S") + def binary2value(self, data): + x = bcd2num(data) + return time.mktime(( + 0, + 0, + 0, + x // 10000 % 100, + x // 100 % 100, + x % 100, + 0, + 0, + 0)) - time.timezone + def value2binary(self, value): + tm = time.localtime(value) + dt = tm[5] + tm[4]*100 + tm[3]*10000 + return num2bcd(dt, self.nybble_count) + def parse(self, s): + return time.mktime((0,0,0) + time.strptime(s, self.format)[3:]) + time.timezone + +class WindDirectionConversion(Conversion): + def __init__(self): + Conversion.__init__(self, "deg", 1, "North=0 clockwise") + def binary2value(self, data): + return data[0] * 22.5 + def value2binary(self, value): + return (int((value + 11.25) / 22.5),) + def str(self, value): + return "%g" % value + def parse(self, s): + return float(s) + +class WindVelocityConversion(Conversion): + def __init__(self): + Conversion.__init__(self, "ms,d", 4, "wind speed and direction") + def binary2value(self, data): + return (bin2num(data[:3])/10.0, bin2num(data[3:4]) * 22.5) + def value2binary(self, value): + return num2bin(value[0]*10, 3) + num2bin((value[1] + 11.5) / 22.5, 1) + def str(self, value): + return "%.1f,%g" % value + def parse(self, s): + return tuple([float(x) for x in s.split(",")]) + +# The ws2300 1.8 implementation does not calculate wind speed correctly - +# it uses bcd2num instead of bin2num. This conversion object uses bin2num +# decoding and it reads all wind data in a single transcation so that we do +# not suffer coherency problems. +class WindConversion(Conversion): + def __init__(self): + Conversion.__init__(self, "ms,d,o,v", 12, "wind speed, dir, validity") + def binary2value(self, data): + overflow = data[0] + validity = data[1] + speed = bin2num(data[2:5]) / 10.0 + direction = data[5] * 22.5 + return (speed, direction, overflow, validity) + def str(self, value): + return "%.1f,%g,%s,%s" % value + def parse(self, s): + return tuple([float(x) for x in s.split(",")]) + +# +# For non-numerical values. +# +class TextConversion(Conversion): + constants = None + def __init__(self, constants): + items = list(constants.items())[:] + items.sort() + fullname = ",".join([c[1]+"="+str(c[0]) for c in items]) + ",unknown-X" + Conversion.__init__(self, "", 1, fullname) + self.constants = constants + def binary2value(self, data): + return data[0] + def value2binary(self, value): + return (value,) + def str(self, value): + result = self.constants.get(value, None) + if result != None: + return result + return "unknown-%d" % value + def parse(self, s): + result = [c[0] for c in self.constants.items() if c[1] == s] + if result: + return result[0] + return None + +# +# For values that are represented by one bit. +# +class ConversionBit(Conversion): + bit = None + desc = None + def __init__(self, bit, desc): + self.bit = bit + self.desc = desc + Conversion.__init__(self, "", 1, desc[0] + "=0," + desc[1] + "=1") + def binary2value(self, data): + return data[0] & (1 << self.bit) and 1 or 0 + def value2binary(self, value): + return (value << self.bit,) + def str(self, value): + return self.desc[value] + def parse(self, s): + return [c[0] for c in self.desc.items() if c[1] == s][0] + +class BitConversion(ConversionBit): + def __init__(self, bit, desc): + ConversionBit.__init__(self, bit, desc) + # + # Since Ws2300.write_safe() only writes nybbles and we have just one bit, + # we have to insert that bit into the data_read so it can be written as + # a nybble. + # + def write(self, data, nybble): + data = (nybble & ~(1 << self.bit) | data[0],) + return (data, data) + +class AlarmSetConversion(BitConversion): + bit = None + desc = None + def __init__(self, bit): + BitConversion.__init__(self, bit, {0:"off", 1:"on"}) + +class AlarmActiveConversion(BitConversion): + bit = None + desc = None + def __init__(self, bit): + BitConversion.__init__(self, bit, {0:"inactive", 1:"active"}) + +# +# For values that are represented by one bit, and must be written as +# a single bit. +# +class SetresetConversion(ConversionBit): + bit = None + def __init__(self, bit, desc): + ConversionBit.__init__(self, bit, desc) + # + # Setreset bits use a special write mode. + # + def write(self, data, nybble): + if data[0] == 0: + operation = Ws2300.UNSETBIT + else: + operation = Ws2300.SETBIT + return ((nybble & ~(1 << self.bit) | data[0],), [self.bit], operation) + +# +# Conversion for history. This kludge makes history fit into the framework +# used for all the other measures. +# +class HistoryConversion(Conversion): + class HistoryRecord(object): + temp_indoor = None + temp_outdoor = None + pressure_absolute = None + humidity_indoor = None + humidity_outdoor = None + rain = None + wind_speed = None + wind_direction = None + def __str__(self): + return "%4.1fc %2d%% %4.1fc %2d%% %6.1fhPa %6.1fmm %2dm/s %5g" % ( + self.temp_indoor, self.humidity_indoor, + self.temp_outdoor, self.humidity_outdoor, + self.pressure_absolute, self.rain, + self.wind_speed, self.wind_direction) + def parse(cls, s): + rec = cls() + toks = [tok.rstrip(string.ascii_letters + "%/") for tok in s.split()] + rec.temp_indoor = float(toks[0]) + rec.humidity_indoor = int(toks[1]) + rec.temp_outdoor = float(toks[2]) + rec.humidity_outdoor = int(toks[3]) + rec.pressure_absolute = float(toks[4]) + rec.rain = float(toks[5]) + rec.wind_speed = int(toks[6]) + rec.wind_direction = int((float(toks[7]) + 11.25) / 22.5) % 16 + return rec + parse = classmethod(parse) + def __init__(self): + Conversion.__init__(self, "", 19, "history") + def binary2value(self, data): + value = self.__class__.HistoryRecord() + n = bin2num(data[0:5]) + value.temp_indoor = (n % 1000) / 10.0 - 30 + value.temp_outdoor = (n - (n % 1000)) / 10000.0 - 30 + n = bin2num(data[5:10]) + value.pressure_absolute = (n % 10000) / 10.0 + if value.pressure_absolute < 500: + value.pressure_absolute += 1000 + value.humidity_indoor = (n - (n % 10000)) / 10000.0 + value.humidity_outdoor = bcd2num(data[10:12]) + value.rain = bin2num(data[12:15]) * 0.518 + value.wind_speed = bin2num(data[15:18]) + value.wind_direction = bin2num(data[18:19]) * 22.5 + return value + def value2binary(self, value): + result = () + n = int((value.temp_indoor + 30) * 10.0 + (value.temp_outdoor + 30) * 10000.0 + 0.5) + result = result + num2bin(n, 5) + n = value.pressure_absolute % 1000 + n = int(n * 10.0 + value.humidity_indoor * 10000.0 + 0.5) + result = result + num2bin(n, 5) + result = result + num2bcd(value.humidity_outdoor, 2) + result = result + num2bin(int((value.rain + 0.518/2) / 0.518), 3) + result = result + num2bin(value.wind_speed, 3) + result = result + num2bin(value.wind_direction, 1) + return result + # + # Print value. + # + def str(self, value): + return str(value) + # + # Convert the string produced by "str()" back to the value. + # + def parse(self, s): + return self.__class__.HistoryRecord.parse(s) + +# +# Various conversions we know about. +# +conv_ala0 = AlarmActiveConversion(0) +conv_ala1 = AlarmActiveConversion(1) +conv_ala2 = AlarmActiveConversion(2) +conv_ala3 = AlarmActiveConversion(3) +conv_als0 = AlarmSetConversion(0) +conv_als1 = AlarmSetConversion(1) +conv_als2 = AlarmSetConversion(2) +conv_als3 = AlarmSetConversion(3) +conv_buzz = SetresetConversion(3, {0:'on', 1:'off'}) +conv_lbck = SetresetConversion(0, {0:'off', 1:'on'}) +conv_date = DateConversion() +conv_dtme = DatetimeConversion() +conv_utme = UnixtimeConversion() +conv_hist = HistoryConversion() +conv_stmp = TimestampConversion() +conv_time = TimeConversion() +conv_wdir = WindDirectionConversion() +conv_wvel = WindVelocityConversion() +conv_conn = TextConversion({0:"cable", 3:"lost", 15:"wireless"}) +conv_fore = TextConversion({0:"rainy", 1:"cloudy", 2:"sunny"}) +conv_spdu = TextConversion({0:"m/s", 1:"knots", 2:"beaufort", 3:"km/h", 4:"mph"}) +conv_tend = TextConversion({0:"steady", 1:"rising", 2:"falling"}) +conv_wovr = TextConversion({0:"no", 1:"overflow"}) +conv_wvld = TextConversion({0:"ok", 1:"invalid", 2:"overflow"}) +conv_lcon = BinConversion("", 1, 0, "contrast") +conv_rec2 = BinConversion("", 2, 0, "record number") +conv_humi = BcdConversion("%", 2, 0, "humidity") +conv_pres = PressureConversion() +conv_rain = BcdConversion("mm", 6, 2, "rain") +conv_temp = BcdConversion("C", 4, 2, "temperature", -3000) +conv_per2 = BinConversion("s", 2, 1, "time interval", 5) +conv_per3 = BinConversion("min", 3, 0, "time interval") +conv_wspd = BinConversion("m/s", 3, 1, "speed") +conv_wind = WindConversion() + +# +# Define a measurement on the Ws2300. This encapsulates: +# - The names (abbrev and long) of the thing being measured, eg wind speed. +# - The location it can be found at in the Ws2300's memory map. +# - The Conversion used to represent the figure. +# +class Measure(object): + IDS = {} # map, Measures defined. {id: Measure, ...} + NAMES = {} # map, Measures defined. {name: Measure, ...} + address = None # int, Nybble address in the Ws2300 + conv = None # object, Type of value + id = None # string, Short name + name = None # string, Long name + reset = None # string, Id of measure used to reset this one + def __init__(self, address, id_, conv, name, reset=None): + self.address = address + self.conv = conv + self.reset = reset + if id_ != None: + self.id = id_ + assert not id_ in self.__class__.IDS + self.__class__.IDS[id_] = self + if name != None: + self.name = name + assert not name in self.__class__.NAMES + self.__class__.NAMES[name] = self + def __hash__(self): + return hash(self.id) + def __cmp__(self, other): + if isinstance(other, Measure): + return cmp(self.id, other.id) + return cmp(type(self), type(other)) + + +# +# Conversion for raw Hex data. These are created as needed. +# +class HexConversion(Conversion): + def __init__(self, nybble_count): + Conversion.__init__(self, "", nybble_count, "hex data") + def binary2value(self, data): + return data + def value2binary(self, value): + return value + def str(self, value): + return ",".join(["%x" % nybble for nybble in value]) + def parse(self, s): + toks = s.replace(","," ").split() + for i in range(len(toks)): + s = list(toks[i]) + s.reverse() + toks[i] = ''.join(s) + list_str = list(''.join(toks)) + self.nybble_count = len(list_str) + return tuple([int(nybble) for nybble in list_str]) + +# +# The raw nybble measure. +# +class HexMeasure(Measure): + def __init__(self, address, id_, conv, name): + self.address = address + self.name = name + self.conv = conv + +# +# A History record. Again a kludge to make history fit into the framework +# developed for the other measurements. History records are identified +# by their record number. Record number 0 is the most recently written +# record, record number 1 is the next most recently written and so on. +# +class HistoryMeasure(Measure): + HISTORY_BUFFER_ADDR = 0x6c6 # int, Address of the first history record + MAX_HISTORY_RECORDS = 0xaf # string, Max number of history records stored + LAST_POINTER = None # int, Pointer to last record + RECORD_COUNT = None # int, Number of records in use + recno = None # int, The record number this represents + conv = conv_hist + def __init__(self, recno): + self.recno = recno + def set_constants(cls, ws2300): + measures = [Measure.IDS["hp"], Measure.IDS["hn"]] + data = read_measurements(ws2300, measures) + cls.LAST_POINTER = int(measures[0].conv.binary2value(data[0])) + cls.RECORD_COUNT = int(measures[1].conv.binary2value(data[1])) + set_constants = classmethod(set_constants) + def id(self): + return "h%03d" % self.recno + id = property(id) + def name(self): + return "history record %d" % self.recno + name = property(name) + def offset(self): + if self.LAST_POINTER is None: + raise Exception("HistoryMeasure.set_constants hasn't been called") + return (self.LAST_POINTER - self.recno) % self.MAX_HISTORY_RECORDS + offset = property(offset) + def address(self): + return self.HISTORY_BUFFER_ADDR + self.conv.nybble_count * self.offset + address = property(address) + +# +# The measurements we know about. This is all of them documented in +# memory_map_2300.txt, bar the history. History is handled specially. +# And of course, the "c?"'s aren't real measures at all - its the current +# time on this machine. +# +Measure( -18, "ct", conv_time, "this computer's time") +Measure( -12, "cw", conv_utme, "this computer's date time") +Measure( -6, "cd", conv_date, "this computer's date") +Measure(0x006, "bz", conv_buzz, "buzzer") +Measure(0x00f, "wsu", conv_spdu, "wind speed units") +Measure(0x016, "lb", conv_lbck, "lcd backlight") +Measure(0x019, "sss", conv_als2, "storm warn alarm set") +Measure(0x019, "sts", conv_als0, "station time alarm set") +Measure(0x01a, "phs", conv_als3, "pressure max alarm set") +Measure(0x01a, "pls", conv_als2, "pressure min alarm set") +Measure(0x01b, "oths", conv_als3, "out temp max alarm set") +Measure(0x01b, "otls", conv_als2, "out temp min alarm set") +Measure(0x01b, "iths", conv_als1, "in temp max alarm set") +Measure(0x01b, "itls", conv_als0, "in temp min alarm set") +Measure(0x01c, "dphs", conv_als3, "dew point max alarm set") +Measure(0x01c, "dpls", conv_als2, "dew point min alarm set") +Measure(0x01c, "wchs", conv_als1, "wind chill max alarm set") +Measure(0x01c, "wcls", conv_als0, "wind chill min alarm set") +Measure(0x01d, "ihhs", conv_als3, "in humidity max alarm set") +Measure(0x01d, "ihls", conv_als2, "in humidity min alarm set") +Measure(0x01d, "ohhs", conv_als1, "out humidity max alarm set") +Measure(0x01d, "ohls", conv_als0, "out humidity min alarm set") +Measure(0x01e, "rhhs", conv_als1, "rain 1h alarm set") +Measure(0x01e, "rdhs", conv_als0, "rain 24h alarm set") +Measure(0x01f, "wds", conv_als2, "wind direction alarm set") +Measure(0x01f, "wshs", conv_als1, "wind speed max alarm set") +Measure(0x01f, "wsls", conv_als0, "wind speed min alarm set") +Measure(0x020, "siv", conv_ala2, "icon alarm active") +Measure(0x020, "stv", conv_ala0, "station time alarm active") +Measure(0x021, "phv", conv_ala3, "pressure max alarm active") +Measure(0x021, "plv", conv_ala2, "pressure min alarm active") +Measure(0x022, "othv", conv_ala3, "out temp max alarm active") +Measure(0x022, "otlv", conv_ala2, "out temp min alarm active") +Measure(0x022, "ithv", conv_ala1, "in temp max alarm active") +Measure(0x022, "itlv", conv_ala0, "in temp min alarm active") +Measure(0x023, "dphv", conv_ala3, "dew point max alarm active") +Measure(0x023, "dplv", conv_ala2, "dew point min alarm active") +Measure(0x023, "wchv", conv_ala1, "wind chill max alarm active") +Measure(0x023, "wclv", conv_ala0, "wind chill min alarm active") +Measure(0x024, "ihhv", conv_ala3, "in humidity max alarm active") +Measure(0x024, "ihlv", conv_ala2, "in humidity min alarm active") +Measure(0x024, "ohhv", conv_ala1, "out humidity max alarm active") +Measure(0x024, "ohlv", conv_ala0, "out humidity min alarm active") +Measure(0x025, "rhhv", conv_ala1, "rain 1h alarm active") +Measure(0x025, "rdhv", conv_ala0, "rain 24h alarm active") +Measure(0x026, "wdv", conv_ala2, "wind direction alarm active") +Measure(0x026, "wshv", conv_ala1, "wind speed max alarm active") +Measure(0x026, "wslv", conv_ala0, "wind speed min alarm active") +Measure(0x027, None, conv_ala3, "pressure max alarm active alias") +Measure(0x027, None, conv_ala2, "pressure min alarm active alias") +Measure(0x028, None, conv_ala3, "out temp max alarm active alias") +Measure(0x028, None, conv_ala2, "out temp min alarm active alias") +Measure(0x028, None, conv_ala1, "in temp max alarm active alias") +Measure(0x028, None, conv_ala0, "in temp min alarm active alias") +Measure(0x029, None, conv_ala3, "dew point max alarm active alias") +Measure(0x029, None, conv_ala2, "dew point min alarm active alias") +Measure(0x029, None, conv_ala1, "wind chill max alarm active alias") +Measure(0x029, None, conv_ala0, "wind chill min alarm active alias") +Measure(0x02a, None, conv_ala3, "in humidity max alarm active alias") +Measure(0x02a, None, conv_ala2, "in humidity min alarm active alias") +Measure(0x02a, None, conv_ala1, "out humidity max alarm active alias") +Measure(0x02a, None, conv_ala0, "out humidity min alarm active alias") +Measure(0x02b, None, conv_ala1, "rain 1h alarm active alias") +Measure(0x02b, None, conv_ala0, "rain 24h alarm active alias") +Measure(0x02c, None, conv_ala2, "wind direction alarm active alias") +Measure(0x02c, None, conv_ala2, "wind speed max alarm active alias") +Measure(0x02c, None, conv_ala2, "wind speed min alarm active alias") +Measure(0x200, "st", conv_time, "station set time", reset="ct") +Measure(0x23b, "sw", conv_dtme, "station current date time") +Measure(0x24d, "sd", conv_date, "station set date", reset="cd") +Measure(0x266, "lc", conv_lcon, "lcd contrast (ro)") +Measure(0x26b, "for", conv_fore, "forecast") +Measure(0x26c, "ten", conv_tend, "tendency") +Measure(0x346, "it", conv_temp, "in temp") +Measure(0x34b, "itl", conv_temp, "in temp min", reset="it") +Measure(0x350, "ith", conv_temp, "in temp max", reset="it") +Measure(0x354, "itlw", conv_stmp, "in temp min when", reset="sw") +Measure(0x35e, "ithw", conv_stmp, "in temp max when", reset="sw") +Measure(0x369, "itla", conv_temp, "in temp min alarm") +Measure(0x36e, "itha", conv_temp, "in temp max alarm") +Measure(0x373, "ot", conv_temp, "out temp") +Measure(0x378, "otl", conv_temp, "out temp min", reset="ot") +Measure(0x37d, "oth", conv_temp, "out temp max", reset="ot") +Measure(0x381, "otlw", conv_stmp, "out temp min when", reset="sw") +Measure(0x38b, "othw", conv_stmp, "out temp max when", reset="sw") +Measure(0x396, "otla", conv_temp, "out temp min alarm") +Measure(0x39b, "otha", conv_temp, "out temp max alarm") +Measure(0x3a0, "wc", conv_temp, "wind chill") +Measure(0x3a5, "wcl", conv_temp, "wind chill min", reset="wc") +Measure(0x3aa, "wch", conv_temp, "wind chill max", reset="wc") +Measure(0x3ae, "wclw", conv_stmp, "wind chill min when", reset="sw") +Measure(0x3b8, "wchw", conv_stmp, "wind chill max when", reset="sw") +Measure(0x3c3, "wcla", conv_temp, "wind chill min alarm") +Measure(0x3c8, "wcha", conv_temp, "wind chill max alarm") +Measure(0x3ce, "dp", conv_temp, "dew point") +Measure(0x3d3, "dpl", conv_temp, "dew point min", reset="dp") +Measure(0x3d8, "dph", conv_temp, "dew point max", reset="dp") +Measure(0x3dc, "dplw", conv_stmp, "dew point min when", reset="sw") +Measure(0x3e6, "dphw", conv_stmp, "dew point max when", reset="sw") +Measure(0x3f1, "dpla", conv_temp, "dew point min alarm") +Measure(0x3f6, "dpha", conv_temp, "dew point max alarm") +Measure(0x3fb, "ih", conv_humi, "in humidity") +Measure(0x3fd, "ihl", conv_humi, "in humidity min", reset="ih") +Measure(0x3ff, "ihh", conv_humi, "in humidity max", reset="ih") +Measure(0x401, "ihlw", conv_stmp, "in humidity min when", reset="sw") +Measure(0x40b, "ihhw", conv_stmp, "in humidity max when", reset="sw") +Measure(0x415, "ihla", conv_humi, "in humidity min alarm") +Measure(0x417, "ihha", conv_humi, "in humidity max alarm") +Measure(0x419, "oh", conv_humi, "out humidity") +Measure(0x41b, "ohl", conv_humi, "out humidity min", reset="oh") +Measure(0x41d, "ohh", conv_humi, "out humidity max", reset="oh") +Measure(0x41f, "ohlw", conv_stmp, "out humidity min when", reset="sw") +Measure(0x429, "ohhw", conv_stmp, "out humidity max when", reset="sw") +Measure(0x433, "ohla", conv_humi, "out humidity min alarm") +Measure(0x435, "ohha", conv_humi, "out humidity max alarm") +Measure(0x497, "rd", conv_rain, "rain 24h") +Measure(0x49d, "rdh", conv_rain, "rain 24h max", reset="rd") +Measure(0x4a3, "rdhw", conv_stmp, "rain 24h max when", reset="sw") +Measure(0x4ae, "rdha", conv_rain, "rain 24h max alarm") +Measure(0x4b4, "rh", conv_rain, "rain 1h") +Measure(0x4ba, "rhh", conv_rain, "rain 1h max", reset="rh") +Measure(0x4c0, "rhhw", conv_stmp, "rain 1h max when", reset="sw") +Measure(0x4cb, "rhha", conv_rain, "rain 1h max alarm") +Measure(0x4d2, "rt", conv_rain, "rain total", reset=0) +Measure(0x4d8, "rtrw", conv_stmp, "rain total reset when", reset="sw") +Measure(0x4ee, "wsl", conv_wspd, "wind speed min", reset="ws") +Measure(0x4f4, "wsh", conv_wspd, "wind speed max", reset="ws") +Measure(0x4f8, "wslw", conv_stmp, "wind speed min when", reset="sw") +Measure(0x502, "wshw", conv_stmp, "wind speed max when", reset="sw") +Measure(0x527, "wso", conv_wovr, "wind speed overflow") +Measure(0x528, "wsv", conv_wvld, "wind speed validity") +Measure(0x529, "wv", conv_wvel, "wind velocity") +Measure(0x529, "ws", conv_wspd, "wind speed") +Measure(0x52c, "w0", conv_wdir, "wind direction") +Measure(0x52d, "w1", conv_wdir, "wind direction 1") +Measure(0x52e, "w2", conv_wdir, "wind direction 2") +Measure(0x52f, "w3", conv_wdir, "wind direction 3") +Measure(0x530, "w4", conv_wdir, "wind direction 4") +Measure(0x531, "w5", conv_wdir, "wind direction 5") +Measure(0x533, "wsla", conv_wspd, "wind speed min alarm") +Measure(0x538, "wsha", conv_wspd, "wind speed max alarm") +Measure(0x54d, "cn", conv_conn, "connection type") +Measure(0x54f, "cc", conv_per2, "connection time till connect") +Measure(0x5d8, "pa", conv_pres, "pressure absolute") +Measure(0x5e2, "pr", conv_pres, "pressure relative") +Measure(0x5ec, "pc", conv_pres, "pressure correction") +Measure(0x5f6, "pal", conv_pres, "pressure absolute min", reset="pa") +Measure(0x600, "prl", conv_pres, "pressure relative min", reset="pr") +Measure(0x60a, "pah", conv_pres, "pressure absolute max", reset="pa") +Measure(0x614, "prh", conv_pres, "pressure relative max", reset="pr") +Measure(0x61e, "plw", conv_stmp, "pressure min when", reset="sw") +Measure(0x628, "phw", conv_stmp, "pressure max when", reset="sw") +Measure(0x63c, "pla", conv_pres, "pressure min alarm") +Measure(0x650, "pha", conv_pres, "pressure max alarm") +Measure(0x6b2, "hi", conv_per3, "history interval") +Measure(0x6b5, "hc", conv_per3, "history time till sample") +Measure(0x6b8, "hw", conv_stmp, "history last sample when") +Measure(0x6c2, "hp", conv_rec2, "history last record pointer",reset=0) +Measure(0x6c4, "hn", conv_rec2, "history number of records", reset=0) +# get all of the wind info in a single invocation +Measure(0x527, "wind", conv_wind, "wind") + +# +# Read the requests. +# +def read_measurements(ws2300, read_requests): + if not read_requests: + return [] + # + # Optimise what we have to read. + # + batches = [(m.address, m.conv.nybble_count) for m in read_requests] + batches.sort() + index = 1 + addr = {batches[0][0]: 0} + while index < len(batches): + same_sign = (batches[index-1][0] < 0) == (batches[index][0] < 0) + same_area = batches[index-1][0] + batches[index-1][1] + 6 >= batches[index][0] + if not same_sign or not same_area: + addr[batches[index][0]] = index + index += 1 + continue + addr[batches[index][0]] = index-1 + batches[index-1] = batches[index-1][0], batches[index][0] + batches[index][1] - batches[index-1][0] + del batches[index] + # + # Read the data. + # + nybbles = ws2300.read_batch(batches) + # + # Return the data read in the order it was requested. + # + results = [] + for measure in read_requests: + index = addr[measure.address] + offset = measure.address - batches[index][0] + results.append(nybbles[index][offset:offset+measure.conv.nybble_count]) + return results + + +class WS23xxConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WS23xx] + # This section is for the La Crosse WS-2300 series of weather stations. + + # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0 + port = /dev/ttyUSB0 + + # The station model, e.g., 'LaCrosse WS2317' or 'TFA Primus' + model = LaCrosse WS23xx + + # The driver to use: + driver = weewx.drivers.ws23xx +""" + + def prompt_for_settings(self): + print("Specify the serial port on which the station is connected, for") + print("example /dev/ttyUSB0 or /dev/ttyS0.") + port = self._prompt('port', '/dev/ttyUSB0') + return {'port': port} + + def modify_config(self, config_dict): + print(""" +Setting record_generation to software.""") + config_dict['StdArchive']['record_generation'] = 'software' + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/weewx/drivers/ws23xx.py + +if __name__ == '__main__': + import optparse + + import weewx + import weeutil.logger + + usage = """%prog [options] [--debug] [--help]""" + + port = DEFAULT_PORT + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='display diagnostic information while running') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected') + parser.add_option('--readings', dest='readings', action='store_true', + help='display sensor readings') + parser.add_option("--records", dest="records", type=int, metavar="N", + help="display N station records, oldest to newest") + parser.add_option('--help-measures', dest='hm', action='store_true', + help='display measure names') + parser.add_option('--measure', dest='measure', type=str, + metavar="MEASURE", help='display single measure') + + (options, args) = parser.parse_args() + + if options.version: + print("ws23xx driver version %s" % DRIVER_VERSION) + exit(1) + + if options.debug: + weewx.debug = 1 + + weeutil.logger.setup('wee_ws23xx') + + if options.port: + port = options.port + + with WS23xx(port) as s: + if options.readings: + data = s.get_raw_data(SENSOR_IDS) + print(data) + if options.records is not None: + for ts,record in s.gen_records(count=options.records): + print(ts,record) + if options.measure: + data = s.get_raw_data([options.measure]) + print(data) + if options.hm: + for m in Measure.IDS: + print("%s\t%s" % (m, Measure.IDS[m].name)) diff --git a/dist/weewx-5.0.2/src/weewx/drivers/ws28xx.py b/dist/weewx-5.0.2/src/weewx/drivers/ws28xx.py new file mode 100644 index 0000000..69e3a69 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/drivers/ws28xx.py @@ -0,0 +1,4156 @@ +# Copyright 2013-2024 Matthew Wall +# See the file LICENSE.txt for your full rights. +# +# Thanks to Eddie De Pieri for the first Python implementation for WS-28xx. +# Eddie did the difficult work of decompiling HeavyWeather then converting +# and reverse engineering into a functional Python implementation. Eddie's +# work was based on reverse engineering of HeavyWeather 2800 v 1.54 +# +# Thanks to Lucas Heijst for enumerating the console message types and for +# debugging the transceiver/console communication timing issues. + +"""Classes and functions for interfacing with WS-28xx weather stations. + +LaCrosse makes a number of stations in the 28xx series, including: + + WS-2810, WS-2810U-IT + WS-2811, WS-2811SAL-IT, WS-2811BRN-IT, WS-2811OAK-IT + WS-2812, WS-2812U-IT + WS-2813 + WS-2814, WS-2814U-IT + WS-2815, WS-2815U-IT + C86234 + +The station is also sold as the TFA Primus, TFA Opus, and TechnoLine. + +HeavyWeather is the software provided by LaCrosse. + +There are two versions of HeavyWeather for the WS-28xx series: 1.5.4 and 1.5.4b +Apparently there is a difference between TX59UN-1-IT and TX59U-IT models (this +identifier is printed on the thermo-hygro sensor). + + HeavyWeather Version Firmware Version Thermo-Hygro Model + 1.54 333 or 332 TX59UN-1-IT + 1.54b 288, 262, 222 TX59U-IT + +HeavyWeather provides the following weather station settings: + + time display: 12|24 hour + temperature display: C|F + air pressure display: inhg|hpa + wind speed display: m/s|knots|bft|km/h|mph + rain display: mm|inch + recording interval: 1m + keep weather station in hi-speed communication mode: true/false + +According to the HeavyWeatherPro User Manual (1.54, rev2), "Hi speed mode wears +down batteries on your display much faster, and similarly consumes more power +on the PC. We do not believe most users need to enable this setting. It was +provided at the request of users who prefer ultra-frequent uploads." + +The HeavyWeatherPro 'CurrentWeather' view is updated as data arrive from the +console. The console sends current weather data approximately every 13 +seconds. + +Historical data are updated less frequently - every 2 hours in the default +HeavyWeatherPro configuration. + +According to the User Manual, "The 2800 series weather station uses the +'original' wind chill calculation rather than the 2001 'North American' +formula because the original formula is international." + +Apparently the station console determines when data will be sent, and, once +paired, the transceiver is always listening. The station console sends a +broadcast on the hour. If the transceiver responds, the station console may +continue to broadcast data, depending on the transceiver response and the +timing of the transceiver response. + +According to the C86234 Operations Manual (Revision 7): + - Temperature and humidity data are sent to the console every 13 seconds. + - Wind data are sent to the temperature/humidity sensor every 17 seconds. + - Rain data are sent to the temperature/humidity sensor every 19 seconds. + - Air pressure is measured every 15 seconds. + +Each tip of the rain bucket is 0.26 mm of rain. + +The following information was obtained by logging messages from the ws28xx.py +driver in weewx and by capturing USB messages between Heavy Weather Pro for +ws2800 and the TFA Primus Weather Station via windows program USB sniffer +busdog64_v0.2.1. + +Pairing + +The transceiver must be paired with a console before it can receive data. Each +frame sent by the console includes the device identifier of the transceiver +with which it is paired. + +Synchronizing + +When the console and transceiver stop communicating, they can be synchronized +by one of the following methods: + +- Push the SET button on the console +- Wait till the next full hour when the console sends a clock message + +In each case a Request Time message is received by the transceiver from the +console. The 'Send Time to WS' message should be sent within ms (10 ms +typical). The transceiver should handle the 'Time SET' message then send a +'Time/Config written' message about 85 ms after the 'Send Time to WS' message. +When complete, the console and transceiver will have been synchronized. + +Timing + +Current Weather messages, History messages, getConfig/setConfig messages, and +setTime messages each have their own timing. Missed History messages - as a +result of bad timing - result in console and transceiver becoming out of synch. + +Current Weather + +The console periodically sends Current Weather messages, each with the latest +values from the sensors. The CommModeInterval determines how often the console +will send Current Weather messages. + +History + +The console records data periodically at an interval defined by the +HistoryInterval parameter. The factory default setting is 2 hours. +Each history record contains a timestamp. Timestamps use the time from the +console clock. The console can record up to 1797 history records. + +Reading 1795 history records took about 110 minutes on a raspberry pi, for +an average of 3.6 seconds per history record. + +Reading 1795 history records took 65 minutes on a synology ds209+ii, for +an average of 2.2 seconds per history record. + +Reading 1750 history records took 19 minutes using HeavyWeatherPro on a +Windows 7 64-bit laptop. + +Message Types + +The first byte of a message determines the message type. + +ID Type Length + +01 ? 0x0f (15) +d0 SetRX 0x15 (21) +d1 SetTX 0x15 (21) +d5 SetFrame 0x111 (273) +d6 GetFrame 0x111 (273) +d7 SetState 0x15 (21) +d8 SetPreamblePattern 0x15 (21) +d9 Execute 0x0f (15) +dc ReadConfigFlash< 0x15 (21) +dd ReadConfigFlash> 0x15 (21) +de GetState 0x0a (10) +f0 WriteReg 0x05 (5) + +In the following sections, some messages are decomposed using the following +structure: + + start position in message buffer + hi-lo data starts on first (hi) or second (lo) nibble + chars data length in characters (nibbles) + rem remark + name variable + +------------------------------------------------------------------------------- +1. 01 message (15 bytes) + +000: 01 15 00 0b 08 58 3f 53 00 00 00 00 ff 15 0b (detected via USB sniffer) +000: 01 15 00 57 01 92 3f 53 00 00 00 00 ff 15 0a (detected via USB sniffer) + +00: messageID +02-15: ?? + +------------------------------------------------------------------------------- +2. SetRX message (21 bytes) + +000: d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +020: 00 + +00: messageID +01-20: 00 + +------------------------------------------------------------------------------- +3. SetTX message (21 bytes) + +000: d1 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +020: 00 + +00: messageID +01-20: 00 + +------------------------------------------------------------------------------- +4. SetFrame message (273 bytes) + +Action: +00: rtGetHistory - Ask for History message +01: rtSetTime - Ask for Send Time to weather station message +02: rtSetConfig - Ask for Send Config to weather station message +03: rtGetConfig - Ask for Config message +05: rtGetCurrent - Ask for Current Weather message +c0: Send Time - Send Time to WS +40: Send Config - Send Config to WS + +000: d5 00 09 DevID 00 CfgCS cIntThisAdr xx xx xx rtGetHistory +000: d5 00 09 DevID 01 CfgCS cIntThisAdr xx xx xx rtReqSetTime +000: d5 00 09 f0 f0 02 CfgCS cIntThisAdr xx xx xx rtReqFirstConfig +000: d5 00 09 DevID 02 CfgCS cIntThisAdr xx xx xx rtReqSetConfig +000: d5 00 09 DevID 03 CfgCS cIntThisAdr xx xx xx rtGetConfig +000: d5 00 09 DevID 05 CfgCS cIntThisAdr xx xx xx rtGetCurrent +000: d5 00 0c DevID c0 CfgCS [TimeData . .. .. .. Send Time +000: d5 00 30 DevID 40 CfgCS [ConfigData .. .. .. Send Config + +All SetFrame messages: +00: messageID +01: 00 +02: Message Length (starting with next byte) +03-04: DeviceID [DevID] +05: Action +06-07: Config checksum [CfgCS] + +Additional bytes rtGetCurrent, rtGetHistory, rtSetTime messages: +08-09hi: ComInt [cINT] 1.5 bytes (high byte first) +09lo-11: ThisHistoryAddress [ThisAdr] 2.5 bytes (high byte first) + +Additional bytes Send Time message: +08: seconds +09: minutes +10: hours +11hi: DayOfWeek +11lo: day_lo (low byte) +12hi: month_lo (low byte) +12lo: day_hi (high byte) +13hi: (year-2000)_lo (low byte) +13lo: month_hi (high byte) +14lo: (year-2000)_hi (high byte) + +------------------------------------------------------------------------------- +5. GetFrame message + +Response type: +20: WS SetTime / SetConfig - Data written +40: GetConfig +60: Current Weather +80: Actual / Outstanding History +a1: Request First-Time Config +a2: Request SetConfig +a3: Request SetTime + +000: 00 00 06 DevID 20 64 CfgCS xx xx xx xx xx xx xx xx xx Time/Config written +000: 00 00 30 DevID 40 64 [ConfigData .. .. .. .. .. .. .. GetConfig +000: 00 00 d7 DevID 60 64 CfgCS [CurData .. .. .. .. .. .. Current Weather +000: 00 00 1e DevID 80 64 CfgCS 0LateAdr 0ThisAdr [HisData Outstanding History +000: 00 00 1e DevID 80 64 CfgCS 0LateAdr 0ThisAdr [HisData Actual History +000: 00 00 06 DevID a1 64 CfgCS xx xx xx xx xx xx xx xx xx Request FirstConfig +000: 00 00 06 DevID a2 64 CfgCS xx xx xx xx xx xx xx xx xx Request SetConfig +000: 00 00 06 DevID a3 64 CfgCS xx xx xx xx xx xx xx xx xx Request SetTime + +ReadConfig example: +000: 01 2e 40 5f 36 53 02 00 00 00 00 81 00 04 10 00 82 00 04 20 +020: 00 71 41 72 42 00 05 00 00 00 27 10 00 02 83 60 96 01 03 07 +040: 21 04 01 00 00 00 CfgCS + +WriteConfig example: +000: 01 2e 40 64 36 53 02 00 00 00 00 00 10 04 00 81 00 20 04 00 +020: 82 41 71 42 72 00 00 05 00 00 00 10 27 01 96 60 83 02 01 04 +040: 21 07 03 10 00 00 CfgCS + +00: messageID +01: 00 +02: Message Length (starting with next byte) +03-04: DeviceID [devID] +05hi: responseType +06: Quality (in steps of 5) + +Additional byte GetFrame messages except Request SetConfig and Request SetTime: +05lo: BatteryStat 8=WS bat low; 4=TMP bat low; 2=RAIN bat low; 1=WIND bat low + +Additional byte Request SetConfig and Request SetTime: +05lo: RequestID + +Additional bytes all GetFrame messages except ReadConfig and WriteConfig +07-08: Config checksum [CfgCS] + +Additional bytes Outstanding History: +09lo-11: LatestHistoryAddress [LateAdr] 2.5 bytes (Latest to sent) +12lo-14: ThisHistoryAddress [ThisAdr] 2.5 bytes (Outstanding) + +Additional bytes Actual History: +09lo-11: LatestHistoryAddress [ThisAdr] 2.5 bytes (LatestHistoryAddress is the) +12lo-14: ThisHistoryAddress [ThisAdr] 2.5 bytes (same as ThisHistoryAddress) + +Additional bytes ReadConfig and WriteConfig +43-45: ResetMinMaxFlags (Output only; not included in checksum calculation) +46-47: Config checksum [CfgCS] (CheckSum = sum of bytes (00-42) + 7) + +------------------------------------------------------------------------------- +6. SetState message + +000: d7 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +00: messageID +01-14: 00 + +------------------------------------------------------------------------------- +7. SetPreamblePattern message + +000: d8 aa 00 00 00 00 00 00 00 00 00 00 00 00 00 + +00: messageID +01: ?? +02-14: 00 + +------------------------------------------------------------------------------- +8. Execute message + +000: d9 05 00 00 00 00 00 00 00 00 00 00 00 00 00 + +00: messageID +01: ?? +02-14: 00 + +------------------------------------------------------------------------------- +9. ReadConfigFlash in - receive data + +000: dc 0a 01 f5 00 01 78 a0 01 02 0a 0c 0c 01 2e ff ff ff ff ff - freq correction +000: dc 0a 01 f9 01 02 0a 0c 0c 01 2e ff ff ff ff ff ff ff ff ff - transceiver data + +00: messageID +01: length +02-03: address + +Additional bytes frequency correction +05lo-07hi: frequency correction + +Additional bytes transceiver data +05-10: serial number +09-10: DeviceID [devID] + +------------------------------------------------------------------------------- +10. ReadConfigFlash out - ask for data + +000: dd 0a 01 f5 cc cc cc cc cc cc cc cc cc cc cc - Ask for freq correction +000: dd 0a 01 f9 cc cc cc cc cc cc cc cc cc cc cc - Ask for transceiver data + +00: messageID +01: length +02-03: address +04-14: cc + +------------------------------------------------------------------------------- +11. GetState message + +000: de 14 00 00 00 00 (between SetPreamblePattern and first de16 message) +000: de 15 00 00 00 00 Idle message +000: de 16 00 00 00 00 Normal message +000: de 0b 00 00 00 00 (detected via USB sniffer) + +00: messageID +01: stateID +02-05: 00 + +------------------------------------------------------------------------------- +12. Writereg message + +000: f0 08 01 00 00 - AX5051RegisterNames.IFMODE +000: f0 10 01 41 00 - AX5051RegisterNames.MODULATION +000: f0 11 01 07 00 - AX5051RegisterNames.ENCODING +... +000: f0 7b 01 88 00 - AX5051RegisterNames.TXRATEMID +000: f0 7c 01 23 00 - AX5051RegisterNames.TXRATELO +000: f0 7d 01 35 00 - AX5051RegisterNames.TXDRIVER + +00: messageID +01: register address +02: 01 +03: AX5051RegisterName +04: 00 + +------------------------------------------------------------------------------- +13. Current Weather message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality +4 hi 4 DeviceCS +6 hi 4 6 _AlarmRingingFlags +8 hi 1 _WeatherTendency +8 lo 1 _WeatherState +9 hi 1 not used +9 lo 10 _TempIndoorMinMax._Max._Time +14 lo 10 _TempIndoorMinMax._Min._Time +19 lo 5 _TempIndoorMinMax._Max._Value +22 hi 5 _TempIndoorMinMax._Min._Value +24 lo 5 _TempIndoor (C) +27 lo 10 _TempOutdoorMinMax._Max._Time +32 lo 10 _TempOutdoorMinMax._Min._Time +37 lo 5 _TempOutdoorMinMax._Max._Value +40 hi 5 _TempOutdoorMinMax._Min._Value +42 lo 5 _TempOutdoor (C) +45 hi 1 not used +45 lo 10 1 _WindchillMinMax._Max._Time +50 lo 10 2 _WindchillMinMax._Min._Time +55 lo 5 1 _WindchillMinMax._Max._Value +57 hi 5 1 _WindchillMinMax._Min._Value +60 lo 6 _Windchill (C) +63 hi 1 not used +63 lo 10 _DewpointMinMax._Max._Time +68 lo 10 _DewpointMinMax._Min._Time +73 lo 5 _DewpointMinMax._Max._Value +76 hi 5 _DewpointMinMax._Min._Value +78 lo 5 _Dewpoint (C) +81 hi 10 _HumidityIndoorMinMax._Max._Time +86 hi 10 _HumidityIndoorMinMax._Min._Time +91 hi 2 _HumidityIndoorMinMax._Max._Value +92 hi 2 _HumidityIndoorMinMax._Min._Value +93 hi 2 _HumidityIndoor (%) +94 hi 10 _HumidityOutdoorMinMax._Max._Time +99 hi 10 _HumidityOutdoorMinMax._Min._Time +104 hi 2 _HumidityOutdoorMinMax._Max._Value +105 hi 2 _HumidityOutdoorMinMax._Min._Value +106 hi 2 _HumidityOutdoor (%) +107 hi 10 3 _RainLastMonthMax._Time +112 hi 6 3 _RainLastMonthMax._Max._Value +115 hi 6 _RainLastMonth (mm) +118 hi 10 3 _RainLastWeekMax._Time +123 hi 6 3 _RainLastWeekMax._Max._Value +126 hi 6 _RainLastWeek (mm) +129 hi 10 _Rain24HMax._Time +134 hi 6 _Rain24HMax._Max._Value +137 hi 6 _Rain24H (mm) +140 hi 10 _Rain24HMax._Time +145 hi 6 _Rain24HMax._Max._Value +148 hi 6 _Rain24H (mm) +151 hi 1 not used +152 lo 10 _LastRainReset +158 lo 7 _RainTotal (mm) +160 hi 1 _WindDirection5 +160 lo 1 _WindDirection4 +161 hi 1 _WindDirection3 +161 lo 1 _WindDirection2 +162 hi 1 _WindDirection1 +162 lo 1 _WindDirection (0-15) +163 hi 18 unknown data +172 hi 6 _WindSpeed (km/h) +175 hi 1 _GustDirection5 +175 lo 1 _GustDirection4 +176 hi 1 _GustDirection3 +176 lo 1 _GustDirection2 +177 hi 1 _GustDirection1 +177 lo 1 _GustDirection (0-15) +178 hi 2 not used +179 hi 10 _GustMax._Max._Time +184 hi 6 _GustMax._Max._Value +187 hi 6 _Gust (km/h) +190 hi 10 4 _PressureRelative_MinMax._Max/Min._Time +195 hi 5 5 _PressureRelative_inHgMinMax._Max._Value +197 lo 5 5 _PressureRelative_hPaMinMax._Max._Value +200 hi 5 _PressureRelative_inHgMinMax._Max._Value +202 lo 5 _PressureRelative_hPaMinMax._Max._Value +205 hi 5 _PressureRelative_inHgMinMax._Min._Value +207 lo 5 _PressureRelative_hPaMinMax._Min._Value +210 hi 5 _PressureRelative_inHg +212 lo 5 _PressureRelative_hPa + +214 lo 430 end + +Remarks + 1 since factory reset + 2 since software reset + 3 not used? + 4 should be: _PressureRelative_MinMax._Max._Time + 5 should be: _PressureRelative_MinMax._Min._Time + 6 _AlarmRingingFlags (values in hex) + 80 00 = Hi Al Gust + 40 00 = Al WindDir + 20 00 = One or more WindDirs set + 10 00 = Hi Al Rain24H + 08 00 = Hi Al Outdoor Humidity + 04 00 = Lo Al Outdoor Humidity + 02 00 = Hi Al Indoor Humidity + 01 00 = Lo Al Indoor Humidity + 00 80 = Hi Al Outdoor Temp + 00 40 = Lo Al Outdoor Temp + 00 20 = Hi Al Indoor Temp + 00 10 = Lo Al Indoor Temp + 00 08 = Hi Al Pressure + 00 04 = Lo Al Pressure + 00 02 = not used + 00 01 = not used + +------------------------------------------------------------------------------- +14. History Message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality (%) +4 hi 4 DeviceCS +6 hi 6 LatestAddress +9 hi 6 ThisAddress +12 hi 1 not used +12 lo 3 Gust (m/s) +14 hi 1 WindDirection (0-15, also GustDirection) +14 lo 3 WindSpeed (m/s) +16 hi 3 RainCounterRaw (total in period in 0.1 inch) +17 lo 2 HumidityOutdoor (%) +18 lo 2 HumidityIndoor (%) +19 lo 5 PressureRelative (hPa) +22 hi 3 TempOutdoor (C) +23 lo 3 TempIndoor (C) +25 hi 10 Time + +29 lo 60 end + +------------------------------------------------------------------------------- +15. Set Config Message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality +4 hi 1 1 _WindspeedFormat +4 lo 0,25 2 _RainFormat +4 lo 0,25 3 _PressureFormat +4 lo 0,25 4 _TemperatureFormat +4 lo 0,25 5 _ClockMode +5 hi 1 _WeatherThreshold +5 lo 1 _StormThreshold +6 hi 1 _LowBatFlags +6 lo 1 6 _LCDContrast +7 hi 4 7 _WindDirAlarmFlags (reverse group 1) +9 hi 4 8 _OtherAlarmFlags (reverse group 1) +11 hi 10 _TempIndoorMinMax._Min._Value (reverse group 2) + _TempIndoorMinMax._Max._Value (reverse group 2) +16 hi 10 _TempOutdoorMinMax._Min._Value (reverse group 3) + _TempOutdoorMinMax._Max._Value (reverse group 3) +21 hi 2 _HumidityIndoorMinMax._Min._Value +22 hi 2 _HumidityIndoorMinMax._Max._Value +23 hi 2 _HumidityOutdoorMinMax._Min._Value +24 hi 2 _HumidityOutdoorMinMax._Max._Value +25 hi 1 not used +25 lo 7 _Rain24HMax._Max._Value (reverse bytes) +29 hi 2 _HistoryInterval +30 hi 1 not used +30 lo 5 _GustMax._Max._Value (reverse bytes) +33 hi 10 _PressureRelative_hPaMinMax._Min._Value (rev grp4) + _PressureRelative_inHgMinMax._Min._Value(rev grp4) +38 hi 10 _PressureRelative_hPaMinMax._Max._Value (rev grp5) + _PressureRelative_inHgMinMax._Max._Value(rev grp5) +43 hi 6 9 _ResetMinMaxFlags +46 hi 4 10 _InBufCS + +47 lo 96 end + +Remarks + 1 0=m/s 1=knots 2=bft 3=km/h 4=mph + 2 0=mm 1=inch + 3 0=inHg 2=hPa + 4 0=F 1=C + 5 0=24h 1=12h + 6 values 0-7 => LCD contrast 1-8 + 7 WindDir Alarms (not-reversed values in hex) + 80 00 = NNW + 40 00 = NW + 20 00 = WNW + 10 00 = W + 08 00 = WSW + 04 00 = SW + 02 00 = SSW + 01 00 = S + 00 80 = SSE + 00 40 = SE + 00 20 = ESE + 00 10 = E + 00 08 = ENE + 00 04 = NE + 00 02 = NNE + 00 01 = N + 8 Other Alarms (not-reversed values in hex) + 80 00 = Hi Al Gust + 40 00 = Al WindDir + 20 00 = One or more WindDirs set + 10 00 = Hi Al Rain24H + 08 00 = Hi Al Outdoor Humidity + 04 00 = Lo Al Outdoor Humidity + 02 00 = Hi Al Indoor Humidity + 01 00 = Lo Al Indoor Humidity + 00 80 = Hi Al Outdoor Temp + 00 40 = Lo Al Outdoor Temp + 00 20 = Hi Al Indoor Temp + 00 10 = Lo Al Indoor Temp + 00 08 = Hi Al Pressure + 00 04 = Lo Al Pressure + 00 02 = not used + 00 01 = not used + 9 ResetMinMaxFlags (not-reversed values in hex) + "Output only; not included in checksum calc" + 80 00 00 = Reset DewpointMax + 40 00 00 = Reset DewpointMin + 20 00 00 = not used + 10 00 00 = Reset WindchillMin* + "*Reset dateTime only; Min._Value is preserved" + 08 00 00 = Reset TempOutMax + 04 00 00 = Reset TempOutMin + 02 00 00 = Reset TempInMax + 01 00 00 = Reset TempInMin + 00 80 00 = Reset Gust + 00 40 00 = not used + 00 20 00 = not used + 00 10 00 = not used + 00 08 00 = Reset HumOutMax + 00 04 00 = Reset HumOutMin + 00 02 00 = Reset HumInMax + 00 01 00 = Reset HumInMin + 00 00 80 = not used + 00 00 40 = Reset Rain Total + 00 00 20 = Reset last month? + 00 00 10 = Reset lastweek? + 00 00 08 = Reset Rain24H + 00 00 04 = Reset Rain1H + 00 00 02 = Reset PresRelMax + 00 00 01 = Reset PresRelMin + 10 Checksum = sum bytes (0-42) + 7 + +------------------------------------------------------------------------------- +16. Get Config Message + +start hi-lo chars rem name +0 hi 4 DevID +2 hi 2 Action +3 hi 2 Quality +4 hi 1 1 _WindspeedFormat +4 lo 0,25 2 _RainFormat +4 lo 0,25 3 _PressureFormat +4 lo 0,25 4 _TemperatureFormat +4 lo 0,25 5 _ClockMode +5 hi 1 _WeatherThreshold +5 lo 1 _StormThreshold +6 hi 1 _LowBatFlags +6 lo 1 6 _LCDContrast +7 hi 4 7 _WindDirAlarmFlags +9 hi 4 8 _OtherAlarmFlags +11 hi 5 _TempIndoorMinMax._Min._Value +13 lo 5 _TempIndoorMinMax._Max._Value +16 hi 5 _TempOutdoorMinMax._Min._Value +18 lo 5 _TempOutdoorMinMax._Max._Value +21 hi 2 _HumidityIndoorMinMax._Max._Value +22 hi 2 _HumidityIndoorMinMax._Min._Value +23 hi 2 _HumidityOutdoorMinMax._Max._Value +24 hi 2 _HumidityOutdoorMinMax._Min._Value +25 hi 1 not used +25 lo 7 _Rain24HMax._Max._Value +29 hi 2 _HistoryInterval +30 hi 5 _GustMax._Max._Value +32 lo 1 not used +33 hi 5 _PressureRelative_hPaMinMax._Min._Value +35 lo 5 _PressureRelative_inHgMinMax._Min._Value +38 hi 5 _PressureRelative_hPaMinMax._Max._Value +40 lo 5 _PressureRelative_inHgMinMax._Max._Value +43 hi 6 9 _ResetMinMaxFlags +46 hi 4 10 _InBufCS + +47 lo 96 end + +Remarks + 1 0=m/s 1=knots 2=bft 3=km/h 4=mph + 2 0=mm 1=inch + 3 0=inHg 2=hPa + 4 0=F 1=C + 5 0=24h 1=12h + 6 values 0-7 => LCD contrast 1-8 + 7 WindDir Alarms (values in hex) + 80 00 = NNW + 40 00 = NW + 20 00 = WNW + 10 00 = W + 08 00 = WSW + 04 00 = SW + 02 00 = SSW + 01 00 = S + 00 80 = SSE + 00 40 = SE + 00 20 = ESE + 00 10 = E + 00 08 = ENE + 00 04 = NE + 00 02 = NNE + 00 01 = N + 8 Other Alarms (values in hex) + 80 00 = Hi Al Gust + 40 00 = Al WindDir + 20 00 = One or more WindDirs set + 10 00 = Hi Al Rain24H + 08 00 = Hi Al Outdoor Humidity + 04 00 = Lo Al Outdoor Humidity + 02 00 = Hi Al Indoor Humidity + 01 00 = Lo Al Indoor Humidity + 00 80 = Hi Al Outdoor Temp + 00 40 = Lo Al Outdoor Temp + 00 20 = Hi Al Indoor Temp + 00 10 = Lo Al Indoor Temp + 00 08 = Hi Al Pressure + 00 04 = Lo Al Pressure + 00 02 = not used + 00 01 = not used + 9 ResetMinMaxFlags (values in hex) + "Output only; input = 00 00 00" + 10 Checksum = sum bytes (0-42) + 7 + + +------------------------------------------------------------------------------- +Examples of messages + +readCurrentWeather +Cur 000: 01 2e 60 5f 05 1b 00 00 12 01 30 62 21 54 41 30 62 40 75 36 +Cur 020: 59 00 60 70 06 35 00 01 30 62 31 61 21 30 62 30 55 95 92 00 +Cur 040: 53 10 05 37 00 01 30 62 01 90 81 30 62 40 90 66 38 00 49 00 +Cur 060: 05 37 00 01 30 62 21 53 01 30 62 22 31 75 51 11 50 40 05 13 +Cur 080: 80 13 06 22 21 40 13 06 23 19 37 67 52 59 13 06 23 06 09 13 +Cur 100: 06 23 16 19 91 65 86 00 00 00 00 00 00 00 00 00 00 00 00 00 +Cur 120: 00 00 00 00 00 00 00 00 00 13 06 23 09 59 00 06 19 00 00 51 +Cur 140: 13 06 22 20 43 00 01 54 00 00 00 01 30 62 21 51 00 00 38 70 +Cur 160: a7 cc 7b 50 09 01 01 00 00 00 00 00 00 fc 00 a7 cc 7b 14 13 +Cur 180: 06 23 14 06 0e a0 00 01 b0 00 13 06 23 06 34 03 00 91 01 92 +Cur 200: 03 00 91 01 92 02 97 41 00 74 03 00 91 01 92 + +WeatherState: Sunny(Good) WeatherTendency: Rising(Up) AlarmRingingFlags: 0000 +TempIndoor 23.500 Min:20.700 2013-06-24 07:53 Max:25.900 2013-06-22 15:44 +HumidityIndoor 59.000 Min:52.000 2013-06-23 19:37 Max:67.000 2013-06-22 21:40 +TempOutdoor 13.700 Min:13.100 2013-06-23 05:59 Max:19.200 2013-06-23 16:12 +HumidityOutdoor 86.000 Min:65.000 2013-06-23 16:19 Max:91.000 2013-06-23 06:09 +Windchill 13.700 Min: 9.000 2013-06-24 09:06 Max:23.800 2013-06-20 19:08 +Dewpoint 11.380 Min:10.400 2013-06-22 23:17 Max:15.111 2013-06-22 15:30 +WindSpeed 2.520 +Gust 4.320 Max:37.440 2013-06-23 14:06 +WindDirection WSW GustDirection WSW +WindDirection1 SSE GustDirection1 SSE +WindDirection2 W GustDirection2 W +WindDirection3 W GustDirection3 W +WindDirection4 SSE GustDirection4 SSE +WindDirection5 SW GustDirection5 SW +RainLastMonth 0.000 Max: 0.000 1900-01-01 00:00 +RainLastWeek 0.000 Max: 0.000 1900-01-01 00:00 +Rain24H 0.510 Max: 6.190 2013-06-23 09:59 +Rain1H 0.000 Max: 1.540 2013-06-22 20:43 +RainTotal 3.870 LastRainReset 2013-06-22 15:10 +PresRelhPa 1019.200 Min:1007.400 2013-06-23 06:34 Max:1019.200 2013-06-23 06:34 +PresRel_inHg 30.090 Min: 29.740 2013-06-23 06:34 Max: 30.090 2013-06-23 06:34 +Bytes with unknown meaning at 157-165: 50 09 01 01 00 00 00 00 00 + +------------------------------------------------------------------------------- +readHistory +His 000: 01 2e 80 5f 05 1b 00 7b 32 00 7b 32 00 0c 70 0a 00 08 65 91 +His 020: 01 92 53 76 35 13 06 24 09 10 + +Time 2013-06-24 09:10:00 +TempIndoor= 23.5 +HumidityIndoor= 59 +TempOutdoor= 13.7 +HumidityOutdoor= 86 +PressureRelative= 1019.2 +RainCounterRaw= 0.0 +WindDirection= SSE +WindSpeed= 1.0 +Gust= 1.2 + +------------------------------------------------------------------------------- +readConfig +In 000: 01 2e 40 5f 36 53 02 00 00 00 00 81 00 04 10 00 82 00 04 20 +In 020: 00 71 41 72 42 00 05 00 00 00 27 10 00 02 83 60 96 01 03 07 +In 040: 21 04 01 00 00 00 05 1b + +------------------------------------------------------------------------------- +writeConfig +Out 000: 01 2e 40 64 36 53 02 00 00 00 00 00 10 04 00 81 00 20 04 00 +Out 020: 82 41 71 42 72 00 00 05 00 00 00 10 27 01 96 60 83 02 01 04 +Out 040: 21 07 03 10 00 00 05 1b + +OutBufCS= 051b +ClockMode= 0 +TemperatureFormat= 1 +PressureFormat= 1 +RainFormat= 0 +WindspeedFormat= 3 +WeatherThreshold= 3 +StormThreshold= 5 +LCDContrast= 2 +LowBatFlags= 0 +WindDirAlarmFlags= 0000 +OtherAlarmFlags= 0000 +HistoryInterval= 0 +TempIndoor_Min= 1.0 +TempIndoor_Max= 41.0 +TempOutdoor_Min= 2.0 +TempOutdoor_Max= 42.0 +HumidityIndoor_Min= 41 +HumidityIndoor_Max= 71 +HumidityOutdoor_Min= 42 +HumidityOutdoor_Max= 72 +Rain24HMax= 50.0 +GustMax= 100.0 +PressureRel_hPa_Min= 960.1 +PressureRel_inHg_Min= 28.36 +PressureRel_hPa_Max= 1040.1 +PressureRel_inHg_Max= 30.72 +ResetMinMaxFlags= 100000 (Output only; Input always 00 00 00) + +------------------------------------------------------------------------------- +class EHistoryInterval: +Constant Value Message received at +hi01Min = 0 00:00, 00:01, 00:02, 00:03 ... 23:59 +hi05Min = 1 00:00, 00:05, 00:10, 00:15 ... 23:55 +hi10Min = 2 00:00, 00:10, 00:20, 00:30 ... 23:50 +hi15Min = 3 00:00, 00:15, 00:30, 00:45 ... 23:45 +hi20Min = 4 00:00, 00:20, 00:40, 01:00 ... 23:40 +hi30Min = 5 00:00, 00:30, 01:00, 01:30 ... 23:30 +hi60Min = 6 00:00, 01:00, 02:00, 03:00 ... 23:00 +hi02Std = 7 00:00, 02:00, 04:00, 06:00 ... 22:00 +hi04Std = 8 00:00, 04:00, 08:00, 12:00 ... 20:00 +hi06Std = 9 00:00, 06:00, 12:00, 18:00 +hi08Std = 0xA 00:00, 08:00, 16:00 +hi12Std = 0xB 00:00, 12:00 +hi24Std = 0xC 00:00 + +------------------------------------------------------------------------------- +WS SetTime - Send time to WS +Time 000: 01 2e c0 05 1b 19 14 12 40 62 30 01 +time sent: 2013-06-24 12:14:19 + +------------------------------------------------------------------------------- +ReadConfigFlash data + +Ask for frequency correction +rcfo 000: dd 0a 01 f5 cc cc cc cc cc cc cc cc cc cc cc + +readConfigFlash frequency correction +rcfi 000: dc 0a 01 f5 00 01 78 a0 01 02 0a 0c 0c 01 2e ff ff ff ff ff +frequency correction: 96416 (0x178a0) +adjusted frequency: 910574957 (3646456d) + +Ask for transceiver data +rcfo 000: dd 0a 01 f9 cc cc cc cc cc cc cc cc cc cc cc + +readConfigFlash serial number and DevID +rcfi 000: dc 0a 01 f9 01 02 0a 0c 0c 01 2e ff ff ff ff ff ff ff ff ff +transceiver ID: 302 (0x012e) +transceiver serial: 01021012120146 + +Program Logic + +The RF communication thread uses the following logic to communicate with the +weather station console: + +Step 1. Perform in a while loop getState commands until state 0xde16 + is received. + +Step 2. Perform a getFrame command to read the message data. + +Step 3. Handle the contents of the message. The type of message depends on + the response type: + + Response type (hex): + 20: WS SetTime / SetConfig - Data written + confirmation the setTime/setConfig setFrame message has been received + by the console + 40: GetConfig + save the contents of the configuration for later use (i.e. a setConfig + message with one ore more parameters changed) + 60: Current Weather + handle the weather data of the current weather message + 80: Actual / Outstanding History + ignore the data of the actual history record when there is no data gap; + handle the data of a (one) requested history record (note: in step 4 we + can decide to request another history record). + a1: Request First-Time Config + prepare a setFrame first time message + a2: Request SetConfig + prepare a setFrame setConfig message + a3: Request SetTime + prepare a setFrame setTime message + +Step 4. When you didn't receive the message in step 3 you asked for (see + step 5 how to request a certain type of message), decide if you want + to ignore or handle the received message. Then go to step 5 to + request for a certain type of message unless the received message + has response type a1, a2 or a3, then prepare first the setFrame + message the wireless console asked for. + +Step 5. Decide what kind of message you want to receive next time. The + request is done via a setFrame message (see step 6). It is + not guaranteed that you will receive that kind of message the next + time but setting the proper timing parameters of firstSleep and + nextSleep increase the chance you will get the requested type of + message. + +Step 6. The action parameter in the setFrame message sets the type of the + next to receive message. + + Action (hex): + 00: rtGetHistory - Ask for History message + setSleep(0.300,0.010) + 01: rtSetTime - Ask for Send Time to weather station message + setSleep(0.085,0.005) + 02: rtSetConfig - Ask for Send Config to weather station message + setSleep(0.300,0.010) + 03: rtGetConfig - Ask for Config message + setSleep(0.400,0.400) + 05: rtGetCurrent - Ask for Current Weather message + setSleep(0.300,0.010) + c0: Send Time - Send Time to WS + setSleep(0.085,0.005) + 40: Send Config - Send Config to WS + setSleep(0.085,0.005) + + Note: after the Request First-Time Config message (response type = 0xa1) + perform a rtGetConfig with setSleep(0.085,0.005) + +Step 7. Perform a setTX command + +Step 8. Go to step 1 to wait for state 0xde16 again. + +""" + +# TODO: how often is currdat.lst modified with/without hi-speed mode? +# TODO: thread locking around observation data +# TODO: eliminate polling, make MainThread get data as soon as RFThread updates +# TODO: get rid of Length/Buffer construct, replace with a Buffer class or obj + +# FIXME: the history retrieval assumes a constant archive interval across all +# history records. this means anything that modifies the archive +# interval should clear the history. + +import logging +import sys +import threading +import time +import usb +from datetime import datetime + +import weeutil.logger +import weeutil.weeutil +import weewx.drivers +import weewx.wxformulas + +log = logging.getLogger(__name__) + +DRIVER_NAME = 'WS28xx' +DRIVER_VERSION = '0.51' + + +def loader(config_dict, engine): + return WS28xxDriver(**config_dict[DRIVER_NAME]) + +def configurator_loader(config_dict): + return WS28xxConfigurator() + +def confeditor_loader(): + return WS28xxConfEditor() + + +# flags for enabling/disabling debug verbosity +DEBUG_COMM = 0 +DEBUG_CONFIG_DATA = 0 +DEBUG_WEATHER_DATA = 0 +DEBUG_HISTORY_DATA = 0 +DEBUG_DUMP_FORMAT = 'auto' + +def log_frame(n, buf): + log.debug('frame length is %d' % n) + strbuf = '' + for i in range(n): + strbuf += str('%02x ' % buf[i]) + if (i + 1) % 16 == 0: + log.debug(strbuf) + strbuf = '' + if strbuf: + log.debug(strbuf) + +def get_datum_diff(v, np, ofl): + if abs(np - v) < 0.001 or abs(ofl - v) < 0.001: + return None + return v + +def get_datum_match(v, np, ofl): + if np == v or ofl == v: + return None + return v + +def calc_checksum(buf, start, end=None): + if end is None: + end = len(buf[0]) - start + cs = 0 + for i in range(end): + cs += buf[0][i+start] + return cs + +def get_next_index(idx): + return get_index(idx + 1) + +def get_index(idx): + if idx < 0: + return idx + WS28xxDriver.max_records + elif idx >= WS28xxDriver.max_records: + return idx - WS28xxDriver.max_records + return idx + +def tstr_to_ts(tstr): + try: + return int(time.mktime(time.strptime(tstr, "%Y-%m-%d %H:%M:%S"))) + except (OverflowError, ValueError, TypeError): + pass + return None + +def bytes_to_addr(a, b, c): + return ((((a & 0xF) << 8) | b) << 8) | c + +def addr_to_index(addr): + return (addr - 416) // 18 + +def index_to_addr(idx): + return 18 * idx + 416 + +def print_dict(data): + for x in sorted(data.keys()): + if x == 'dateTime': + print('%s: %s' % (x, weeutil.weeutil.timestamp_to_string(data[x]))) + else: + print('%s: %s' % (x, data[x])) + + +class WS28xxConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[WS28xx] + # This section is for the La Crosse WS-2800 series of weather stations. + + # Radio frequency to use between USB transceiver and console: US or EU + # US uses 915 MHz, EU uses 868.3 MHz. Default is US. + transceiver_frequency = US + + # The station model, e.g., 'LaCrosse C86234' or 'TFA Primus' + model = LaCrosse WS28xx + + # The driver to use: + driver = weewx.drivers.ws28xx +""" + + def prompt_for_settings(self): + print("Specify the frequency used between the station and the") + print("transceiver, either 'US' (915 MHz) or 'EU' (868.3 MHz).") + freq = self._prompt('frequency', 'US', ['US', 'EU']) + return {'transceiver_frequency': freq} + + +class WS28xxConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super().add_options(parser) + parser.add_option("--check-transceiver", dest="check", + action="store_true", + help="check USB transceiver") + parser.add_option("--pair", dest="pair", action="store_true", + help="pair the USB transceiver with station console") + parser.add_option("--info", dest="info", action="store_true", + help="display weather station configuration") + parser.add_option("--set-interval", dest="interval", + type=int, metavar="N", + help="set logging interval to N minutes") + parser.add_option("--current", dest="current", action="store_true", + help="get the current weather conditions") + parser.add_option("--history", dest="nrecords", type=int, metavar="N", + help="display N history records") + parser.add_option("--history-since", dest="recmin", + type=int, metavar="N", + help="display history records since N minutes ago") + parser.add_option("--maxtries", dest="maxtries", type=int, + help="maximum number of retries, 0 indicates no max") + + def do_options(self, options, parser, config_dict, prompt): + maxtries = 3 if options.maxtries is None else int(options.maxtries) + self.station = WS28xxDriver(**config_dict[DRIVER_NAME]) + if options.check: + self.check_transceiver(maxtries) + elif options.pair: + self.pair(maxtries) + elif options.interval is not None: + self.set_interval(maxtries, options.interval, prompt) + elif options.current: + self.show_current(maxtries) + elif options.nrecords is not None: + self.show_history(maxtries, count=options.nrecords) + elif options.recmin is not None: + ts = int(time.time()) - options.recmin * 60 + self.show_history(maxtries, ts=ts) + else: + self.show_info(maxtries) + self.station.closePort() + + def check_transceiver(self, maxtries): + """See if the transceiver is installed and operational.""" + print('Checking for transceiver...') + ntries = 0 + while ntries < maxtries: + ntries += 1 + if self.station.transceiver_is_present(): + print('Transceiver is present') + sn = self.station.get_transceiver_serial() + print('serial: %s' % sn) + tid = self.station.get_transceiver_id() + print('id: %d (0x%04x)' % (tid, tid)) + break + print('Not found (attempt %d of %d) ...' % (ntries, maxtries)) + time.sleep(5) + else: + print('Transceiver not responding.') + + def pair(self, maxtries): + """Pair the transceiver with the station console.""" + print('Pairing transceiver with console...') + maxwait = 90 # how long to wait between button presses, in seconds + ntries = 0 + while ntries < maxtries or maxtries == 0: + if self.station.transceiver_is_paired(): + print('Transceiver is paired to console') + break + ntries += 1 + msg = 'Press and hold the [v] key until "PC" appears' + if maxtries > 0: + msg += ' (attempt %d of %d)' % (ntries, maxtries) + else: + msg += ' (attempt %d)' % ntries + print(msg) + now = start_ts = int(time.time()) + while (now - start_ts < maxwait and + not self.station.transceiver_is_paired()): + time.sleep(5) + now = int(time.time()) + else: + print('Transceiver not paired to console.') + + def get_interval(self, maxtries): + cfg = self.get_config(maxtries) + if cfg is None: + return None + return getHistoryInterval(cfg['history_interval']) + + def get_config(self, maxtries): + start_ts = None + ntries = 0 + while ntries < maxtries or maxtries == 0: + cfg = self.station.get_config() + if cfg is not None: + return cfg + ntries += 1 + if start_ts is None: + start_ts = int(time.time()) + else: + dur = int(time.time()) - start_ts + print('No data after %d seconds (press SET to sync)' % dur) + time.sleep(30) + return None + + def set_interval(self, maxtries, interval, prompt): + """Set the station archive interval""" + print("This feature is not yet implemented") + + def show_info(self, maxtries): + """Query the station then display the settings.""" + print('Querying the station for the configuration...') + cfg = self.get_config(maxtries) + if cfg is not None: + print_dict(cfg) + + def show_current(self, maxtries): + """Get current weather observation.""" + print('Querying the station for current weather data...') + start_ts = None + ntries = 0 + while ntries < maxtries or maxtries == 0: + packet = self.station.get_observation() + if packet is not None: + print_dict(packet) + break + ntries += 1 + if start_ts is None: + start_ts = int(time.time()) + else: + dur = int(time.time()) - start_ts + print('No data after %d seconds (press SET to sync)' % dur) + time.sleep(30) + + def show_history(self, maxtries, ts=0, count=0): + """Display the indicated number of records or the records since the + specified timestamp (local time, in seconds)""" + print("Querying the station for historical records...") + ntries = 0 + last_n = nrem = None + last_ts = int(time.time()) + self.station.start_caching_history(since_ts=ts, num_rec=count) + while nrem is None or nrem > 0: + if ntries >= maxtries: + print('Giving up after %d tries' % ntries) + break + time.sleep(30) + ntries += 1 + now = int(time.time()) + n = self.station.get_num_history_scanned() + if n == last_n: + dur = now - last_ts + print('No data after %d seconds (press SET to sync)' % dur) + else: + ntries = 0 + last_ts = now + last_n = n + nrem = self.station.get_uncached_history_count() + ni = self.station.get_next_history_index() + li = self.station.get_latest_history_index() + msg = " scanned %s records: current=%s latest=%s remaining=%s\r" % (n, ni, li, nrem) + sys.stdout.write(msg) + sys.stdout.flush() + self.station.stop_caching_history() + records = self.station.get_history_cache_records() + self.station.clear_history_cache() + print() + print('Found %d records' % len(records)) + for r in records: + print(r) + + +class WS28xxDriver(weewx.drivers.AbstractDevice): + """Driver for LaCrosse WS28xx stations.""" + + max_records = 1797 + + def __init__(self, **stn_dict) : + """Initialize the station object. + + model: Which station model is this? + [Optional. Default is 'LaCrosse WS28xx'] + + transceiver_frequency: Frequency for transceiver-to-console. Specify + either US or EU. + [Required. Default is US] + + polling_interval: How often to sample the USB interface for data. + [Optional. Default is 30 seconds] + + comm_interval: Communications mode interval + [Optional. Default is 3] + + device_id: The USB device ID for the transceiver. If there are + multiple devices with the same vendor and product IDs on the bus, + each will have a unique device identifier. Use this identifier + to indicate which device should be used. + [Optional. Default is None] + + serial: The transceiver serial number. If there are multiple + devices with the same vendor and product IDs on the bus, each will + have a unique serial number. Use the serial number to indicate which + transceiver should be used. + [Optional. Default is None] + """ + + self.model = stn_dict.get('model', 'LaCrosse WS28xx') + self.polling_interval = int(stn_dict.get('polling_interval', 30)) + self.comm_interval = int(stn_dict.get('comm_interval', 3)) + self.frequency = stn_dict.get('transceiver_frequency', 'US') + self.device_id = stn_dict.get('device_id', None) + self.serial = stn_dict.get('serial', None) + + self.vendor_id = 0x6666 + self.product_id = 0x5555 + + now = int(time.time()) + self._service = None + self._last_rain = None + self._last_obs_ts = None + self._last_nodata_log_ts = now + self._nodata_interval = 300 # how often to check for no data + self._last_contact_log_ts = now + self._nocontact_interval = 300 # how often to check for no contact + self._log_interval = 600 # how often to log + + global DEBUG_COMM + DEBUG_COMM = int(stn_dict.get('debug_comm', 0)) + global DEBUG_CONFIG_DATA + DEBUG_CONFIG_DATA = int(stn_dict.get('debug_config_data', 0)) + global DEBUG_WEATHER_DATA + DEBUG_WEATHER_DATA = int(stn_dict.get('debug_weather_data', 0)) + global DEBUG_HISTORY_DATA + DEBUG_HISTORY_DATA = int(stn_dict.get('debug_history_data', 0)) + global DEBUG_DUMP_FORMAT + DEBUG_DUMP_FORMAT = stn_dict.get('debug_dump_format', 'auto') + + log.info('driver version is %s' % DRIVER_VERSION) + log.info('frequency is %s' % self.frequency) + + self.startUp() + time.sleep(10) # give the rf thread time to start up + + @property + def hardware_name(self): + return self.model + + # this is invoked by StdEngine as it shuts down + def closePort(self): + self.shutDown() + + def genLoopPackets(self): + """Generator function that continuously returns decoded packets.""" + while self._service.isRunning(): + now = int(time.time()+0.5) + packet = self.get_observation() + if packet is not None: + ts = packet['dateTime'] + if self._last_obs_ts is None or self._last_obs_ts != ts: + self._last_obs_ts = ts + self._last_nodata_log_ts = now + self._last_contact_log_ts = now + else: + packet = None + + # if no new weather data, log it + if (packet is None + and (self._last_obs_ts is None + or now - self._last_obs_ts > self._nodata_interval) + and (now - self._last_nodata_log_ts > self._log_interval)): + msg = 'no new weather data' + if self._last_obs_ts is not None: + msg += ' after %d seconds' % (now - self._last_obs_ts) + log.info(msg) + self._last_nodata_log_ts = now + + # if no contact with console for awhile, log it + ts = self.get_last_contact() + if (ts is None or now - ts > self._nocontact_interval + and now - self._last_contact_log_ts > self._log_interval): + msg = 'no contact with console' + if ts is not None: + msg += ' after %d seconds' % (now - ts) + msg += ': press [SET] to sync' + log.info(msg) + self._last_contact_log_ts = now + + if packet is not None: + yield packet + time.sleep(self.polling_interval) + else: + raise weewx.WeeWxIOError('RF thread is not running') + + def genStartupRecords(self, ts): + log.info('Scanning historical records') + maxtries = 65 + ntries = 0 + last_n = n = nrem = None + last_ts = now = int(time.time()) + self.start_caching_history(since_ts=ts) + while nrem is None or nrem > 0: + if ntries >= maxtries: + log.error('No historical data after %d tries' % ntries) + return + time.sleep(60) + ntries += 1 + now = int(time.time()) + n = self.get_num_history_scanned() + if n == last_n: + dur = now - last_ts + if self._service.isRunning(): + log.info('No data after %d seconds (press SET to sync)' % dur) + else: + log.info('No data after %d seconds: RF thread is not running' % dur) + break + else: + ntries = 0 + last_ts = now + last_n = n + nrem = self.get_uncached_history_count() + ni = self.get_next_history_index() + li = self.get_latest_history_index() + log.info("Scanned %s records: current=%s latest=%s remaining=%s" + % (n, ni, li, nrem)) + self.stop_caching_history() + records = self.get_history_cache_records() + self.clear_history_cache() + log.info('Found %d historical records' % len(records)) + last_ts = None + for r in records: + if last_ts is not None and r['dateTime'] is not None: + r['usUnits'] = weewx.METRIC + # Calculate interval in minutes, rounding to the nearest minute + r['interval'] = int((r['dateTime'] - last_ts) / 60 + 0.5) + yield r + last_ts = r['dateTime'] + +# FIXME: do not implement hardware record generation until we figure +# out how to query the historical records faster. +# def genArchiveRecords(self, since_ts): +# pass + +# FIXME: implement retries for this so that rf thread has time to get +# configuration data from the station +# @property +# def archive_interval(self): +# cfg = self.get_config() +# return getHistoryInterval(cfg['history_interval']) * 60 + +# FIXME: implement set/get time +# def setTime(self): +# pass +# def getTime(self): +# pass + + def startUp(self): + if self._service is not None: + return + self._service = CCommunicationService() + self._service.setup(self.frequency, + self.vendor_id, self.product_id, self.device_id, + self.serial, comm_interval=self.comm_interval) + self._service.startRFThread() + + def shutDown(self): + self._service.stopRFThread() + self._service.teardown() + self._service = None + + def transceiver_is_present(self): + return self._service.DataStore.getTransceiverPresent() + + def transceiver_is_paired(self): + return self._service.DataStore.getDeviceRegistered() + + def get_transceiver_serial(self): + return self._service.DataStore.getTransceiverSerNo() + + def get_transceiver_id(self): + return self._service.DataStore.getDeviceID() + + def get_last_contact(self): + return self._service.getLastStat().last_seen_ts + + def get_observation(self): + data = self._service.getWeatherData() + ts = data._timestamp + if ts is None: + return None + + # add elements required for weewx LOOP packets + packet = {} + packet['usUnits'] = weewx.METRIC + packet['dateTime'] = ts + + # data from the station sensors + packet['inTemp'] = get_datum_diff(data._TempIndoor, + CWeatherTraits.TemperatureNP(), + CWeatherTraits.TemperatureOFL()) + packet['inHumidity'] = get_datum_diff(data._HumidityIndoor, + CWeatherTraits.HumidityNP(), + CWeatherTraits.HumidityOFL()) + packet['outTemp'] = get_datum_diff(data._TempOutdoor, + CWeatherTraits.TemperatureNP(), + CWeatherTraits.TemperatureOFL()) + packet['outHumidity'] = get_datum_diff(data._HumidityOutdoor, + CWeatherTraits.HumidityNP(), + CWeatherTraits.HumidityOFL()) + packet['pressure'] = get_datum_diff(data._PressureRelative_hPa, + CWeatherTraits.PressureNP(), + CWeatherTraits.PressureOFL()) + packet['windSpeed'] = get_datum_diff(data._WindSpeed, + CWeatherTraits.WindNP(), + CWeatherTraits.WindOFL()) + packet['windGust'] = get_datum_diff(data._Gust, + CWeatherTraits.WindNP(), + CWeatherTraits.WindOFL()) + + packet['windDir'] = getWindDir(data._WindDirection, + packet['windSpeed']) + packet['windGustDir'] = getWindDir(data._GustDirection, + packet['windGust']) + + # calculated elements not directly reported by station + packet['rainRate'] = get_datum_match(data._Rain1H, + CWeatherTraits.RainNP(), + CWeatherTraits.RainOFL()) + if packet['rainRate'] is not None: + packet['rainRate'] /= 10.0 # weewx wants cm/hr + rain_total = get_datum_match(data._RainTotal, + CWeatherTraits.RainNP(), + CWeatherTraits.RainOFL()) + delta = weewx.wxformulas.calculate_rain(rain_total, self._last_rain) + self._last_rain = rain_total + packet['rain'] = delta + if packet['rain'] is not None: + packet['rain'] /= 10.0 # weewx wants cm + + # track the signal strength and battery levels + laststat = self._service.getLastStat() + packet['rxCheckPercent'] = laststat.LastLinkQuality + packet['windBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'wind') + packet['rainBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'rain') + packet['outTempBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'th') + packet['inTempBatteryStatus'] = getBatteryStatus( + laststat.LastBatteryStatus, 'console') + + return packet + + def get_config(self): + log.debug('get station configuration') + cfg = self._service.getConfigData().asDict() + cs = cfg.get('checksum_out') + if cs is None or cs == 0: + return None + return cfg + + def start_caching_history(self, since_ts=0, num_rec=0): + self._service.startCachingHistory(since_ts, num_rec) + + def stop_caching_history(self): + self._service.stopCachingHistory() + + def get_uncached_history_count(self): + return self._service.getUncachedHistoryCount() + + def get_next_history_index(self): + return self._service.getNextHistoryIndex() + + def get_latest_history_index(self): + return self._service.getLatestHistoryIndex() + + def get_num_history_scanned(self): + return self._service.getNumHistoryScanned() + + def get_history_cache_records(self): + return self._service.getHistoryCacheRecords() + + def clear_history_cache(self): + self._service.clearHistoryCache() + + def set_interval(self, interval): + # FIXME: set the archive interval + pass + +# The following classes and methods are adapted from the implementation by +# eddie de pieri, which is in turn based on the HeavyWeather implementation. + +class BadResponse(Exception): + """raised when unexpected data found in frame buffer""" + pass + +class DataWritten(Exception): + """raised when message 'data written' in frame buffer""" + pass + +class BitHandling: + # return a nonzero result, 2**offset, if the bit at 'offset' is one. + @staticmethod + def testBit(int_type, offset): + mask = 1 << offset + return int_type & mask + + # return an integer with the bit at 'offset' set to 1. + @staticmethod + def setBit(int_type, offset): + mask = 1 << offset + return int_type | mask + + # return an integer with the bit at 'offset' set to 1. + @staticmethod + def setBitVal(int_type, offset, val): + mask = val << offset + return int_type | mask + + # return an integer with the bit at 'offset' cleared. + @staticmethod + def clearBit(int_type, offset): + mask = ~(1 << offset) + return int_type & mask + + # return an integer with the bit at 'offset' inverted, 0->1 and 1->0. + @staticmethod + def toggleBit(int_type, offset): + mask = 1 << offset + return int_type ^ mask + +class EHistoryInterval: + hi01Min = 0 + hi05Min = 1 + hi10Min = 2 + hi15Min = 3 + hi20Min = 4 + hi30Min = 5 + hi60Min = 6 + hi02Std = 7 + hi04Std = 8 + hi06Std = 9 + hi08Std = 0xA + hi12Std = 0xB + hi24Std = 0xC + +class EWindspeedFormat: + wfMs = 0 + wfKnots = 1 + wfBFT = 2 + wfKmh = 3 + wfMph = 4 + +class ERainFormat: + rfMm = 0 + rfInch = 1 + +class EPressureFormat: + pfinHg = 0 + pfHPa = 1 + +class ETemperatureFormat: + tfFahrenheit = 0 + tfCelsius = 1 + +class EClockMode: + ct24H = 0 + ctAmPm = 1 + +class EWeatherTendency: + TREND_NEUTRAL = 0 + TREND_UP = 1 + TREND_DOWN = 2 + TREND_ERR = 3 + +class EWeatherState: + WEATHER_BAD = 0 + WEATHER_NEUTRAL = 1 + WEATHER_GOOD = 2 + WEATHER_ERR = 3 + +class EWindDirection: + wdN = 0 + wdNNE = 1 + wdNE = 2 + wdENE = 3 + wdE = 4 + wdESE = 5 + wdSE = 6 + wdSSE = 7 + wdS = 8 + wdSSW = 9 + wdSW = 0x0A + wdWSW = 0x0B + wdW = 0x0C + wdWNW = 0x0D + wdNW = 0x0E + wdNNW = 0x0F + wdERR = 0x10 + wdInvalid = 0x11 + wdNone = 0x12 + +def getWindDir(wdir, wspeed): + if wspeed is None or wspeed == 0: + return None + if wdir < 0 or wdir >= 16: + return None + return wdir * 360.0 / 16 + +class EResetMinMaxFlags: + rmTempIndoorHi = 0 + rmTempIndoorLo = 1 + rmTempOutdoorHi = 2 + rmTempOutdoorLo = 3 + rmWindchillHi = 4 + rmWindchillLo = 5 + rmDewpointHi = 6 + rmDewpointLo = 7 + rmHumidityIndoorLo = 8 + rmHumidityIndoorHi = 9 + rmHumidityOutdoorLo = 0x0A + rmHumidityOutdoorHi = 0x0B + rmWindspeedHi = 0x0C + rmWindspeedLo = 0x0D + rmGustHi = 0x0E + rmGustLo = 0x0F + rmPressureLo = 0x10 + rmPressureHi = 0x11 + rmRain1hHi = 0x12 + rmRain24hHi = 0x13 + rmRainLastWeekHi = 0x14 + rmRainLastMonthHi = 0x15 + rmRainTotal = 0x16 + rmInvalid = 0x17 + +class ERequestType: + rtGetCurrent = 0 + rtGetHistory = 1 + rtGetConfig = 2 + rtSetConfig = 3 + rtSetTime = 4 + rtFirstConfig = 5 + rtINVALID = 6 + +class EAction: + aGetHistory = 0 + aReqSetTime = 1 + aReqSetConfig = 2 + aGetConfig = 3 + aGetCurrent = 5 + aSendTime = 0xc0 + aSendConfig = 0x40 + +class ERequestState: + rsQueued = 0 + rsRunning = 1 + rsFinished = 2 + rsPreamble = 3 + rsWaitDevice = 4 + rsWaitConfig = 5 + rsError = 6 + rsChanged = 7 + rsINVALID = 8 + +class EResponseType: + rtDataWritten = 0x20 + rtGetConfig = 0x40 + rtGetCurrentWeather = 0x60 + rtGetHistory = 0x80 + rtRequest = 0xa0 + rtReqFirstConfig = 0xa1 + rtReqSetConfig = 0xa2 + rtReqSetTime = 0xa3 + +# frequency standards and their associated transmission frequencies +class EFrequency: + fsUS = 'US' + tfUS = 905000000 + fsEU = 'EU' + tfEU = 868300000 + +def getFrequency(standard): + if standard == EFrequency.fsUS: + return EFrequency.tfUS + elif standard == EFrequency.fsEU: + return EFrequency.tfEU + log.error("unknown frequency standard '%s', using US" % standard) + return EFrequency.tfUS + +def getFrequencyStandard(frequency): + if frequency == EFrequency.tfUS: + return EFrequency.fsUS + elif frequency == EFrequency.tfEU: + return EFrequency.fsEU + log.error("unknown frequency '%s', using US" % frequency) + return EFrequency.fsUS + +# bit value battery_flag +# 0 1 thermo/hygro +# 1 2 rain +# 2 4 wind +# 3 8 console + +batterybits = {'th':0, 'rain':1, 'wind':2, 'console':3} + +def getBatteryStatus(status, flag): + """Return 1 if bit is set, 0 otherwise""" + bit = batterybits.get(flag) + if bit is None: + return None + if BitHandling.testBit(status, bit): + return 1 + return 0 + +history_intervals = { + EHistoryInterval.hi01Min: 1, + EHistoryInterval.hi05Min: 5, + EHistoryInterval.hi10Min: 10, + EHistoryInterval.hi20Min: 20, + EHistoryInterval.hi30Min: 30, + EHistoryInterval.hi60Min: 60, + EHistoryInterval.hi02Std: 120, + EHistoryInterval.hi04Std: 240, + EHistoryInterval.hi06Std: 360, + EHistoryInterval.hi08Std: 480, + EHistoryInterval.hi12Std: 720, + EHistoryInterval.hi24Std: 1440, + } + +def getHistoryInterval(i): + return history_intervals.get(i) + +# NP - not present +# OFL - outside factory limits +class CWeatherTraits(object): + windDirMap = { + 0: "N", 1: "NNE", 2: "NE", 3: "ENE", 4: "E", 5: "ESE", 6: "SE", + 7: "SSE", 8: "S", 9: "SSW", 10: "SW", 11: "WSW", 12: "W", + 13: "WNW", 14: "NW", 15: "NWN", 16: "err", 17: "inv", 18: "None" } + forecastMap = { + 0: "Rainy(Bad)", 1: "Cloudy(Neutral)", 2: "Sunny(Good)", 3: "Error" } + trendMap = { + 0: "Stable(Neutral)", 1: "Rising(Up)", 2: "Falling(Down)", 3: "Error" } + + @staticmethod + def TemperatureNP(): + return 81.099998 + + @staticmethod + def TemperatureOFL(): + return 136.0 + + @staticmethod + def PressureNP(): + return 10101010.0 + + @staticmethod + def PressureOFL(): + return 16666.5 + + @staticmethod + def HumidityNP(): + return 110.0 + + @staticmethod + def HumidityOFL(): + return 121.0 + + @staticmethod + def RainNP(): + return -0.2 + + @staticmethod + def RainOFL(): + return 16666.664 + + @staticmethod + def WindNP(): + return 183.6 # km/h = 51.0 m/s + + @staticmethod + def WindOFL(): + return 183.96 # km/h = 51.099998 m/s + + @staticmethod + def TemperatureOffset(): + return 40.0 + +class CMeasurement: + _Value = 0.0 + _ResetFlag = 23 + _IsError = 1 + _IsOverflow = 1 + _Time = None + + def Reset(self): + self._Value = 0.0 + self._ResetFlag = 23 + self._IsError = 1 + self._IsOverflow = 1 + +class CMinMaxMeasurement(object): + def __init__(self): + self._Min = CMeasurement() + self._Max = CMeasurement() + +# firmware XXX has bogus date values for these fields +_bad_labels = ['RainLastMonthMax','RainLastWeekMax','PressureRelativeMin'] + +class USBHardware(object): + @staticmethod + def isOFL2(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) == 15 \ + or (buf[0][start+0] & 0xF) == 15 + else: + result = (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 + return result + + @staticmethod + def isOFL3(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) == 15 \ + or (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 + else: + result = (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 \ + or (buf[0][start+1] & 0xF) == 15 + return result + + @staticmethod + def isOFL5(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) == 15 \ + or (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 \ + or (buf[0][start+1] & 0xF) == 15 \ + or (buf[0][start+2] >> 4) == 15 + else: + result = (buf[0][start+0] & 0xF) == 15 \ + or (buf[0][start+1] >> 4) == 15 \ + or (buf[0][start+1] & 0xF) == 15 \ + or (buf[0][start+2] >> 4) == 15 \ + or (buf[0][start+2] & 0xF) == 15 + return result + + @staticmethod + def isErr2(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) >= 10 \ + and (buf[0][start+0] >> 4) != 15 \ + or (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 + else: + result = (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 + return result + + @staticmethod + def isErr3(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) >= 10 \ + and (buf[0][start+0] >> 4) != 15 \ + or (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 + else: + result = (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 \ + or (buf[0][start+1] & 0xF) >= 10 \ + and (buf[0][start+1] & 0xF) != 15 + return result + + @staticmethod + def isErr5(buf, start, StartOnHiNibble): + if StartOnHiNibble: + result = (buf[0][start+0] >> 4) >= 10 \ + and (buf[0][start+0] >> 4) != 15 \ + or (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 \ + or (buf[0][start+1] & 0xF) >= 10 \ + and (buf[0][start+1] & 0xF) != 15 \ + or (buf[0][start+2] >> 4) >= 10 \ + and (buf[0][start+2] >> 4) != 15 + else: + result = (buf[0][start+0] & 0xF) >= 10 \ + and (buf[0][start+0] & 0xF) != 15 \ + or (buf[0][start+1] >> 4) >= 10 \ + and (buf[0][start+1] >> 4) != 15 \ + or (buf[0][start+1] & 0xF) >= 10 \ + and (buf[0][start+1] & 0xF) != 15 \ + or (buf[0][start+2] >> 4) >= 10 \ + and (buf[0][start+2] >> 4) != 15 \ + or (buf[0][start+2] & 0xF) >= 10 \ + and (buf[0][start+2] & 0xF) != 15 + return result + + @staticmethod + def reverseByteOrder(buf, start, Count): + nbuf=buf[0] + for i in range(Count >> 1): + tmp = nbuf[start + i] + nbuf[start + i] = nbuf[start + Count - i - 1] + nbuf[start + Count - i - 1 ] = tmp + buf[0]=nbuf + + @staticmethod + def readWindDirectionShared(buf, start): + return (buf[0][0+start] & 0xF, buf[0][start] >> 4) + + @staticmethod + def toInt_2(buf, start, StartOnHiNibble): + """read 2 nibbles""" + if StartOnHiNibble: + rawpre = (buf[0][start+0] >> 4)* 10 \ + + (buf[0][start+0] & 0xF)* 1 + else: + rawpre = (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 + return rawpre + + @staticmethod + def toRain_7_3(buf, start, StartOnHiNibble): + """read 7 nibbles, presentation with 3 decimals; units of mm""" + if (USBHardware.isErr2(buf, start+0, StartOnHiNibble) or + USBHardware.isErr5(buf, start+1, StartOnHiNibble)): + result = CWeatherTraits.RainNP() + elif (USBHardware.isOFL2(buf, start+0, StartOnHiNibble) or + USBHardware.isOFL5(buf, start+1, StartOnHiNibble)): + result = CWeatherTraits.RainOFL() + elif StartOnHiNibble: + result = (buf[0][start+0] >> 4)* 1000 \ + + (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 \ + + (buf[0][start+2] & 0xF)* 0.01 \ + + (buf[0][start+3] >> 4)* 0.001 + else: + result = (buf[0][start+0] & 0xF)* 1000 \ + + (buf[0][start+1] >> 4)* 100 \ + + (buf[0][start+1] & 0xF)* 10 \ + + (buf[0][start+2] >> 4)* 1 \ + + (buf[0][start+2] & 0xF)* 0.1 \ + + (buf[0][start+3] >> 4)* 0.01 \ + + (buf[0][start+3] & 0xF)* 0.001 + return result + + @staticmethod + def toRain_6_2(buf, start, StartOnHiNibble): + '''read 6 nibbles, presentation with 2 decimals; units of mm''' + if (USBHardware.isErr2(buf, start+0, StartOnHiNibble) or + USBHardware.isErr2(buf, start+1, StartOnHiNibble) or + USBHardware.isErr2(buf, start+2, StartOnHiNibble) ): + result = CWeatherTraits.RainNP() + elif (USBHardware.isOFL2(buf, start+0, StartOnHiNibble) or + USBHardware.isOFL2(buf, start+1, StartOnHiNibble) or + USBHardware.isOFL2(buf, start+2, StartOnHiNibble)): + result = CWeatherTraits.RainOFL() + elif StartOnHiNibble: + result = (buf[0][start+0] >> 4)* 1000 \ + + (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 \ + + (buf[0][start+2] & 0xF)* 0.01 + else: + result = (buf[0][start+0] & 0xF)* 1000 \ + + (buf[0][start+1] >> 4)* 100 \ + + (buf[0][start+1] & 0xF)* 10 \ + + (buf[0][start+2] >> 4)* 1 \ + + (buf[0][start+2] & 0xF)* 0.1 \ + + (buf[0][start+3] >> 4)* 0.01 + return result + + @staticmethod + def toRain_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal; units of 0.1 inch""" + if StartOnHiNibble: + hibyte = buf[0][start+0] + lobyte = (buf[0][start+1] >> 4) & 0xF + else: + hibyte = 16*(buf[0][start+0] & 0xF) + ((buf[0][start+1] >> 4) & 0xF) + lobyte = buf[0][start+1] & 0xF + if hibyte == 0xFF and lobyte == 0xE : + result = CWeatherTraits.RainNP() + elif hibyte == 0xFF and lobyte == 0xF : + result = CWeatherTraits.RainOFL() + else: + val = USBHardware.toFloat_3_1(buf, start, StartOnHiNibble) # 0.1 inch + result = val * 2.54 # mm + return result + + @staticmethod + def toFloat_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal""" + if StartOnHiNibble: + result = (buf[0][start+0] >> 4)*16**2 \ + + (buf[0][start+0] & 0xF)* 16**1 \ + + (buf[0][start+1] >> 4)* 16**0 + else: + result = (buf[0][start+0] & 0xF)*16**2 \ + + (buf[0][start+1] >> 4)* 16**1 \ + + (buf[0][start+1] & 0xF)* 16**0 + result = result / 10.0 + return result + + @staticmethod + def toDateTime(buf, start, StartOnHiNibble, label): + """read 10 nibbles, presentation as DateTime""" + result = None + if (USBHardware.isErr2(buf, start+0, StartOnHiNibble) + or USBHardware.isErr2(buf, start+1, StartOnHiNibble) + or USBHardware.isErr2(buf, start+2, StartOnHiNibble) + or USBHardware.isErr2(buf, start+3, StartOnHiNibble) + or USBHardware.isErr2(buf, start+4, StartOnHiNibble)): + log.error('ToDateTime: bogus date for %s: error status in buffer' + % label) + else: + year = USBHardware.toInt_2(buf, start+0, StartOnHiNibble) + 2000 + month = USBHardware.toInt_2(buf, start+1, StartOnHiNibble) + days = USBHardware.toInt_2(buf, start+2, StartOnHiNibble) + hours = USBHardware.toInt_2(buf, start+3, StartOnHiNibble) + minutes = USBHardware.toInt_2(buf, start+4, StartOnHiNibble) + try: + result = datetime(year, month, days, hours, minutes) + except ValueError: + if label not in _bad_labels: + log.error('ToDateTime: bogus date for %s:' + ' bad date conversion from' + ' %s %s %s %s %s' + % (label, minutes, hours, days, month, year)) + if result is None: + # FIXME: use None instead of a really old date to indicate invalid + result = datetime(1900, 0o1, 0o1, 00, 00) + return result + + @staticmethod + def toHumidity_2_0(buf, start, StartOnHiNibble): + """read 2 nibbles, presentation with 0 decimal""" + if USBHardware.isErr2(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.HumidityNP() + elif USBHardware.isOFL2(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.HumidityOFL() + else: + result = USBHardware.toInt_2(buf, start, StartOnHiNibble) + return result + + @staticmethod + def toTemperature_5_3(buf, start, StartOnHiNibble): + """read 5 nibbles, presentation with 3 decimals; units of degree C""" + if USBHardware.isErr5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureNP() + elif USBHardware.isOFL5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureOFL() + else: + if StartOnHiNibble: + rawtemp = (buf[0][start+0] >> 4)* 10 \ + + (buf[0][start+0] & 0xF)* 1 \ + + (buf[0][start+1] >> 4)* 0.1 \ + + (buf[0][start+1] & 0xF)* 0.01 \ + + (buf[0][start+2] >> 4)* 0.001 + else: + rawtemp = (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 \ + + (buf[0][start+1] & 0xF)* 0.1 \ + + (buf[0][start+2] >> 4)* 0.01 \ + + (buf[0][start+2] & 0xF)* 0.001 + result = rawtemp - CWeatherTraits.TemperatureOffset() + return result + + @staticmethod + def toTemperature_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal; units of degree C""" + if USBHardware.isErr3(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureNP() + elif USBHardware.isOFL3(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.TemperatureOFL() + else: + if StartOnHiNibble : + rawtemp = (buf[0][start+0] >> 4)* 10 \ + + (buf[0][start+0] & 0xF)* 1 \ + + (buf[0][start+1] >> 4)* 0.1 + else: + rawtemp = (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 \ + + (buf[0][start+1] & 0xF)* 0.1 + result = rawtemp - CWeatherTraits.TemperatureOffset() + return result + + @staticmethod + def toWindspeed_6_2(buf, start): + """read 6 nibbles, presentation with 2 decimals; units of km/h""" + result = (buf[0][start+0] >> 4)* 16**5 \ + + (buf[0][start+0] & 0xF)* 16**4 \ + + (buf[0][start+1] >> 4)* 16**3 \ + + (buf[0][start+1] & 0xF)* 16**2 \ + + (buf[0][start+2] >> 4)* 16**1 \ + + (buf[0][start+2] & 0xF) + result /= 256.0 + result /= 100.0 # km/h + return result + + @staticmethod + def toWindspeed_3_1(buf, start, StartOnHiNibble): + """read 3 nibbles, presentation with 1 decimal; units of m/s""" + if StartOnHiNibble : + hibyte = buf[0][start+0] + lobyte = (buf[0][start+1] >> 4) & 0xF + else: + hibyte = 16*(buf[0][start+0] & 0xF) + ((buf[0][start+1] >> 4) & 0xF) + lobyte = buf[0][start+1] & 0xF + if hibyte == 0xFF and lobyte == 0xE: + result = CWeatherTraits.WindNP() + elif hibyte == 0xFF and lobyte == 0xF: + result = CWeatherTraits.WindOFL() + else: + result = USBHardware.toFloat_3_1(buf, start, StartOnHiNibble) # m/s + result *= 3.6 # km/h + return result + + @staticmethod + def readPressureShared(buf, start, StartOnHiNibble): + return (USBHardware.toPressure_hPa_5_1(buf,start+2,1-StartOnHiNibble), + USBHardware.toPressure_inHg_5_2(buf,start,StartOnHiNibble)) + + @staticmethod + def toPressure_hPa_5_1(buf, start, StartOnHiNibble): + """read 5 nibbles, presentation with 1 decimal; units of hPa (mbar)""" + if USBHardware.isErr5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureNP() + elif USBHardware.isOFL5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureOFL() + elif StartOnHiNibble : + result = (buf[0][start+0] >> 4)* 1000 \ + + (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 + else: + result = (buf[0][start+0] & 0xF)* 1000 \ + + (buf[0][start+1] >> 4)* 100 \ + + (buf[0][start+1] & 0xF)* 10 \ + + (buf[0][start+2] >> 4)* 1 \ + + (buf[0][start+2] & 0xF)* 0.1 + return result + + @staticmethod + def toPressure_inHg_5_2(buf, start, StartOnHiNibble): + """read 5 nibbles, presentation with 2 decimals; units of inHg""" + if USBHardware.isErr5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureNP() + elif USBHardware.isOFL5(buf, start+0, StartOnHiNibble): + result = CWeatherTraits.PressureOFL() + elif StartOnHiNibble : + result = (buf[0][start+0] >> 4)* 100 \ + + (buf[0][start+0] & 0xF)* 10 \ + + (buf[0][start+1] >> 4)* 1 \ + + (buf[0][start+1] & 0xF)* 0.1 \ + + (buf[0][start+2] >> 4)* 0.01 + else: + result = (buf[0][start+0] & 0xF)* 100 \ + + (buf[0][start+1] >> 4)* 10 \ + + (buf[0][start+1] & 0xF)* 1 \ + + (buf[0][start+2] >> 4)* 0.1 \ + + (buf[0][start+2] & 0xF)* 0.01 + return result + + +class CCurrentWeatherData(object): + + def __init__(self): + self._timestamp = None + self._checksum = None + self._PressureRelative_hPa = CWeatherTraits.PressureNP() + self._PressureRelative_hPaMinMax = CMinMaxMeasurement() + self._PressureRelative_inHg = CWeatherTraits.PressureNP() + self._PressureRelative_inHgMinMax = CMinMaxMeasurement() + self._WindSpeed = CWeatherTraits.WindNP() + self._WindDirection = EWindDirection.wdNone + self._WindDirection1 = EWindDirection.wdNone + self._WindDirection2 = EWindDirection.wdNone + self._WindDirection3 = EWindDirection.wdNone + self._WindDirection4 = EWindDirection.wdNone + self._WindDirection5 = EWindDirection.wdNone + self._Gust = CWeatherTraits.WindNP() + self._GustMax = CMinMaxMeasurement() + self._GustDirection = EWindDirection.wdNone + self._GustDirection1 = EWindDirection.wdNone + self._GustDirection2 = EWindDirection.wdNone + self._GustDirection3 = EWindDirection.wdNone + self._GustDirection4 = EWindDirection.wdNone + self._GustDirection5 = EWindDirection.wdNone + self._Rain1H = CWeatherTraits.RainNP() + self._Rain1HMax = CMinMaxMeasurement() + self._Rain24H = CWeatherTraits.RainNP() + self._Rain24HMax = CMinMaxMeasurement() + self._RainLastWeek = CWeatherTraits.RainNP() + self._RainLastWeekMax = CMinMaxMeasurement() + self._RainLastMonth = CWeatherTraits.RainNP() + self._RainLastMonthMax = CMinMaxMeasurement() + self._RainTotal = CWeatherTraits.RainNP() + self._LastRainReset = None + self._TempIndoor = CWeatherTraits.TemperatureNP() + self._TempIndoorMinMax = CMinMaxMeasurement() + self._TempOutdoor = CWeatherTraits.TemperatureNP() + self._TempOutdoorMinMax = CMinMaxMeasurement() + self._HumidityIndoor = CWeatherTraits.HumidityNP() + self._HumidityIndoorMinMax = CMinMaxMeasurement() + self._HumidityOutdoor = CWeatherTraits.HumidityNP() + self._HumidityOutdoorMinMax = CMinMaxMeasurement() + self._Dewpoint = CWeatherTraits.TemperatureNP() + self._DewpointMinMax = CMinMaxMeasurement() + self._Windchill = CWeatherTraits.TemperatureNP() + self._WindchillMinMax = CMinMaxMeasurement() + self._WeatherState = EWeatherState.WEATHER_ERR + self._WeatherTendency = EWeatherTendency.TREND_ERR + self._AlarmRingingFlags = 0 + self._AlarmMarkedFlags = 0 + self._PresRel_hPa_Max = 0.0 + self._PresRel_inHg_Max = 0.0 + + @staticmethod + def calcChecksum(buf): + return calc_checksum(buf, 6) + + def checksum(self): + return self._checksum + + def read(self, buf): + self._timestamp = int(time.time() + 0.5) + self._checksum = CCurrentWeatherData.calcChecksum(buf) + + nbuf = [0] + nbuf[0] = buf[0] + self._StartBytes = nbuf[0][6]*0xF + nbuf[0][7] # FIXME: what is this? + self._WeatherTendency = (nbuf[0][8] >> 4) & 0xF + if self._WeatherTendency > 3: + self._WeatherTendency = 3 + self._WeatherState = nbuf[0][8] & 0xF + if self._WeatherState > 3: + self._WeatherState = 3 + + self._TempIndoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 19, 0) + self._TempIndoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 22, 1) + self._TempIndoor = USBHardware.toTemperature_5_3(nbuf, 24, 0) + self._TempIndoorMinMax._Min._IsError = (self._TempIndoorMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._TempIndoorMinMax._Min._IsOverflow = (self._TempIndoorMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._TempIndoorMinMax._Max._IsError = (self._TempIndoorMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._TempIndoorMinMax._Max._IsOverflow = (self._TempIndoorMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._TempIndoorMinMax._Max._Time = None if self._TempIndoorMinMax._Max._IsError or self._TempIndoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 9, 0, 'TempIndoorMax') + self._TempIndoorMinMax._Min._Time = None if self._TempIndoorMinMax._Min._IsError or self._TempIndoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 14, 0, 'TempIndoorMin') + + self._TempOutdoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 37, 0) + self._TempOutdoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 40, 1) + self._TempOutdoor = USBHardware.toTemperature_5_3(nbuf, 42, 0) + self._TempOutdoorMinMax._Min._IsError = (self._TempOutdoorMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._TempOutdoorMinMax._Min._IsOverflow = (self._TempOutdoorMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._TempOutdoorMinMax._Max._IsError = (self._TempOutdoorMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._TempOutdoorMinMax._Max._IsOverflow = (self._TempOutdoorMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._TempOutdoorMinMax._Max._Time = None if self._TempOutdoorMinMax._Max._IsError or self._TempOutdoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 27, 0, 'TempOutdoorMax') + self._TempOutdoorMinMax._Min._Time = None if self._TempOutdoorMinMax._Min._IsError or self._TempOutdoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 32, 0, 'TempOutdoorMin') + + self._WindchillMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 55, 0) + self._WindchillMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 58, 1) + self._Windchill = USBHardware.toTemperature_5_3(nbuf, 60, 0) + self._WindchillMinMax._Min._IsError = (self._WindchillMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._WindchillMinMax._Min._IsOverflow = (self._WindchillMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._WindchillMinMax._Max._IsError = (self._WindchillMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._WindchillMinMax._Max._IsOverflow = (self._WindchillMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._WindchillMinMax._Max._Time = None if self._WindchillMinMax._Max._IsError or self._WindchillMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 45, 0, 'WindchillMax') + self._WindchillMinMax._Min._Time = None if self._WindchillMinMax._Min._IsError or self._WindchillMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 50, 0, 'WindchillMin') + + self._DewpointMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 73, 0) + self._DewpointMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 76, 1) + self._Dewpoint = USBHardware.toTemperature_5_3(nbuf, 78, 0) + self._DewpointMinMax._Min._IsError = (self._DewpointMinMax._Min._Value == CWeatherTraits.TemperatureNP()) + self._DewpointMinMax._Min._IsOverflow = (self._DewpointMinMax._Min._Value == CWeatherTraits.TemperatureOFL()) + self._DewpointMinMax._Max._IsError = (self._DewpointMinMax._Max._Value == CWeatherTraits.TemperatureNP()) + self._DewpointMinMax._Max._IsOverflow = (self._DewpointMinMax._Max._Value == CWeatherTraits.TemperatureOFL()) + self._DewpointMinMax._Min._Time = None if self._DewpointMinMax._Min._IsError or self._DewpointMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 68, 0, 'DewpointMin') + self._DewpointMinMax._Max._Time = None if self._DewpointMinMax._Max._IsError or self._DewpointMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 63, 0, 'DewpointMax') + + self._HumidityIndoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 91, 1) + self._HumidityIndoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 92, 1) + self._HumidityIndoor = USBHardware.toHumidity_2_0(nbuf, 93, 1) + self._HumidityIndoorMinMax._Min._IsError = (self._HumidityIndoorMinMax._Min._Value == CWeatherTraits.HumidityNP()) + self._HumidityIndoorMinMax._Min._IsOverflow = (self._HumidityIndoorMinMax._Min._Value == CWeatherTraits.HumidityOFL()) + self._HumidityIndoorMinMax._Max._IsError = (self._HumidityIndoorMinMax._Max._Value == CWeatherTraits.HumidityNP()) + self._HumidityIndoorMinMax._Max._IsOverflow = (self._HumidityIndoorMinMax._Max._Value == CWeatherTraits.HumidityOFL()) + self._HumidityIndoorMinMax._Max._Time = None if self._HumidityIndoorMinMax._Max._IsError or self._HumidityIndoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 81, 1, 'HumidityIndoorMax') + self._HumidityIndoorMinMax._Min._Time = None if self._HumidityIndoorMinMax._Min._IsError or self._HumidityIndoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 86, 1, 'HumidityIndoorMin') + + self._HumidityOutdoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 104, 1) + self._HumidityOutdoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 105, 1) + self._HumidityOutdoor = USBHardware.toHumidity_2_0(nbuf, 106, 1) + self._HumidityOutdoorMinMax._Min._IsError = (self._HumidityOutdoorMinMax._Min._Value == CWeatherTraits.HumidityNP()) + self._HumidityOutdoorMinMax._Min._IsOverflow = (self._HumidityOutdoorMinMax._Min._Value == CWeatherTraits.HumidityOFL()) + self._HumidityOutdoorMinMax._Max._IsError = (self._HumidityOutdoorMinMax._Max._Value == CWeatherTraits.HumidityNP()) + self._HumidityOutdoorMinMax._Max._IsOverflow = (self._HumidityOutdoorMinMax._Max._Value == CWeatherTraits.HumidityOFL()) + self._HumidityOutdoorMinMax._Max._Time = None if self._HumidityOutdoorMinMax._Max._IsError or self._HumidityOutdoorMinMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 94, 1, 'HumidityOutdoorMax') + self._HumidityOutdoorMinMax._Min._Time = None if self._HumidityOutdoorMinMax._Min._IsError or self._HumidityOutdoorMinMax._Min._IsOverflow else USBHardware.toDateTime(nbuf, 99, 1, 'HumidityOutdoorMin') + + self._RainLastMonthMax._Max._Time = USBHardware.toDateTime(nbuf, 107, 1, 'RainLastMonthMax') + self._RainLastMonthMax._Max._Value = USBHardware.toRain_6_2(nbuf, 112, 1) + self._RainLastMonth = USBHardware.toRain_6_2(nbuf, 115, 1) + + self._RainLastWeekMax._Max._Time = USBHardware.toDateTime(nbuf, 118, 1, 'RainLastWeekMax') + self._RainLastWeekMax._Max._Value = USBHardware.toRain_6_2(nbuf, 123, 1) + self._RainLastWeek = USBHardware.toRain_6_2(nbuf, 126, 1) + + self._Rain24HMax._Max._Time = USBHardware.toDateTime(nbuf, 129, 1, 'Rain24HMax') + self._Rain24HMax._Max._Value = USBHardware.toRain_6_2(nbuf, 134, 1) + self._Rain24H = USBHardware.toRain_6_2(nbuf, 137, 1) + + self._Rain1HMax._Max._Time = USBHardware.toDateTime(nbuf, 140, 1, 'Rain1HMax') + self._Rain1HMax._Max._Value = USBHardware.toRain_6_2(nbuf, 145, 1) + self._Rain1H = USBHardware.toRain_6_2(nbuf, 148, 1) + + self._LastRainReset = USBHardware.toDateTime(nbuf, 151, 0, 'LastRainReset') + self._RainTotal = USBHardware.toRain_7_3(nbuf, 156, 0) + + (w ,w1) = USBHardware.readWindDirectionShared(nbuf, 162) + (w2,w3) = USBHardware.readWindDirectionShared(nbuf, 161) + (w4,w5) = USBHardware.readWindDirectionShared(nbuf, 160) + self._WindDirection = w + self._WindDirection1 = w1 + self._WindDirection2 = w2 + self._WindDirection3 = w3 + self._WindDirection4 = w4 + self._WindDirection5 = w5 + + if DEBUG_WEATHER_DATA > 2: + unknownbuf = [0]*9 + for i in range(9): + unknownbuf[i] = nbuf[163+i] + strbuf = "" + for i in unknownbuf: + strbuf += str("%.2x " % i) + log.debug('Bytes with unknown meaning at 157-165: %s' % strbuf) + + self._WindSpeed = USBHardware.toWindspeed_6_2(nbuf, 172) + + # FIXME: read the WindErrFlags + (g ,g1) = USBHardware.readWindDirectionShared(nbuf, 177) + (g2,g3) = USBHardware.readWindDirectionShared(nbuf, 176) + (g4,g5) = USBHardware.readWindDirectionShared(nbuf, 175) + self._GustDirection = g + self._GustDirection1 = g1 + self._GustDirection2 = g2 + self._GustDirection3 = g3 + self._GustDirection4 = g4 + self._GustDirection5 = g5 + + self._GustMax._Max._Value = USBHardware.toWindspeed_6_2(nbuf, 184) + self._GustMax._Max._IsError = (self._GustMax._Max._Value == CWeatherTraits.WindNP()) + self._GustMax._Max._IsOverflow = (self._GustMax._Max._Value == CWeatherTraits.WindOFL()) + self._GustMax._Max._Time = None if self._GustMax._Max._IsError or self._GustMax._Max._IsOverflow else USBHardware.toDateTime(nbuf, 179, 1, 'GustMax') + self._Gust = USBHardware.toWindspeed_6_2(nbuf, 187) + + # Apparently the station returns only ONE date time for both hPa/inHg + # Min Time Reset and Max Time Reset + self._PressureRelative_hPaMinMax._Max._Time = USBHardware.toDateTime(nbuf, 190, 1, 'PressureRelative_hPaMax') + self._PressureRelative_inHgMinMax._Max._Time = self._PressureRelative_hPaMinMax._Max._Time + self._PressureRelative_hPaMinMax._Min._Time = self._PressureRelative_hPaMinMax._Max._Time # firmware bug, should be: USBHardware.toDateTime(nbuf, 195, 1) + self._PressureRelative_inHgMinMax._Min._Time = self._PressureRelative_hPaMinMax._Min._Time + + (self._PresRel_hPa_Max, self._PresRel_inHg_Max) = USBHardware.readPressureShared(nbuf, 195, 1) # firmware bug, should be: self._PressureRelative_hPaMinMax._Min._Time + (self._PressureRelative_hPaMinMax._Max._Value, self._PressureRelative_inHgMinMax._Max._Value) = USBHardware.readPressureShared(nbuf, 200, 1) + (self._PressureRelative_hPaMinMax._Min._Value, self._PressureRelative_inHgMinMax._Min._Value) = USBHardware.readPressureShared(nbuf, 205, 1) + (self._PressureRelative_hPa, self._PressureRelative_inHg) = USBHardware.readPressureShared(nbuf, 210, 1) + + def toLog(self): + log.debug("_WeatherState=%s _WeatherTendency=%s _AlarmRingingFlags %04x" % (CWeatherTraits.forecastMap[self._WeatherState], CWeatherTraits.trendMap[self._WeatherTendency], self._AlarmRingingFlags)) + log.debug("_TempIndoor= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._TempIndoor, self._TempIndoorMinMax._Min._Value, self._TempIndoorMinMax._Min._Time, self._TempIndoorMinMax._Max._Value, self._TempIndoorMinMax._Max._Time)) + log.debug("_HumidityIndoor= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._HumidityIndoor, self._HumidityIndoorMinMax._Min._Value, self._HumidityIndoorMinMax._Min._Time, self._HumidityIndoorMinMax._Max._Value, self._HumidityIndoorMinMax._Max._Time)) + log.debug("_TempOutdoor= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._TempOutdoor, self._TempOutdoorMinMax._Min._Value, self._TempOutdoorMinMax._Min._Time, self._TempOutdoorMinMax._Max._Value, self._TempOutdoorMinMax._Max._Time)) + log.debug("_HumidityOutdoor=%8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._HumidityOutdoor, self._HumidityOutdoorMinMax._Min._Value, self._HumidityOutdoorMinMax._Min._Time, self._HumidityOutdoorMinMax._Max._Value, self._HumidityOutdoorMinMax._Max._Time)) + log.debug("_Windchill= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._Windchill, self._WindchillMinMax._Min._Value, self._WindchillMinMax._Min._Time, self._WindchillMinMax._Max._Value, self._WindchillMinMax._Max._Time)) + log.debug("_Dewpoint= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s)" % (self._Dewpoint, self._DewpointMinMax._Min._Value, self._DewpointMinMax._Min._Time, self._DewpointMinMax._Max._Value, self._DewpointMinMax._Max._Time)) + log.debug("_WindSpeed= %8.3f" % self._WindSpeed) + log.debug("_Gust= %8.3f _Max=%8.3f (%s)" % (self._Gust, self._GustMax._Max._Value, self._GustMax._Max._Time)) + log.debug('_WindDirection= %3s _GustDirection= %3s' % (CWeatherTraits.windDirMap[self._WindDirection], CWeatherTraits.windDirMap[self._GustDirection])) + log.debug('_WindDirection1= %3s _GustDirection1= %3s' % (CWeatherTraits.windDirMap[self._WindDirection1], CWeatherTraits.windDirMap[self._GustDirection1])) + log.debug('_WindDirection2= %3s _GustDirection2= %3s' % (CWeatherTraits.windDirMap[self._WindDirection2], CWeatherTraits.windDirMap[self._GustDirection2])) + log.debug('_WindDirection3= %3s _GustDirection3= %3s' % (CWeatherTraits.windDirMap[self._WindDirection3], CWeatherTraits.windDirMap[self._GustDirection3])) + log.debug('_WindDirection4= %3s _GustDirection4= %3s' % (CWeatherTraits.windDirMap[self._WindDirection4], CWeatherTraits.windDirMap[self._GustDirection4])) + log.debug('_WindDirection5= %3s _GustDirection5= %3s' % (CWeatherTraits.windDirMap[self._WindDirection5], CWeatherTraits.windDirMap[self._GustDirection5])) + if (self._RainLastMonth > 0) or (self._RainLastWeek > 0): + log.debug("_RainLastMonth= %8.3f _Max=%8.3f (%s)" % (self._RainLastMonth, self._RainLastMonthMax._Max._Value, self._RainLastMonthMax._Max._Time)) + log.debug("_RainLastWeek= %8.3f _Max=%8.3f (%s)" % (self._RainLastWeek, self._RainLastWeekMax._Max._Value, self._RainLastWeekMax._Max._Time)) + log.debug("_Rain24H= %8.3f _Max=%8.3f (%s)" % (self._Rain24H, self._Rain24HMax._Max._Value, self._Rain24HMax._Max._Time)) + log.debug("_Rain1H= %8.3f _Max=%8.3f (%s)" % (self._Rain1H, self._Rain1HMax._Max._Value, self._Rain1HMax._Max._Time)) + log.debug("_RainTotal= %8.3f _LastRainReset= (%s)" % (self._RainTotal, self._LastRainReset)) + log.debug("PressureRel_hPa= %8.3f _Min=%8.3f (%s) _Max=%8.3f (%s) " % (self._PressureRelative_hPa, self._PressureRelative_hPaMinMax._Min._Value, self._PressureRelative_hPaMinMax._Min._Time, self._PressureRelative_hPaMinMax._Max._Value, self._PressureRelative_hPaMinMax._Max._Time)) + log.debug("PressureRel_inHg=%8.3f _Min=%8.3f (%s) _Max=%8.3f (%s) " % (self._PressureRelative_inHg, self._PressureRelative_inHgMinMax._Min._Value, self._PressureRelative_inHgMinMax._Min._Time, self._PressureRelative_inHgMinMax._Max._Value, self._PressureRelative_inHgMinMax._Max._Time)) + ###log.debug('(* Bug in Weather Station: PressureRelative._Min._Time is written to location of _PressureRelative._Max._Time') + ###log.debug('Instead of PressureRelative._Min._Time we get: _PresRel_hPa_Max= %8.3f, _PresRel_inHg_max =%8.3f;' % (self._PresRel_hPa_Max, self._PresRel_inHg_Max)) + + +class CWeatherStationConfig(object): + def __init__(self): + self._InBufCS = 0 # checksum of received config + self._OutBufCS = 0 # calculated config checksum from outbuf config + self._ClockMode = 0 + self._TemperatureFormat = 0 + self._PressureFormat = 0 + self._RainFormat = 0 + self._WindspeedFormat = 0 + self._WeatherThreshold = 0 + self._StormThreshold = 0 + self._LCDContrast = 0 + self._LowBatFlags = 0 + self._WindDirAlarmFlags = 0 + self._OtherAlarmFlags = 0 + self._ResetMinMaxFlags = 0 # output only + self._HistoryInterval = 0 + self._TempIndoorMinMax = CMinMaxMeasurement() + self._TempOutdoorMinMax = CMinMaxMeasurement() + self._HumidityIndoorMinMax = CMinMaxMeasurement() + self._HumidityOutdoorMinMax = CMinMaxMeasurement() + self._Rain24HMax = CMinMaxMeasurement() + self._GustMax = CMinMaxMeasurement() + self._PressureRelative_hPaMinMax = CMinMaxMeasurement() + self._PressureRelative_inHgMinMax = CMinMaxMeasurement() + + def setTemps(self,TempFormat,InTempLo,InTempHi,OutTempLo,OutTempHi): + f1 = TempFormat + t1 = InTempLo + t2 = InTempHi + t3 = OutTempLo + t4 = OutTempHi + if f1 not in [ETemperatureFormat.tfFahrenheit, + ETemperatureFormat.tfCelsius]: + log.error('setTemps: unknown temperature format %s' % TempFormat) + return 0 + if t1 < -40.0 or t1 > 59.9 or t2 < -40.0 or t2 > 59.9 or \ + t3 < -40.0 or t3 > 59.9 or t4 < -40.0 or t4 > 59.9: + log.error('setTemps: one or more values out of range') + return 0 + self._TemperatureFormat = f1 + self._TempIndoorMinMax._Min._Value = t1 + self._TempIndoorMinMax._Max._Value = t2 + self._TempOutdoorMinMax._Min._Value = t3 + self._TempOutdoorMinMax._Max._Value = t4 + return 1 + + def setHums(self,InHumLo,InHumHi,OutHumLo,OutHumHi): + h1 = InHumLo + h2 = InHumHi + h3 = OutHumLo + h4 = OutHumHi + if h1 < 1 or h1 > 99 or h2 < 1 or h2 > 99 or \ + h3 < 1 or h3 > 99 or h4 < 1 or h4 > 99: + log.error('setHums: one or more values out of range') + return 0 + self._HumidityIndoorMinMax._Min._Value = h1 + self._HumidityIndoorMinMax._Max._Value = h2 + self._HumidityOutdoorMinMax._Min._Value = h3 + self._HumidityOutdoorMinMax._Max._Value = h4 + return 1 + + def setRain24H(self,RainFormat,Rain24hHi): + f1 = RainFormat + r1 = Rain24hHi + if f1 not in [ERainFormat.rfMm, ERainFormat.rfInch]: + log.error('setRain24: unknown format %s' % RainFormat) + return 0 + if r1 < 0.0 or r1 > 9999.9: + log.error('setRain24: value outside range') + return 0 + self._RainFormat = f1 + self._Rain24HMax._Max._Value = r1 + return 1 + + def setGust(self,WindSpeedFormat,GustHi): + # When the units of a max gust alarm are changed in the weather + # station itself, automatically the value is converted to the new + # unit and rounded to a whole number. Weewx receives a value + # converted to km/h. + # + # It is too much trouble to sort out what exactly the internal + # conversion algoritms are for the other wind units. + # + # Setting a value in km/h units is tested and works, so this will + # be the only option available. + f1 = WindSpeedFormat + g1 = GustHi + if f1 < EWindspeedFormat.wfMs or f1 > EWindspeedFormat.wfMph: + log.error('setGust: unknown format %s' % WindSpeedFormat) + return 0 + if f1 != EWindspeedFormat.wfKmh: + log.error('setGust: only units of km/h are supported') + return 0 + if g1 < 0.0 or g1 > 180.0: + log.error('setGust: value outside range') + return 0 + self._WindSpeedFormat = f1 + self._GustMax._Max._Value = int(g1) # apparently gust value is always an integer + return 1 + + def setPresRels(self,PressureFormat,PresRelhPaLo,PresRelhPaHi,PresRelinHgLo,PresRelinHgHi): + f1 = PressureFormat + p1 = PresRelhPaLo + p2 = PresRelhPaHi + p3 = PresRelinHgLo + p4 = PresRelinHgHi + if f1 not in [EPressureFormat.pfinHg, EPressureFormat.pfHPa]: + log.error('setPresRel: unknown format %s' % PressureFormat) + return 0 + if p1 < 920.0 or p1 > 1080.0 or p2 < 920.0 or p2 > 1080.0 or \ + p3 < 27.10 or p3 > 31.90 or p4 < 27.10 or p4 > 31.90: + log.error('setPresRel: value outside range') + return 0 + self._RainFormat = f1 + self._PressureRelative_hPaMinMax._Min._Value = p1 + self._PressureRelative_hPaMinMax._Max._Value = p2 + self._PressureRelative_inHgMinMax._Min._Value = p3 + self._PressureRelative_inHgMinMax._Max._Value = p4 + return 1 + + def getOutBufCS(self): + return self._OutBufCS + + def getInBufCS(self): + return self._InBufCS + + def setResetMinMaxFlags(self, resetMinMaxFlags): + log.debug('setResetMinMaxFlags: %s' % resetMinMaxFlags) + self._ResetMinMaxFlags = resetMinMaxFlags + + def parseRain_3(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 7-digit number with 3 decimals''' + num = int(number*1000) + parsebuf=[0]*7 + for i in range(7-numbytes,7): + parsebuf[i] = num%10 + num = num//10 + if StartOnHiNibble: + buf[0][0+start] = parsebuf[6]*16 + parsebuf[5] + buf[0][1+start] = parsebuf[4]*16 + parsebuf[3] + buf[0][2+start] = parsebuf[2]*16 + parsebuf[1] + buf[0][3+start] = parsebuf[0]*16 + (buf[0][3+start] & 0xF) + else: + buf[0][0+start] = (buf[0][0+start] & 0xF0) + parsebuf[6] + buf[0][1+start] = parsebuf[5]*16 + parsebuf[4] + buf[0][2+start] = parsebuf[3]*16 + parsebuf[2] + buf[0][3+start] = parsebuf[1]*16 + parsebuf[0] + + def parseWind_6(self, number, buf, start): + '''Parse float number to 6 bytes''' + num = int(number*100*256) + parsebuf=[0]*6 + for i in range(6): + parsebuf[i] = num%16 + num = num//16 + buf[0][0+start] = parsebuf[5]*16 + parsebuf[4] + buf[0][1+start] = parsebuf[3]*16 + parsebuf[2] + buf[0][2+start] = parsebuf[1]*16 + parsebuf[0] + + def parse_0(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5-digit number with 0 decimals''' + num = int(number) + nbuf=[0]*5 + for i in range(5-numbytes,5): + nbuf[i] = num%10 + num = num//10 + if StartOnHiNibble: + buf[0][0+start] = nbuf[4]*16 + nbuf[3] + buf[0][1+start] = nbuf[2]*16 + nbuf[1] + buf[0][2+start] = nbuf[0]*16 + (buf[0][2+start] & 0x0F) + else: + buf[0][0+start] = (buf[0][0+start] & 0xF0) + nbuf[4] + buf[0][1+start] = nbuf[3]*16 + nbuf[2] + buf[0][2+start] = nbuf[1]*16 + nbuf[0] + + def parse_1(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5 digit number with 1 decimal''' + self.parse_0(number*10.0, buf, start, StartOnHiNibble, numbytes) + + def parse_2(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5 digit number with 2 decimals''' + self.parse_0(number*100.0, buf, start, StartOnHiNibble, numbytes) + + def parse_3(self, number, buf, start, StartOnHiNibble, numbytes): + '''Parse 5 digit number with 3 decimals''' + self.parse_0(number*1000.0, buf, start, StartOnHiNibble, numbytes) + + def read(self,buf): + nbuf=[0] + nbuf[0]=buf[0] + self._WindspeedFormat = (nbuf[0][4] >> 4) & 0xF + self._RainFormat = (nbuf[0][4] >> 3) & 1 + self._PressureFormat = (nbuf[0][4] >> 2) & 1 + self._TemperatureFormat = (nbuf[0][4] >> 1) & 1 + self._ClockMode = nbuf[0][4] & 1 + self._StormThreshold = (nbuf[0][5] >> 4) & 0xF + self._WeatherThreshold = nbuf[0][5] & 0xF + self._LowBatFlags = (nbuf[0][6] >> 4) & 0xF + self._LCDContrast = nbuf[0][6] & 0xF + self._WindDirAlarmFlags = (nbuf[0][7] << 8) | nbuf[0][8] + self._OtherAlarmFlags = (nbuf[0][9] << 8) | nbuf[0][10] + self._TempIndoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 11, 1) + self._TempIndoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 13, 0) + self._TempOutdoorMinMax._Max._Value = USBHardware.toTemperature_5_3(nbuf, 16, 1) + self._TempOutdoorMinMax._Min._Value = USBHardware.toTemperature_5_3(nbuf, 18, 0) + self._HumidityIndoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 21, 1) + self._HumidityIndoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 22, 1) + self._HumidityOutdoorMinMax._Max._Value = USBHardware.toHumidity_2_0(nbuf, 23, 1) + self._HumidityOutdoorMinMax._Min._Value = USBHardware.toHumidity_2_0(nbuf, 24, 1) + self._Rain24HMax._Max._Value = USBHardware.toRain_7_3(nbuf, 25, 0) + self._HistoryInterval = nbuf[0][29] + self._GustMax._Max._Value = USBHardware.toWindspeed_6_2(nbuf, 30) + (self._PressureRelative_hPaMinMax._Min._Value, self._PressureRelative_inHgMinMax._Min._Value) = USBHardware.readPressureShared(nbuf, 33, 1) + (self._PressureRelative_hPaMinMax._Max._Value, self._PressureRelative_inHgMinMax._Max._Value) = USBHardware.readPressureShared(nbuf, 38, 1) + self._ResetMinMaxFlags = (nbuf[0][43]) <<16 | (nbuf[0][44] << 8) | (nbuf[0][45]) + self._InBufCS = (nbuf[0][46] << 8) | nbuf[0][47] + self._OutBufCS = calc_checksum(buf, 4, end=39) + 7 + + """ + Reset DewpointMax 80 00 00 + Reset DewpointMin 40 00 00 + not used 20 00 00 + Reset WindchillMin* 10 00 00 *dateTime only; Min._Value is preserved + + Reset TempOutMax 08 00 00 + Reset TempOutMin 04 00 00 + Reset TempInMax 02 00 00 + Reset TempInMin 01 00 00 + + Reset Gust 00 80 00 + not used 00 40 00 + not used 00 20 00 + not used 00 10 00 + + Reset HumOutMax 00 08 00 + Reset HumOutMin 00 04 00 + Reset HumInMax 00 02 00 + Reset HumInMin 00 01 00 + + not used 00 00 80 + Reset Rain Total 00 00 40 + Reset last month? 00 00 20 + Reset last week? 00 00 10 + + Reset Rain24H 00 00 08 + Reset Rain1H 00 00 04 + Reset PresRelMax 00 00 02 + Reset PresRelMin 00 00 01 + """ + #self._ResetMinMaxFlags = 0x000000 + #log.debug('set _ResetMinMaxFlags to %06x' % self._ResetMinMaxFlags) + + """ + setTemps(self,TempFormat,InTempLo,InTempHi,OutTempLo,OutTempHi) + setHums(self,InHumLo,InHumHi,OutHumLo,OutHumHi) + setPresRels(self,PressureFormat,PresRelhPaLo,PresRelhPaHi,PresRelinHgLo,PresRelinHgHi) + setGust(self,WindSpeedFormat,GustHi) + setRain24H(self,RainFormat,Rain24hHi) + """ + # Examples: + #self.setTemps(ETemperatureFormat.tfCelsius,1.0,41.0,2.0,42.0) + #self.setHums(41,71,42,72) + #self.setPresRels(EPressureFormat.pfHPa,960.1,1040.1,28.36,30.72) + #self.setGust(EWindspeedFormat.wfKmh,040.0) + #self.setRain24H(ERainFormat.rfMm,50.0) + + # Set historyInterval to 5 minutes (default: 2 hours) + self._HistoryInterval = EHistoryInterval.hi05Min + # Clear all alarm flags, otherwise the datastream from the weather + # station will pause during an alarm and connection will be lost. + self._WindDirAlarmFlags = 0x0000 + self._OtherAlarmFlags = 0x0000 + + def testConfigChanged(self,buf): + nbuf = [0] + nbuf[0] = buf[0] + nbuf[0][0] = 16*(self._WindspeedFormat & 0xF) + 8*(self._RainFormat & 1) + 4*(self._PressureFormat & 1) + 2*(self._TemperatureFormat & 1) + (self._ClockMode & 1) + nbuf[0][1] = self._WeatherThreshold & 0xF | 16 * self._StormThreshold & 0xF0 + nbuf[0][2] = self._LCDContrast & 0xF | 16 * self._LowBatFlags & 0xF0 + nbuf[0][3] = (self._OtherAlarmFlags >> 0) & 0xFF + nbuf[0][4] = (self._OtherAlarmFlags >> 8) & 0xFF + nbuf[0][5] = (self._WindDirAlarmFlags >> 0) & 0xFF + nbuf[0][6] = (self._WindDirAlarmFlags >> 8) & 0xFF + # reverse buf from here + self.parse_2(self._PressureRelative_inHgMinMax._Max._Value, nbuf, 7, 1, 5) + self.parse_1(self._PressureRelative_hPaMinMax._Max._Value, nbuf, 9, 0, 5) + self.parse_2(self._PressureRelative_inHgMinMax._Min._Value, nbuf, 12, 1, 5) + self.parse_1(self._PressureRelative_hPaMinMax._Min._Value, nbuf, 14, 0, 5) + self.parseWind_6(self._GustMax._Max._Value, nbuf, 17) + nbuf[0][20] = self._HistoryInterval & 0xF + self.parseRain_3(self._Rain24HMax._Max._Value, nbuf, 21, 0, 7) + self.parse_0(self._HumidityOutdoorMinMax._Max._Value, nbuf, 25, 1, 2) + self.parse_0(self._HumidityOutdoorMinMax._Min._Value, nbuf, 26, 1, 2) + self.parse_0(self._HumidityIndoorMinMax._Max._Value, nbuf, 27, 1, 2) + self.parse_0(self._HumidityIndoorMinMax._Min._Value, nbuf, 28, 1, 2) + self.parse_3(self._TempOutdoorMinMax._Max._Value + CWeatherTraits.TemperatureOffset(), nbuf, 29, 1, 5) + self.parse_3(self._TempOutdoorMinMax._Min._Value + CWeatherTraits.TemperatureOffset(), nbuf, 31, 0, 5) + self.parse_3(self._TempIndoorMinMax._Max._Value + CWeatherTraits.TemperatureOffset(), nbuf, 34, 1, 5) + self.parse_3(self._TempIndoorMinMax._Min._Value + CWeatherTraits.TemperatureOffset(), nbuf, 36, 0, 5) + # reverse buf to here + USBHardware.reverseByteOrder(nbuf, 7, 32) + # do not include the ResetMinMaxFlags bytes when calculating checksum + nbuf[0][39] = (self._ResetMinMaxFlags >> 16) & 0xFF + nbuf[0][40] = (self._ResetMinMaxFlags >> 8) & 0xFF + nbuf[0][41] = (self._ResetMinMaxFlags >> 0) & 0xFF + self._OutBufCS = calc_checksum(nbuf, 0, end=39) + 7 + nbuf[0][42] = (self._OutBufCS >> 8) & 0xFF + nbuf[0][43] = (self._OutBufCS >> 0) & 0xFF + buf[0] = nbuf[0] + if self._OutBufCS == self._InBufCS and self._ResetMinMaxFlags == 0: + if DEBUG_CONFIG_DATA > 2: + log.debug('testConfigChanged: checksum not changed: OutBufCS=%04x' % self._OutBufCS) + changed = 0 + else: + if DEBUG_CONFIG_DATA > 0: + log.debug('testConfigChanged: checksum or resetMinMaxFlags changed: ' + 'OutBufCS=%04x InBufCS=%04x _ResetMinMaxFlags=%06x' + % (self._OutBufCS, self._InBufCS, self._ResetMinMaxFlags)) + if DEBUG_CONFIG_DATA > 1: + self.toLog() + changed = 1 + return changed + + def toLog(self): + log.debug('OutBufCS= %04x' % self._OutBufCS) + log.debug('InBufCS= %04x' % self._InBufCS) + log.debug('ClockMode= %s' % self._ClockMode) + log.debug('TemperatureFormat= %s' % self._TemperatureFormat) + log.debug('PressureFormat= %s' % self._PressureFormat) + log.debug('RainFormat= %s' % self._RainFormat) + log.debug('WindspeedFormat= %s' % self._WindspeedFormat) + log.debug('WeatherThreshold= %s' % self._WeatherThreshold) + log.debug('StormThreshold= %s' % self._StormThreshold) + log.debug('LCDContrast= %s' % self._LCDContrast) + log.debug('LowBatFlags= %01x' % self._LowBatFlags) + log.debug('WindDirAlarmFlags= %04x' % self._WindDirAlarmFlags) + log.debug('OtherAlarmFlags= %04x' % self._OtherAlarmFlags) + log.debug('HistoryInterval= %s' % self._HistoryInterval) + log.debug('TempIndoor_Min= %s' % self._TempIndoorMinMax._Min._Value) + log.debug('TempIndoor_Max= %s' % self._TempIndoorMinMax._Max._Value) + log.debug('TempOutdoor_Min= %s' % self._TempOutdoorMinMax._Min._Value) + log.debug('TempOutdoor_Max= %s' % self._TempOutdoorMinMax._Max._Value) + log.debug('HumidityIndoor_Min= %s' % self._HumidityIndoorMinMax._Min._Value) + log.debug('HumidityIndoor_Max= %s' % self._HumidityIndoorMinMax._Max._Value) + log.debug('HumidityOutdoor_Min= %s' % self._HumidityOutdoorMinMax._Min._Value) + log.debug('HumidityOutdoor_Max= %s' % self._HumidityOutdoorMinMax._Max._Value) + log.debug('Rain24HMax= %s' % self._Rain24HMax._Max._Value) + log.debug('GustMax= %s' % self._GustMax._Max._Value) + log.debug('PressureRel_hPa_Min= %s' % self._PressureRelative_hPaMinMax._Min._Value) + log.debug('PressureRel_inHg_Min= %s' % self._PressureRelative_inHgMinMax._Min._Value) + log.debug('PressureRel_hPa_Max= %s' % self._PressureRelative_hPaMinMax._Max._Value) + log.debug('PressureRel_inHg_Max= %s' % self._PressureRelative_inHgMinMax._Max._Value) + log.debug('ResetMinMaxFlags= %06x (Output only)' % self._ResetMinMaxFlags) + + def asDict(self): + return { + 'checksum_in': self._InBufCS, + 'checksum_out': self._OutBufCS, + 'format_clock': self._ClockMode, + 'format_temperature': self._TemperatureFormat, + 'format_pressure': self._PressureFormat, + 'format_rain': self._RainFormat, + 'format_windspeed': self._WindspeedFormat, + 'threshold_weather': self._WeatherThreshold, + 'threshold_storm': self._StormThreshold, + 'lcd_contrast': self._LCDContrast, + 'low_battery_flags': self._LowBatFlags, + 'alarm_flags_wind_dir': self._WindDirAlarmFlags, + 'alarm_flags_other': self._OtherAlarmFlags, +# 'reset_minmax_flags': self._ResetMinMaxFlags, + 'history_interval': self._HistoryInterval, + 'indoor_temp_min': self._TempIndoorMinMax._Min._Value, + 'indoor_temp_min_time': self._TempIndoorMinMax._Min._Time, + 'indoor_temp_max': self._TempIndoorMinMax._Max._Value, + 'indoor_temp_max_time': self._TempIndoorMinMax._Max._Time, + 'indoor_humidity_min': self._HumidityIndoorMinMax._Min._Value, + 'indoor_humidity_min_time': self._HumidityIndoorMinMax._Min._Time, + 'indoor_humidity_max': self._HumidityIndoorMinMax._Max._Value, + 'indoor_humidity_max_time': self._HumidityIndoorMinMax._Max._Time, + 'outdoor_temp_min': self._TempOutdoorMinMax._Min._Value, + 'outdoor_temp_min_time': self._TempOutdoorMinMax._Min._Time, + 'outdoor_temp_max': self._TempOutdoorMinMax._Max._Value, + 'outdoor_temp_max_time': self._TempOutdoorMinMax._Max._Time, + 'outdoor_humidity_min': self._HumidityOutdoorMinMax._Min._Value, + 'outdoor_humidity_min_time':self._HumidityOutdoorMinMax._Min._Time, + 'outdoor_humidity_max': self._HumidityOutdoorMinMax._Max._Value, + 'outdoor_humidity_max_time':self._HumidityOutdoorMinMax._Max._Time, + 'rain_24h_max': self._Rain24HMax._Max._Value, + 'rain_24h_max_time': self._Rain24HMax._Max._Time, + 'wind_gust_max': self._GustMax._Max._Value, + 'wind_gust_max_time': self._GustMax._Max._Time, + 'pressure_min': self._PressureRelative_hPaMinMax._Min._Value, + 'pressure_min_time': self._PressureRelative_hPaMinMax._Min._Time, + 'pressure_max': self._PressureRelative_hPaMinMax._Max._Value, + 'pressure_max_time': self._PressureRelative_hPaMinMax._Max._Time + # do not bother with pressure inHg + } + + +class CHistoryData(object): + + def __init__(self): + self.Time = None + self.TempIndoor = CWeatherTraits.TemperatureNP() + self.HumidityIndoor = CWeatherTraits.HumidityNP() + self.TempOutdoor = CWeatherTraits.TemperatureNP() + self.HumidityOutdoor = CWeatherTraits.HumidityNP() + self.PressureRelative = None + self.RainCounterRaw = 0 + self.WindSpeed = CWeatherTraits.WindNP() + self.WindDirection = EWindDirection.wdNone + self.Gust = CWeatherTraits.WindNP() + self.GustDirection = EWindDirection.wdNone + + def read(self, buf): + nbuf = [0] + nbuf[0] = buf[0] + self.Gust = USBHardware.toWindspeed_3_1(nbuf, 12, 0) + self.GustDirection = (nbuf[0][14] >> 4) & 0xF + self.WindSpeed = USBHardware.toWindspeed_3_1(nbuf, 14, 0) + self.WindDirection = (nbuf[0][14] >> 4) & 0xF + self.RainCounterRaw = USBHardware.toRain_3_1(nbuf, 16, 1) + self.HumidityOutdoor = USBHardware.toHumidity_2_0(nbuf, 17, 0) + self.HumidityIndoor = USBHardware.toHumidity_2_0(nbuf, 18, 0) + self.PressureRelative = USBHardware.toPressure_hPa_5_1(nbuf, 19, 0) + self.TempIndoor = USBHardware.toTemperature_3_1(nbuf, 23, 0) + self.TempOutdoor = USBHardware.toTemperature_3_1(nbuf, 22, 1) + self.Time = USBHardware.toDateTime(nbuf, 25, 1, 'HistoryData') + + def toLog(self): + """emit raw historical data""" + log.debug("Time %s" % self.Time) + log.debug("TempIndoor= %7.1f" % self.TempIndoor) + log.debug("HumidityIndoor= %7.0f" % self.HumidityIndoor) + log.debug("TempOutdoor= %7.1f" % self.TempOutdoor) + log.debug("HumidityOutdoor= %7.0f" % self.HumidityOutdoor) + log.debug("PressureRelative= %7.1f" % self.PressureRelative) + log.debug("RainCounterRaw= %7.3f" % self.RainCounterRaw) + log.debug("WindSpeed= %7.3f" % self.WindSpeed) + log.debug("WindDirection= % 3s" % CWeatherTraits.windDirMap[self.WindDirection]) + log.debug("Gust= %7.3f" % self.Gust) + log.debug("GustDirection= % 3s" % CWeatherTraits.windDirMap[self.GustDirection]) + + def asDict(self): + """emit historical data as a dict with weewx conventions""" + return { + 'dateTime': tstr_to_ts(str(self.Time)), + 'inTemp': self.TempIndoor, + 'inHumidity': self.HumidityIndoor, + 'outTemp': self.TempOutdoor, + 'outHumidity': self.HumidityOutdoor, + 'pressure': self.PressureRelative, + 'rain': self.RainCounterRaw / 10.0, # weewx wants cm + 'windSpeed': self.WindSpeed, + 'windDir': getWindDir(self.WindDirection, self.WindSpeed), + 'windGust': self.Gust, + 'windGustDir': getWindDir(self.GustDirection, self.Gust), + } + +class HistoryCache: + def __init__(self): + self.clear_records() + def clear_records(self): + self.since_ts = 0 + self.num_rec = 0 + self.start_index = None + self.next_index = None + self.records = [] + self.num_outstanding_records = None + self.num_scanned = 0 + self.last_ts = 0 + +class CDataStore(object): + + class TTransceiverSettings(object): + def __init__(self): + self.VendorId = 0x6666 + self.ProductId = 0x5555 + self.VersionNo = 1 + self.manufacturer = "LA CROSSE TECHNOLOGY" + self.product = "Weather Direct Light Wireless Device" + self.FrequencyStandard = EFrequency.fsUS + self.Frequency = getFrequency(self.FrequencyStandard) + self.SerialNumber = None + self.DeviceID = None + + class TLastStat(object): + def __init__(self): + self.LastBatteryStatus = None + self.LastLinkQuality = None + self.LastHistoryIndex = None + self.LatestHistoryIndex = None + self.last_seen_ts = None + self.last_weather_ts = 0 + self.last_history_ts = 0 + self.last_config_ts = 0 + + def __init__(self): + self.transceiverPresent = False + self.commModeInterval = 3 + self.registeredDeviceID = None + self.LastStat = CDataStore.TLastStat() + self.TransceiverSettings = CDataStore.TTransceiverSettings() + self.StationConfig = CWeatherStationConfig() + self.CurrentWeather = CCurrentWeatherData() + + def getFrequencyStandard(self): + return self.TransceiverSettings.FrequencyStandard + + def setFrequencyStandard(self, val): + log.debug('setFrequency: %s' % val) + self.TransceiverSettings.FrequencyStandard = val + self.TransceiverSettings.Frequency = getFrequency(val) + + def getDeviceID(self): + return self.TransceiverSettings.DeviceID + + def setDeviceID(self,val): + log.debug("setDeviceID: %04x" % val) + self.TransceiverSettings.DeviceID = val + + def getRegisteredDeviceID(self): + return self.registeredDeviceID + + def setRegisteredDeviceID(self, val): + if val != self.registeredDeviceID: + log.info("console is paired to device with ID %04x" % val) + self.registeredDeviceID = val + + def getTransceiverPresent(self): + return self.transceiverPresent + + def setTransceiverPresent(self, val): + self.transceiverPresent = val + + def setLastStatCache(self, seen_ts=None, + quality=None, battery=None, + weather_ts=None, + history_ts=None, + config_ts=None): + if DEBUG_COMM > 1: + log.debug('setLastStatCache: seen=%s quality=%s battery=%s weather=%s history=%s config=%s' + % (seen_ts, quality, battery, weather_ts, history_ts, config_ts)) + if seen_ts is not None: + self.LastStat.last_seen_ts = seen_ts + if quality is not None: + self.LastStat.LastLinkQuality = quality + if battery is not None: + self.LastStat.LastBatteryStatus = battery + if weather_ts is not None: + self.LastStat.last_weather_ts = weather_ts + if history_ts is not None: + self.LastStat.last_history_ts = history_ts + if config_ts is not None: + self.LastStat.last_config_ts = config_ts + + def setLastHistoryIndex(self,val): + self.LastStat.LastHistoryIndex = val + + def getLastHistoryIndex(self): + return self.LastStat.LastHistoryIndex + + def setLatestHistoryIndex(self,val): + self.LastStat.LatestHistoryIndex = val + + def getLatestHistoryIndex(self): + return self.LastStat.LatestHistoryIndex + + def setCurrentWeather(self, data): + self.CurrentWeather = data + + def getDeviceRegistered(self): + if ( self.registeredDeviceID is None + or self.TransceiverSettings.DeviceID is None + or self.registeredDeviceID != self.TransceiverSettings.DeviceID ): + return False + return True + + def getCommModeInterval(self): + return self.commModeInterval + + def setCommModeInterval(self,val): + log.debug("setCommModeInterval to %x" % val) + self.commModeInterval = val + + def setTransceiverSerNo(self,val): + log.debug("setTransceiverSerialNumber to %s" % val) + self.TransceiverSettings.SerialNumber = val + + def getTransceiverSerNo(self): + return self.TransceiverSettings.SerialNumber + + +class sHID(object): + """USB driver abstraction""" + + def __init__(self): + self.devh = None + self.timeout = 1000 + self.last_dump = None + + def open(self, vid, pid, did, serial): + device = self._find_device(vid, pid, did, serial) + if device is None: + log.critical('Cannot find USB device with ' + 'Vendor=0x%04x ProdID=0x%04x ' + 'Device=%s Serial=%s' + % (vid, pid, did, serial)) + raise weewx.WeeWxIOError('Unable to find transceiver on USB') + self._open_device(device) + + def close(self): + self._close_device() + + def _find_device(self, vid, pid, did, serial): + for bus in usb.busses(): + for dev in bus.devices: + if dev.idVendor == vid and dev.idProduct == pid: + if did is None or dev.filename == did: + if serial is None: + log.info('found transceiver at bus=%s device=%s' + % (bus.dirname, dev.filename)) + return dev + else: + handle = dev.open() + try: + buf = self.readCfg(handle, 0x1F9, 7) + sn = str("%02d" % (buf[0])) + sn += str("%02d" % (buf[1])) + sn += str("%02d" % (buf[2])) + sn += str("%02d" % (buf[3])) + sn += str("%02d" % (buf[4])) + sn += str("%02d" % (buf[5])) + sn += str("%02d" % (buf[6])) + if str(serial) == sn: + log.info('found transceiver at bus=%s device=%s serial=%s' + % (bus.dirname, dev.filename, sn)) + return dev + else: + log.info('skipping transceiver with serial %s (looking for %s)' + % (sn, serial)) + finally: + del handle + return None + + def _open_device(self, dev, interface=0): + self.devh = dev.open() + if not self.devh: + raise weewx.WeeWxIOError('Open USB device failed') + + log.info('manufacturer: %s' % self.devh.getString(dev.iManufacturer,30)) + log.info('product: %s' % self.devh.getString(dev.iProduct,30)) + log.info('interface: %d' % interface) + + # be sure kernel does not claim the interface + try: + self.devh.detachKernelDriver(interface) + except Exception: + pass + + # attempt to claim the interface + try: + log.debug('claiming USB interface %d' % interface) + self.devh.claimInterface(interface) + self.devh.setAltInterface(interface) + except usb.USBError as e: + self._close_device() + log.critical('Unable to claim USB interface %s: %s' % (interface, e)) + raise weewx.WeeWxIOError(e) + + # FIXME: this seems to be specific to ws28xx? + # FIXME: check return values + usbWait = 0.05 + self.devh.getDescriptor(0x1, 0, 0x12) + time.sleep(usbWait) + self.devh.getDescriptor(0x2, 0, 0x9) + time.sleep(usbWait) + self.devh.getDescriptor(0x2, 0, 0x22) + time.sleep(usbWait) + self.devh.controlMsg( + usb.TYPE_CLASS + usb.RECIP_INTERFACE, 0xa, [], 0x0, 0x0, 1000) + time.sleep(usbWait) + self.devh.getDescriptor(0x22, 0, 0x2a9) + time.sleep(usbWait) + + def _close_device(self): + try: + log.debug('releasing USB interface') + self.devh.releaseInterface() + except Exception: + pass + self.devh = None + + def setTX(self): + buf = [0]*0x15 + buf[0] = 0xD1 + if DEBUG_COMM > 1: + self.dump('setTX', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d1, + index=0x0000000, + timeout=self.timeout) + + def setRX(self): + buf = [0]*0x15 + buf[0] = 0xD0 + if DEBUG_COMM > 1: + self.dump('setRX', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d0, + index=0x0000000, + timeout=self.timeout) + + def getState(self): + buf = self.devh.controlMsg( + requestType=usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x0a, + value=0x00003de, + index=0x0000000, + timeout=self.timeout) + if DEBUG_COMM > 1: + self.dump('getState', buf, fmt=DEBUG_DUMP_FORMAT) + return buf + + def readConfigFlash(self, addr, numBytes): + if numBytes > 512: + raise Exception('bad number of bytes (%s)' % numBytes) + + data = None + while numBytes: + buf=[0xcc]*0x0f #0x15 + buf[0] = 0xdd + buf[1] = 0x0a + buf[2] = (addr >>8) & 0xFF + buf[3] = (addr >>0) & 0xFF + if DEBUG_COMM > 1: + self.dump('readCfgFlash>', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003dd, + index=0x0000000, + timeout=self.timeout) + buf = self.devh.controlMsg( + usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x15, + value=0x00003dc, + index=0x0000000, + timeout=self.timeout) + data=[0]*0x15 + if numBytes < 16: + for i in range(numBytes): + data[i] = buf[i+4] + numBytes = 0 + else: + for i in range(16): + data[i] = buf[i+4] + numBytes -= 16 + addr += 16 + if DEBUG_COMM > 1: + self.dump('readCfgFlash<', buf, fmt=DEBUG_DUMP_FORMAT) + return data + + def setState(self,state): + buf = [0]*0x15 + buf[0] = 0xd7 + buf[1] = state + if DEBUG_COMM > 1: + self.dump('setState', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d7, + index=0x0000000, + timeout=self.timeout) + + def setFrame(self,data,numBytes): + buf = [0]*0x111 + buf[0] = 0xd5 + buf[1] = numBytes >> 8 + buf[2] = numBytes + for i in range(numBytes): + buf[i+3] = data[i] + if DEBUG_COMM == 1: + self.dump('setFrame', buf, 'short') + elif DEBUG_COMM > 1: + self.dump('setFrame', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d5, + index=0x0000000, + timeout=self.timeout) + + def getFrame(self,data,numBytes): + buf = self.devh.controlMsg( + requestType=usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x111, + value=0x00003d6, + index=0x0000000, + timeout=self.timeout) + new_data=[0]*0x131 + new_numBytes=(buf[1] << 8 | buf[2])& 0x1ff + for i in range(new_numBytes): + new_data[i] = buf[i+3] + if DEBUG_COMM == 1: + self.dump('getFrame', buf, 'short') + elif DEBUG_COMM > 1: + self.dump('getFrame', buf, fmt=DEBUG_DUMP_FORMAT) + data[0] = new_data + numBytes[0] = new_numBytes + + def writeReg(self,regAddr,data): + buf = [0]*0x05 + buf[0] = 0xf0 + buf[1] = regAddr & 0x7F + buf[2] = 0x01 + buf[3] = data + buf[4] = 0x00 + if DEBUG_COMM > 1: + self.dump('writeReg', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003f0, + index=0x0000000, + timeout=self.timeout) + + def execute(self, command): + buf = [0]*0x0f #*0x15 + buf[0] = 0xd9 + buf[1] = command + if DEBUG_COMM > 1: + self.dump('execute', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d9, + index=0x0000000, + timeout=self.timeout) + + def setPreamblePattern(self,pattern): + buf = [0]*0x15 + buf[0] = 0xd8 + buf[1] = pattern + if DEBUG_COMM > 1: + self.dump('setPreamble', buf, fmt=DEBUG_DUMP_FORMAT) + self.devh.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003d8, + index=0x0000000, + timeout=self.timeout) + + # three formats, long, short, auto. short shows only the first 16 bytes. + # long shows the full length of the buffer. auto shows the message length + # as indicated by the length in the message itself for setFrame and + # getFrame, or the first 16 bytes for any other message. + def dump(self, cmd, buf, fmt='auto'): + strbuf = '' + msglen = None + if fmt == 'auto': + if buf[0] in [0xd5, 0x00]: + msglen = buf[2] + 3 # use msg length for set/get frame + else: + msglen = 16 # otherwise do same as short format + elif fmt == 'short': + msglen = 16 + for i,x in enumerate(buf): + strbuf += str('%02x ' % x) + if (i+1) % 16 == 0: + self.dumpstr(cmd, strbuf) + strbuf = '' + if msglen is not None and i+1 >= msglen: + break + if strbuf: + self.dumpstr(cmd, strbuf) + + # filter output that we do not care about, pad the command string. + def dumpstr(self, cmd, strbuf): + pad = ' ' * (15-len(cmd)) + # de15 is idle, de14 is intermediate + if strbuf in ['de 15 00 00 00 00 ','de 14 00 00 00 00 ']: + if strbuf != self.last_dump or DEBUG_COMM > 2: + log.debug('%s: %s%s' % (cmd, pad, strbuf)) + self.last_dump = strbuf + else: + log.debug('%s: %s%s' % (cmd, pad, strbuf)) + self.last_dump = None + + def readCfg(self, handle, addr, numBytes): + while numBytes: + buf=[0xcc]*0x0f #0x15 + buf[0] = 0xdd + buf[1] = 0x0a + buf[2] = (addr >>8) & 0xFF + buf[3] = (addr >>0) & 0xFF + handle.controlMsg( + requestType=usb.TYPE_CLASS + usb.RECIP_INTERFACE, + request=0x0000009, + buffer=buf, + value=0x00003dd, + index=0x0000000, + timeout=1000) + buf = handle.controlMsg( + usb.TYPE_CLASS | usb.RECIP_INTERFACE | usb.ENDPOINT_IN, + request=usb.REQ_CLEAR_FEATURE, + buffer=0x15, + value=0x00003dc, + index=0x0000000, + timeout=1000) + new_data=[0]*0x15 + if numBytes < 16: + for i in range(numBytes): + new_data[i] = buf[i+4] + numBytes = 0 + else: + for i in range(16): + new_data[i] = buf[i+4] + numBytes -= 16 + addr += 16 + return new_data + +class CCommunicationService(object): + + reg_names = dict() + + class AX5051RegisterNames: + REVISION = 0x0 + SCRATCH = 0x1 + POWERMODE = 0x2 + XTALOSC = 0x3 + FIFOCTRL = 0x4 + FIFODATA = 0x5 + IRQMASK = 0x6 + IFMODE = 0x8 + PINCFG1 = 0x0C + PINCFG2 = 0x0D + MODULATION = 0x10 + ENCODING = 0x11 + FRAMING = 0x12 + CRCINIT3 = 0x14 + CRCINIT2 = 0x15 + CRCINIT1 = 0x16 + CRCINIT0 = 0x17 + FREQ3 = 0x20 + FREQ2 = 0x21 + FREQ1 = 0x22 + FREQ0 = 0x23 + FSKDEV2 = 0x25 + FSKDEV1 = 0x26 + FSKDEV0 = 0x27 + IFFREQHI = 0x28 + IFFREQLO = 0x29 + PLLLOOP = 0x2C + PLLRANGING = 0x2D + PLLRNGCLK = 0x2E + TXPWR = 0x30 + TXRATEHI = 0x31 + TXRATEMID = 0x32 + TXRATELO = 0x33 + MODMISC = 0x34 + FIFOCONTROL2 = 0x37 + ADCMISC = 0x38 + AGCTARGET = 0x39 + AGCATTACK = 0x3A + AGCDECAY = 0x3B + AGCCOUNTER = 0x3C + CICDEC = 0x3F + DATARATEHI = 0x40 + DATARATELO = 0x41 + TMGGAINHI = 0x42 + TMGGAINLO = 0x43 + PHASEGAIN = 0x44 + FREQGAIN = 0x45 + FREQGAIN2 = 0x46 + AMPLGAIN = 0x47 + TRKFREQHI = 0x4C + TRKFREQLO = 0x4D + XTALCAP = 0x4F + SPAREOUT = 0x60 + TESTOBS = 0x68 + APEOVER = 0x70 + TMMUX = 0x71 + PLLVCOI = 0x72 + PLLCPEN = 0x73 + PLLRNGMISC = 0x74 + AGCMANUAL = 0x78 + ADCDCLEVEL = 0x79 + RFMISC = 0x7A + TXDRIVER = 0x7B + REF = 0x7C + RXMISC = 0x7D + + def __init__(self): + log.debug('CCommunicationService.init') + + self.shid = sHID() + self.DataStore = CDataStore() + + self.firstSleep = 1 + self.nextSleep = 1 + self.pollCount = 0 + + self.running = False + self.child = None + self.thread_wait = 60.0 # seconds + + self.command = None + self.history_cache = HistoryCache() + # do not set time when offset to whole hour is <= _a3_offset + self._a3_offset = 3 + + def buildFirstConfigFrame(self, Buffer, cs): + log.debug('buildFirstConfigFrame: cs=%04x' % cs) + newBuffer = [0] + newBuffer[0] = [0]*9 + comInt = self.DataStore.getCommModeInterval() + historyAddress = 0xFFFFFF + newBuffer[0][0] = 0xf0 + newBuffer[0][1] = 0xf0 + newBuffer[0][2] = EAction.aGetConfig + newBuffer[0][3] = (cs >> 8) & 0xff + newBuffer[0][4] = (cs >> 0) & 0xff + newBuffer[0][5] = (comInt >> 4) & 0xff + newBuffer[0][6] = (historyAddress >> 16) & 0x0f | 16 * (comInt & 0xf) + newBuffer[0][7] = (historyAddress >> 8 ) & 0xff + newBuffer[0][8] = (historyAddress >> 0 ) & 0xff + Buffer[0] = newBuffer[0] + Length = 0x09 + return Length + + def buildConfigFrame(self, Buffer): + log.debug("buildConfigFrame") + newBuffer = [0] + newBuffer[0] = [0]*48 + cfgBuffer = [0] + cfgBuffer[0] = [0]*44 + changed = self.DataStore.StationConfig.testConfigChanged(cfgBuffer) + if changed: + self.shid.dump('OutBuf', cfgBuffer[0], fmt='long') + newBuffer[0][0] = Buffer[0][0] + newBuffer[0][1] = Buffer[0][1] + newBuffer[0][2] = EAction.aSendConfig # 0x40 # change this value if we won't store config + newBuffer[0][3] = Buffer[0][3] + for i in range(44): + newBuffer[0][i+4] = cfgBuffer[0][i] + Buffer[0] = newBuffer[0] + Length = 48 # 0x30 + else: # current config not up to date; do not write yet + Length = 0 + return Length + + def buildTimeFrame(self, Buffer, cs): + log.debug("buildTimeFrame: cs=%04x" % cs) + + now = time.time() + tm = time.localtime(now) + + newBuffer=[0] + newBuffer[0]=Buffer[0] + #00000000: d5 00 0c 00 32 c0 00 8f 45 25 15 91 31 20 01 00 + #00000000: d5 00 0c 00 32 c0 06 c1 47 25 15 91 31 20 01 00 + # 3 4 5 6 7 8 9 10 11 + newBuffer[0][2] = EAction.aSendTime # 0xc0 + newBuffer[0][3] = (cs >> 8) & 0xFF + newBuffer[0][4] = (cs >> 0) & 0xFF + newBuffer[0][5] = (tm[5] % 10) + 0x10 * (tm[5] // 10) #sec + newBuffer[0][6] = (tm[4] % 10) + 0x10 * (tm[4] // 10) #min + newBuffer[0][7] = (tm[3] % 10) + 0x10 * (tm[3] // 10) #hour + #DayOfWeek = tm[6] - 1; #ole from 1 - 7 - 1=Sun... 0-6 0=Sun + DayOfWeek = tm[6] #py from 0 - 6 - 0=Mon + newBuffer[0][8] = DayOfWeek % 10 + 0x10 * (tm[2] % 10) #DoW + Day + newBuffer[0][9] = (tm[2] // 10) + 0x10 * (tm[1] % 10) #day + month + newBuffer[0][10] = (tm[1] // 10) + 0x10 * ((tm[0] - 2000) % 10) #month + year + newBuffer[0][11] = (tm[0] - 2000) // 10 #year + Buffer[0]=newBuffer[0] + Length = 0x0c + return Length + + def buildACKFrame(self, Buffer, action, cs, hidx=None): + if DEBUG_COMM > 1: + log.debug("buildACKFrame: action=%x cs=%04x historyIndex=%s" + % (action, cs, hidx)) + newBuffer = [0] + newBuffer[0] = [0]*9 + for i in range(2): + newBuffer[0][i] = Buffer[0][i] + + comInt = self.DataStore.getCommModeInterval() + + # When last weather is stale, change action to get current weather + # This is only needed during long periods of history data catchup + if self.command == EAction.aGetHistory: + now = int(time.time()) + age = now - self.DataStore.LastStat.last_weather_ts + # Morphing action only with GetHistory requests, + # and stale data after a period of twice the CommModeInterval, + # but not with init GetHistory requests (0xF0) + if action == EAction.aGetHistory and age >= (comInt +1) * 2 and newBuffer[0][1] != 0xF0: + if DEBUG_COMM > 0: + log.debug('buildACKFrame: morphing action from %d to 5 (age=%s)' % (action, age)) + action = EAction.aGetCurrent + + if hidx is None: + if self.command == EAction.aGetHistory: + hidx = self.history_cache.next_index + elif self.DataStore.getLastHistoryIndex() is not None: + hidx = self.DataStore.getLastHistoryIndex() + if hidx is None or hidx < 0 or hidx >= WS28xxDriver.max_records: + haddr = 0xffffff + else: + haddr = index_to_addr(hidx) + if DEBUG_COMM > 1: + log.debug('buildACKFrame: idx: %s addr: 0x%04x' % (hidx, haddr)) + + newBuffer[0][2] = action & 0xF + newBuffer[0][3] = (cs >> 8) & 0xFF + newBuffer[0][4] = (cs >> 0) & 0xFF + newBuffer[0][5] = (comInt >> 4) & 0xFF + newBuffer[0][6] = (haddr >> 16) & 0x0F | 16 * (comInt & 0xF) + newBuffer[0][7] = (haddr >> 8 ) & 0xFF + newBuffer[0][8] = (haddr >> 0 ) & 0xFF + + #d5 00 09 f0 f0 03 00 32 00 3f ff ff + Buffer[0]=newBuffer[0] + return 9 + + def handleWsAck(self,Buffer,Length): + log.debug('handleWsAck') + self.DataStore.setLastStatCache(seen_ts=int(time.time()), + quality=(Buffer[0][3] & 0x7f), + battery=(Buffer[0][2] & 0xf)) + + def handleConfig(self,Buffer,Length): + log.debug('handleConfig: %s' % self.timing()) + if DEBUG_CONFIG_DATA > 2: + self.shid.dump('InBuf', Buffer[0], fmt='long') + newBuffer=[0] + newBuffer[0] = Buffer[0] + newLength = [0] + now = int(time.time()) + self.DataStore.StationConfig.read(newBuffer) + if DEBUG_CONFIG_DATA > 1: + self.DataStore.StationConfig.toLog() + self.DataStore.setLastStatCache(seen_ts=now, + quality=(Buffer[0][3] & 0x7f), + battery=(Buffer[0][2] & 0xf), + config_ts=now) + cs = newBuffer[0][47] | (newBuffer[0][46] << 8) + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + + Buffer[0] = newBuffer[0] + Length[0] = newLength[0] + + def handleCurrentData(self,Buffer,Length): + if DEBUG_WEATHER_DATA > 0: + log.debug('handleCurrentData: %s' % self.timing()) + + now = int(time.time()) + + # update the weather data cache if changed or stale + chksum = CCurrentWeatherData.calcChecksum(Buffer) + age = now - self.DataStore.LastStat.last_weather_ts + if age >= 10 or chksum != self.DataStore.CurrentWeather.checksum(): + if DEBUG_WEATHER_DATA > 2: + self.shid.dump('CurWea', Buffer[0], fmt='long') + data = CCurrentWeatherData() + data.read(Buffer) + self.DataStore.setCurrentWeather(data) + if DEBUG_WEATHER_DATA > 1: + data.toLog() + + # update the connection cache + self.DataStore.setLastStatCache(seen_ts=now, + quality=(Buffer[0][3] & 0x7f), + battery=(Buffer[0][2] & 0xf), + weather_ts=now) + + newBuffer = [0] + newBuffer[0] = Buffer[0] + newLength = [0] + + cs = newBuffer[0][5] | (newBuffer[0][4] << 8) + + cfgBuffer = [0] + cfgBuffer[0] = [0]*44 + changed = self.DataStore.StationConfig.testConfigChanged(cfgBuffer) + inBufCS = self.DataStore.StationConfig.getInBufCS() + if inBufCS == 0 or inBufCS != cs: + # request for a get config + log.debug('handleCurrentData: inBufCS of station does not match') + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetConfig, cs) + elif changed: + # Request for a set config + log.debug('handleCurrentData: outBufCS of station changed') + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aReqSetConfig, cs) + else: + # Request for either a history message or a current weather message + # In general we don't use EAction.aGetCurrent to ask for a current + # weather message; they also come when requested for + # EAction.aGetHistory. This we learned from the Heavy Weather Pro + # messages (via USB sniffer). + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + + Length[0] = newLength[0] + Buffer[0] = newBuffer[0] + + def handleHistoryData(self, buf, buflen): + if DEBUG_HISTORY_DATA > 0: + log.debug('handleHistoryData: %s' % self.timing()) + + now = int(time.time()) + self.DataStore.setLastStatCache(seen_ts=now, + quality=(buf[0][3] & 0x7f), + battery=(buf[0][2] & 0xf), + history_ts=now) + + newbuf = [0] + newbuf[0] = buf[0] + newlen = [0] + data = CHistoryData() + data.read(newbuf) + if DEBUG_HISTORY_DATA > 1: + data.toLog() + + cs = newbuf[0][5] | (newbuf[0][4] << 8) + latestAddr = bytes_to_addr(buf[0][6], buf[0][7], buf[0][8]) + thisAddr = bytes_to_addr(buf[0][9], buf[0][10], buf[0][11]) + latestIndex = addr_to_index(latestAddr) + thisIndex = addr_to_index(thisAddr) + ts = tstr_to_ts(str(data.Time)) + + nrec = get_index(latestIndex - thisIndex) + log.debug('handleHistoryData: time=%s' + ' this=%d (0x%04x) latest=%d (0x%04x) nrec=%d' + % (data.Time, thisIndex, thisAddr, latestIndex, latestAddr, nrec)) + + # track the latest history index + self.DataStore.setLastHistoryIndex(thisIndex) + self.DataStore.setLatestHistoryIndex(latestIndex) + + nextIndex = None + if self.command == EAction.aGetHistory: + if self.history_cache.start_index is None: + nreq = 0 + if self.history_cache.num_rec > 0: + log.info('handleHistoryData: request for %s records' + % self.history_cache.num_rec) + nreq = self.history_cache.num_rec + else: + log.info('handleHistoryData: request records since %s' + % weeutil.weeutil.timestamp_to_string(self.history_cache.since_ts)) + span = int(time.time()) - self.history_cache.since_ts + # FIXME: what if we do not have config data yet? + cfg = self.getConfigData().asDict() + arcint = 60 * getHistoryInterval(cfg['history_interval']) + # FIXME: this assumes a constant archive interval for all + # records in the station history + nreq = int(span / arcint) + 5 # FIXME: punt 5 + if nreq > nrec: + log.info('handleHistoryData: too many records requested (%d)' + ', clipping to number stored (%d)' % (nreq, nrec)) + nreq = nrec + idx = get_index(latestIndex - nreq) + self.history_cache.start_index = idx + self.history_cache.next_index = idx + self.DataStore.setLastHistoryIndex(idx) + self.history_cache.num_outstanding_records = nreq + log.debug('handleHistoryData: start_index=%s' + ' num_outstanding_records=%s' % (idx, nreq)) + nextIndex = idx + elif self.history_cache.next_index is not None: + # thisIndex should be the next record after next_index + thisIndexTst = get_next_index(self.history_cache.next_index) + if thisIndexTst == thisIndex: + self.history_cache.num_scanned += 1 + # get the next history record + if ts is not None and self.history_cache.since_ts <= ts: + # Check if two records in a row with the same ts + if self.history_cache.last_ts == ts: + log.debug('handleHistoryData: remove previous record' + ' with duplicate timestamp: %s' % + weeutil.weeutil.timestamp_to_string(ts)) + self.history_cache.records.pop() + self.history_cache.last_ts = ts + # append to the history + log.debug('handleHistoryData: appending history record' + ' %s: %s' % (thisIndex, data.asDict())) + self.history_cache.records.append(data.asDict()) + self.history_cache.num_outstanding_records = nrec + elif ts is None: + log.error('handleHistoryData: skip record: this_ts=None') + else: + log.debug('handleHistoryData: skip record: since_ts=%s this_ts=%s' + % (weeutil.weeutil.timestamp_to_string(self.history_cache.since_ts), + weeutil.weeutil.timestamp_to_string(ts))) + self.history_cache.next_index = thisIndex + else: + log.info('handleHistoryData: index mismatch: %s != %s' + % (thisIndexTst, thisIndex)) + nextIndex = self.history_cache.next_index + + log.debug('handleHistoryData: next=%s' % nextIndex) + self.setSleep(0.300,0.010) + newlen[0] = self.buildACKFrame(newbuf, EAction.aGetHistory, cs, nextIndex) + + buflen[0] = newlen[0] + buf[0] = newbuf[0] + + def handleNextAction(self,Buffer,Length): + newBuffer = [0] + newBuffer[0] = Buffer[0] + newLength = [0] + newLength[0] = Length[0] + self.DataStore.setLastStatCache(seen_ts=int(time.time()), + quality=(Buffer[0][3] & 0x7f)) + cs = newBuffer[0][5] | (newBuffer[0][4] << 8) + if (Buffer[0][2] & 0xEF) == EResponseType.rtReqFirstConfig: + log.debug('handleNextAction: a1 (first-time config)') + self.setSleep(0.085,0.005) + newLength[0] = self.buildFirstConfigFrame(newBuffer, cs) + elif (Buffer[0][2] & 0xEF) == EResponseType.rtReqSetConfig: + log.debug('handleNextAction: a2 (set config data)') + self.setSleep(0.085,0.005) + newLength[0] = self.buildConfigFrame(newBuffer) + elif (Buffer[0][2] & 0xEF) == EResponseType.rtReqSetTime: + log.debug('handleNextAction: a3 (set time data)') + now = int(time.time()) + age = now - self.DataStore.LastStat.last_weather_ts + if age >= (self.DataStore.getCommModeInterval() +1) * 2: + # always set time if init or stale communication + self.setSleep(0.085,0.005) + newLength[0] = self.buildTimeFrame(newBuffer, cs) + else: + # When time is set at the whole hour we may get an extra + # historical record with time stamp a history period ahead + # We will skip settime if offset to whole hour is too small + # (time difference between WS and server < self._a3_offset) + m, s = divmod(now, 60) + h, m = divmod(m, 60) + log.debug('Time: hh:%02d:%02d' % (m,s)) + if (m == 59 and s >= (60 - self._a3_offset)) or (m == 0 and s <= self._a3_offset): + log.debug('Skip settime; time difference <= %s s' % int(self._a3_offset)) + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + else: + # set time + self.setSleep(0.085,0.005) + newLength[0] = self.buildTimeFrame(newBuffer, cs) + else: + log.debug('handleNextAction: %02x' % (Buffer[0][2] & 0xEF)) + self.setSleep(0.300,0.010) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetHistory, cs) + + Length[0] = newLength[0] + Buffer[0] = newBuffer[0] + + def generateResponse(self, Buffer, Length): + if DEBUG_COMM > 1: + log.debug('generateResponse: %s' % self.timing()) + newBuffer = [0] + newBuffer[0] = Buffer[0] + newLength = [0] + newLength[0] = Length[0] + if Length[0] == 0: + raise BadResponse('zero length buffer') + + bufferID = (Buffer[0][0] <<8) | Buffer[0][1] + respType = (Buffer[0][2] & 0xE0) + if DEBUG_COMM > 1: + log.debug("generateResponse: id=%04x resp=%x length=%x" + % (bufferID, respType, Length[0])) + deviceID = self.DataStore.getDeviceID() + if bufferID != 0xF0F0: + self.DataStore.setRegisteredDeviceID(bufferID) + + if bufferID == 0xF0F0: + log.info('generateResponse: console not paired, attempting to pair to 0x%04x' % deviceID) + newLength[0] = self.buildACKFrame(newBuffer, EAction.aGetConfig, deviceID, 0xFFFF) + elif bufferID == deviceID: + if respType == EResponseType.rtDataWritten: + # 00000000: 00 00 06 00 32 20 + if Length[0] == 0x06: + self.DataStore.StationConfig.setResetMinMaxFlags(0) + self.shid.setRX() + raise DataWritten() + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtGetConfig: + # 00000000: 00 00 30 00 32 40 + if Length[0] == 0x30: + self.handleConfig(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtGetCurrentWeather: + # 00000000: 00 00 d7 00 32 60 + if Length[0] == 0xd7: #215 + self.handleCurrentData(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtGetHistory: + # 00000000: 00 00 1e 00 32 80 + if Length[0] == 0x1e: + self.handleHistoryData(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + elif respType == EResponseType.rtRequest: + # 00000000: 00 00 06 f0 f0 a1 + # 00000000: 00 00 06 00 32 a3 + # 00000000: 00 00 06 00 32 a2 + if Length[0] == 0x06: + self.handleNextAction(newBuffer, newLength) + else: + raise BadResponse('len=%x resp=%x' % (Length[0], respType)) + else: + raise BadResponse('unexpected response type %x' % respType) + elif respType not in [0x20,0x40,0x60,0x80,0xa1,0xa2,0xa3]: + # message is probably corrupt + raise BadResponse('unknown response type %x' % respType) + else: + msg = 'message from console contains unknown device ID (id=%04x resp=%x)' % (bufferID, respType) + log.debug(msg) + log_frame(Length[0],Buffer[0]) + raise BadResponse(msg) + + Buffer[0] = newBuffer[0] + Length[0] = newLength[0] + + def configureRegisterNames(self): + self.reg_names[self.AX5051RegisterNames.IFMODE] =0x00 + self.reg_names[self.AX5051RegisterNames.MODULATION]=0x41 #fsk + self.reg_names[self.AX5051RegisterNames.ENCODING] =0x07 + self.reg_names[self.AX5051RegisterNames.FRAMING] =0x84 #1000:0100 ##?hdlc? |1000 010 0 + self.reg_names[self.AX5051RegisterNames.CRCINIT3] =0xff + self.reg_names[self.AX5051RegisterNames.CRCINIT2] =0xff + self.reg_names[self.AX5051RegisterNames.CRCINIT1] =0xff + self.reg_names[self.AX5051RegisterNames.CRCINIT0] =0xff + self.reg_names[self.AX5051RegisterNames.FREQ3] =0x38 + self.reg_names[self.AX5051RegisterNames.FREQ2] =0x90 + self.reg_names[self.AX5051RegisterNames.FREQ1] =0x00 + self.reg_names[self.AX5051RegisterNames.FREQ0] =0x01 + self.reg_names[self.AX5051RegisterNames.PLLLOOP] =0x1d + self.reg_names[self.AX5051RegisterNames.PLLRANGING]=0x08 + self.reg_names[self.AX5051RegisterNames.PLLRNGCLK] =0x03 + self.reg_names[self.AX5051RegisterNames.MODMISC] =0x03 + self.reg_names[self.AX5051RegisterNames.SPAREOUT] =0x00 + self.reg_names[self.AX5051RegisterNames.TESTOBS] =0x00 + self.reg_names[self.AX5051RegisterNames.APEOVER] =0x00 + self.reg_names[self.AX5051RegisterNames.TMMUX] =0x00 + self.reg_names[self.AX5051RegisterNames.PLLVCOI] =0x01 + self.reg_names[self.AX5051RegisterNames.PLLCPEN] =0x01 + self.reg_names[self.AX5051RegisterNames.RFMISC] =0xb0 + self.reg_names[self.AX5051RegisterNames.REF] =0x23 + self.reg_names[self.AX5051RegisterNames.IFFREQHI] =0x20 + self.reg_names[self.AX5051RegisterNames.IFFREQLO] =0x00 + self.reg_names[self.AX5051RegisterNames.ADCMISC] =0x01 + self.reg_names[self.AX5051RegisterNames.AGCTARGET] =0x0e + self.reg_names[self.AX5051RegisterNames.AGCATTACK] =0x11 + self.reg_names[self.AX5051RegisterNames.AGCDECAY] =0x0e + self.reg_names[self.AX5051RegisterNames.CICDEC] =0x3f + self.reg_names[self.AX5051RegisterNames.DATARATEHI]=0x19 + self.reg_names[self.AX5051RegisterNames.DATARATELO]=0x66 + self.reg_names[self.AX5051RegisterNames.TMGGAINHI] =0x01 + self.reg_names[self.AX5051RegisterNames.TMGGAINLO] =0x96 + self.reg_names[self.AX5051RegisterNames.PHASEGAIN] =0x03 + self.reg_names[self.AX5051RegisterNames.FREQGAIN] =0x04 + self.reg_names[self.AX5051RegisterNames.FREQGAIN2] =0x0a + self.reg_names[self.AX5051RegisterNames.AMPLGAIN] =0x06 + self.reg_names[self.AX5051RegisterNames.AGCMANUAL] =0x00 + self.reg_names[self.AX5051RegisterNames.ADCDCLEVEL]=0x10 + self.reg_names[self.AX5051RegisterNames.RXMISC] =0x35 + self.reg_names[self.AX5051RegisterNames.FSKDEV2] =0x00 + self.reg_names[self.AX5051RegisterNames.FSKDEV1] =0x31 + self.reg_names[self.AX5051RegisterNames.FSKDEV0] =0x27 + self.reg_names[self.AX5051RegisterNames.TXPWR] =0x03 + self.reg_names[self.AX5051RegisterNames.TXRATEHI] =0x00 + self.reg_names[self.AX5051RegisterNames.TXRATEMID] =0x51 + self.reg_names[self.AX5051RegisterNames.TXRATELO] =0xec + self.reg_names[self.AX5051RegisterNames.TXDRIVER] =0x88 + + def initTransceiver(self, frequency_standard): + log.debug('initTransceiver: frequency_standard=%s' % frequency_standard) + + self.DataStore.setFrequencyStandard(frequency_standard) + self.configureRegisterNames() + + # calculate the frequency then set frequency registers + freq = self.DataStore.TransceiverSettings.Frequency + log.info('base frequency: %d' % freq) + freqVal = int(freq / 16000000.0 * 16777216.0) + corVec = self.shid.readConfigFlash(0x1F5, 4) + corVal = corVec[0] << 8 + corVal |= corVec[1] + corVal <<= 8 + corVal |= corVec[2] + corVal <<= 8 + corVal |= corVec[3] + log.info('frequency correction: %d (0x%x)' % (corVal, corVal)) + freqVal += corVal + if not (freqVal % 2): + freqVal += 1 + log.info('adjusted frequency: %d (0x%x)' % (freqVal, freqVal)) + self.reg_names[self.AX5051RegisterNames.FREQ3] = (freqVal >>24) & 0xFF + self.reg_names[self.AX5051RegisterNames.FREQ2] = (freqVal >>16) & 0xFF + self.reg_names[self.AX5051RegisterNames.FREQ1] = (freqVal >>8) & 0xFF + self.reg_names[self.AX5051RegisterNames.FREQ0] = (freqVal >>0) & 0xFF + log.debug('frequency registers: %x %x %x %x' % ( + self.reg_names[self.AX5051RegisterNames.FREQ3], + self.reg_names[self.AX5051RegisterNames.FREQ2], + self.reg_names[self.AX5051RegisterNames.FREQ1], + self.reg_names[self.AX5051RegisterNames.FREQ0])) + + # figure out the transceiver id + buf = self.shid.readConfigFlash(0x1F9, 7) + tid = buf[5] << 8 + tid += buf[6] + log.info('transceiver identifier: %d (0x%04x)' % (tid, tid)) + self.DataStore.setDeviceID(tid) + + # figure out the transceiver serial number + sn = str("%02d"%(buf[0])) + sn += str("%02d"%(buf[1])) + sn += str("%02d"%(buf[2])) + sn += str("%02d"%(buf[3])) + sn += str("%02d"%(buf[4])) + sn += str("%02d"%(buf[5])) + sn += str("%02d"%(buf[6])) + log.info('transceiver serial: %s' % sn) + self.DataStore.setTransceiverSerNo(sn) + + for r in self.reg_names: + self.shid.writeReg(r, self.reg_names[r]) + + def setup(self, frequency_standard, + vendor_id, product_id, device_id, serial, + comm_interval=3): + self.DataStore.setCommModeInterval(comm_interval) + self.shid.open(vendor_id, product_id, device_id, serial) + self.initTransceiver(frequency_standard) + self.DataStore.setTransceiverPresent(True) + + def teardown(self): + self.shid.close() + + # FIXME: make this thread-safe + def getWeatherData(self): + return self.DataStore.CurrentWeather + + # FIXME: make this thread-safe + def getLastStat(self): + return self.DataStore.LastStat + + # FIXME: make this thread-safe + def getConfigData(self): + return self.DataStore.StationConfig + + def startCachingHistory(self, since_ts=0, num_rec=0): + self.history_cache.clear_records() + if since_ts is None: + since_ts = 0 + self.history_cache.since_ts = since_ts + if num_rec > WS28xxDriver.max_records - 2: + num_rec = WS28xxDriver.max_records - 2 + self.history_cache.num_rec = num_rec + self.command = EAction.aGetHistory + + def stopCachingHistory(self): + self.command = None + + def getUncachedHistoryCount(self): + return self.history_cache.num_outstanding_records + + def getNextHistoryIndex(self): + return self.history_cache.next_index + + def getNumHistoryScanned(self): + return self.history_cache.num_scanned + + def getLatestHistoryIndex(self): + return self.DataStore.LastStat.LatestHistoryIndex + + def getHistoryCacheRecords(self): + return self.history_cache.records + + def clearHistoryCache(self): + self.history_cache.clear_records() + + def startRFThread(self): + if self.child is not None: + return + log.debug('startRFThread: spawning RF thread') + self.running = True + self.child = threading.Thread(target=self.doRF) + self.child.name = 'RFComm' + self.child.daemon = True + self.child.start() + + def stopRFThread(self): + self.running = False + log.debug('stopRFThread: waiting for RF thread to terminate') + self.child.join(self.thread_wait) + if self.child.is_alive(): + log.error('unable to terminate RF thread after %d seconds' + % self.thread_wait) + else: + self.child = None + + def isRunning(self): + return self.running + + def doRF(self): + try: + log.debug('setting up rf communication') + self.doRFSetup() + log.debug('starting rf communication') + while self.running: + self.doRFCommunication() + except Exception as e: + log.error('exception in doRF: %s' % e) + weeutil.logger.log_traceback(log.error) + self.running = False +# raise + finally: + log.debug('stopping rf communication') + + # it is probably not necessary to have two setPreamblePattern invocations. + # however, HeavyWeatherPro seems to do it this way on a first time config. + # doing it this way makes configuration easier during a factory reset and + # when re-establishing communication with the station sensors. + def doRFSetup(self): + self.shid.execute(5) + self.shid.setPreamblePattern(0xaa) + self.shid.setState(0) + time.sleep(1) + self.shid.setRX() + + self.shid.setPreamblePattern(0xaa) + self.shid.setState(0x1e) + time.sleep(1) + self.shid.setRX() + self.setSleep(0.085,0.005) + + def doRFCommunication(self): + time.sleep(self.firstSleep) + self.pollCount = 0 + while self.running: + state_buffer = self.shid.getState() + self.pollCount += 1 + if state_buffer[1] == 0x16: + break + time.sleep(self.nextSleep) + else: + return + + DataLength = [0] + DataLength[0] = 0 + FrameBuffer=[0] + FrameBuffer[0]=[0]*0x03 + self.shid.getFrame(FrameBuffer, DataLength) + try: + self.generateResponse(FrameBuffer, DataLength) + self.shid.setFrame(FrameBuffer[0], DataLength[0]) + except BadResponse as e: + log.error('generateResponse failed: %s' % e) + except DataWritten as e: + log.debug('SetTime/SetConfig data written') + self.shid.setTX() + + # these are for diagnostics and debugging + def setSleep(self, firstsleep, nextsleep): + self.firstSleep = firstsleep + self.nextSleep = nextsleep + + def timing(self): + s = self.firstSleep + self.nextSleep * (self.pollCount - 1) + return 'sleep=%s first=%s next=%s count=%s' % ( + s, self.firstSleep, self.nextSleep, self.pollCount) diff --git a/dist/weewx-5.0.2/src/weewx/engine.py b/dist/weewx-5.0.2/src/weewx/engine.py new file mode 100644 index 0000000..c89c089 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/engine.py @@ -0,0 +1,900 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Main engine for the weewx weather system.""" + +# Python imports +import gc +import logging +import math +import socket +import sys +import threading +import time + +# weewx imports: +import weeutil.config +import weeutil.logger +import weeutil.weeutil +import weewx.accum +import weewx.manager +import weewx.qc +import weewx.station +import weewx.units +from weeutil.weeutil import to_bool, to_int, to_sorted_string +from weewx import all_service_groups + +log = logging.getLogger(__name__) + + +class BreakLoop(Exception): + """Exception raised when it's time to break the main loop.""" + + +class InitializationError(weewx.WeeWxIOError): + """Exception raised when unable to initialize the console.""" + + +# ============================================================================== +# Class StdEngine +# ============================================================================== + +class StdEngine(object): + """The main engine responsible for the creating and dispatching of events + from the weather station. + + It loads a set of services, specified by an option in the configuration + file. + + When a service loads, it binds callbacks to events. When an event occurs, + the bound callback will be called.""" + + def __init__(self, config_dict): + """Initialize an instance of StdEngine. + + config_dict: The configuration dictionary. """ + + # Set a default socket time out, in case FTP or HTTP hang: + timeout = int(config_dict.get('socket_timeout', 20)) + socket.setdefaulttimeout(timeout) + + # Default garbage collection is every 3 hours: + self.gc_interval = int(config_dict.get('gc_interval', 3 * 3600)) + + # Whether to log events. This can be very verbose. + self.log_events = to_bool(config_dict.get('log_events', False)) + + # The callback dictionary: + self.callbacks = dict() + + # This will hold an instance of the device driver + self.console = None + + # Set up the device driver: + self.setupStation(config_dict) + + # Set up information about the station + self.stn_info = weewx.station.StationInfo(self.console, **config_dict['Station']) + + # Set up the database binder + self.db_binder = weewx.manager.DBBinder(config_dict) + + # The list of instantiated services + self.service_obj = [] + + # Load the services: + self.loadServices(config_dict) + + def setupStation(self, config_dict): + """Set up the weather station hardware.""" + + # Get the hardware type from the configuration dictionary. This will be + # a string such as "VantagePro" + station_type = config_dict['Station']['station_type'] + + # Find the driver name for this type of hardware + driver = config_dict[station_type]['driver'] + + log.info("Loading station type %s (%s)", station_type, driver) + + # Import the driver: + __import__(driver) + + # Open up the weather station, wrapping it in a try block in case + # of failure. + try: + # This is a bit of Python wizardry. First, find the driver module + # in sys.modules. + driver_module = sys.modules[driver] + # Find the function 'loader' within the module: + loader_function = getattr(driver_module, 'loader') + # Call it with the configuration dictionary as the only argument: + self.console = loader_function(config_dict, self) + except Exception as ex: + log.error("Import of driver failed: %s (%s)", ex, type(ex)) + weeutil.logger.log_traceback(log.critical, " **** ") + # Signal that we have an initialization error: + raise InitializationError(ex) + + def loadServices(self, config_dict): + """Set up the services to be run.""" + + # Make sure all service groups are lists (if there's just a single entry, ConfigObj + # will parse it as a string if it did not have a trailing comma). + for service_group in config_dict['Engine']['Services']: + if not isinstance(config_dict['Engine']['Services'][service_group], list): + config_dict['Engine']['Services'][service_group] \ + = [config_dict['Engine']['Services'][service_group]] + + # Versions before v4.2 did not have the service group 'xtype_services'. Set a default + # for them: + config_dict['Engine']['Services'].setdefault('xtype_services', + ['weewx.wxxtypes.StdWXXTypes', + 'weewx.wxxtypes.StdPressureCooker', + 'weewx.wxxtypes.StdRainRater', + 'weewx.wxxtypes.StdDelta']) + + # Wrap the instantiation of the services in a try block, so if an + # exception occurs, any service that may have started can be shut + # down in an orderly way. + try: + # Go through each of the service lists one by one: + for service_group in all_service_groups: + # For each service list, retrieve all the listed services. + # Provide a default, empty list in case the service list is + # missing completely: + svcs = config_dict['Engine']['Services'].get(service_group, []) + for svc in svcs: + if svc == '': + log.debug("No services in service group %s", service_group) + continue + log.debug("Loading service %s", svc) + # Get the class, then instantiate it with self and the config dictionary as + # arguments: + obj = weeutil.weeutil.get_object(svc)(self, config_dict) + # Append it to the list of open services. + self.service_obj.append(obj) + log.debug("Finished loading service %s", svc) + except Exception: + # An exception occurred. Shut down any running services, then + # reraise the exception. + self.shutDown() + raise + + def run(self): + """Main execution entry point.""" + + # Wrap the outer loop in a try block so we can do an orderly shutdown + # should an exception occur: + try: + # Send out a STARTUP event: + self.dispatchEvent(weewx.Event(weewx.STARTUP)) + + log.info("Starting main packet loop.") + + last_gc = time.time() + + # This is the outer loop. + while True: + + # See if garbage collection is scheduled: + if time.time() - last_gc > self.gc_interval: + gc_start = time.time() + ngc = gc.collect() + last_gc = time.time() + gc_time = last_gc - gc_start + log.info("Garbage collected %d objects in %.2f seconds", ngc, gc_time) + + # First, let any interested services know the packet LOOP is + # about to start + self.dispatchEvent(weewx.Event(weewx.PRE_LOOP)) + + # Get ready to enter the main packet loop. An exception of type + # BreakLoop will get thrown when a service wants to break the + # loop and interact with the console. + try: + + # And this is the main packet LOOP. It will continuously + # generate LOOP packets until some service breaks it by + # throwing an exception (usually when an archive period + # has passed). + for packet in self.console.genLoopPackets(): + # Package the packet as an event, then dispatch it. + self.dispatchEvent(weewx.Event(weewx.NEW_LOOP_PACKET, packet=packet)) + + # Allow services to break the loop by throwing + # an exception: + self.dispatchEvent(weewx.Event(weewx.CHECK_LOOP, packet=packet)) + + log.critical("Internal error. Packet loop has exited.") + + except BreakLoop: + + # Send out an event saying the packet LOOP is done: + self.dispatchEvent(weewx.Event(weewx.POST_LOOP)) + + finally: + # The main loop has exited. Shut the engine down. + log.info("Main loop exiting. Shutting engine down.") + self.shutDown() + + def bind(self, event_type, callback): + """Binds an event to a callback function.""" + + # Each event type has a list of callback functions to be called. + # If we have not seen the event type yet, then create an empty list, + # otherwise append to the existing list: + self.callbacks.setdefault(event_type, []).append(callback) + + def dispatchEvent(self, event): + """Call all registered callbacks for an event.""" + # See if any callbacks have been registered for this event type: + if event.event_type in self.callbacks: + if self.log_events: + log.debug(event) + # Yes, at least one has been registered. Call them in order: + for callback in self.callbacks[event.event_type]: + # Call the function with the event as an argument: + callback(event) + + def shutDown(self): + """Run when an engine shutdown is requested.""" + + # Shut down all the services + while self.service_obj: + # Wrap each individual service shutdown, in case of a problem. + try: + # Start from the end of the list and move forward + self.service_obj[-1].shutDown() + except: + pass + # Delete the actual service + del self.service_obj[-1] + + try: + # Close the console: + self.console.closePort() + except: + pass + + try: + self.db_binder.close() + except: + pass + + def _get_console_time(self): + try: + return self.console.getTime() + except NotImplementedError: + return int(time.time() + 0.5) + + +# ============================================================================== +# Class DummyEngine +# ============================================================================== + +class DummyEngine(StdEngine): + """A dummy engine, useful for loading services, but without actually running the engine.""" + + class DummyConsole(object): + """A dummy console, used to offer an archive_interval.""" + + def __init__(self, config_dict): + try: + self.archive_interval = to_int(config_dict['StdArchive']['archive_interval']) + except KeyError: + self.archive_interval = 300 + + def closePort(self): + pass + + def setupStation(self, config_dict): + self.console = DummyEngine.DummyConsole(config_dict) + + +# ============================================================================== +# Class StdService +# ============================================================================== + +class StdService(object): + """Abstract base class for all services.""" + + def __init__(self, engine, config_dict): + self.engine = engine + self.config_dict = config_dict + + def bind(self, event_type, callback): + """Bind the specified event to a callback.""" + # Just forward the request to the main engine: + self.engine.bind(event_type, callback) + + def shutDown(self): + pass + + +# ============================================================================== +# Class StdConvert +# ============================================================================== + +class StdConvert(StdService): + """Service for performing unit conversions. + + This service acts as a filter. Whatever packets and records come in are + converted to a target unit system. + + This service should be run before most of the others, so observations appear + in the correct unit.""" + + def __init__(self, engine, config_dict): + # Initialize my base class: + super().__init__(engine, config_dict) + + # Get the target unit nickname (something like 'US' or 'METRIC'). If there is no + # target, then do nothing + try: + target_unit_nickname = config_dict['StdConvert']['target_unit'] + except KeyError: + # Missing target unit. + return + # Get the target unit: weewx.US, weewx.METRIC, weewx.METRICWX + self.target_unit = weewx.units.unit_constants[target_unit_nickname.upper()] + # Bind self.converter to the appropriate standard converter + self.converter = weewx.units.StdUnitConverters[self.target_unit] + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + log.info("StdConvert target unit is 0x%x", self.target_unit) + + def new_loop_packet(self, event): + """Do unit conversions for a LOOP packet""" + # No need to do anything if the packet is already in the target + # unit system + if event.packet['usUnits'] == self.target_unit: + return + # Perform the conversion + converted_packet = self.converter.convertDict(event.packet) + # Add the new unit system + converted_packet['usUnits'] = self.target_unit + # Replace the old packet with the new, converted packet: + event.packet = converted_packet + + def new_archive_record(self, event): + """Do unit conversions for an archive record.""" + # No need to do anything if the record is already in the target + # unit system + if event.record['usUnits'] == self.target_unit: + return + # Perform the conversion + converted_record = self.converter.convertDict(event.record) + # Add the new unit system + converted_record['usUnits'] = self.target_unit + # Replace the old record with the new, converted record + event.record = converted_record + + +# ============================================================================== +# Class StdCalibrate +# ============================================================================== + +class StdCalibrate(StdService): + """Adjust data using calibration expressions. + + This service must be run before StdArchive, so the correction is applied + before the data is archived. + + To use: + [StdWXCalibrate] + [[Corrections]] + obstype = expression[, loop][, archive] + + where "expression" is a valid Python expression involving any observation types in the same + record, or functions in the "math" module. If only "loop" is present, then only do the + correction in LOOP packets. If only "archive" is present, then only do the corrections in the + archive records. If neither is present, always do the correction in LOOP packets, and do it in + archive records if software record generation is being done. + """ + + def __init__(self, engine, config_dict): + # Initialize my base class: + super().__init__(engine, config_dict) + + if 'StdCalibrate' not in config_dict or 'Corrections' not in config_dict['StdCalibrate']: + log.info("No calibration information in config file. Ignored.") + return + + # Get the set of calibration corrections to apply. + correction_dict = config_dict['StdCalibrate']['Corrections'] + self.expressions = {} + self.corrections = {} + self.which = {} + for obs_type in correction_dict.scalars: + if obs_type == 'foo': continue + # Ensure that the value is always a list + option = weeutil.weeutil.option_as_list(correction_dict[obs_type]) + # Save the uncompiled expression + self.expressions[obs_type] = option[0] + # For each correction, compile it, then save in a dictionary of + # corrections to be applied: + self.corrections[obs_type] = compile(option[0], 'StdCalibrate', 'eval') + # Now save which types of packets/records it should apply to. This will be either + # an empty list, or a list of one or two elements, which can be the strings 'loop', + # or 'archive', or both. + self.which[obs_type] = [v.lower() for v in option[1:]] + # Check the directives: + for val in self.which[obs_type]: + if val not in ('loop', 'archive'): + raise ValueError(f"Invalid directive for StdCalibrate: {val}") + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + """Apply a calibration correction to a LOOP packet""" + for obs_type in self.corrections: + # If no directives were specified (self.which is empty), then always do the correction. + # If a directive has been specified, do the correction if 'loop' is in the directive. + if len(self.which[obs_type]) == 0 or 'loop' in self.which[obs_type]: + try: + event.packet[obs_type] = eval(self.corrections[obs_type], {'math': math}, + event.packet) + except (TypeError, NameError): + pass + except ValueError as e: + log.error("StdCalibration loop error %s", e) + + def new_archive_record(self, event): + """Apply a calibration correction to an archive packet""" + for obs_type in self.corrections: + # If a record was softwrae-generated, then the correction has presumably been + # already applied in the LOOP packet. So, unless told otherwise, do not do the + # correction again. + if ((len(self.which[obs_type]) == 0 and event.origin != 'software') + or 'archive' in self.which[obs_type]): + try: + event.record[obs_type] = eval(self.corrections[obs_type], {'math': math}, + event.record) + except (TypeError, NameError): + pass + except ValueError as e: + log.error("StdCalibration archive error %s", e) + + +# ============================================================================== +# Class StdQC +# ============================================================================== + +class StdQC(StdService): + """Service that performs quality check on incoming data. + + A StdService wrapper for a QC object, so it may be called as a service. This + also allows the weewx.qc.QC class to be used elsewhere without the + overheads of running it as a weewx service. + """ + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + # If the 'StdQC' or 'MinMax' sections do not exist in the configuration + # dictionary, then an exception will get thrown and nothing will be + # done. + try: + mm_dict = config_dict['StdQC']['MinMax'] + except KeyError: + log.info("No QC information in config file.") + return + log_failure = to_bool(weeutil.config.search_up(config_dict['StdQC'], + 'log_failure', True)) + + self.qc = weewx.qc.QC(mm_dict, log_failure) + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + """Apply quality check to the data in a loop packet""" + + self.qc.apply_qc(event.packet, 'LOOP') + + def new_archive_record(self, event): + """Apply quality check to the data in an archive record""" + + self.qc.apply_qc(event.record, 'Archive') + + +# ============================================================================== +# Class StdArchive +# ============================================================================== + +class StdArchive(StdService): + """Service that archives LOOP and archive data in the SQL databases.""" + + # This service manages an "accumulator", which records high/lows and + # averages of LOOP packets over an archive period. At the end of the + # archive period it then emits an archive record. + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + # Extract the various options from the config file. If it's missing, fill in with defaults: + archive_dict = config_dict.get('StdArchive', {}) + self.data_binding = archive_dict.get('data_binding', 'wx_binding') + self.record_generation = archive_dict.get('record_generation', 'hardware').lower() + self.no_catchup = to_bool(archive_dict.get('no_catchup', False)) + self.archive_delay = to_int(archive_dict.get('archive_delay', 15)) + software_interval = to_int(archive_dict.get('archive_interval', 300)) + self.loop_hilo = to_bool(archive_dict.get('loop_hilo', True)) + self.record_augmentation = to_bool(archive_dict.get('record_augmentation', True)) + self.log_success = to_bool(weeutil.config.search_up(archive_dict, 'log_success', True)) + self.log_failure = to_bool(weeutil.config.search_up(archive_dict, 'log_failure', True)) + + log.info("Archive will use data binding %s", self.data_binding) + log.info("Record generation will be attempted in '%s'", self.record_generation) + + # The timestamp that marks the end of the archive period + self.end_archive_period_ts = None + # The timestamp that marks the end of the archive period, plus a delay + self.end_archive_delay_ts = None + # The accumulator to be used for the current archive period + self.accumulator = None + # The accumulator that was used for the last archive period. Set to None after it has + # been processed. + self.old_accumulator = None + + if self.record_generation == 'software': + self.archive_interval = software_interval + ival_msg = "(software record generation)" + elif self.record_generation == 'hardware': + # If the station supports a hardware archive interval, use that. + # Warn if it is different from what is in config. + try: + if software_interval != self.engine.console.archive_interval: + log.info("The archive interval in the configuration file (%d) does not " + "match the station hardware interval (%d).", + software_interval, + self.engine.console.archive_interval) + self.archive_interval = self.engine.console.archive_interval + ival_msg = "(specified by hardware)" + except NotImplementedError: + self.archive_interval = software_interval + ival_msg = "(specified in weewx configuration)" + else: + log.error("Unknown type of record generation: %s", self.record_generation) + raise ValueError(self.record_generation) + + log.info("Using archive interval of %d seconds %s", self.archive_interval, ival_msg) + + if self.archive_delay <= 0: + raise weewx.ViolatedPrecondition("Archive delay (%.1f) must be greater than zero." + % (self.archive_delay,)) + if self.archive_delay >= self.archive_interval / 2: + log.warning("Archive delay (%d) is unusually long", self.archive_delay) + + log.debug("Use LOOP data in hi/low calculations: %d", self.loop_hilo) + + weewx.accum.initialize(config_dict) + + self.bind(weewx.STARTUP, self.startup) + self.bind(weewx.PRE_LOOP, self.pre_loop) + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.CHECK_LOOP, self.check_loop) + self.bind(weewx.POST_LOOP, self.post_loop) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def startup(self, _unused): + """Called when the engine is starting up. Main task is to set up the database, backfill it, + then perform a catch-up if the hardware supports it. """ + + # This will create the database if it doesn't exist: + dbmanager = self.engine.db_binder.get_manager(self.data_binding, initialize=True) + log.info("Using binding '%s' to database '%s'", self.data_binding, dbmanager.database_name) + + # Make sure the daily summaries have not been partially updated + if dbmanager._read_metadata('lastWeightPatch'): + raise weewx.ViolatedPrecondition("Update of daily summary for database '%s' not" + " complete. Finish the update first." + % dbmanager.database_name) + + # Backfill the daily summaries. + _nrecs, _ndays = dbmanager.backfill_day_summary() + + # Do a catch-up on any data still on the station, but not yet put in the database. + if self.no_catchup: + log.debug("No catchup specified.") + else: + # Not all consoles can do a hardware catchup, so be prepared to catch the exception: + try: + self._catchup(self.engine.console.genStartupRecords) + except NotImplementedError: + pass + + def pre_loop(self, _event): + """Called before the main packet loop is entered.""" + + # If this is the initial time through the loop, then the end of + # the archive and delay periods need to be primed: + if not self.end_archive_period_ts: + now = self.engine._get_console_time() + start_archive_period_ts = weeutil.weeutil.startOfInterval(now, self.archive_interval) + self.end_archive_period_ts = start_archive_period_ts + self.archive_interval + self.end_archive_delay_ts = self.end_archive_period_ts + self.archive_delay + self.old_accumulator = None + + def new_loop_packet(self, event): + """Called when A new LOOP record has arrived.""" + + # Do we have an accumulator at all? If not, create one: + if not self.accumulator: + self.accumulator = self._new_accumulator(event.packet['dateTime']) + + # Try adding the LOOP packet to the existing accumulator. If the + # timestamp is outside the timespan of the accumulator, an exception + # will be thrown: + try: + self.accumulator.addRecord(event.packet, add_hilo=self.loop_hilo) + except weewx.accum.OutOfSpan: + # Shuffle accumulators: + (self.old_accumulator, self.accumulator) = \ + (self.accumulator, self._new_accumulator(event.packet['dateTime'])) + # Try again: + self.accumulator.addRecord(event.packet, add_hilo=self.loop_hilo) + + def check_loop(self, event): + """Called after any loop packets have been processed. This is the opportunity + to break the main loop by throwing an exception.""" + # Is this the end of the archive period? If so, dispatch an + # END_ARCHIVE_PERIOD event + if event.packet['dateTime'] > self.end_archive_period_ts: + self.engine.dispatchEvent(weewx.Event(weewx.END_ARCHIVE_PERIOD, + packet=event.packet, + end=self.end_archive_period_ts)) + start_archive_period_ts = weeutil.weeutil.startOfInterval(event.packet['dateTime'], + self.archive_interval) + self.end_archive_period_ts = start_archive_period_ts + self.archive_interval + + # Has the end of the archive delay period ended? If so, break the loop. + if event.packet['dateTime'] >= self.end_archive_delay_ts: + raise BreakLoop + + def post_loop(self, _event): + """The main packet loop has ended, so process the old accumulator.""" + # If weewx happens to startup in the small time interval between the end of + # the archive interval and the end of the archive delay period, then + # there will be no old accumulator. Check for this. + if self.old_accumulator: + # If the user has requested software generation, then do that: + if self.record_generation == 'software': + self._software_catchup() + elif self.record_generation == 'hardware': + # Otherwise, try to honor hardware generation. An exception + # will be raised if the console does not support it. In that + # case, fall back to software generation. + try: + self._catchup(self.engine.console.genArchiveRecords) + except NotImplementedError: + self._software_catchup() + else: + raise ValueError("Unknown station record generation value %s" + % self.record_generation) + self.old_accumulator = None + + # Set the time of the next break loop: + self.end_archive_delay_ts = self.end_archive_period_ts + self.archive_delay + + def new_archive_record(self, event): + """Called when a new archive record has arrived. + Put it in the archive database.""" + + # If requested, extract any extra information we can out of the accumulator and put it in + # the record. Not necessary in the case of software record generation because it has + # already been done. + if self.record_augmentation \ + and self.old_accumulator \ + and event.record['dateTime'] == self.old_accumulator.timespan.stop \ + and event.origin != 'software': + self.old_accumulator.augmentRecord(event.record) + + dbmanager = self.engine.db_binder.get_manager(self.data_binding) + dbmanager.addRecord(event.record, + accumulator=self.old_accumulator, + log_success=self.log_success, + log_failure=self.log_failure) + + def _catchup(self, generator): + """Pull any unarchived records off the console and archive them. + + If the hardware does not support hardware archives, an exception of + type NotImplementedError will be thrown.""" + + dbmanager = self.engine.db_binder.get_manager(self.data_binding) + # Find out when the database was last updated. + lastgood_ts = dbmanager.lastGoodStamp() + + try: + # Now ask the console for any new records since then. Not all + # consoles support this feature. Note that for some consoles, + # notably the Vantage, when doing a long catchup the archive + # records may not be on the same boundaries as the archive + # interval. Reject any records that have a timestamp in the + # future, but provide some lenience for clock drift. + for record in generator(lastgood_ts): + ts = record.get('dateTime') + if ts and ts < time.time() + self.archive_delay: + self.engine.dispatchEvent(weewx.Event(weewx.NEW_ARCHIVE_RECORD, + record=record, + origin='hardware')) + else: + log.warning("Ignore historical record: %s" % record) + except weewx.HardwareError as e: + log.error("Internal error detected. Catchup abandoned") + log.error("**** %s" % e) + + def _software_catchup(self): + # Extract a record out of the old accumulator. + record = self.old_accumulator.getRecord() + # Add the archive interval + record['interval'] = self.archive_interval / 60 + # Send out an event with the new record: + self.engine.dispatchEvent(weewx.Event(weewx.NEW_ARCHIVE_RECORD, + record=record, + origin='software')) + + def _new_accumulator(self, timestamp): + start_ts = weeutil.weeutil.startOfInterval(timestamp, + self.archive_interval) + end_ts = start_ts + self.archive_interval + + # Instantiate a new accumulator + new_accumulator = weewx.accum.Accum(weeutil.weeutil.TimeSpan(start_ts, end_ts)) + return new_accumulator + + +# ============================================================================== +# Class StdTimeSynch +# ============================================================================== + +class StdTimeSynch(StdService): + """Regularly asks the station to synch up its clock.""" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + # Zero out the time of last synch, and get the time between synchs. + self.last_synch_ts = 0 + self.clock_check = int(config_dict.get('StdTimeSynch', + {'clock_check': 14400}).get('clock_check', 14400)) + self.max_drift = int(config_dict.get('StdTimeSynch', + {'max_drift': 5}).get('max_drift', 5)) + + self.bind(weewx.STARTUP, self.startup) + self.bind(weewx.PRE_LOOP, self.pre_loop) + + def startup(self, _event): + """Called when the engine is starting up.""" + self.do_sync() + + def pre_loop(self, _event): + """Called before the main event loop is started.""" + self.do_sync() + + def do_sync(self): + """Ask the station to synch up if enough time has passed.""" + # Synch up the station's clock if it's been more than clock_check + # seconds since the last check: + now_ts = time.time() + if now_ts - self.last_synch_ts >= self.clock_check: + self.last_synch_ts = now_ts + try: + console_time = self.engine.console.getTime() + if console_time is None: + return + # getTime can take a long time to run, so we use the current + # system time + diff = console_time - time.time() + log.info("Clock error is %.2f seconds (positive is fast)", diff) + if abs(diff) > self.max_drift: + try: + self.engine.console.setTime() + except NotImplementedError: + log.debug("Station does not support setting the time") + except NotImplementedError: + log.debug("Station does not support reading the time") + except weewx.WeeWxIOError as e: + log.info("Error reading time: %s" % e) + + +# ============================================================================== +# Class StdPrint +# ============================================================================== + +class StdPrint(StdService): + """Service that prints diagnostic information when a LOOP + or archive packet is received.""" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + """Print out the new LOOP packet""" + print("LOOP: ", + weeutil.weeutil.timestamp_to_string(event.packet['dateTime']), + to_sorted_string(event.packet)) + + def new_archive_record(self, event): + """Print out the new archive record.""" + print("REC: ", + weeutil.weeutil.timestamp_to_string(event.record['dateTime']), + to_sorted_string(event.record)) + + +# ============================================================================== +# Class StdReport +# ============================================================================== + +class StdReport(StdService): + """Launches a separate thread to do reporting.""" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + self.max_wait = int(config_dict['StdReport'].get('max_wait', 600)) + self.thread = None + self.launch_time = None + self.record = None + + # check if pyephem is installed and make a suitable log entry + try: + import ephem + log.info("'pyephem' detected, extended almanac data is available") + del ephem + except ImportError: + log.info("'pyephem' not detected, extended almanac data is not available") + + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + self.bind(weewx.POST_LOOP, self.launch_report_thread) + + def new_archive_record(self, event): + """Cache the archive record to pass to the report thread.""" + self.record = event.record + + def launch_report_thread(self, _event): + """Called after the packet LOOP. Processes any new data.""" + import weewx.reportengine + # Do not launch the reporting thread if an old one is still alive. + # To guard against a zombie thread (alive, but doing nothing) launch + # anyway if enough time has passed. + if self.thread and self.thread.is_alive(): + thread_age = time.time() - self.launch_time + if thread_age < self.max_wait: + log.info("Launch of report thread aborted: existing report thread still running") + return + else: + log.warning("Previous report thread has been running" + " %s seconds. Launching report thread anyway.", thread_age) + + try: + self.thread = weewx.reportengine.StdReportEngine(self.config_dict, + self.engine.stn_info, + self.record, + first_run=not self.launch_time) + self.thread.start() + self.launch_time = time.time() + except threading.ThreadError: + log.error("Unable to launch report thread.") + self.thread = None + + def shutDown(self): + if self.thread: + log.info("Shutting down StdReport thread") + self.thread.join(20.0) + if self.thread.is_alive(): + log.error("Unable to shut down StdReport thread") + else: + log.debug("StdReport thread has been terminated") + self.thread = None + self.launch_time = None diff --git a/dist/weewx-5.0.2/src/weewx/filegenerator.py b/dist/weewx-5.0.2/src/weewx/filegenerator.py new file mode 100644 index 0000000..816947d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/filegenerator.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +import weewx.cheetahgenerator + +# For backwards compatibility: +FileGenerator = weewx.cheetahgenerator.CheetahGenerator diff --git a/dist/weewx-5.0.2/src/weewx/imagegenerator.py b/dist/weewx-5.0.2/src/weewx/imagegenerator.py new file mode 100644 index 0000000..62d5f36 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/imagegenerator.py @@ -0,0 +1,423 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Generate images for up to an effective date. +Should probably be refactored into smaller functions.""" + +import datetime +import logging +import os.path +import time + +import weeplot.genplot +import weeplot.utilities +import weeutil.logger +import weeutil.weeutil +import weewx.reportengine +import weewx.units +import weewx.xtypes +from weeutil.config import search_up, accumulateLeaves +from weeutil.weeutil import to_bool, to_int, to_float, TimeSpan +from weewx.units import ValueTuple + +log = logging.getLogger(__name__) + + +# ============================================================================= +# Class ImageGenerator +# ============================================================================= + +class ImageGenerator(weewx.reportengine.ReportGenerator): + """Class for managing the image generator.""" + + def run(self): + self.setup() + self.gen_images(self.gen_ts) + + def setup(self): + # generic_dict will contain "generic" labels, such as "Outside Temperature" + try: + self.generic_dict = self.skin_dict['Labels']['Generic'] + except KeyError: + self.generic_dict = {} + # text_dict contains translated text strings + self.text_dict = self.skin_dict.get('Texts', {}) + self.image_dict = self.skin_dict['ImageGenerator'] + self.formatter = weewx.units.Formatter.fromSkinDict(self.skin_dict) + self.converter = weewx.units.Converter.fromSkinDict(self.skin_dict) + # ensure that the skin_dir is in the image_dict + self.image_dict['skin_dir'] = os.path.join( + self.config_dict['WEEWX_ROOT'], + self.skin_dict['SKIN_ROOT'], + self.skin_dict['skin']) + + def gen_images(self, gen_ts): + """Generate the images. + + The time scales will be chosen to include the given timestamp, with nice beginning and + ending times. + + Args: + gen_ts (int): The time around which plots are to be generated. This will also be used + as the bottom label in the plots. [optional. Default is to use the time of the last + record in the database.] + """ + t1 = time.time() + ngen = 0 + + # determine how much logging is desired + log_success = to_bool(search_up(self.image_dict, 'log_success', True)) + + # Loop over each time span class (day, week, month, etc.): + for timespan in self.image_dict.sections: + + # Now, loop over all plot names in this time span class: + for plotname in self.image_dict[timespan].sections: + + # Accumulate all options from parent nodes: + plot_options = accumulateLeaves(self.image_dict[timespan][plotname]) + + plotgen_ts = gen_ts + if not plotgen_ts: + binding = plot_options['data_binding'] + db_manager = self.db_binder.get_manager(binding) + plotgen_ts = db_manager.lastGoodStamp() + if not plotgen_ts: + plotgen_ts = time.time() + + image_root = os.path.join(self.config_dict['WEEWX_ROOT'], + plot_options['HTML_ROOT']) + # Get the path that the image is going to be saved to: + img_file = os.path.join(image_root, '%s.png' % plotname) + + # Check whether this plot needs to be done at all: + if _skip_this_plot(plotgen_ts, plot_options, img_file): + continue + + # Generate the plot. + plot = self.gen_plot(plotgen_ts, + plot_options, + self.image_dict[timespan][plotname]) + + # 'plot' will be None if skip_if_empty was truthy, and the plot contains no data + if plot: + # We have a valid plot. Render it onto an image + image = plot.render() + + # Create the subdirectory that the image is to be put in. Wrap in a try block + # in case it already exists. + try: + os.makedirs(os.path.dirname(img_file)) + except OSError: + pass + + try: + # Now save the image + image.save(img_file) + ngen += 1 + except IOError as e: + log.error("Unable to save to file '%s' %s:", img_file, e) + + t2 = time.time() + + if log_success: + log.info("Generated %d images for report %s in %.2f seconds", + ngen, + self.skin_dict['REPORT_NAME'], t2 - t1) + + def gen_plot(self, plotgen_ts, plot_options, plot_dict): + """Generate a single plot image. + + Args: + plotgen_ts: A timestamp for which the plot will be valid. This is generally the last + datum to be plotted. + + plot_options: A dictionary of plot options. + + plot_dict: A section in a ConfigObj. Each subsection will contain data about plots + to be generated + + Returns: + An instance of weeplot.genplot.TimePlot or None. If the former, it will be ready + to render. If None, then skip_if_empty was truthy and no valid data were found. + """ + + # Create a new instance of a time plot and start adding to it + plot = weeplot.genplot.TimePlot(plot_options) + + time_length = weeutil.weeutil.nominal_spans(plot_options.get('time_length', 86400)) + # Calculate a suitable min, max time for the requested time. + minstamp, maxstamp, timeinc = weeplot.utilities.scaletime(plotgen_ts - time_length, + plotgen_ts) + x_domain = weeutil.weeutil.TimeSpan(minstamp, maxstamp) + + # Override the x interval if the user has given an explicit interval: + timeinc_user = to_int(plot_options.get('x_interval')) + if timeinc_user is not None: + timeinc = timeinc_user + plot.setXScaling((x_domain.start, x_domain.stop, timeinc)) + + # Set the y-scaling, using any user-supplied hints: + yscale = plot_options.get('yscale', ['None', 'None', 'None']) + plot.setYScaling(weeutil.weeutil.convertToFloat(yscale)) + + # Get a suitable bottom label: + bottom_label_format = plot_options.get('bottom_label_format', '%m/%d/%y %H:%M') + bottom_label = time.strftime(bottom_label_format, time.localtime(plotgen_ts)) + plot.setBottomLabel(bottom_label) + + # Set day/night display + plot.setLocation(self.stn_info.latitude_f, self.stn_info.longitude_f) + plot.setDayNight(to_bool(plot_options.get('show_daynight', False)), + weeplot.utilities.tobgr(plot_options.get('daynight_day_color', + '0xffffff')), + weeplot.utilities.tobgr(plot_options.get('daynight_night_color', + '0xf0f0f0')), + weeplot.utilities.tobgr(plot_options.get('daynight_edge_color', + '0xefefef'))) + + # Calculate the domain over which we should check for non-null data. It will be + # 'None' if we are not to do the check at all. + check_domain = _get_check_domain(plot_options.get('skip_if_empty', False), x_domain) + + # Set to True if we have _any_ data for the plot + have_data = False + + # Loop over each line to be added to the plot. + for line_name in plot_dict.sections: + + # Accumulate options from parent nodes. + line_options = accumulateLeaves(plot_dict[line_name]) + + # See what observation type to use for this line. By default, use the section + # name. + var_type = line_options.get('data_type', line_name) + + # Find the database + binding = line_options['data_binding'] + db_manager = self.db_binder.get_manager(binding) + + # If we were asked, see if there is any non-null data in the plot + skip = _skip_if_empty(db_manager, var_type, check_domain) + if skip: + # Nothing but null data. Skip this line and keep going + continue + # Either we found some non-null data, or skip_if_empty was false, and we don't care. + have_data = True + + # Look for aggregation type: + aggregate_type = line_options.get('aggregate_type') + if aggregate_type in (None, '', 'None', 'none'): + # No aggregation specified. + aggregate_type = aggregate_interval = None + else: + try: + # Aggregation specified. Get the interval. + aggregate_interval = weeutil.weeutil.nominal_spans( + line_options['aggregate_interval']) + except KeyError: + log.error("Aggregate interval required for aggregate type %s", + aggregate_type) + log.error("Line type %s skipped", var_type) + continue + + # We need to pass the line options and plotgen_ts to our xtype. + # First get a copy of line_options... + option_dict = dict(line_options) + # ...then pop off aggregate_type and aggregate_interval because they appear as explicit + # arguments in our xtypes call... + option_dict.pop('aggregate_type', None) + option_dict.pop('aggregate_interval', None) + # ...then add plotgen_ts. + option_dict['plotgen_ts'] = plotgen_ts + # Now we're ready to fetch the data + start_vec_t, stop_vec_t, data_vec_t = weewx.xtypes.get_series( + var_type, + x_domain, + db_manager, + aggregate_type=aggregate_type, + aggregate_interval=aggregate_interval, + **option_dict) + + # Get the type of plot ('bar', 'line', or 'vector') + plot_type = line_options.get('plot_type', 'line').lower() + if plot_type not in {'line', 'bar', 'vector'}: + log.error("Unknown plot type '%s'. Ignored", plot_type) + continue + + if aggregate_type and plot_type != 'bar': + # If aggregating, put the point in the middle of the interval + start_vec_t = ValueTuple( + [x - aggregate_interval / 2.0 for x in start_vec_t[0]], # Value + start_vec_t[1], # Unit + start_vec_t[2]) # Unit group + stop_vec_t = ValueTuple( + [x - aggregate_interval / 2.0 for x in stop_vec_t[0]], # Velue + stop_vec_t[1], # Unit + stop_vec_t[2]) # Unit group + + # Convert the data to the requested units + if plot_options.get('unit'): + # User has specified an override using option 'unit'. Convert to the explicit unit + new_data_vec_t = weewx.units.convert(data_vec_t, plot_options['unit']) + else: + # No override. Convert to whatever the unit group specified. + new_data_vec_t = self.converter.convert(data_vec_t) + + # Add a unit label. NB: all will get overwritten except the last. Get the label + # from the configuration dictionary. + unit_label = line_options.get( + 'y_label', self.formatter.get_label_string(new_data_vec_t[1])) + # Strip off any leading and trailing whitespace so it's easy to center + plot.setUnitLabel(unit_label.strip()) + + # See if a line label has been explicitly requested: + label = line_options.get('label') + if label: + # Yes. Get the text translation. Use the untranslated version if no translation + # is available. + label = self.text_dict.get(label, label) + else: + # No explicit label. Look up a generic one. Use the variable type itself if + # there is no generic label. + label = self.generic_dict.get(var_type, var_type) + + # See if a color has been explicitly requested. + color = line_options.get('color') + if color is not None: color = weeplot.utilities.tobgr(color) + fill_color = line_options.get('fill_color') + if fill_color is not None: fill_color = weeplot.utilities.tobgr(fill_color) + + # Get the line width, if explicitly requested. + width = to_int(line_options.get('width')) + + interval_vec = None + line_gap_fraction = None + vector_rotate = None + + # Some plot types require special treatments: + if plot_type == 'vector': + vector_rotate_str = line_options.get('vector_rotate') + vector_rotate = -float(vector_rotate_str) \ + if vector_rotate_str is not None else None + elif plot_type == 'bar': + interval_vec = [x[1] - x[0] for x in + zip(start_vec_t.value, stop_vec_t.value)] + if plot_type in ('line', 'bar'): + line_gap_fraction = to_float(line_options.get('line_gap_fraction')) + if line_gap_fraction and not 0 <= line_gap_fraction <= 1: + log.error("Gap fraction %5.3f outside range 0 to 1. Ignored.", + line_gap_fraction) + line_gap_fraction = None + + # Get the type of line (only 'solid' or 'none' for now) + line_type = line_options.get('line_type', 'solid') + if line_type.strip().lower() in ['', 'none']: + line_type = None + + marker_type = line_options.get('marker_type') + marker_size = to_int(line_options.get('marker_size', 8)) + + # Add the line to the emerging plot: + plot.addLine(weeplot.genplot.PlotLine( + stop_vec_t[0], new_data_vec_t[0], + label=label, + color=color, + fill_color=fill_color, + width=width, + plot_type=plot_type, + line_type=line_type, + marker_type=marker_type, + marker_size=marker_size, + bar_width=interval_vec, + vector_rotate=vector_rotate, + line_gap_fraction=line_gap_fraction)) + + # Return the constructed plot if it has any non-null data, otherwise return None + return plot if have_data else None + + +def _skip_this_plot(time_ts, plot_options, img_file): + """A plot can be skipped if it was generated recently and has not changed. This happens if the + time since the plot was generated is less than the aggregation interval. + + If a stale_age has been specified, then it can also be skipped if the file has been + freshly generated. + """ + + # Convert from possible string to an integer: + aggregate_interval = weeutil.weeutil.nominal_spans(plot_options.get('aggregate_interval')) + + # Images without an aggregation interval have to be plotted every time. Also, the image + # definitely has to be generated if it doesn't exist. + if aggregate_interval is None or not os.path.exists(img_file): + return False + + # If it's a very old image, then it has to be regenerated + if time_ts - os.stat(img_file).st_mtime >= aggregate_interval: + return False + + # If we're on an aggregation boundary, regenerate. + time_dt = datetime.datetime.fromtimestamp(time_ts) + tdiff = time_dt - time_dt.replace(hour=0, minute=0, second=0, microsecond=0) + if abs(tdiff.seconds % aggregate_interval) < 1: + return False + + # Check for stale plots, but only if 'stale_age' is defined + stale = to_int(plot_options.get('stale_age')) + if stale: + t_now = time.time() + try: + last_mod = os.path.getmtime(img_file) + if t_now - last_mod < stale: + log.debug("Skip '%s': last_mod=%s age=%s stale=%s", + img_file, last_mod, t_now - last_mod, stale) + return True + else: + return False + except os.error: + pass + return True + + +def _get_check_domain(skip_if_empty, x_domain): + # Convert to lower-case. It might not be a string, so be prepared for an AttributeError + try: + skip_if_empty = skip_if_empty.lower() + except AttributeError: + pass + # If it's something we recognize as False, return None + if skip_if_empty in ['false', False, None]: + return None + # If it's True, then return the existing time domain + elif skip_if_empty in ['true', True]: + return x_domain + # Otherwise, it's probably a string (such as 'day', 'month', etc.). Return the corresponding + # time domain + else: + return weeutil.weeutil.timespan_by_name(skip_if_empty, x_domain.stop) + + +def _skip_if_empty(db_manager, var_type, check_domain): + """ + + Args: + db_manager: An open instance of weewx.manager.Manager, or a subclass. + + var_type: An observation type to check (e.g., 'outTemp') + + check_domain: A two-way tuple of timestamps that contain the time domain to be checked + for non-null data. + + Returns: + True if there is no non-null data in the domain. False otherwise. + """ + if check_domain is None: + return False + try: + val = weewx.xtypes.get_aggregate(var_type, check_domain, 'not_null', db_manager) + except weewx.UnknownAggregation: + return True + return not val[0] diff --git a/dist/weewx-5.0.2/src/weewx/manager.py b/dist/weewx-5.0.2/src/weewx/manager.py new file mode 100644 index 0000000..7eddd6e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/manager.py @@ -0,0 +1,1678 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes and functions for interfacing with a weewx database archive. + +This module includes two classes for managing database connections: + + Manager: For managing a WeeWX database without a daily summary. + DaySummaryManager: For managing a WeeWX database with a daily summary. It inherits from Manager. + +While one could instantiate these classes directly, it's easier with these two class methods: + + cls.open(): For opening an existing database. + cls.open_with_create(): For opening a database that may or may not have been created. + +where: + cls is the class to be opened, either weewx.manager.Manager or weewx.manager.DaySummaryManager. + +Which manager to choose depends on whether a daily summary is desired for performance +reasons. Generally, it's a good idea to use one. The database binding section in weewx.conf +is responsible for choosing the type of manager. Here's a typical entry in the configuration file. +Note the entry 'manager': + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The manager handles aggregation of data for historical summaries: + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +To avoid making the user dig into the configuration dictionary to figure out which type of +database manager to open, there is a convenience function for doing so: + + open_manager_with_config(config_dict, data_binding, initialize, default_binding_dict) + +This will return a database manager of the proper type for the specified data binding. + +Because opening a database and creating a manager can be expensive, the module also provides +a caching utility, DBBinder. + +Example: + + db_binder = DBBinder(config_dict) + db_manager = db_binder.get_manager(data_binding='wx_binding') + for row in db_manager.genBatchRows(1664389800, 1664391600): + print(row) + +""" +import datetime +import logging +import os.path +import sys +import time + +import weedb +import weeutil.config +import weeutil.weeutil +import weewx.accum +import weewx.units +import weewx.xtypes +from weeutil.weeutil import timestamp_to_string, to_int, TimeSpan + +log = logging.getLogger(__name__) + + +class IntervalError(ValueError): + """Raised when a bad value of 'interval' is encountered.""" + + +# ============================================================================== +# class Manager +# ============================================================================== + +class Manager(object): + """Manages a database table. Offers a number of convenient member functions for querying and + inserting data into the table. These functions encapsulate whatever sql statements are needed. + + A limitation of this implementation is that it caches the timestamps of the first and last + record in the table. Normally, the caches get updated as data comes in. However, if one manager + is updating the table, while another is doing aggregate queries, the latter manager will be + unaware of later records in the database, and may choose the wrong query strategy. If this + might be the case, call member function _sync() before starting the query. + + Attributes: + connection (weedb.Connection): The underlying database connection. + table_name (str): The name of the main, archive table. + first_timestamp (int): The timestamp of the earliest record in the table. + last_timestamp (int): The timestamp of the last record in the table. + std_unit_system (int): The unit system used by the database table. + sqlkeys (list[str]): A list of the SQL keys that the database table supports. + """ + + def __init__(self, connection, table_name='archive', schema=None): + """Initialize an object of type Manager. + + Args: + connection (weedb.Connection): A weedb connection to the database to be managed. + table_name (str): The name of the table to be used in the database. + Default is 'archive'. + schema (dict): The schema to be used. Optional. + + Raises: + weedb.NoDatabaseError: If the database does not exist and no schema has been + supplied. + weedb.ProgrammingError: If the database exists, but has not been initialized and no + schema has been supplied. + """ + + self.connection = connection + self.table_name = table_name + self.first_timestamp = None + self.last_timestamp = None + self.std_unit_system = None + + # Now get the SQL types. + try: + self.sqlkeys = self.connection.columnsOf(self.table_name) + except weedb.ProgrammingError: + # Database exists, but is uninitialized. Did the caller supply + # a schema? + if schema is None: + # No. Nothing to be done. + log.error("Cannot get columns of table %s, and no schema specified", + self.table_name) + raise + # Database exists, but has not been initialized. Initialize it. + self._initialize_database(schema) + # Try again: + self.sqlkeys = self.connection.columnsOf(self.table_name) + + # Set up cached data. Make sure to call my version, not any subclass's version. This is + # because the subclass has not been initialized yet. + Manager._sync(self) + + @classmethod + def open(cls, database_dict, table_name='archive'): + """Open and return a Manager or a subclass of Manager. The database must exist. + + Args: + cls: The class object to be created. Typically, something + like weewx.manager.DaySummaryManager. + database_dict (dict): A database dictionary holding the information necessary to open + the database. + + For example, for sqlite, it looks something like this: + + { + 'SQLITE_ROOT' : '/home/weewx/archive', + 'database_name' : 'weewx.sdb', + 'driver' : 'weedb.sqlite' + } + + For MySQL: + { + 'host': 'localhost', + 'user': 'weewx', + 'password': 'weewx-password', + 'database_name' : 'weewx', + 'driver' : 'weedb.mysql' + } + + table_name (str): The name of the table to be used in the database. Default + is 'archive'. + + Returns: + cls: An instantiated instance of class "cls". + + Raises: + weedb.NoDatabaseError: If the database does not exist. + weedb.ProgrammingError: If the database exists, but has not been initialized. + """ + + # This will raise a weedb.OperationalError if the database does not exist. The 'open' + # method we are implementing never attempts an initialization, so let it go by. + connection = weedb.connect(database_dict) + + # Create an instance of the right class and return it: + dbmanager = cls(connection, table_name) + return dbmanager + + @classmethod + def open_with_create(cls, database_dict, table_name='archive', schema=None): + """Open and return a Manager or a subclass of Manager, initializing if necessary. + + Args: + cls: The class object to be created. Typically, something + like weewx.manager.DaySummaryManager. + database_dict (dict): A database dictionary holding the information necessary to open + the database. + + For example, for sqlite, it looks something like this: + + { + 'SQLITE_ROOT' : '/home/weewx/archive', + 'database_name' : 'weewx.sdb', + 'driver' : 'weedb.sqlite' + } + + For MySQL: + { + 'host': 'localhost', + 'user': 'weewx', + 'password': 'weewx-password', + 'database_name' : 'weeewx', + 'driver' : 'weedb.mysql' + } + + table_name (str): The name of the table to be used in the database. Default + is 'archive'. + schema: The schema to be used. + Returns: + cls: An instantiated instance of class "cls". + Raises: + weedb.NoDatabaseError: Raised if the database does not exist and a schema has + not been supplied. + weedb.ProgrammingError: Raised if the database exists, but has not been initialized + and no schema has been supplied. + """ + + # This will raise a weedb.OperationalError if the database does not exist. + try: + connection = weedb.connect(database_dict) + except weedb.OperationalError: + # Database does not exist. Did the caller supply a schema? + if schema is None: + # No. Nothing to be done. + log.error("Cannot open database, and no schema specified") + raise + # Yes. Create the database: + weedb.create(database_dict) + # Now I can get a connection + connection = weedb.connect(database_dict) + + # Create an instance of the right class and return it: + dbmanager = cls(connection, table_name=table_name, schema=schema) + return dbmanager + + @property + def database_name(self): + """str: The name of the database the manager is bound to.""" + return self.connection.database_name + + @property + def obskeys(self): + """list[str]: The list of observation types""" + return [obs_type for obs_type in self.sqlkeys + if obs_type not in ['dateTime', 'usUnits', 'interval']] + + def close(self): + self.connection.close() + self.sqlkeys = None + self.first_timestamp = None + self.last_timestamp = None + self.std_unit_system = None + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.close() + + def _initialize_database(self, schema): + """Initialize the tables needed for the archive. + Args: + schema(list|dict): The schema to be used + """ + # If this is an old-style schema, this will raise an exception. Be prepared to catch it. + try: + table_schema = schema['table'] + except TypeError: + # Old style schema: + table_schema = schema + + # List comprehension of the types, joined together with commas. Put the SQL type in + # backquotes, because at least one of them ('interval') is a MySQL reserved word + sqltypestr = ', '.join(["`%s` %s" % _type for _type in table_schema]) + + try: + with weedb.Transaction(self.connection) as cursor: + cursor.execute("CREATE TABLE %s (%s);" % (self.table_name, sqltypestr)) + except weedb.DatabaseError as e: + log.error("Unable to create table '%s' in database '%s': %s", + self.table_name, self.database_name, e) + raise + + log.info("Created and initialized table '%s' in database '%s'", + self.table_name, self.database_name) + + def _create_sync(self): + """Create the internal caches.""" + + # Fetch the first row in the database to determine the unit system in use. If the database + # has never been used, then the unit system is still indeterminate --- set it to 'None'. + _row = self.getSql("SELECT usUnits FROM %s LIMIT 1;" % self.table_name) + self.std_unit_system = _row[0] if _row is not None else None + + # Cache the first and last timestamps + self.first_timestamp = self.firstGoodStamp() + self.last_timestamp = self.lastGoodStamp() + + def _sync(self): + Manager._create_sync(self) + + def lastGoodStamp(self): + """Retrieves the epoch time of the last good archive record. + + Returns: + int|None: Time of the last good archive record as an epoch time, + or None if there are no records. + """ + _row = self.getSql("SELECT MAX(dateTime) FROM %s" % self.table_name) + return _row[0] if _row else None + + def firstGoodStamp(self): + """Retrieves the earliest timestamp in the archive. + + Returns: + int|None: Time of the first good archive record as an epoch time, + or None if there are no records. + """ + _row = self.getSql("SELECT MIN(dateTime) FROM %s" % self.table_name) + return _row[0] if _row else None + + def exists(self, obs_type): + """Checks whether the observation type exists in the database. + + Args: + obs_type(str): The observation type to check for existence. + + Returns: + bool: True if the observation type is in the database schema. False otherwise. + """ + + # Check to see if this is a valid observation type: + return obs_type in self.obskeys + + def has_data(self, obs_type, timespan): + """Checks whether the observation type exists in the database and whether it has any + data. + + Args: + obs_type(str): The observation type to check for existence. + timespan (tuple): A 2-way tuple with the start and stop time to be checked for data. + + Returns: + bool: True if the type is in the schema, and has some data within the given timespan. + Otherwise, return False. + """ + return self.exists(obs_type) \ + and bool(weewx.xtypes.get_aggregate(obs_type, timespan, 'not_null', self)[0]) + + def addRecord(self, record_obj, + accumulator=None, + progress_fn=None, + log_success=True, + log_failure=True): + """ + Commit a single record or a collection of records to the archive. + + Args: + record_obj (typing.Iterable[dict] | dict): Either a data record, or an iterable that can return + data records. Each data record must look like a dictionary, where the keys are the + SQL types and the values are the values to be stored in the database. + accumulator (weewx.accum.Accum): An optional accumulator. If given, the record + will be added to the accumulator. + progress_fn (function): This function will be called every 1000 insertions. It should + have the signature fn(time, N) where time is the unix epoch time, and N is the + insertion count. + log_success (bool): Set to True to have successful insertions logged. + log_failure (bool): Set to True to have unsuccessful insertions logged + + Returns: + int: The number of successful insertions. + """ + + # Determine if record_obj is just a single dictionary instance (in which case it will have + # method 'keys'). If so, wrap it in something iterable (a list): + record_list = [record_obj] if hasattr(record_obj, 'keys') else record_obj + + min_ts = float('inf') # A "big number" + max_ts = 0 + N = 0 + with weedb.Transaction(self.connection) as cursor: + + for record in record_list: + try: + # If the accumulator time matches the record we are working with, + # use it to update the highs and lows. + if accumulator and record_obj['dateTime'] == accumulator.timespan.stop: + self._updateHiLo(accumulator, cursor) + + # Then add the record to the archives: + self._addSingleRecord(record, cursor, log_success, log_failure) + + N += 1 + if progress_fn and N % 1000 == 0: + progress_fn(record['dateTime'], N) + + min_ts = min(min_ts, record['dateTime']) + max_ts = max(max_ts, record['dateTime']) + except (weedb.IntegrityError, weedb.OperationalError) as e: + if log_failure: + log.error("Unable to add record %s to database '%s': %s", + timestamp_to_string(record['dateTime']), + self.database_name, e) + + # Update the cached timestamps. This has to sit outside the transaction context, + # in case an exception occurs. + if self.first_timestamp is not None: + self.first_timestamp = min(min_ts, self.first_timestamp) + if self.last_timestamp is not None: + self.last_timestamp = max(max_ts, self.last_timestamp) + + return N + + def _addSingleRecord(self, record, cursor, log_success=True, log_failure=True): + """Internal function for adding a single record to the main archive table.""" + + if record['dateTime'] is None: + if log_failure: + log.error("Archive record with null time encountered") + raise weewx.ViolatedPrecondition("Manager record with null time encountered.") + + # Check to make sure the incoming record is in the same unit system as the records already + # in the database: + self._check_unit_system(record['usUnits']) + + # Only data types that appear in the database schema can be inserted. To find them, form + # the intersection between the set of all record keys and the set of all sql keys + record_key_set = set(record.keys()) + insert_key_set = record_key_set.intersection(self.sqlkeys) + # Convert to an ordered list: + key_list = list(insert_key_set) + # Get the values in the same order: + value_list = [record[k] for k in key_list] + + # This will a string of sql types, separated by commas. Because some weewx sql keys + # (notably 'interval') are reserved words in MySQL, put them in backquotes. + k_str = ','.join(["`%s`" % k for k in key_list]) + # This will be a string with the correct number of placeholder + # question marks: + q_str = ','.join('?' * len(key_list)) + # Form the SQL insert statement: + sql_insert_stmt = "INSERT INTO %s (%s) VALUES (%s)" % (self.table_name, k_str, q_str) + cursor.execute(sql_insert_stmt, value_list) + if log_success: + log.info("Added record %s to database '%s'", + timestamp_to_string(record['dateTime']), + self.database_name) + + def _updateHiLo(self, accumulator, cursor): + pass + + def genBatchRows(self, startstamp=None, stopstamp=None): + """Generator function that yields raw rows from the archive database with timestamps within + an interval. + + Args: + startstamp (int|None): Exclusive start of the interval in epoch time. If 'None', + then start at earliest archive record. + stopstamp (int|None): Inclusive end of the interval in epoch time. If 'None', + then end at last archive record. + + Yields: + list: Each iteration yields a single data row as a list. + """ + + with self.connection.cursor() as cursor: + + if startstamp is None: + if stopstamp is None: + gen = cursor.execute("SELECT * FROM %s " + "ORDER BY dateTime ASC" % self.table_name) + else: + gen = cursor.execute("SELECT * FROM %s " + "WHERE dateTime <= ? " + "ORDER BY dateTime ASC" % self.table_name, + (stopstamp,)) + else: + if stopstamp is None: + gen = cursor.execute("SELECT * FROM %s " + "WHERE dateTime > ? " + "ORDER BY dateTime ASC" % self.table_name, + (startstamp,)) + else: + gen = cursor.execute("SELECT * FROM %s " + "WHERE dateTime > ? AND dateTime <= ? " + "ORDER BY dateTime ASC" % self.table_name, + (startstamp, stopstamp)) + + for row in gen: + yield row + + def genBatchRecords(self, startstamp=None, stopstamp=None): + """Generator function that yields records with timestamps within an interval. + + Args: + startstamp (int|float|None): Exclusive start of the interval in epoch time. If 'None', + then start at earliest archive record. + stopstamp (int|float|None): Inclusive end of the interval in epoch time. If 'None', + then end at last archive record. + + Yields: + dict: A dictionary where key is the observation type (eg, 'outTemp') and the + value is the observation value. + """ + + last_time = 0 + for row in self.genBatchRows(startstamp, stopstamp): + record = dict(zip(self.sqlkeys, row)) + # The following is to get around a bug in sqlite when all the + # tables are in one file: + if record['dateTime'] <= last_time: + continue + last_time = record['dateTime'] + yield record + + def getRecord(self, timestamp, max_delta=None): + """Get a single archive record with a given epoch time stamp. + + Args: + timestamp (int): The epoch time of the desired record. + max_delta (int|None): The largest difference in time that is acceptable. + [Optional. The default is no difference] + + Returns: + dict|None: a record dictionary or None if the record does not exist. + """ + + with self.connection.cursor() as _cursor: + + if max_delta: + time_start_ts = timestamp - max_delta + time_stop_ts = timestamp + max_delta + _cursor.execute("SELECT * FROM %s WHERE dateTime>=? AND dateTime<=? " + "ORDER BY ABS(dateTime-?) ASC LIMIT 1" % self.table_name, + (time_start_ts, time_stop_ts, timestamp)) + else: + _cursor.execute("SELECT * FROM %s WHERE dateTime=?" + % self.table_name, (timestamp,)) + _row = _cursor.fetchone() + return dict(zip(self.sqlkeys, _row)) if _row else None + + def updateValue(self, timestamp, obs_type, new_value): + """Update (replace) a single value in the database. + + Args: + timestamp (int): The timestamp of the record to be updated. + obs_type (str): The observation type to be updated. + new_value (float | str): The updated value + """ + + self.connection.execute("UPDATE %s SET %s=? WHERE dateTime=?" % + (self.table_name, obs_type), (new_value, timestamp)) + + def getSql(self, sql, sqlargs=(), cursor=None): + """Executes an arbitrary SQL statement on the database. The result will be a single row. + + Args: + sql (str): The SQL statement + sqlargs (tuple): A tuple containing the arguments for the SQL statement + cursor (cursor| None): An optional cursor to be used. If not given, then one will be + created and closed when finished. + + Returns: + tuple: a tuple containing a single result set. + """ + _cursor = cursor or self.connection.cursor() + try: + _cursor.execute(sql, sqlargs) + return _cursor.fetchone() + finally: + if cursor is None: + _cursor.close() + + def genSql(self, sql, sqlargs=()): + """Generator function that executes an arbitrary SQL statement on + the database, returning a result set. + + Args: + sql (str): The SQL statement + sqlargs (tuple): A tuple containing the arguments for the SQL statement. + + Yields: + list: A row in the result set. + """ + + with self.connection.cursor() as _cursor: + for _row in _cursor.execute(sql, sqlargs): + yield _row + + def getAggregate(self, timespan, obs_type, + aggregate_type, **option_dict): + """ OBSOLETE. Use weewx.xtypes.get_aggregate() instead. """ + + return weewx.xtypes.get_aggregate(obs_type, timespan, aggregate_type, self, **option_dict) + + def getSqlVectors(self, timespan, obs_type, + aggregate_type=None, + aggregate_interval=None): + """ OBSOLETE. Use weewx.xtypes.get_series() instead """ + + return weewx.xtypes.get_series(obs_type, timespan, self, + aggregate_type, aggregate_interval) + + def add_column(self, column_name, column_type="REAL"): + """Add a single new column to the database. + + Args: + column_name (str): The name of the new column. + column_type (str): The type ("REAL"|"INTEGER|) of the new column. Default is "REAL". + """ + with weedb.Transaction(self.connection) as cursor: + self._add_column(column_name, column_type, cursor) + + def _add_column(self, column_name, column_type, cursor): + """Add a column to the main archive table""" + cursor.execute("ALTER TABLE %s ADD COLUMN `%s` %s" + % (self.table_name, column_name, column_type)) + + def rename_column(self, old_column_name, new_column_name): + """Rename an existing column + + Args: + old_column_name (str): Tne old name of the column to be renamed. + new_column_name (str): Its new name + """ + with weedb.Transaction(self.connection) as cursor: + self._rename_column(old_column_name, new_column_name, cursor) + + def _rename_column(self, old_column_name, new_column_name, cursor): + """Rename a column in the main archive table.""" + cursor.execute("ALTER TABLE %s RENAME COLUMN %s TO %s" + % (self.table_name, old_column_name, new_column_name)) + + def drop_columns(self, column_names): + """Drop a list of columns from the database + + Args: + column_names (list[str]): A list containing the observation types to be dropped. + """ + with weedb.Transaction(self.connection) as cursor: + self._drop_columns(column_names, cursor) + + def _drop_columns(self, column_names, cursor): + """Drop a column in the main archive table""" + cursor.drop_columns(self.table_name, column_names) + + def _check_unit_system(self, unit_system): + """Check to make sure a unit system is the same as what's already in use in the database. + """ + + if self.std_unit_system is not None: + if unit_system != self.std_unit_system: + raise weewx.UnitError("Unit system of incoming record (0x%02x) " + "differs from '%s' table in '%s' database (0x%02x)" % + (unit_system, self.table_name, self.database_name, + self.std_unit_system)) + else: + # This is the first record. Remember the unit system to check against subsequent + # records: + self.std_unit_system = unit_system + + +def reconfig(old_db_dict, new_db_dict, new_unit_system=None, new_schema=None, dry_run=False): + """Copy over an old archive to a new one, using an optionally new unit system and schema. + + Args: + old_db_dict (dict): The database dictionary for the old database. See + method Manager.open() for the definition of a database dictionary. + new_db_dict (dict): THe database dictionary for the new database. See + method Manager.open() for the definition of a database dictionary. + new_unit_system (int|None): The new unit system to be used, or None to keep the old one. + new_schema (dict|None): The new schema to use, or None to use the old one. + dry_run (bool|None): Set to True to do a dry run without altering anything. + """ + + with Manager.open(old_db_dict) as old_archive: + if new_schema is None: + import schemas.wview_extended + new_schema = schemas.wview_extended.schema + with Manager.open_with_create(new_db_dict, schema=new_schema) as new_archive: + # Wrap the input generator in a unit converter. + record_generator = weewx.units.GenWithConvert(old_archive.genBatchRecords(), + new_unit_system) + if not dry_run: + # This is very fast because it is done in a single transaction context: + new_archive.addRecord(record_generator) + + +# =============================================================================== +# Class DBBinder +# =============================================================================== + +class DBBinder(object): + """Given a binding name, it returns the matching database as a managed object. Caches + results. + """ + + def __init__(self, config_dict): + """ Initialize a DBBinder object. + + Args: + config_dict (dict): The configuration dictionary. + """ + + self.config_dict = config_dict + self.default_binding_dict = {} + self.manager_cache = {} + + def close(self): + for data_binding in list(self.manager_cache.keys()): + self.manager_cache[data_binding].close() + del self.manager_cache[data_binding] + + def __enter__(self): + return self + + def __exit__(self, etyp, einst, etb): # @UnusedVariable + self.close() + + def set_binding_defaults(self, binding_name, new_default_binding_dict): + """Set the defaults for the binding binding_name.""" + self.default_binding_dict[binding_name] = new_default_binding_dict + + def get_manager(self, data_binding='wx_binding', initialize=False): + """Given a binding name, returns the managed object + + Args: + data_binding (str): The returned Manager object will be bound to this binding. + initialize (bool): True to initialize the database first. + + Returns: + weewx.manager.Manager: Or its subclass, weewx.manager.DaySummaryManager, depending + on the settings under the [DataBindings] section. + """ + global default_binding_dict + + if data_binding not in self.manager_cache: + # If this binding has a set of defaults, use them. Otherwise, use the generic + # defaults + defaults = self.default_binding_dict.get(data_binding, default_binding_dict) + manager_dict = get_manager_dict_from_config(self.config_dict, + data_binding, + default_binding_dict=defaults) + self.manager_cache[data_binding] = open_manager(manager_dict, initialize) + + return self.manager_cache[data_binding] + + # For backwards compatibility with early V3.1 alphas: + get_database = get_manager + + def bind_default(self, default_binding='wx_binding'): + """Returns a function that holds a default database binding.""" + + def db_lookup(data_binding=None): + if data_binding is None: + data_binding = default_binding + return self.get_manager(data_binding) + + return db_lookup + + +# =============================================================================== +# Utilities +# =============================================================================== + +# If the [DataBindings] section is missing or incomplete, this is the set +# of defaults that will be used. +default_binding_dict = {'database': 'archive_sqlite', + 'table_name': 'archive', + 'manager': 'weewx.manager.DaySummaryManager', + 'schema': 'schemas.wview_extended.schema'} + + +def get_database_dict_from_config(config_dict, database): + """Convenience function that given a configuration dictionary and a database name, + returns a database dictionary that can be used to open the database using Manager.open(). + + Args: + + config_dict (dict): The configuration dictionary. + database (str): The database whose database dict is to be retrieved + (example: 'archive_sqlite') + + Returns: + dict: A database dictionary, with everything needed to pass on to a Manager or weedb in + order to open a database. + + Example: + Given a configuration file snippet that looks like: + + >>> import configobj + >>> from io import StringIO + >>> config_snippet = ''' + ... WEEWX_ROOT = /home/weewx + ... [DatabaseTypes] + ... [[SQLite]] + ... driver = weedb.sqlite + ... SQLITE_ROOT = %(WEEWX_ROOT)s/archive + ... [Databases] + ... [[archive_sqlite]] + ... database_name = weewx.sdb + ... database_type = SQLite''' + >>> c_dict = configobj.ConfigObj(StringIO(config_snippet)) + >>> d_dict = get_database_dict_from_config(c_dict, 'archive_sqlite') + >>> keys = sorted(d_dict) + >>> for k in keys: + ... print("%15s: %12s" % (k, d_dict[k])) + SQLITE_ROOT: /home/weewx/archive + database_name: weewx.sdb + driver: weedb.sqlite + """ + try: + database_dict = dict(config_dict['Databases'][database]) + except KeyError as e: + raise weewx.UnknownDatabase("Unknown database '%s'" % e) + + # See if a 'database_type' is specified. This is something + # like 'SQLite' or 'MySQL'. If it is, use it to augment any + # missing information in the database_dict: + if 'database_type' in database_dict: + database_type = database_dict.pop('database_type') + + # Augment any missing information in the database dictionary with + # the top-level stanza + if database_type in config_dict['DatabaseTypes']: + weeutil.config.conditional_merge(database_dict, + config_dict['DatabaseTypes'][database_type]) + else: + raise weewx.UnknownDatabaseType('database_type') + + # This requires a bit of database-specific knowledge, but it's easier than some sort of + # dependency injection from the SQLite code... + if 'SQLITE_ROOT' in database_dict: + database_dict['SQLITE_ROOT'] = os.path.join(config_dict['WEEWX_ROOT'], + database_dict['SQLITE_ROOT']) + return database_dict + + +# +# A "manager dict" includes keys: +# +# manager: The manager class +# table_name: The name of the internal table +# schema: The schema to be used in case of initialization +# database_dict: The database dictionary. This will be passed on to weedb. +# +def get_manager_dict_from_config(config_dict, data_binding, + default_binding_dict=default_binding_dict): + # Start with a copy of the bindings in the config dictionary (we will be adding to it): + try: + manager_dict = dict(config_dict['DataBindings'][data_binding]) + except KeyError as e: + raise weewx.UnknownBinding("Unknown data binding '%s'" % e) + + # If anything is missing, substitute from the default dictionary: + weeutil.config.conditional_merge(manager_dict, default_binding_dict) + + # Now get the database dictionary if it's missing: + if 'database_dict' not in manager_dict: + database = manager_dict.pop('database') + manager_dict['database_dict'] = get_database_dict_from_config(config_dict, database) + + # The schema may be specified as a string, in which case we resolve the python object to which + # it refers. Or it may be specified as a dict with field_name=sql_type pairs. + schema_name = manager_dict.get('schema') + if schema_name is None: + manager_dict['schema'] = None + elif isinstance(schema_name, dict): + # Schema is a ConfigObj section (that is, a dictionary). Retrieve the + # elements of the schema in order: + manager_dict['schema'] = [(col_name, manager_dict['schema'][col_name]) for col_name in + manager_dict['schema']] + else: + # Schema is a string, with the name of the schema object + manager_dict['schema'] = weeutil.weeutil.get_object(schema_name) + + return manager_dict + + +# The following is for backwards compatibility: +def get_manager_dict(bindings_dict, databases_dict, data_binding, + default_binding_dict=default_binding_dict): + if bindings_dict.parent != databases_dict.parent: + raise weewx.UnsupportedFeature("Database and binding dictionaries" + " require common parent") + return get_manager_dict_from_config(bindings_dict.parent, data_binding, + default_binding_dict) + + +def open_manager(manager_dict, initialize=False): + manager_cls = weeutil.weeutil.get_object(manager_dict['manager']) + if initialize: + return manager_cls.open_with_create(manager_dict['database_dict'], + manager_dict['table_name'], + manager_dict['schema']) + else: + return manager_cls.open(manager_dict['database_dict'], + manager_dict['table_name']) + + +def open_manager_with_config(config_dict, data_binding, + initialize=False, default_binding_dict=default_binding_dict): + """Given a binding name, returns an open manager object.""" + manager_dict = get_manager_dict_from_config(config_dict, + data_binding=data_binding, + default_binding_dict=default_binding_dict) + return open_manager(manager_dict, initialize) + + +def drop_database(manager_dict): + """Drop (delete) a database, given a manager dict""" + + weedb.drop(manager_dict['database_dict']) + + +def drop_database_with_config(config_dict, data_binding, + default_binding_dict=default_binding_dict): + """Drop (delete) the database associated with a binding name""" + + manager_dict = get_manager_dict_from_config(config_dict, + data_binding=data_binding, + default_binding_dict=default_binding_dict) + drop_database(manager_dict) + + +def show_progress(last_time, nrec=None): + """Utility function to show our progress""" + if nrec: + msg = "Records processed: %d; time: %s\r" \ + % (nrec, timestamp_to_string(last_time)) + else: + msg = "Processed through: %s\r" % timestamp_to_string(last_time) + print(msg, end='', file=sys.stdout) + sys.stdout.flush() + + +# =============================================================================== +# Class DaySummaryManager +# +# Adds daily summaries to the database. +# +# This class specializes method _addSingleRecord so that it adds the data to a daily summary, +# as well as the regular archive table. +# +# Note that a date does not include midnight --- that belongs to the previous day. That is +# because a data record archives the *previous* interval. So, for the date 5-Oct-2008 with a +# five-minute archive interval, the statistics would include the following records (local +# time): +# 5-Oct-2008 00:05:00 +# 5-Oct-2008 00:10:00 +# 5-Oct-2008 00:15:00 +# . +# . +# . +# 5-Oct-2008 23:55:00 +# 6-Oct-2008 00:00:00 +# +# =============================================================================== + +class DaySummaryManager(Manager): + """Manage a daily statistical summary. + + The daily summary consists of a separate table for each type. The columns of each table are + things like min, max, the timestamps for min and max, sum and sumtime. The values sum and + sumtime are kept to make it easy to calculate averages for different time periods. + + For example, for type 'outTemp' (outside temperature), there is a table of name + 'archive_day_outTemp' with the following column names: + + dateTime, min, mintime, max, maxtime, sum, count, wsum, sumtime + + wsum is the "Weighted sum," that is, the sum weighted by the archive interval. sumtime is the + sum of the archive intervals. + + In addition to all the tables for each type, there is one additional table called + 'archive_day__metadata', which currently holds the version number and the time of the last + update. + """ + + version = "4.0" + + # Schemas used by the daily summaries: + day_schemas = { + 'scalar': [ + ('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('min', 'REAL'), + ('mintime', 'INTEGER'), + ('max', 'REAL'), + ('maxtime', 'INTEGER'), + ('sum', 'REAL'), + ('count', 'INTEGER'), + ('wsum', 'REAL'), + ('sumtime', 'INTEGER') + ], + 'vector': [ + ('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('min', 'REAL'), + ('mintime', 'INTEGER'), + ('max', 'REAL'), + ('maxtime', 'INTEGER'), + ('sum', 'REAL'), + ('count', 'INTEGER'), + ('wsum', 'REAL'), + ('sumtime', 'INTEGER'), + ('max_dir', 'REAL'), + ('xsum', 'REAL'), + ('ysum', 'REAL'), + ('dirsumtime', 'INTEGER'), + ('squaresum', 'REAL'), + ('wsquaresum', 'REAL'), + ] + } + + # SQL statements used by the metadata in the daily summaries. + meta_create_str = "CREATE TABLE %s_day__metadata (name CHAR(20) NOT NULL " \ + "UNIQUE PRIMARY KEY, value TEXT);" + meta_replace_str = "REPLACE INTO %s_day__metadata VALUES(?, ?)" + meta_select_str = "SELECT value FROM %s_day__metadata WHERE name=?" + + def __init__(self, connection, table_name='archive', schema=None): + """Initialize an instance of DaySummaryManager + + Args: + connection (weedb.Connection): A weedb connection to the database to be managed. + table_name (str): The name of the table to be used in the database. + Default is 'archive'. + schema (None|dict|list): The schema to be used. Optional. + + Raises: + weedb.OperationalError: If a schema has not been supplied and the database does + not exist. + weedb.Uninitialized: If the database exists, but has not been initialized. + """ + # Initialize my superclass: + super().__init__(connection, table_name, schema) + + # Has the database been initialized with the daily summaries? + if '%s_day__metadata' % self.table_name not in self.connection.tables(): + # Database has not been initialized. Initialize it: + self._initialize_day_tables(schema) + + self.version = None + self.daykeys = None + DaySummaryManager._create_sync(self) + self.patch_sums() + + def exists(self, obs_type): + """Checks whether the observation type exists in the database.""" + + # Check both with the superclass, and my own set of daily summaries + return super().exists(obs_type) or obs_type in self.daykeys + + def close(self): + self.version = None + self.daykeys = None + super().close() + + def _create_sync(self): + # Get a list of all the observation types which have daily summaries + all_tables = self.connection.tables() + prefix = "%s_day_" % self.table_name + n_prefix = len(prefix) + meta_name = '%s_day__metadata' % self.table_name + # Create a set of types that are in the daily summaries: + self.daykeys = {x[n_prefix:] for x in all_tables + if (x.startswith(prefix) and x != meta_name)} + + self.version = self._read_metadata('Version') + if self.version is None: + self.version = '1.0' + log.debug('Daily summary version is %s', self.version) + + def _sync(self): + super()._sync() + self._create_sync() + + def _initialize_day_tables(self, schema): + """Initialize the tables needed for the daily summary.""" + + if schema is None: + # Uninitialized, but no schema was supplied. Raise an exception + raise weedb.OperationalError("No day summary schema for table '%s' in database '%s'" + % (self.table_name, self.connection.database_name)) + # See if we have new-style daily summaries, or old-style. Old-style will raise an + # exception. Be prepared to catch it. + try: + day_summaries_schemas = schema['day_summaries'] + except TypeError: + # Old-style schema. Include a daily summary for each observation type in the archive + # table. + day_summaries_schemas = [(e, 'scalar') for e in self.sqlkeys if + e not in ('dateTime', 'usUnits', 'interval')] + import weewx.wxmanager + if type(self) == weewx.wxmanager.WXDaySummaryManager or 'windSpeed' in self.sqlkeys: + # For backwards compatibility, include 'wind' + day_summaries_schemas += [('wind', 'vector')] + + # Create the tables needed for the daily summaries in one transaction: + with weedb.Transaction(self.connection) as cursor: + # obs will be a 2-way tuple (obs_type, ('scalar'|'vector')) + for obs in day_summaries_schemas: + self._initialize_day_table(obs[0], obs[1].lower(), cursor) + + # Now create the meta table... + cursor.execute(DaySummaryManager.meta_create_str % self.table_name) + # ... then put the version number in it: + self._write_metadata('Version', DaySummaryManager.version, cursor) + + log.info("Created daily summary tables") + + def _initialize_day_table(self, obs_type, day_schema_type, cursor): + """Initialize a single daily summary. + + Args: + + obs_type(str): An observation type, such as 'outTemp' + day_schema_type (str): The schema to be used. Either 'scalar', or 'vector' + cursor (weedb.Cursor): An open cursor + """ + s = ', '.join( + ["%s %s" % column_type + for column_type in DaySummaryManager.day_schemas[day_schema_type]]) + + sql_create_str = "CREATE TABLE %s_day_%s (%s);" % (self.table_name, obs_type, s) + cursor.execute(sql_create_str) + + def _add_column(self, column_name, column_type, cursor): + # First call my superclass's version... + Manager._add_column(self, column_name, column_type, cursor) + # ... then do mine + self._initialize_day_table(column_name, 'scalar', cursor) + + def _rename_column(self, old_column_name, new_column_name, cursor): + # First call my superclass's version... + Manager._rename_column(self, old_column_name, new_column_name, cursor) + # ... then do mine + cursor.execute("ALTER TABLE %s_day_%s RENAME TO %s_day_%s;" + % (self.table_name, old_column_name, self.table_name, new_column_name)) + + def _drop_columns(self, column_names, cursor): + # First call my superclass's version... + Manager._drop_columns(self, column_names, cursor) + # ... then do mine + for column_name in column_names: + cursor.execute("DROP TABLE IF EXISTS %s_day_%s;" % (self.table_name, column_name)) + + def _addSingleRecord(self, record, cursor, log_success=True, log_failure=True): + """Specialized version that updates the daily summaries, as well as the main archive + table. + """ + + # First let my superclass handle adding the record to the main archive table: + super()._addSingleRecord(record, cursor, log_success, log_failure) + + # Get the start of day for the record: + _sod_ts = weeutil.weeutil.startOfArchiveDay(record['dateTime']) + + # Get the weight. If the value for 'interval' is bad, an exception will be raised. + try: + _weight = self._calc_weight(record) + except IntervalError as e: + # Bad value for interval. Ignore this record + if log_failure: + log.info(e) + log.info('*** record ignored') + return + + # Now add to the daily summary for the appropriate day: + _day_summary = self._get_day_summary(_sod_ts, cursor) + _day_summary.addRecord(record, weight=_weight) + self._set_day_summary(_day_summary, record['dateTime'], cursor) + if log_success: + log.info("Added record %s to daily summary in '%s'", + timestamp_to_string(record['dateTime']), + self.database_name) + + def _updateHiLo(self, accumulator, cursor): + """Use the contents of an accumulator to update the daily hi/lows.""" + + # Get the start-of-day for the timespan in the accumulator + _sod_ts = weeutil.weeutil.startOfArchiveDay(accumulator.timespan.stop) + + # Retrieve the daily summaries seen so far: + _stats_dict = self._get_day_summary(_sod_ts, cursor) + # Update them with the contents of the accumulator: + _stats_dict.updateHiLo(accumulator) + # Then save the results: + self._set_day_summary(_stats_dict, accumulator.timespan.stop, cursor) + + def backfill_day_summary(self, start_d=None, stop_d=None, + progress_fn=show_progress, trans_days=5): + + """Fill the daily summaries from an archive database. + + Normally, the daily summaries get filled by LOOP packets (to get maximum time resolution), + but if the database gets corrupted, or if a new user is starting up with imported wview + data, it's necessary to recreate it from straight archive data. The Hi/Lows will all be + there, but the times won't be any more accurate than the archive period. + + To help prevent database errors for large archives, database transactions are limited to + trans_days days of archive data. This is a trade-off between speed and memory usage. + + Args: + + start_d (datetime.date|None): The first day to be included, specified as a + datetime.date object [Optional. Default is to start with the first datum + in the archive.] + stop_d (datetime.date|None): The last day to be included, specified as a datetime.date + object [Optional. Default is to include the date of the last archive record.] + progress_fn (function): This function will be called after processing + every 1000 records. + trans_days (int): Number of days of archive data to be used for each daily summaries + database transaction. [Optional. Default is 5.] + + Returns: + tuple[int,int]: A 2-way tuple (nrecs, ndays) where + nrecs is the number of records backfilled; + ndays is the number of days + """ + # Definition: + # last_daily_ts: Timestamp of the last record that was incorporated into the + # daily summary. Usually it is equal to last_record, but it can be less + # if a backfill was aborted. + + log.info("Starting backfill of daily summaries") + + if self.first_timestamp is None: + # Nothing in the archive database, so there's nothing to do. + log.info("Empty database") + return 0, 0 + + # Convert tranch size to a timedelta object, so we can perform arithmetic with it. + tranche_days = datetime.timedelta(days=trans_days) + + t1 = time.time() + + last_daily_ts = to_int(self._read_metadata('lastUpdate')) + + # The goal here is to figure out: + # first_d: A datetime.date object, representing the first date to be rebuilt. + # last_d: A datetime.date object, representing the date after the last date + # to be rebuilt. + + # Check preconditions. Cannot specify start_d or stop_d unless the summaries are complete. + if last_daily_ts != self.last_timestamp and (start_d or stop_d): + raise weewx.ViolatedPrecondition("Daily summaries are not complete. " + "Try again without from/to dates.") + + # If we were doing a complete rebuild, these would be the first and + # last dates to be processed: + first_d = datetime.date.fromtimestamp(weeutil.weeutil.startOfArchiveDay( + self.first_timestamp)) + last_d = datetime.date.fromtimestamp(weeutil.weeutil.startOfArchiveDay( + self.last_timestamp)) + + # Are there existing daily summaries? + if last_daily_ts: + # Yes. Is it an aborted rebuild? + if last_daily_ts < self.last_timestamp: + # We are restarting from an aborted build. Pick up from where we left off. + # Because last_daily_ts always sits on the boundary of a day, this will include the + # following day to be included, but not the actual record with + # timestamp last_daily_ts. + first_d = datetime.date.fromtimestamp(last_daily_ts) + else: + # Daily summaries exist, and they are complete. + if not start_d and not stop_d: + # The daily summaries are complete, yet the user has not specified anything. + # Guess we're done. + log.info("Daily summaries up to date") + return 0, 0 + # Trim what we rebuild to what the user has specified + if start_d: + first_d = max(first_d, start_d) + if stop_d: + last_d = min(last_d, stop_d) + + # For what follows, last_d needs to point to the day *after* the last desired day + last_d += datetime.timedelta(days=1) + + nrecs = 0 + ndays = 0 + + mark_d = first_d + + while mark_d < last_d: + # Calculate the last date included in this transaction + stop_transaction = min(mark_d + tranche_days, last_d) + day_accum = None + + with weedb.Transaction(self.connection) as cursor: + # Go through all the archive records in the time span, adding them to the + # daily summaries + start_batch_ts = time.mktime(mark_d.timetuple()) + stop_batch_ts = time.mktime(stop_transaction.timetuple()) + for rec in self.genBatchRecords(start_batch_ts, stop_batch_ts): + # If this is the very first record, fetch a new accumulator + if not day_accum: + # Get a TimeSpan that includes the record's timestamp: + timespan = weeutil.weeutil.archiveDaySpan(rec['dateTime']) + # Get an empty day accumulator: + day_accum = weewx.accum.Accum(timespan) + try: + weight = self._calc_weight(rec) + except IntervalError as e: + # Ignore records with bad values for 'interval' + log.info(e) + log.info('*** ignored.') + continue + # Try updating. If the time is out of the accumulator's time span, an + # exception will get raised. + try: + day_accum.addRecord(rec, weight=weight) + except weewx.accum.OutOfSpan: + # The record is out of the time span. + # Save the old accumulator: + self._set_day_summary(day_accum, None, cursor) + ndays += 1 + # Get a new accumulator: + timespan = weeutil.weeutil.archiveDaySpan(rec['dateTime']) + day_accum = weewx.accum.Accum(timespan) + # try again + day_accum.addRecord(rec, weight=weight) + + if last_daily_ts is None: + last_daily_ts = rec['dateTime'] + else: + last_daily_ts = max(last_daily_ts, rec['dateTime']) + nrecs += 1 + if progress_fn and nrecs % 1000 == 0: + progress_fn(rec['dateTime'], nrecs) + + # We're done with this transaction. Unless it is empty, save the daily summary for + # the last day + if day_accum and not day_accum.isEmpty: + self._set_day_summary(day_accum, None, cursor) + ndays += 1 + # Patch lastUpdate: + if last_daily_ts: + self._write_metadata('lastUpdate', str(int(last_daily_ts)), cursor) + + # Advance to the next tranche + mark_d += tranche_days + + tdiff = time.time() - t1 + log.info("Processed %d records to backfill %d day summaries in %.2f seconds", + nrecs, ndays, tdiff) + + return nrecs, ndays + + def drop_daily(self): + """Drop the daily summaries.""" + + log.info("Dropping daily summary tables from '%s' ...", self.connection.database_name) + try: + _all_tables = self.connection.tables() + with weedb.Transaction(self.connection) as _cursor: + for _table_name in _all_tables: + if _table_name.startswith('%s_day_' % self.table_name): + _cursor.execute("DROP TABLE %s" % _table_name) + + self.daykeys = None + except weedb.OperationalError as e: + log.error("Drop daily summary tables failed for database '%s': %s", + self.connection.database_name, e) + raise + else: + log.info("Dropped daily summary tables from database '%s'", + self.connection.database_name) + + def recalculate_weights(self, start_d=None, stop_d=None, + tranche_size=100, weight_fn=None, progress_fn=show_progress): + """Recalculate just the daily summary weights. + + Rather than backfill all the daily summaries, this function simply recalculates the + weighted sums. + + Args: + start_d (datetime.date|None): The first day to be included. [Optional. + Default is to start with the first record in the daily summaries.] + stop_d (datetime.date|None): The last day to be included. [Optional. + Default is to end with the last record in the daily summaries.] + tranche_size (int): How many days to do in a single transaction. + weight_fn (function): A function used to calculate the weights for a record. Default + is _calc_weight(). + progress_fn (function): This function will be called after every tranche with the timestamp of the + last record processed. + """ + + log.info("recalculate_weights: Using database '%s'" % self.database_name) + log.debug("recalculate_weights: Tranche size %d" % tranche_size) + + # Convert tranch size to a timedelta object, so we can perform arithmetic with it. + tranche_days = datetime.timedelta(days=tranche_size) + + # Get the first and last timestamps for all the tables in the daily summaries. + first_ts, last_ts = self.get_first_last() + if first_ts is None or last_ts is None: + log.info("recalculate_weights: Empty daily summaries. Nothing done.") + return + + # Convert to date objects + first_d = datetime.date.fromtimestamp(first_ts) + last_d = datetime.date.fromtimestamp(last_ts) + + # Trim according to the requested dates + if start_d: + first_d = max(first_d, start_d) + if stop_d: + last_d = min(last_d, stop_d) + + # For what follows, last_date needs to point to the day *after* the last desired day. + last_d += datetime.timedelta(days=1) + + mark_d = first_d + + # March forward, tranche by tranche + while mark_d < last_d: + end_of_tranche_d = min(mark_d + tranche_days, last_d) + self._do_tranche(mark_d, end_of_tranche_d, weight_fn, progress_fn) + mark_d = end_of_tranche_d + + def _do_tranche(self, start_d, last_d, weight_fn=None, progress_fn=None): + """Reweight a tranche of daily summaries. + + Args: + start_d (datetime.date): First date in the tranche to be reweighted. + last_d (datetime.date): Last date in the tranche to be reweighted. + weight_fn (function): A function used to calculate the weights for a record. Default is + _calc_weight(). + progress_fn (function): A function to call to show progress. It will be called after every + update. + """ + + if weight_fn is None: + weight_fn = DaySummaryManager._calc_weight + + # Do all the dates in the tranche as a single transaction + with weedb.Transaction(self.connection) as cursor: + + # March down the tranche, day by day + mark_d = start_d + while mark_d < last_d: + next_d = mark_d + datetime.timedelta(days=1) + day_span = TimeSpan(time.mktime(mark_d.timetuple()), + time.mktime(next_d.timetuple())) + # Get an accumulator for the day + day_accum = weewx.accum.Accum(day_span) + # Now populate it with a day's worth of records + for rec in self.genBatchRecords(day_span.start, day_span.stop): + try: + weight = weight_fn(self, rec) + except IntervalError as e: + log.info("%s: %s", timestamp_to_string(rec['dateTime']), e) + log.info('*** ignored.') + else: + day_accum.addRecord(rec, weight=weight) + # Write out the results of the accumulator + self._set_day_sums(day_accum, cursor) + if progress_fn: + # Update our progress + progress_fn(day_accum.timespan.stop) + # On to the next day + mark_d += datetime.timedelta(days=1) + + def _set_day_sums(self, day_accum, cursor): + """Replace the weighted sums for all types for a day. Don't touch the mins and maxes.""" + for obs_type in day_accum: + # Skip any types that are not in the daily summary schema + if obs_type not in self.daykeys: + continue + # This will be list that looks like ['sum=2345.65', 'count=123', ... etc.] + # It will only include attributes that are in the accumulator for this type. + set_list = ['%s=%s' % (k, getattr(day_accum[obs_type], k)) + for k in ['sum', 'count', 'wsum', 'sumtime', + 'xsum', 'ysum', 'dirsumtime', + 'squaresum', 'wsquaresum'] + if hasattr(day_accum[obs_type], k)] + update_sql = "UPDATE {archive_table}_day_{obs_type} SET {set_stmt} " \ + "WHERE dateTime = ?;".format(archive_table=self.table_name, + obs_type=obs_type, + set_stmt=', '.join(set_list)) + # Update this observation type's weighted sums: + cursor.execute(update_sql, (day_accum.timespan.start,)) + + def patch_sums(self): + """Version 4.2.0 accidentally interpreted V2.0 daily sums as V1.0, so the weighted sums + were all given a weight of 1.0, instead of the interval length. Version 4.3.0 attempted + to fix this bug but introduced its own bug by failing to weight 'dirsumtime'. This fixes + both bugs.""" + if '1.0' < self.version < '4.0': + msg = "Daily summaries at V%s. Patching to V%s" \ + % (self.version, DaySummaryManager.version) + print(msg) + log.info(msg) + # We need to upgrade from V2.0 or V3.0 to V4.0. The only difference is + # that the patch has been supplied to V4.0 daily summaries. The patch + # need only be done from a date well before the V4.2 release. + # We pick 1-Jun-2020. + self.recalculate_weights(start_d=datetime.date(2020, 6, 1)) + self._write_metadata('Version', DaySummaryManager.version) + self.version = DaySummaryManager.version + log.info("Patch finished.") + + def update(self): + """Update the database to V4.0. + + - all V1.0 daily sums need to be upgraded + - V2.0 daily sums need to be upgraded but only those after a date well before the + V4.2.0 release (we pick 1 June 2020) + - V3.0 daily sums need to be upgraded due to a bug in the V4.2.0 and V4.3.0 releases + but only those after 1 June 2020 + """ + if self.version == '1.0': + self.recalculate_weights(weight_fn=DaySummaryManager._get_weight) + self._write_metadata('Version', DaySummaryManager.version) + self.version = DaySummaryManager.version + elif self.version == '2.0' or self.version == '3.0': + self.patch_sums() + + # --------------------------- UTILITY FUNCTIONS ----------------------------------- + + def get_first_last(self): + """Obtain the first and last timestamp of all the daily summaries. + + Returns: + tuple[int,int]|None: A two-way tuple (first_ts, last_ts) with the first timestamp and + the last timestamp. Returns None if there is nothing in the daily summaries. + """ + + big_select = ["SELECT MIN(dateTime) AS mtime FROM %s_day_%s" + % (self.table_name, key) for key in self.daykeys] + big_sql = " UNION ".join(big_select) + " ORDER BY mtime ASC LIMIT 1" + first_ts = self.getSql(big_sql) + + big_select = ["SELECT MAX(dateTime) AS mtime FROM %s_day_%s" + % (self.table_name, key) for key in self.daykeys] + big_sql = " UNION ".join(big_select) + " ORDER BY mtime DESC LIMIT 1" + last_ts = self.getSql(big_sql) + + return first_ts[0], last_ts[0] + + def _get_day_summary(self, sod_ts, cursor=None): + """Return an instance of an appropriate accumulator, initialized to a given day's + statistics. + Args: + sod_ts(float|int): The timestamp of the start-of-day of the desired day. + cursor(Cursor|None): Optional cursor. If one is not supplied, one will be + opened. + Returns: + weewx.accum.Accum + """ + + # Get the TimeSpan for the day starting with sod_ts: + _timespan = weeutil.weeutil.daySpan(sod_ts) + + # Get an empty day accumulator: + _day_accum = weewx.accum.Accum(_timespan, self.std_unit_system) + + _cursor = cursor or self.connection.cursor() + + try: + # For each observation type, execute the SQL query and hand the results on to the + # accumulator. + for _day_key in self.daykeys: + _cursor.execute( + "SELECT * FROM %s_day_%s WHERE dateTime = ?" % (self.table_name, _day_key), + (_day_accum.timespan.start,)) + _row = _cursor.fetchone() + # If the date does not exist in the database yet then _row will be None. + _stats_tuple = _row[1:] if _row is not None else None + _day_accum.set_stats(_day_key, _stats_tuple) + + return _day_accum + finally: + if not cursor: + _cursor.close() + + def _set_day_summary(self, day_accum, lastUpdate, cursor): + """Write all statistics for a day to the database in a single transaction. + + Args: + day_accum (weewx.accum.Accum): an accumulator with the daily summary. See weewx.accum + lastUpdate (float|int): the time of the last update will be set to this unless it is + None. Normally, this is the timestamp of the last archive record added to the + instance day_accum. + cursor (Cursor): An open cursor. + """ + + # Make sure the new data uses the same unit system as the database. + self._check_unit_system(day_accum.unit_system) + + _sod = day_accum.timespan.start + + # For each daily summary type... + for _summary_type in day_accum: + # Don't try an update for types not in the database: + if _summary_type not in self.daykeys: + continue + # ... get the stats tuple to be written to the database... + _write_tuple = (_sod,) + day_accum[_summary_type].getStatsTuple() + # ... and an appropriate SQL command with the correct number of question marks ... + _qmarks = ','.join(len(_write_tuple) * '?') + _sql_replace_str = "REPLACE INTO %s_day_%s VALUES(%s)" % ( + self.table_name, _summary_type, _qmarks) + # ... and write to the database. In case the type doesn't appear in the database, + # be prepared to catch an exception: + try: + cursor.execute(_sql_replace_str, _write_tuple) + except weedb.OperationalError as e: + log.error("Replace failed for database %s: %s", self.database_name, e) + + # If requested, update the time of the last daily summary update: + if lastUpdate is not None: + self._write_metadata('lastUpdate', str(int(lastUpdate)), cursor) + + def _calc_weight(self, record): + """Returns the weighting to be used, depending on the version of the daily summaries.""" + if 'interval' not in record: + raise ValueError("Missing value for record field 'interval'") + elif record['interval'] <= 0: + raise IntervalError( + "Non-positive value for record field 'interval': %s" % (record['interval'],)) + weight = 60.0 * record['interval'] if self.version >= '2.0' else 1.0 + return weight + + def _get_weight(self, record): + """Always returns a weight based on the field 'interval'.""" + if 'interval' not in record: + raise ValueError("Missing value for record field 'interval'") + elif record['interval'] <= 0: + raise IntervalError( + "Non-positive value for record field 'interval': %s" % (record['interval'],)) + return 60.0 * record['interval'] + + def _read_metadata(self, key, cursor=None): + """Obtain a value from the daily summary metadata table. + + Returns: + str|None: Value of the metadata field. Returns None if no value was found. + """ + _row = self.getSql(DaySummaryManager.meta_select_str % self.table_name, (key,), cursor) + return _row[0] if _row else None + + def _write_metadata(self, key, value, cursor=None): + """Write a value to the daily summary metadata table. + + Args: + key (str): The name of the metadata field to be written to. + value (str): The value to be written to the metadata field. + cursor (Cursor|None): An optional cursor to use. If None, a cursor will be opened up. + """ + _cursor = cursor or self.connection.cursor() + + try: + _cursor.execute(DaySummaryManager.meta_replace_str % self.table_name, + (key, value)) + finally: + if cursor is None: + _cursor.close() + + +if __name__ == '__main__': + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weewx/qc.py b/dist/weewx-5.0.2/src/weewx/qc.py new file mode 100644 index 0000000..f284f62 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/qc.py @@ -0,0 +1,72 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes and functions related to Quality Control of incoming data.""" + +# Python imports +import logging + +# weewx imports +import weeutil.weeutil +import weewx.units +from weeutil.weeutil import to_float + +log = logging.getLogger(__name__) + + +# ============================================================================== +# Class QC +# ============================================================================== + +class QC(object): + """Class to apply quality checks to a record.""" + + def __init__(self, mm_dict, log_failure=True): + """ + Initialize + Args: + mm_dict: A dictionary containing the limits. The key is an observation type, the value + is a 2- or 3-way tuple. If a 2-way tuple, then the values are (min, max) acceptable + value in a record for that observation type. If a 3-way tuple, then the values are + (min, max, unit), where min and max are as before, but the value 'unit' is the unit the + min and max values are in. If 'unit' is not specified, then the values must be in the + same unit as the incoming record (a risky supposition!). + + log_failure: True to log values outside their limits. False otherwise. + """ + + self.mm_dict = {} + for obs_type in mm_dict: + self.mm_dict[obs_type] = list(mm_dict[obs_type]) + # The incoming min, max values may be from a ConfigObj, which are typically strings. + # Convert to floats. + self.mm_dict[obs_type][0] = to_float(self.mm_dict[obs_type][0]) + self.mm_dict[obs_type][1] = to_float(self.mm_dict[obs_type][1]) + + self.log_failure = log_failure + + def apply_qc(self, data_dict, data_type=''): + """Apply quality checks to the data in a record""" + + converter = weewx.units.StdUnitConverters[data_dict['usUnits']] + + for obs_type in self.mm_dict: + if obs_type in data_dict and data_dict[obs_type] is not None: + # Extract the minimum and maximum acceptable values + min_v, max_v = self.mm_dict[obs_type][0:2] + # If a unit has been specified, convert the min, max acceptable value to the same + # unit system as the incoming record: + if len(self.mm_dict[obs_type]) == 3: + min_max_unit = self.mm_dict[obs_type][2] + group = weewx.units.getUnitGroup(obs_type) + min_v = converter.convert((min_v, min_max_unit, group))[0] + max_v = converter.convert((max_v, min_max_unit, group))[0] + + if not min_v <= data_dict[obs_type] <= max_v: + if self.log_failure: + log.warning("%s %s value '%s' %s outside limits (%s, %s)", + weeutil.weeutil.timestamp_to_string(data_dict['dateTime']), + data_type, obs_type, data_dict[obs_type], min_v, max_v) + data_dict[obs_type] = None diff --git a/dist/weewx-5.0.2/src/weewx/reportengine.py b/dist/weewx-5.0.2/src/weewx/reportengine.py new file mode 100644 index 0000000..7588a25 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/reportengine.py @@ -0,0 +1,864 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Engine for generating reports""" + +# System imports: +import datetime +import ftplib +import glob +import logging +import os.path +import threading +import time +import traceback +from contextlib import contextmanager + +# 3rd party imports +import configobj + +# WeeWX imports: +import weeutil.config +import weeutil.logger +import weeutil.weeutil +import weewx.defaults +import weewx.manager +import weewx.units +from weeutil.weeutil import to_bool, to_int + +log = logging.getLogger(__name__) + +# spans of valid values for each CRON like field +MINUTES = (0, 59) +HOURS = (0, 23) +DOM = (1, 31) +MONTHS = (1, 12) +DOW = (0, 6) +# valid day names for DOW field +DAY_NAMES = ('sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat') +# valid month names for month field +MONTH_NAMES = ('jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec') +# map month names to month number +MONTH_NAME_MAP = list(zip(('jan', 'feb', 'mar', 'apr', + 'may', 'jun', 'jul', 'aug', + 'sep', 'oct', 'nov', 'dec'), list(range(1, 13)))) +# map day names to day number +DAY_NAME_MAP = list(zip(('sun', 'mon', 'tue', 'wed', + 'thu', 'fri', 'sat'), list(range(7)))) +# map CRON like nicknames to equivalent CRON like line +NICKNAME_MAP = { + "@yearly": "0 0 1 1 *", + "@anually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@hourly": "0 * * * *" +} +# list of valid spans for CRON like fields +SPANS = (MINUTES, HOURS, DOM, MONTHS, DOW) +# list of valid names for CRON lik efields +NAMES = ((), (), (), MONTH_NAMES, DAY_NAMES) +# list of name maps for CRON like fields +MAPS = ((), (), (), MONTH_NAME_MAP, DAY_NAME_MAP) + + +@contextmanager +def set_cwd(new_cwd): + """Set the current working directory within a context manager""" + old_cwd = os.getcwd() + try: + os.chdir(new_cwd) + yield new_cwd + finally: + os.chdir(old_cwd) + + +# ============================================================================= +# Class StdReportEngine +# ============================================================================= + +class StdReportEngine(threading.Thread): + """Reporting engine for weewx. + + This engine runs zero or more reports. Each report uses a skin. A skin + has its own configuration file specifying things such as which 'generators' + should be run, which templates are to be used, what units are to be used, + etc. + A 'generator' is a class inheriting from class ReportGenerator, that + produces the parts of the report, such as image plots, HTML files. + + StdReportEngine inherits from threading.Thread, so it will be run in a + separate thread. + + See below for examples of generators. + """ + + def __init__(self, config_dict, stn_info, record=None, gen_ts=None, first_run=True): + """Initializer for the report engine. + + Args: + config_dict(dict): The configuration dictionary. + stn_info(StationInfo): An instance of weewx.station.StationInfo, with static + station information. + record(dict|None): The current archive record [Optional; default is None] + gen_ts(float|int|None): The timestamp for which the output is to be current + [Optional; default is the last time in the database] + first_run(bool): True if this is the first time the report engine has been + run. If this is the case, then any 'one time' events should be done. + """ + threading.Thread.__init__(self, name="ReportThread") + + self.config_dict = config_dict + self.stn_info = stn_info + self.record = record + self.gen_ts = gen_ts + self.first_run = first_run + + def run(self, reports=None): + """This is where the actual work gets done. + + Args: + reports(list[str]|None): If None, run all enabled reports. If a list, run only the + reports in the list, whether they are enabled or not. + """ + + if self.gen_ts: + log.debug("Running reports for time %s", + weeutil.weeutil.timestamp_to_string(self.gen_ts)) + else: + log.debug("Running reports for latest time in the database.") + + # If we have not been given a list of reports to run, then run all reports (although not + # all of them may be enabled). + run_reports = reports or self.config_dict['StdReport'].sections + + # Iterate over each requested report + for report in run_reports: + + # Ignore the [[Defaults]] section + if report == 'Defaults': + continue + + # If reports is None, then we need to check whether this particular report has + # been enabled. + if reports is None: + enabled = to_bool(self.config_dict['StdReport'][report].get('enable', True)) + if not enabled: + log.debug("Report '%s' not enabled. Skipping.", report) + continue + + log.debug("Running report '%s'", report) + + # Fetch and build the skin_dict: + try: + skin_dict = build_skin_dict(self.config_dict, report) + except SyntaxError as e: + log.error("Syntax error: %s", e) + log.error(" **** Report ignored") + continue + + # Default action is to run the report. Only reason to not run it is + # if we have a valid report report_timing, and it did not trigger. + if self.record: + # StdReport called us not "weectl report run" so look for a report_timing + # entry if we have one. + timing_line = skin_dict.get('report_timing') + if timing_line: + # Get a ReportTiming object. + timing = ReportTiming(timing_line) + if timing.is_valid: + # Get timestamp and interval, so we can check if the + # report timing is triggered. + _ts = self.record['dateTime'] + _interval = self.record['interval'] * 60 + # Is our report timing triggered? timing.is_triggered + # returns True if triggered, False if not triggered + # and None if an invalid report timing line. + if timing.is_triggered(_ts, _ts - _interval) is False: + # report timing was valid but not triggered so do + # not run the report. + log.debug("Report '%s' skipped due to report_timing setting", report) + continue + else: + log.debug("Invalid report_timing setting for report '%s', " + "running report anyway", report) + log.debug(" **** %s", timing.validation_error) + + # Set the current working directory to the skin's location. This allows #include + # statements to work. + with set_cwd(os.path.join(self.config_dict['WEEWX_ROOT'], + skin_dict['SKIN_ROOT'], + skin_dict['skin'])) as cwd: + log.debug("Running generators for report '%s' in directory '%s'", report, cwd) + + if 'Generators' in skin_dict and 'generator_list' in skin_dict['Generators']: + for generator in weeutil.weeutil.option_as_list( + skin_dict['Generators']['generator_list']): + + try: + # Instantiate an instance of the class. + obj = weeutil.weeutil.get_object(generator)( + self.config_dict, + skin_dict, + self.gen_ts, + self.first_run, + self.stn_info, + self.record) + except Exception as e: + log.error("Unable to instantiate generator '%s'", generator) + log.error(" **** %s", e) + weeutil.logger.log_traceback(log.error, " **** ") + log.error(" **** Generator ignored") + traceback.print_exc() + continue + + try: + # Call its start() method + obj.start() + + except Exception as e: + # Caught unrecoverable error. Log it, continue on to the + # next generator. + log.error("Caught unrecoverable exception in generator '%s'", + generator) + log.error(" **** %s", e) + weeutil.logger.log_traceback(log.error, " **** ") + log.error(" **** Generator terminated") + traceback.print_exc() + continue + + finally: + obj.finalize() + + else: + log.debug("No generators specified for report '%s'", report) + + +def build_skin_dict(config_dict, report): + """Find and build the skin_dict for the given report""" + + ####################################################################### + # Start with the defaults in the defaults module. Because we will be modifying it, we need + # to make a deep copy. + skin_dict = weeutil.config.deep_copy(weewx.defaults.defaults) + + # Turn off interpolation for the copy. It will interfere with interpretation of delta + # time fields + skin_dict.interpolation = False + # Add the report name: + skin_dict['REPORT_NAME'] = report + + ####################################################################### + # Add in the global values for log_success and log_failure: + if 'log_success' in config_dict: + skin_dict['log_success'] = to_bool(config_dict['log_success']) + if 'log_failure' in config_dict: + skin_dict['log_failure'] = to_bool(config_dict['log_failure']) + + ####################################################################### + # Now add the options in the report's skin.conf file. + # Start by figuring out where it is located. + skin_config_path = os.path.join( + config_dict['WEEWX_ROOT'], + config_dict['StdReport']['SKIN_ROOT'], + config_dict['StdReport'][report].get('skin', ''), + 'skin.conf') + + # Retrieve the configuration dictionary for the skin. Wrap it in a try block in case we + # fail. It is ok if there is no file - everything for a skin might be defined in the weewx + # configuration. + try: + merge_dict = configobj.ConfigObj(skin_config_path, + encoding='utf-8', + interpolation=False, + file_error=True) + except IOError as e: + log.debug("Cannot read skin configuration file %s for report '%s': %s", + skin_config_path, report, e) + except SyntaxError as e: + log.error("Failed to read skin configuration file %s for report '%s': %s", + skin_config_path, report, e) + raise + else: + log.debug("Found configuration file %s for report '%s'", skin_config_path, report) + # If a language is specified, honor it. + if 'lang' in merge_dict: + merge_lang(merge_dict['lang'], config_dict, report, skin_dict) + # If the file has a unit_system specified, honor it. + if 'unit_system' in merge_dict: + merge_unit_system(merge_dict['unit_system'], skin_dict) + # Merge the rest of the config file in: + weeutil.config.merge_config(skin_dict, merge_dict) + + ####################################################################### + # Merge in the [[Defaults]] section + if 'Defaults' in config_dict['StdReport']: + # Because we will be modifying the results, make a deep copy of the section. + merge_dict = weeutil.config.deep_copy(config_dict)['StdReport']['Defaults'] + # If a language is specified, honor it + if 'lang' in merge_dict: + merge_lang(merge_dict['lang'], config_dict, report, skin_dict) + # If a unit_system is specified, honor it + if 'unit_system' in merge_dict: + merge_unit_system(merge_dict['unit_system'], skin_dict) + weeutil.config.merge_config(skin_dict, merge_dict) + + # Any scalar overrides have lower-precedence than report-specific options, so do them now. + for scalar in config_dict['StdReport'].scalars: + skin_dict[scalar] = config_dict['StdReport'][scalar] + + # Finally the report-specific section. + if report in config_dict['StdReport']: + # Because we will be modifying the results, make a deep copy of the section. + merge_dict = weeutil.config.deep_copy(config_dict)['StdReport'][report] + # If a language is specified, honor it + if 'lang' in merge_dict: + merge_lang(merge_dict['lang'], config_dict, report, skin_dict) + # If a unit_system is specified, honor it + if 'unit_system' in merge_dict: + merge_unit_system(merge_dict['unit_system'], skin_dict) + weeutil.config.merge_config(skin_dict, merge_dict) + + return skin_dict + + +def merge_unit_system(report_units_base, skin_dict): + """ + Given a unit system, merge its unit groups into a configuration dictionary + Args: + report_units_base (str): A unit base (such as 'us', or 'metricwx') + skin_dict (dict): A configuration dictionary + + Returns: + None + """ + report_units_base = report_units_base.upper() + # Get the chosen unit system out of units.py, then merge it into skin_dict. + units_dict = weewx.units.std_groups[ + weewx.units.unit_constants[report_units_base]] + skin_dict['Units']['Groups'].update(units_dict) + + +def get_lang_dict(lang_spec, config_dict, report): + """Given a language specification, return its corresponding locale dictionary. """ + + # The language's corresponding locale file will be found in subdirectory 'lang', with + # a suffix '.conf'. Find the path to it:. + lang_config_path = os.path.join( + config_dict['WEEWX_ROOT'], + config_dict['StdReport']['SKIN_ROOT'], + config_dict['StdReport'][report].get('skin', ''), + 'lang', + lang_spec+'.conf') + + # Retrieve the language dictionary for the skin and requested language. Wrap it in a + # try block in case we fail. It is ok if there is no file - everything for a skin + # might be defined in the weewx configuration. + try: + lang_dict = configobj.ConfigObj(lang_config_path, + encoding='utf-8', + interpolation=False, + file_error=True) + except IOError as e: + log.debug("Cannot read localization file %s for report '%s': %s", + lang_config_path, report, e) + log.debug("**** Using defaults instead.") + lang_dict = configobj.ConfigObj({}, + encoding='utf-8', + interpolation=False) + except SyntaxError as e: + log.error("Syntax error while reading localization file %s for report '%s': %s", + lang_config_path, report, e) + raise + + if 'Texts' not in lang_dict: + lang_dict['Texts'] = {} + + return lang_dict + + +def merge_lang(lang_spec, config_dict, report, skin_dict): + lang_dict = get_lang_dict(lang_spec, config_dict, report) + # There may or may not be a unit system specified. If so, honor it. + if 'unit_system' in lang_dict: + merge_unit_system(lang_dict['unit_system'], skin_dict) + weeutil.config.merge_config(skin_dict, lang_dict) + return skin_dict + + +# ============================================================================= +# Class ReportGenerator +# ============================================================================= + +class ReportGenerator(object): + """Base class for all report generators.""" + + def __init__(self, config_dict, skin_dict, gen_ts, first_run, stn_info, record=None): + self.config_dict = config_dict + self.skin_dict = skin_dict + self.gen_ts = gen_ts + self.first_run = first_run + self.stn_info = stn_info + self.record = record + self.db_binder = weewx.manager.DBBinder(self.config_dict) + + def start(self): + self.run() + + def run(self): + pass + + def finalize(self): + self.db_binder.close() + + +# ============================================================================= +# Class FtpGenerator +# ============================================================================= + +class FtpGenerator(ReportGenerator): + """Class for managing the "FTP generator". + + This will ftp everything in the public_html subdirectory to a webserver.""" + + def run(self): + import weeutil.ftpupload + + # determine how much logging is desired + log_success = to_bool(weeutil.config.search_up(self.skin_dict, 'log_success', True)) + log_failure = to_bool(weeutil.config.search_up(self.skin_dict, 'log_failure', True)) + + t1 = time.time() + try: + local_root = os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict.get('HTML_ROOT', self.config_dict['StdReport']['HTML_ROOT'])) + ftp_data = weeutil.ftpupload.FtpUpload( + server=self.skin_dict['server'], + user=self.skin_dict['user'], + password=self.skin_dict['password'], + local_root=local_root, + remote_root=self.skin_dict['path'], + port=int(self.skin_dict.get('port', 21)), + name=self.skin_dict['REPORT_NAME'], + passive=to_bool(self.skin_dict.get('passive', True)), + secure=to_bool(self.skin_dict.get('secure_ftp', False)), + debug=weewx.debug, + secure_data=to_bool(self.skin_dict.get('secure_data', True)), + reuse_ssl=to_bool(self.skin_dict.get('reuse_ssl', False)), + encoding=self.skin_dict.get('ftp_encoding', 'utf-8'), + ciphers=self.skin_dict.get('ciphers') + ) + except KeyError: + log.debug("ftpgenerator: FTP upload not requested. Skipped.") + return + + max_tries = int(self.skin_dict.get('max_tries', 3)) + for count in range(max_tries): + try: + n = ftp_data.run() + except ftplib.all_errors as e: + log.error("ftpgenerator: (%d): caught exception '%s': %s", count, type(e), e) + weeutil.logger.log_traceback(log.error, " **** ") + else: + if log_success: + t2 = time.time() + log.info("ftpgenerator: Ftp'd %d files in %0.2f seconds", n, (t2 - t1)) + break + else: + # The loop completed normally, meaning the upload failed. + if log_failure: + log.error("ftpgenerator: Upload failed") + + +# ============================================================================= +# Class RsyncGenerator +# ============================================================================= + +class RsyncGenerator(ReportGenerator): + """Class for managing the "rsync generator". + + This will rsync everything in the public_html subdirectory to a server.""" + + def run(self): + import weeutil.rsyncupload + log_success = to_bool(weeutil.config.search_up(self.skin_dict, 'log_success', True)) + log_failure = to_bool(weeutil.config.search_up(self.skin_dict, 'log_failure', True)) + + # We don't try to collect performance statistics about rsync, because + # rsync will report them for us. Check the debug log messages. + try: + local_root = os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict.get('HTML_ROOT', self.config_dict['StdReport']['HTML_ROOT'])) + rsync_data = weeutil.rsyncupload.RsyncUpload( + local_root=local_root, + remote_root=self.skin_dict['path'], + server=self.skin_dict['server'], + user=self.skin_dict.get('user'), + port=to_int(self.skin_dict.get('port')), + ssh_options=self.skin_dict.get('ssh_options'), + compress=to_bool(self.skin_dict.get('compress', False)), + delete=to_bool(self.skin_dict.get('delete', False)), + log_success=log_success, + log_failure=log_failure + ) + except KeyError: + log.debug("rsyncgenerator: Rsync upload not requested. Skipped.") + return + + try: + rsync_data.run() + except IOError as e: + log.error("rsyncgenerator: Caught exception '%s': %s", type(e), e) + + +# ============================================================================= +# Class CopyGenerator +# ============================================================================= + +class CopyGenerator(ReportGenerator): + """Class for managing the 'copy generator.' + + This will copy files from the skin subdirectory to the public_html + subdirectory.""" + + def run(self): + copy_dict = self.skin_dict['CopyGenerator'] + # determine how much logging is desired + log_success = to_bool(weeutil.config.search_up(copy_dict, 'log_success', True)) + + copy_list = [] + + if self.first_run: + # Get the list of files to be copied only once, at the first + # invocation of the generator. Wrap in a try block in case the + # list does not exist. + try: + copy_list += weeutil.weeutil.option_as_list(copy_dict['copy_once']) + except KeyError: + pass + + # Get the list of files to be copied everytime. Again, wrap in a + # try block. + try: + copy_list += weeutil.weeutil.option_as_list(copy_dict['copy_always']) + except KeyError: + pass + + # Figure out the destination of the files + html_dest_dir = os.path.join(self.config_dict['WEEWX_ROOT'], + self.skin_dict['HTML_ROOT']) + + # The copy list can contain wildcard characters. Go through the + # list globbing any character expansions + ncopy = 0 + for pattern in copy_list: + # Glob this pattern; then go through each resultant path: + for path in glob.glob(pattern): + ncopy += weeutil.weeutil.deep_copy_path(path, html_dest_dir) + if log_success: + log.info("Copied %d files to %s", ncopy, html_dest_dir) + + +# =============================================================================== +# Class ReportTiming +# =============================================================================== + +class ReportTiming(object): + """Class for processing a CRON like line and determining whether it should + be fired for a given time. + + The following CRON like capabilities are supported: + - There are two ways to specify the day the line is fired, DOM and DOW. A + match on either all other fields and either DOM or DOW will casue the + line to be fired. + - first-last, *. Matches all possible values for the field concerned. + - step, /x. Matches every xth minute/hour/day etc. May be bounded by a list + or range. + - range, lo-hi. Matches all values from lo to hi inclusive. Ranges using + month and day names are not supported. + - lists, x,y,z. Matches those items in the list. List items may be a range. + Lists using month and day names are not supported. + - month names. Months may be specified by number 1..12 or first 3 (case- + insensitive) letters of the English month name jan..dec. + - weekday names. Weekday names may be specified by number 0..7 + (0,7 = Sunday) or first 3 (case-insensitive) letters of the English + weekday names sun..sat. + - nicknames. Following nicknames are supported: + @yearly : Run once a year, ie "0 0 1 1 *" + @annually : Run once a year, ie "0 0 1 1 *" + @monthly : Run once a month, ie "0 0 1 * *" + @weekly : Run once a week, ie "0 0 * * 0" + @daily : Run once a day, ie "0 0 * * *" + @hourly : Run once an hour, ie "0 * * * *" + + Useful ReportTiming class attributes: + + is_valid: Whether passed line is a valid line or not. + validation_error: Error message if passed line is an invalid line. + raw_line: Raw line data passed to ReportTiming. + line: 5 item list representing the 5 date/time fields after the + raw line has been processed and dom/dow named parameters + replaced with numeric equivalents. + """ + + def __init__(self, raw_line): + """Initialises a ReportTiming object. + + Processes raw line to produce 5 field line suitable for further + processing. + + raw_line: The raw line to be processed. + """ + + # initialise some properties + self.is_valid = None + self.validation_error = None + # To simplify error reporting keep a copy of the raw line passed to us + # as a string. The raw line could be a list if it included any commas. + # Assume a string but catch the error if it is a list and join the list + # elements to make a string + try: + line_str = raw_line.strip() + except AttributeError: + line_str = ','.join(raw_line).strip() + self.raw_line = line_str + # do some basic checking of the line for unsupported characters + for unsupported_char in ('%', '#', 'L', 'W'): + if unsupported_char in line_str: + self.is_valid = False + self.validation_error = "Unsupported character '%s' in '%s'." % (unsupported_char, + self.raw_line) + return + # Six special time definition 'nicknames' are supported which replace + # the line elements with pre-determined values. These nicknames start + # with the @ character. Check for any of these nicknames and substitute + # the corresponding line. + for nickname, nn_line in NICKNAME_MAP.items(): + if line_str == nickname: + line_str = nn_line + break + fields = line_str.split(None, 5) + if len(fields) < 5: + # Not enough fields + self.is_valid = False + self.validation_error = "Insufficient fields found in '%s'" % self.raw_line + return + elif len(fields) == 5: + fields.append(None) + # extract individual line elements + minutes, hours, dom, months, dow, _extra = fields + # save individual fields + self.line = [minutes, hours, dom, months, dow] + # is DOM restricted ie is DOM not '*' + self.dom_restrict = self.line[2] != '*' + # is DOW restricted ie is DOW not '*' + self.dow_restrict = self.line[4] != '*' + # decode the line and generate a set of possible values for each field + (self.is_valid, self.validation_error) = self.decode_fields() + + def decode_fields(self): + """Decode each field and store the sets of valid values. + + Set of valid values is stored in self.decode. Self.decode can only be + considered valid if self.is_valid is True. Returns a 2-way tuple + (True|False, ERROR MESSAGE). First item is True is the line is valid + otherwise False. ERROR MESSAGE is None if the line is valid otherwise a + string containing a short error message. + """ + + # set a list to hold our decoded ranges + self.decode = [] + try: + # step through each field and its associated range, names and maps + for field, span, names, mapp in zip(self.line, SPANS, NAMES, MAPS): + field_set = self.parse_field(field, span, names, mapp) + self.decode.append(field_set) + # if we are this far then our line is valid so return True and no + # error message + return (True, None) + except ValueError as e: + # we picked up a ValueError in self.parse_field() so return False + # and the error message + return (False, e) + + def parse_field(self, field, span, names, mapp, is_rorl=False): + """Return the set of valid values for a field. + + Parses and validates a field and if the field is valid returns a set + containing all the possible field values. Called recursively to + parse sub-fields (e.g., lists of ranges). If a field is invalid a + ValueError is raised. + + field: String containing the raw field to be parsed. + span: Tuple representing the lower and upper numeric values the + field may take. Format is (lower, upper). + names: Tuple containing all valid named values for the field. For + numeric only fields the tuple is empty. + mapp: Tuple of 2 way tuples mapping named values to numeric + equivalents. Format is ((name1, numeric1), ... + (namex, numericx)). For numeric only fields the tuple is empty. + is_rorl: Is field part of a range or list. Either True or False. + """ + + field = field.strip() + if field == '*': # first-last + # simply return a set of all poss values + return set(range(span[0], span[1] + 1)) + elif field.isdigit(): # just a number + # If it's a DOW then replace any 7s with 0 + _field = field.replace('7', '0') if span == DOW else field + # its valid if it's within our span + if span[0] <= int(_field) <= span[1]: + # it's valid so return the field itself as a set + return set((int(_field),)) + else: + # invalid field value so raise ValueError + raise ValueError("Invalid field value '%s' in '%s'" % (field, + self.raw_line)) + elif field.lower() in names: # an abbreviated name + # abbreviated names are only valid if not used in a range or list + if not is_rorl: + # replace all named values with numbers + _field = field + for _name, _ord in mapp: + _field = _field.replace(_name, str(_ord)) + # its valid if it's within our span + if span[0] <= int(_field) <= span[1]: + # it's valid so return the field itself as a set + return set((int(_field),)) + else: + # invalid field value so raise ValueError + raise ValueError("Invalid field value '%s' in '%s'" % (field, + self.raw_line)) + else: + # invalid use of abbreviated name so raise ValueError + raise ValueError("Invalid use of abbreviated name '%s' in '%s'" % (field, + self.raw_line)) + elif ',' in field: # we have a list + # get the first list item and the rest of the list + _first, _rest = field.split(',', 1) + # get _first as a set using a recursive call + _first_set = self.parse_field(_first, span, names, mapp, True) + # get _rest as a set using a recursive call + _rest_set = self.parse_field(_rest, span, names, mapp, True) + # return the union of the _first and _rest sets + return _first_set | _rest_set + elif '/' in field: # a step + # get the value and the step + _val, _step = field.split('/', 1) + # step is valid if it is numeric + if _step.isdigit(): + # get _val as a set using a recursive call + _val_set = self.parse_field(_val, span, names, mapp, True) + # get the set of all possible values using _step + _lowest = min(_val_set) + _step_set = set([x for x in _val_set if ((x - _lowest) % int(_step) == 0)]) + # return the intersection of the _val and _step sets + return _val_set & _step_set + else: + # invalid step so raise ValueError + raise ValueError("Invalid step value '%s' in '%s'" % (field, + self.raw_line)) + elif '-' in field: # we have a range + # get the lo and hi values of the range + lo, hi = field.split('-', 1) + # if lo is numeric and in the span range then the range is valid if + # hi is valid + if lo.isdigit() and span[0] <= int(lo) <= span[1]: + # if hi is numeric and in the span range and greater than or + # equal to lo then the range is valid + if hi.isdigit() and int(hi) >= int(lo) and span[0] <= int(hi) <= span[1]: + # valid range so return a set of the range + return set(range(int(lo), int(hi) + 1)) + else: + # something is wrong, we have an invalid field + raise ValueError("Invalid range specification '%s' in '%s'" % (field, + self.raw_line)) + else: + # something is wrong with lo, we have an invalid field + raise ValueError("Invalid range specification '%s' in '%s'" % (field, + self.raw_line)) + else: + # we have something I don't know how to parse so raise a ValueError + raise ValueError("Invalid field '%s' in '%s'" % (field, + self.raw_line)) + + def is_triggered(self, ts_hi, ts_lo=None): + """Determine if CRON like line is to be triggered. + + Return True if line is triggered between timestamps ts_lo and ts_hi + (exclusive on ts_lo inclusive on ts_hi), False if it is not + triggered or None if the line is invalid or ts_hi is not valid. + If ts_lo is not specified check for triggering on ts_hi only. + + ts_hi: Timestamp of latest time to be checked for triggering. + ts_lo: Timestamp used for earliest time in range of times to be + checked for triggering. May be omitted in which case only + ts_hi is checked. + """ + + if self.is_valid and ts_hi is not None: + # setup ts range to iterate over + if ts_lo is None: + _range = [int(ts_hi)] + else: + # CRON like line has a 1-minute resolution so step backwards every + # 60 sec. + _range = list(range(int(ts_hi), int(ts_lo), -60)) + # Iterate through each ts in our range. All we need is one ts that + # triggers the line. + for _ts in _range: + # convert ts to timetuple and extract required data + trigger_dt = datetime.datetime.fromtimestamp(_ts) + trigger_tt = trigger_dt.timetuple() + month, dow, day, hour, minute = (trigger_tt.tm_mon, + (trigger_tt.tm_wday + 1) % 7, + trigger_tt.tm_mday, + trigger_tt.tm_hour, + trigger_tt.tm_min) + # construct a tuple so we can iterate over and process each + # field + element_tuple = list(zip((minute, hour, day, month, dow), + self.line, + SPANS, + self.decode)) + # Iterate over each field and check if it will prevent + # triggering. Remember, we only need a match on either DOM or + # DOW but all other fields must match. + dom_match = False + dom_restricted_match = False + for period, _field, field_span, decode in element_tuple: + if period in decode: + # we have a match + if field_span == DOM: + # we have a match on DOM, but we need to know if it + # was a match on a restricted DOM field + dom_match = True + dom_restricted_match = self.dom_restrict + elif field_span == DOW and not ( + dom_restricted_match or self.dow_restrict or dom_match): + break + continue + elif field_span == DOW and dom_restricted_match or field_span == DOM: + # No match but consider it a match if this field is DOW, + # and we already have a DOM match. Also, if we didn't + # match on DOM then continue as we might match on DOW. + continue + else: + # The field will prevent the line from triggerring for + # this ts so we break and move to the next ts. + break + else: + # If we arrived here then all fields match and the line + # would be triggered on this ts so return True. + return True + # If we are here it is because we broke out of all inner for loops + # and the line was not triggered so return False. + return False + else: + # Our line is not valid, or we do not have a timestamp to use, + # return None + return None diff --git a/dist/weewx-5.0.2/src/weewx/restx.py b/dist/weewx-5.0.2/src/weewx/restx.py new file mode 100644 index 0000000..ba03ebc --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/restx.py @@ -0,0 +1,1910 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Publish weather data to RESTful sites such as the Weather Underground. + + GENERAL ARCHITECTURE + +Each protocol uses two classes: + + o A weewx service, that runs in the main thread. Call this the + "controlling object" + o A separate "threading" class that runs in its own thread. Call this the + "posting object". + +Communication between the two is via an instance of queue.Queue. New loop +packets or archive records are put into the queue by the controlling object +and received by the posting object. Details below. + +The controlling object should inherit from StdRESTful. The controlling object +is responsible for unpacking any configuration information from weewx.conf, and +supplying any defaults. It sets up the queue. It arranges for any new LOOP or +archive records to be put in the queue. It then launches the thread for the +posting object. + +When a new LOOP or record arrives, the controlling object puts it in the queue, +to be received by the posting object. The controlling object can tell the +posting object to terminate by putting a 'None' in the queue. + +The posting object should inherit from class RESTThread. It monitors the queue +and blocks until a new record arrives. + +The base class RESTThread has a lot of functionality, so specializing classes +should only have to implement a few functions. In particular, + + - format_url(self, record). This function takes a record dictionary as an + argument. It is responsible for formatting it as an appropriate URL. + For example, the station registry's version emits strings such as + http://weewx.com/register/register.cgi?weewx_info=2.6.0a5&python_info= ... + + - skip_this_post(self, time_ts). If this function returns True, then the + post will be skipped. Otherwise, it is done. The default version does two + checks. First, it sees how old the record is. If it is older than the value + 'stale', then the post is skipped. Second, it will not allow posts more + often than 'post_interval'. Both of these can be set in the constructor of + RESTThread. + + - post_request(self, request, data). This function takes an urllib.request.Request object + and is responsible for performing the HTTP GET or POST. The default version + simply uses urllib.request.urlopen(request) and returns the result. If the + post could raise an unusual exception, override this function and catch the + exception. See the WOWThread implementation for an example. + + - check_response(self, response). After an HTTP request gets posted, the + webserver sends back a "response." This response may contain clues + whether the post worked. For example, a request might succeed, but the + actual posting of data might fail, with the reason indicated in the + response. The uploader can then take appropriate action, such as raising + a FailedPost exception, which results in logging the failure but not + retrying the post. See the StationRegistry uploader as an example. + + +In some cases, you might also have to implement the following: + + - get_request(self, url). The default version of this function creates + an urllib.request.Request object from the url, adds a 'User-Agent' header, + then returns it. You may need to override this function if you need to add + other headers, such as "Authorization" header. + + - get_post_body(self, record). Override this function if you want to do an + HTTP POST (instead of GET). It should return a tuple. First element is the + body of the POST, the second element is the type of the body. An example + would be (json.dumps({'city' : 'Sacramento'}), 'application/json'). + + - process_record(self, record, dbmanager). The default version is designed + to handle HTTP GET and POST. However, if your uploader uses some other + protocol, you may need to override this function. See the CWOP version, + CWOPThread.process_record(), for an example that uses sockets. + +See the file restful.md in the "tests" subdirectory for known behaviors +of various RESTful services. + +""" + +import datetime +import http.client +import logging +import platform +import queue +import random +import re +import socket +import ssl +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +import weedb +import weeutil.logger +import weeutil.weeutil +import weewx.engine +import weewx.manager +import weewx.units +from weeutil.config import search_up, accumulateLeaves +from weeutil.weeutil import to_int, to_float, to_bool, timestamp_to_string, to_sorted_string + +log = logging.getLogger(__name__) + + +class FailedPost(IOError): + """Raised when a post fails, and is unlikely to succeed if retried.""" + + +class AbortedPost(Exception): + """Raised when a post is aborted by the client.""" + + +class BadLogin(Exception): + """Raised when login information is bad or missing.""" + + +class ConnectError(IOError): + """Raised when unable to get a socket connection.""" + + +class SendError(IOError): + """Raised when unable to send through a socket.""" + + +# ============================================================================== +# Abstract base classes +# ============================================================================== + +class StdRESTful(weewx.engine.StdService): + """Abstract base class for RESTful weewx services. + + Offers a few common bits of functionality.""" + + def shutDown(self): + """Shut down any threads""" + if hasattr(self, 'loop_queue') and hasattr(self, 'loop_thread'): + StdRESTful.shutDown_thread(self.loop_queue, self.loop_thread) + if hasattr(self, 'archive_queue') and hasattr(self, 'archive_thread'): + StdRESTful.shutDown_thread(self.archive_queue, self.archive_thread) + + @staticmethod + def shutDown_thread(q, t): + """Function to shut down a thread.""" + if q and t.is_alive(): + # Put a None in the queue to signal the thread to shut down + q.put(None) + # Wait up to 20 seconds for the thread to exit: + t.join(20.0) + if t.is_alive(): + log.error("Unable to shut down %s thread", t.name) + else: + log.debug("Shut down %s thread.", t.name) + + +# For backwards compatibility with early v2.6 alphas. In particular, the WeatherCloud uploader depends on it. +StdRESTbase = StdRESTful + + +class RESTThread(threading.Thread): + """Abstract base class for RESTful protocol threads. + + Offers a few bits of common functionality.""" + + def __init__(self, + q, + protocol_name, + essentials={}, + manager_dict=None, + post_interval=None, + max_backlog=sys.maxsize, + stale=None, + log_success=True, + log_failure=True, + timeout=10, + max_tries=3, + retry_wait=5, + retry_login=3600, + retry_ssl=3600, + softwaretype="weewx-%s" % weewx.__version__, + skip_upload=False, + delay_post=None): + """Initializer for the class RESTThread + + Args: + + q (queue.Queue): An instance of queue.Queue where the records will appear. + protocol_name (str): A string holding the name of the protocol. + essentials (dict): An optional dictionary that holds observation types that must + not be None for the post to go ahead. + manager_dict (dict|None): A database manager dictionary, to be used to open up a + database manager. Default is None. + post_interval (int|None): How long to wait between posts in seconds. + Default is None (post every record). + max_backlog (int): How many records are allowed to accumulate in the queue + before the queue is trimmed. Default is sys.maxsize (essentially, allow any number). + stale (int|None): How old a record can be and still considered useful. + Default is None (never becomes too old). + log_success (bool): If True, log a successful post in the system log. + Default is True. + log_failure (bool): If True, log an unsuccessful post in the system log. + Default is True. + timeout (float): How long to wait for the server to respond before giving up. + Default is 10 seconds. + max_tries (int): How many times to try the post before giving up. + Default is 3 + retry_wait (float): How long to wait between retries when failures. + Default is 5 seconds. + retry_login (float): How long to wait before retrying a login. Default + is 3600 seconds (one hour). + retry_ssl (float`): How long to wait before retrying after an SSL error. Default + is 3600 seconds (one hour). + softwaretype (str): Sent as field "softwaretype" in the Ambient post. + Default is "weewx-x.y.z where x.y.z is the weewx version. + skip_upload (bool): Do all record processing, but do not upload the result. + Useful for diagnostic purposes when local debugging should not + interfere with the downstream data service. Default is False. + delay_post (float|None): How long to sleep before actually doing the post. Default + is None (no delay). + """ + # Initialize my superclass: + threading.Thread.__init__(self, name=protocol_name) + self.daemon = True + + self.queue = q + self.protocol_name = protocol_name + self.essentials = essentials + self.manager_dict = manager_dict + self.log_success = to_bool(log_success) + self.log_failure = to_bool(log_failure) + self.max_backlog = to_int(max_backlog) + self.max_tries = to_int(max_tries) + self.stale = to_int(stale) + self.post_interval = to_int(post_interval) + self.timeout = to_int(timeout) + self.retry_wait = to_int(retry_wait) + self.retry_login = to_int(retry_login) + self.retry_ssl = to_int(retry_ssl) + self.softwaretype = softwaretype + self.lastpost = 0 + self.skip_upload = to_bool(skip_upload) + self.delay_post = to_float(delay_post) + + def get_record(self, record, dbmanager): + """Augment record data with additional data from the archive. + Should return results in the same units as the record and the database. + + This is a general version that for each of types 'hourRain', 'rain24', and 'dayRain', + it checks for existence. If not there, then the database is used to add it. This works for: + - WeatherUnderground + - PWSweather + - WOW + - CWOP + + It can be overridden and specialized for additional protocols. + + Args: + record (dict): An incoming record that will be augmented. It will not be touched. + dbmanager (weewx.manager.Manager|None): An instance of a database manager. If set + to None, then the record will not be augmented. + + Returns: + dict: A dictionary of augmented weather values + """ + + if dbmanager is None: + # If we don't have a database, we can't do anything + if self.log_failure and weewx.debug >= 2: + log.debug("No database specified. Augmentation from database skipped.") + return record + + _time_ts = record['dateTime'] + _sod_ts = weeutil.weeutil.startOfDay(_time_ts) + + # Make a copy of the record, then start adding to it: + _datadict = dict(record) + + # If the type 'rain' does not appear in the archive schema, + # or the database is locked, an exception will be raised. Be prepared + # to catch it. + try: + if 'hourRain' not in _datadict: + # CWOP says rain should be "rain that fell in the past hour". + # WU says it should be "the accumulated rainfall in the past + # 60 min". Presumably, this is exclusive of the archive record + # 60 minutes before, so the SQL statement is exclusive on the + # left, inclusive on the right. + _result = dbmanager.getSql( + "SELECT SUM(rain), MIN(usUnits), MAX(usUnits) FROM %s " + "WHERE dateTime>? AND dateTime<=?" + % dbmanager.table_name, (_time_ts - 3600.0, _time_ts)) + if _result is not None and _result[0] is not None: + if not _result[1] == _result[2] == record['usUnits']: + raise ValueError( + "Inconsistent units (%s vs %s vs %s) when querying for hourRain" + % (_result[1], _result[2], record['usUnits'])) + _datadict['hourRain'] = _result[0] + else: + _datadict['hourRain'] = None + + if 'rain24' not in _datadict: + # Similar issue, except for last 24 hours: + _result = dbmanager.getSql( + "SELECT SUM(rain), MIN(usUnits), MAX(usUnits) FROM %s " + "WHERE dateTime>? AND dateTime<=?" + % dbmanager.table_name, (_time_ts - 24 * 3600.0, _time_ts)) + if _result is not None and _result[0] is not None: + if not _result[1] == _result[2] == record['usUnits']: + raise ValueError( + "Inconsistent units (%s vs %s vs %s) when querying for rain24" + % (_result[1], _result[2], record['usUnits'])) + _datadict['rain24'] = _result[0] + else: + _datadict['rain24'] = None + + if 'dayRain' not in _datadict: + # NB: The WU considers the archive with time stamp 00:00 + # (midnight) as (wrongly) belonging to the current day + # (instead of the previous day). But, it's their site, + # so we'll do it their way. That means the SELECT statement + # is inclusive on both time ends: + _result = dbmanager.getSql( + "SELECT SUM(rain), MIN(usUnits), MAX(usUnits) FROM %s " + "WHERE dateTime>=? AND dateTime<=?" + % dbmanager.table_name, (_sod_ts, _time_ts)) + if _result is not None and _result[0] is not None: + if not _result[1] == _result[2] == record['usUnits']: + raise ValueError( + "Inconsistent units (%s vs %s vs %s) when querying for dayRain" + % (_result[1], _result[2], record['usUnits'])) + _datadict['dayRain'] = _result[0] + else: + _datadict['dayRain'] = None + + except weedb.OperationalError as e: + log.debug("%s: Database OperationalError '%s'", self.protocol_name, e) + + return _datadict + + def run(self): + """If there is a database specified, open the database, then call + run_loop() with the database. If no database is specified, simply + call run_loop().""" + + # Open up the archive. Use a 'with' statement. This will automatically + # close the archive in the case of an exception: + if self.manager_dict is not None: + with weewx.manager.open_manager(self.manager_dict) as _manager: + self.run_loop(_manager) + else: + self.run_loop() + + def run_loop(self, dbmanager=None): + """Runs a continuous loop, waiting for records to appear in the queue, + then processing them. + """ + + while True: + while True: + # This will block until something appears in the queue: + _record = self.queue.get() + # A None record is our signal to exit: + if _record is None: + return + # If packets have backed up in the queue, trim it until it's + # no bigger than the max allowed backlog: + if self.queue.qsize() <= self.max_backlog: + break + + if self.skip_this_post(_record['dateTime']): + continue + + try: + # Process the record, using whatever method the specializing + # class provides + self.process_record(_record, dbmanager) + except AbortedPost as e: + if self.log_success: + _time_str = timestamp_to_string(_record['dateTime']) + log.info("%s: Skipped record %s: %s", self.protocol_name, _time_str, e) + except BadLogin: + if self.retry_login: + log.error("%s: Bad login; waiting %s minutes then retrying", + self.protocol_name, self.retry_login / 60.0) + time.sleep(self.retry_login) + else: + log.error("%s: Bad login; no retry specified. Terminating", self.protocol_name) + raise + except FailedPost as e: + if self.log_failure: + _time_str = timestamp_to_string(_record['dateTime']) + log.error("%s: Failed to publish record %s: %s" + % (self.protocol_name, _time_str, e)) + except ssl.SSLError as e: + if self.retry_ssl: + log.error("%s: SSL error (%s); waiting %s minutes then retrying", + self.protocol_name, e, self.retry_ssl / 60.0) + time.sleep(self.retry_ssl) + else: + log.error("%s: SSL error (%s); no retry specified. Terminating", + self.protocol_name, e) + raise + except Exception as e: + # Some unknown exception occurred. This is probably a serious + # problem. Exit. + log.error("%s: Unexpected exception of type %s", self.protocol_name, type(e)) + weeutil.logger.log_traceback(log.error, '*** ') + log.critical("%s: Thread terminating. Reason: %s", self.protocol_name, e) + raise + else: + if self.log_success: + _time_str = timestamp_to_string(_record['dateTime']) + log.info("%s: Published record %s" % (self.protocol_name, _time_str)) + + def process_record(self, record, dbmanager): + """Default version of process_record. + + This version uses HTTP GETs to do the post, which should work for many + protocols, but it can always be replaced by a specializing class.""" + + # Get the full record by querying the database ... + _full_record = self.get_record(record, dbmanager) + # ... check it ... + self.check_this_record(_full_record) + # ... format the URL, using the relevant protocol ... + _url = self.format_url(_full_record) + # ... get the Request to go with it... + _request = self.get_request(_url) + # ... get any POST payload... + _payload = self.get_post_body(_full_record) + # ... add a proper Content-Type if needed... + if _payload: + _request.add_header('Content-Type', _payload[1]) + data = _payload[0] + else: + data = None + # ... check to see if this is just a drill... + if self.skip_upload: + raise AbortedPost("Skip post") + + # ... then, finally, post it + self.post_with_retries(_request, data) + + def get_request(self, url): + """Get a request object. This can be overridden to add any special headers.""" + _request = urllib.request.Request(url) + _request.add_header("User-Agent", "weewx/%s" % weewx.__version__) + return _request + + def post_with_retries(self, request, data=None): + """Post a request, retrying if necessary + + Attempts to post the request object up to max_tries times. + Catches a set of generic exceptions. + + Args: + request (urllib.request.Request): An instance of urllib.request.Request + data (str|None): The body of the POST. If not given, the request will be done as a GET. + """ + if self.delay_post: + log.debug("%s: Delaying post by %d seconds", self.protocol_name, self.delay_post) + time.sleep(self.delay_post) + + # Retry up to max_tries times: + for _count in range(self.max_tries): + try: + if _count: + # If this is not the first time through, sleep a bit before retrying + time.sleep(self.retry_wait) + + # Do a single post. The function post_request() can be + # specialized by a RESTful service to catch any unusual + # exceptions. + _response = self.post_request(request, data) + + if 200 <= _response.code <= 299: + # No exception thrown, and we got a good response code, but + # we're still not done. Some protocols encode a bad + # station ID or password in the return message. + # Give any interested protocols a chance to examine it. + # This must also be inside the try block because some + # implementations defer hitting the socket until the + # response is used. + self.check_response(_response) + # Does not seem to be an error. We're done. + return + # We got a bad response code. By default, log it and try again. + # Provide method for derived classes to behave otherwise if + # necessary. + self.handle_code(_response.code, _count + 1) + except (urllib.error.URLError, socket.error, http.client.HTTPException) as e: + # An exception was thrown. By default, log it and try again. + # Provide method for derived classes to behave otherwise if + # necessary. + self.handle_exception(e, _count + 1) + else: + # This is executed only if the loop terminates normally, meaning + # the upload failed max_tries times. Raise an exception. Caller + # can decide what to do with it. + raise FailedPost("Failed upload after %d tries" % self.max_tries) + + def check_this_record(self, record): + """Raises exception AbortedPost if the record should not be posted. + Otherwise, does nothing""" + for obs_type in self.essentials: + if to_bool(self.essentials[obs_type]) and record.get(obs_type) is None: + raise AbortedPost("Observation type %s missing" % obs_type) + + def check_response(self, response): + """Check the response from an HTTP post. This version does nothing.""" + pass + + def handle_code(self, code, count): + """Check code from HTTP post. This simply logs the response.""" + log.debug("%s: Failed upload attempt %d: Code %s" + % (self.protocol_name, count, code)) + + def handle_exception(self, e, count): + """Check exception from HTTP post. """ + # If it's a 429 error ("TOO MANY REQUESTS") don't bother retrying. + if getattr(e, 'code', None) == 429: + log.debug("%s: Posting too frequently: %s" % (self.protocol_name, e)) + raise FailedPost(str(e)) + else: + # Otherwise, log it and move on. + log.debug("%s: Failed upload attempt %d: %s" % (self.protocol_name, count, e)) + + def post_request(self, request, data=None): + """Post a request object. This version does not catch any HTTP + exceptions. + + Specializing versions can can catch any unusual exceptions that might + get raised by their protocol. + + request: An instance of urllib.request.Request + + data: If given, the request will be done as a POST. Otherwise, + as a GET. [optional] + """ + # Data might be a unicode string. Encode it first. + if data is not None and not isinstance(data, bytes): + data = data.encode('utf-8') + if weewx.debug >= 2: + log.debug("%s url: '%s'", self.protocol_name, request.get_full_url()) + _response = urllib.request.urlopen(request, data=data, timeout=self.timeout) + return _response + + def skip_this_post(self, time_ts): + """Check whether the post is current""" + # Don't post if this record is too old + if self.stale is not None: + _how_old = time.time() - time_ts + if _how_old > self.stale: + log.debug("%s: record %s is stale (%d > %d).", + self.protocol_name, timestamp_to_string(time_ts), _how_old, self.stale) + return True + + if self.post_interval is not None: + # We don't want to post more often than the post interval + _how_long = time_ts - self.lastpost + if _how_long < self.post_interval: + log.debug("%s: wait interval (%d < %d) has not passed for record %s", + self.protocol_name, _how_long, + self.post_interval, timestamp_to_string(time_ts)) + return True + + self.lastpost = time_ts + return False + + def get_post_body(self, record): # @UnusedVariable + """Return any POST payload. + + The returned value should be a 2-way tuple. First element is the Python + object to be included as the payload. Second element is the MIME type it + is in (such as "application/json"). + + Return a simple 'None' if there is no POST payload. This is the default. + """ + # Maintain backwards compatibility with the old format_data() function. + body = self.format_data(record) + if body: + return body, 'application/x-www-form-urlencoded' + return None + + def format_data(self, _): + """Return a POST payload as an urlencoded object. + + DEPRECATED. Use get_post_body() instead. + """ + return None + + def format_url(self, _): + raise NotImplementedError + + +# ============================================================================== +# Ambient protocols +# ============================================================================== + +class StdWunderground(StdRESTful): + """Specialized version of the Ambient protocol for the Weather Underground. + """ + + # the rapidfire URL: + rf_url = "https://rtupdate.wunderground.com/weatherstation/updateweatherstation.php" + # the personal weather station URL: + pws_url = "https://weatherstation.wunderground.com/weatherstation/updateweatherstation.php" + + def __init__(self, engine, config_dict): + + super().__init__(engine, config_dict) + + _ambient_dict = get_site_dict( + config_dict, 'Wunderground', 'station', 'password') + if _ambient_dict is None: + return + + _essentials_dict = search_up(config_dict['StdRESTful']['Wunderground'], 'Essentials', {}) + + log.debug("WU essentials: %s", _essentials_dict) + + # Get the manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + # The default is to not do an archive post if a rapidfire post + # has been specified, but this can be overridden + do_rapidfire_post = to_bool(_ambient_dict.pop('rapidfire', False)) + do_archive_post = to_bool(_ambient_dict.pop('archive_post', + not do_rapidfire_post)) + + if do_archive_post: + _ambient_dict.setdefault('server_url', StdWunderground.pws_url) + self.archive_queue = queue.Queue() + self.archive_thread = AmbientThread( + self.archive_queue, + _manager_dict, + protocol_name="Wunderground-PWS", + essentials=_essentials_dict, + **_ambient_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("Wunderground-PWS: Data for station %s will be posted", + _ambient_dict['station']) + + if do_rapidfire_post: + _ambient_dict.setdefault('server_url', StdWunderground.rf_url) + _ambient_dict.setdefault('log_success', False) + _ambient_dict.setdefault('log_failure', False) + _ambient_dict.setdefault('max_backlog', 0) + _ambient_dict.setdefault('max_tries', 1) + _ambient_dict.setdefault('rtfreq', 2.5) + self.cached_values = CachedValues() + self.loop_queue = queue.Queue() + self.loop_thread = AmbientLoopThread( + self.loop_queue, + _manager_dict, + protocol_name="Wunderground-RF", + essentials=_essentials_dict, + **_ambient_dict) + self.loop_thread.start() + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + log.info("Wunderground-RF: Data for station %s will be posted", + _ambient_dict['station']) + + def new_loop_packet(self, event): + """Puts new LOOP packets in the loop queue""" + if weewx.debug >= 3: + log.debug("Raw packet: %s", to_sorted_string(event.packet)) + self.cached_values.update(event.packet, event.packet['dateTime']) + if weewx.debug >= 3: + log.debug("Cached packet: %s", + to_sorted_string(self.cached_values.get_packet(event.packet['dateTime']))) + self.loop_queue.put( + self.cached_values.get_packet(event.packet['dateTime'])) + + def new_archive_record(self, event): + """Puts new archive records in the archive queue""" + self.archive_queue.put(event.record) + + +class CachedValues(object): + """Dictionary of value-timestamp pairs. Each timestamp indicates when the + corresponding value was last updated.""" + + def __init__(self): + self.unit_system = None + self.values = dict() + + def update(self, packet, ts): + # update the cache with values from the specified packet, using the + # specified timestamp. + for k in packet: + if k is None: + # well-formed packets do not have None as key, but just in case + continue + elif k == 'dateTime': + # do not cache the timestamp + continue + elif k == 'usUnits': + # assume unit system of first packet, then enforce consistency + if self.unit_system is None: + self.unit_system = packet['usUnits'] + elif packet['usUnits'] != self.unit_system: + raise ValueError("Mixed units encountered in cache. %s vs %s" + % (self.unit_system, packet['usUnits'])) + else: + # cache each value, associating it with the time it was cached + self.values[k] = {'value': packet[k], 'ts': ts} + + def get_value(self, k, ts, stale_age): + # get the value for the specified key. if the value is older than + # stale_age (seconds) then return None. + if k in self.values and ts - self.values[k]['ts'] < stale_age: + return self.values[k]['value'] + return None + + def get_packet(self, ts=None, stale_age=960): + if ts is None: + ts = int(time.time() + 0.5) + pkt = {'dateTime': ts, 'usUnits': self.unit_system} + for k in self.values: + pkt[k] = self.get_value(k, ts, stale_age) + return pkt + + +class StdPWSWeather(StdRESTful): + """Specialized version of the Ambient protocol for PWSWeather""" + + # The URL used by PWSWeather: + archive_url = "https://www.pwsweather.com/pwsupdate/pwsupdate.php" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + _ambient_dict = get_site_dict( + config_dict, 'PWSweather', 'station', 'password') + if _ambient_dict is None: + return + + # Get the manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + _ambient_dict.setdefault('server_url', StdPWSWeather.archive_url) + self.archive_queue = queue.Queue() + self.archive_thread = AmbientThread(self.archive_queue, _manager_dict, + protocol_name="PWSWeather", + **_ambient_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("PWSWeather: Data for station %s will be posted", _ambient_dict['station']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +# For backwards compatibility with early alpha versions: +StdPWSweather = StdPWSWeather + + +class StdWOW(StdRESTful): + """Upload using the UK Met Office's WOW protocol. + + For details of the WOW upload protocol, see + http://wow.metoffice.gov.uk/support/dataformats#dataFileUpload + """ + + # The URL used by WOW: + archive_url = "https://wow.metoffice.gov.uk/automaticreading" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + _ambient_dict = get_site_dict( + config_dict, 'WOW', 'station', 'password') + if _ambient_dict is None: + return + + # Get the manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + _ambient_dict.setdefault('server_url', StdWOW.archive_url) + self.archive_queue = queue.Queue() + self.archive_thread = WOWThread(self.archive_queue, _manager_dict, + protocol_name="WOW", + **_ambient_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("WOW: Data for station %s will be posted", _ambient_dict['station']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +class AmbientThread(RESTThread): + """Concrete class for threads posting from the archive queue, + using the Ambient PWS protocol. + """ + + def __init__(self, + q, + manager_dict, + station, + password, + server_url, + post_indoor_observations=False, + api_key=None, # Not used. + protocol_name="Unknown-Ambient", + essentials={}, + post_interval=None, + max_backlog=sys.maxsize, + stale=None, + log_success=True, + log_failure=True, + timeout=10, + max_tries=3, + retry_wait=5, + retry_login=3600, + retry_ssl=3600, + softwaretype="weewx-%s" % weewx.__version__, + skip_upload=False, + force_direction=False): + + """ + Initializer for the AmbientThread class. + + Parameters specific to this class: + + station: The name of the station. For example, for the WU, this + would be something like "KORHOODR3". + + password: Password used for the station. + + server_url: An url where the server for this protocol can be found. + """ + super().__init__(q, + protocol_name, + essentials=essentials, + manager_dict=manager_dict, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + retry_login=retry_login, + retry_ssl=retry_ssl, + softwaretype=softwaretype, + skip_upload=skip_upload) + self.station = station + self.password = password + self.server_url = server_url + self.formats = dict(AmbientThread._FORMATS) + if to_bool(post_indoor_observations): + self.formats.update(AmbientThread._INDOOR_FORMATS) + self.force_direction = to_bool(force_direction) + self.last_direction = 0 + + # Types and formats of the data to be published. + # See https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US + # for definitions. + _FORMATS = { + 'barometer': 'baromin=%.3f', + 'co': 'AqCO=%f', + 'dateTime': 'dateutc=%s', + 'dayRain': 'dailyrainin=%.2f', + 'dewpoint': 'dewptf=%.1f', + 'hourRain': 'rainin=%.2f', + 'leafWet1': "leafwetness=%03.0f", + 'leafWet2': "leafwetness2=%03.0f", + 'no2': 'AqNO2=%f', + 'o3': 'AqOZONE=%f', + 'outHumidity': 'humidity=%03.0f', + 'outTemp': 'tempf=%.1f', + 'pm10_0': 'AqPM10=%.1f', + 'pm2_5': 'AqPM2.5=%.1f', + 'radiation': 'solarradiation=%.2f', + 'realtime': 'realtime=%d', + 'rtfreq': 'rtfreq=%.1f', + 'so2': 'AqSO2=%f', + 'soilMoist1': "soilmoisture=%03.0f", + 'soilMoist2': "soilmoisture2=%03.0f", + 'soilMoist3': "soilmoisture3=%03.0f", + 'soilMoist4': "soilmoisture4=%03.0f", + 'soilTemp1': "soiltempf=%.1f", + 'soilTemp2': "soiltemp2f=%.1f", + 'soilTemp3': "soiltemp3f=%.1f", + 'soilTemp4': "soiltemp4f=%.1f", + 'UV': 'UV=%.2f', + 'windDir': 'winddir=%03.0f', + 'windGust': 'windgustmph=%03.1f', + 'windGust10': 'windgustmph_10m=%03.1f', + 'windGustDir10': 'windgustdir_10m=%03.0f', + 'windSpeed': 'windspeedmph=%03.1f', + 'windSpeed2': 'windspdmph_avg2m=%03.1f', + # The following four formats have been commented out until the WU + # fixes the bug that causes them to be displayed as soil moisture. + # 'extraTemp1' : "temp2f=%.1f", + # 'extraTemp2' : "temp3f=%.1f", + # 'extraTemp3' : "temp4f=%.1f", + # 'extraTemp4' : "temp5f=%.1f", + } + + _INDOOR_FORMATS = { + 'inTemp': 'indoortempf=%.1f', + 'inHumidity': 'indoorhumidity=%.0f'} + + def format_url(self, incoming_record): + """Return an URL for posting using the Ambient protocol.""" + + record = weewx.units.to_US(incoming_record) + + _liststr = ["action=updateraw", + "ID=%s" % self.station, + "PASSWORD=%s" % urllib.parse.quote(self.password), + "softwaretype=%s" % self.softwaretype] + + # Go through each of the supported types, formatting it, then adding + # to _liststr: + for _key in self.formats: + _v = record.get(_key) + # WU claims a station is "offline" if it sends a null wind direction, even when wind + # speed is zero. If option 'force_direction' is set, cache the last non-null wind + # direction and use it instead. + if _key == 'windDir' and self.force_direction: + if _v is None: + _v = self.last_direction + else: + self.last_direction = _v + # Check to make sure the type is not null + if _v is not None: + if _key == 'dateTime': + # Create a datetime object in UTC + _dt = datetime.datetime.fromtimestamp(_v, datetime.timezone.utc) + # Now format the time. The results will look something + # like '2020-10-19%2021%3A43%3A18' + _v = urllib.parse.quote(_dt.strftime("%Y-%m-%d %H:%M:%S")) + # Format the value, and accumulate in _liststr: + _liststr.append(self.formats[_key] % _v) + # Now stick all the pieces together with an ampersand between them: + _urlquery = '&'.join(_liststr) + # This will be the complete URL for the HTTP GET: + _url = "%s?%s" % (self.server_url, _urlquery) + # show the url in the logs for debug, but mask any password + if weewx.debug >= 2: + log.debug("Ambient: url: %s", re.sub(r"PASSWORD=[^\&]*", "PASSWORD=XXX", _url)) + return _url + + def check_response(self, response): + """Check the HTTP response for an Ambient related error.""" + for line in response: + # PWSweather signals a bad login with 'ERROR' + if line.startswith(b'ERROR'): + # Bad login. No reason to retry. Raise an exception. + raise BadLogin(line) + # PWS signals something garbled with a line that includes 'invalid'. + elif line.find(b'invalid') != -1: + # Again, no reason to retry. Raise an exception. + raise FailedPost(line) + + +class AmbientLoopThread(AmbientThread): + """Version used for the Rapidfire protocol.""" + + def __init__(self, + q, + manager_dict, + station, + password, + server_url, + post_indoor_observations=False, + api_key=None, # Not used + protocol_name="Unknown-Ambient", + essentials={}, + post_interval=None, + max_backlog=sys.maxsize, + stale=None, + log_success=True, + log_failure=True, + timeout=10, + max_tries=3, + retry_wait=5, + retry_login=3600, + retry_ssl=3600, + softwaretype="weewx-%s" % weewx.__version__, + skip_upload=False, + force_direction=False, + rtfreq=2.5 # This is the only one added by AmbientLoopThread + ): + """ + Initializer for the AmbientLoopThread class. + + Parameters specific to this class: + + rtfreq: Frequency of update in seconds for RapidFire + """ + super().__init__(q, + manager_dict=manager_dict, + station=station, + password=password, + server_url=server_url, + post_indoor_observations=post_indoor_observations, + api_key=api_key, + protocol_name=protocol_name, + essentials=essentials, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + retry_login=retry_login, + retry_ssl=retry_ssl, + softwaretype=softwaretype, + skip_upload=skip_upload, + force_direction=force_direction) + + self.rtfreq = float(rtfreq) + self.formats.update(AmbientLoopThread.WUONLY_FORMATS) + + # may also be used by non-rapidfire; this is the least invasive way to just fix rapidfire, + # which I know supports windGustDir, while the Ambient class is used elsewhere + WUONLY_FORMATS = { + 'windGustDir': 'windgustdir=%03.0f' + } + + def get_record(self, record, dbmanager): + """Prepare a record for the Rapidfire protocol.""" + + # Call the regular Ambient PWS version + _record = AmbientThread.get_record(self, record, dbmanager) + # Add the Rapidfire-specific keywords: + _record['realtime'] = 1 + _record['rtfreq'] = self.rtfreq + + return _record + + +class WOWThread(AmbientThread): + """Class for posting to the WOW variant of the Ambient protocol.""" + + # Types and formats of the data to be published: + _FORMATS = {'dateTime': 'dateutc=%s', + 'barometer': 'baromin=%.3f', + 'outTemp': 'tempf=%.1f', + 'outHumidity': 'humidity=%.0f', + 'windSpeed': 'windspeedmph=%.0f', + 'windDir': 'winddir=%.0f', + 'windGust': 'windgustmph=%.0f', + 'windGustDir': 'windgustdir=%.0f', + 'dewpoint': 'dewptf=%.1f', + 'hourRain': 'rainin=%.2f', + 'dayRain': 'dailyrainin=%.3f'} + + def format_url(self, incoming_record): + """Return an URL for posting using WOW's version of the Ambient + protocol.""" + + record = weewx.units.to_US(incoming_record) + + _liststr = ["action=updateraw", + "siteid=%s" % self.station, + "siteAuthenticationKey=%s" % self.password, + "softwaretype=weewx-%s" % weewx.__version__] + + # Go through each of the supported types, formatting it, then adding + # to _liststr: + for _key in WOWThread._FORMATS: + _v = record.get(_key) + # Check to make sure the type is not null + if _v is not None: + if _key == 'dateTime': + # Create a datetime object in UTC + _dt = datetime.datetime.fromtimestamp(_v, datetime.timezone.utc) + # Now format the time. The results will look something + # like '2020-10-19%2021%3A43%3A18' + _v = urllib.parse.quote(_dt.strftime("%Y-%m-%d %H:%M:%S")) + # Format the value, and accumulate in _liststr: + _liststr.append(WOWThread._FORMATS[_key] % _v) + # Now stick all the pieces together with an ampersand between them: + _urlquery = '&'.join(_liststr) + # This will be the complete URL for the HTTP GET: + _url = "%s?%s" % (self.server_url, _urlquery) + # show the url in the logs for debug, but mask any password + if weewx.debug >= 2: + log.debug("WOW: url: %s", re.sub(r"siteAuthenticationKey=[^\&]*", + "siteAuthenticationKey=XXX", _url)) + return _url + + def post_request(self, request, data=None): # @UnusedVariable + """Version of post_request() for the WOW protocol, which + uses a response error code to signal a bad login.""" + try: + _response = urllib.request.urlopen(request, timeout=self.timeout) + except urllib.error.HTTPError as e: + # WOW signals a bad login with an HTML Error 403 code: + if e.code == 403: + raise BadLogin(e) + elif e.code >= 400: + raise FailedPost(e) + else: + raise + else: + return _response + + +# ============================================================================== +# CWOP +# ============================================================================== + +class StdCWOP(StdRESTful): + """Weewx service for posting using the CWOP protocol. + + Manages a separate thread CWOPThread""" + + # Default list of CWOP servers to try: + default_servers = ['cwop.aprs.net:14580', 'cwop.aprs.net:23'] + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + _cwop_dict = get_site_dict(config_dict, 'CWOP', 'station') + if _cwop_dict is None: + return + + if 'passcode' not in _cwop_dict or _cwop_dict['passcode'] == 'replace_me': + _cwop_dict['passcode'] = '-1' + _cwop_dict['station'] = _cwop_dict['station'].upper() + _cwop_dict.setdefault('latitude', self.engine.stn_info.latitude_f) + _cwop_dict.setdefault('longitude', self.engine.stn_info.longitude_f) + _cwop_dict.setdefault('station_type', config_dict['Station'].get( + 'station_type', 'Unknown')) + + # Get the database manager dictionary: + _manager_dict = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + self.archive_queue = queue.Queue() + self.archive_thread = CWOPThread(self.archive_queue, _manager_dict, + **_cwop_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("CWOP: Data for station %s will be posted", _cwop_dict['station']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +class CWOPThread(RESTThread): + """Concrete class for threads posting from the archive queue, using the CWOP protocol. For + details on the protocol, see http://www.wxqa.com/faq.html.""" + + def __init__(self, q, manager_dict, + station, passcode, latitude, longitude, station_type, + server_list=StdCWOP.default_servers, + post_interval=600, max_backlog=sys.maxsize, stale=600, + log_success=True, log_failure=True, + timeout=10, max_tries=3, retry_wait=5, skip_upload=False): + + """ + Initializer for the CWOPThread class. + + Parameters specific to this class: + + station: The name of the station. Something like "DW1234". + + passcode: Some stations require a passcode. + + latitude: Latitude of the station in decimal degrees. + + longitude: Longitude of the station in decimal degrees. + + station_type: The type of station. Generally, this is the driver + symbolic name, such as "Vantage". + + server_list: A list of strings holding the CWOP server name and + port. Default is ['cwop.aprs.net:14580', 'cwop.aprs.net:23'] + + Parameters customized for this class: + + post_interval: How long to wait between posts. + Default is 600 (every 10 minutes). + + stale: How old a record can be and still considered useful. + Default is 60 (one minute). + """ + # Initialize my superclass + super().__init__(q, + protocol_name="CWOP", + manager_dict=manager_dict, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + skip_upload=skip_upload) + self.station = station + self.passcode = passcode + # In case we have a single server that would likely appear as a string + # not a list + self.server_list = weeutil.weeutil.option_as_list(server_list) + self.latitude = to_float(latitude) + self.longitude = to_float(longitude) + self.station_type = station_type + + def process_record(self, record, dbmanager): + """Process a record in accordance with the CWOP protocol.""" + + # Get the full record by querying the database ... + _full_record = self.get_record(record, dbmanager) + # ... convert to US if necessary ... + _us_record = weewx.units.to_US(_full_record) + # ... get the login and packet strings... + _login = self.get_login_string() + _tnc_packet = self.get_tnc_packet(_us_record) + if self.skip_upload: + raise AbortedPost("Skip post") + # ... then post them: + self.send_packet(_login, _tnc_packet) + + def get_login_string(self): + _login = "user %s pass %s vers weewx %s\r\n" % ( + self.station, self.passcode, weewx.__version__) + return _login + + def get_tnc_packet(self, record): + """Form the TNC2 packet used by CWOP.""" + + # Preamble to the TNC packet: + _prefix = "%s>APWEE5,TCPIP*:" % self.station + + # Time: + _time_tt = time.gmtime(record['dateTime']) + _time_str = time.strftime("@%d%H%Mz", _time_tt) + + # Position: + _lat_str = weeutil.weeutil.latlon_string(self.latitude, + ('N', 'S'), 'lat') + _lon_str = weeutil.weeutil.latlon_string(self.longitude, + ('E', 'W'), 'lon') + # noinspection PyStringFormat + _latlon_str = '%s%s%s/%s%s%s' % (_lat_str + _lon_str) + + # Wind and temperature + _wt_list = [] + for _obs_type in ['windDir', 'windSpeed', 'windGust', 'outTemp']: + _v = record.get(_obs_type) + _wt_list.append("%03d" % int(_v + 0.5) if _v is not None else '...') + _wt_str = "_%s/%sg%st%s" % tuple(_wt_list) + + # Rain + _rain_list = [] + for _obs_type in ['hourRain', 'rain24', 'dayRain']: + _v = record.get(_obs_type) + _rain_list.append("%03d" % int(_v * 100.0 + 0.5) if _v is not None else '...') + _rain_str = "r%sp%sP%s" % tuple(_rain_list) + + # Barometer: + _baro = record.get('altimeter') + if _baro is None: + _baro_str = "b....." + else: + # While everything else in the CWOP protocol is in US Customary, + # they want the barometer in millibars. + _baro_vt = weewx.units.convert((_baro, 'inHg', 'group_pressure'), + 'mbar') + _baro_str = "b%05d" % int(_baro_vt[0] * 10.0 + 0.5) + + # Humidity: + _humidity = record.get('outHumidity') + if _humidity is None: + _humid_str = "h.." + else: + _humid_str = ("h%02d" % int(_humidity + 0.5)) if _humidity < 99.5 else "h00" + + # Radiation: + _radiation = record.get('radiation') + if _radiation is None: + _radiation_str = "" + elif _radiation < 999.5: + _radiation_str = "L%03d" % int(_radiation + 0.5) + elif _radiation < 1999.5: + _radiation_str = "l%03d" % int(_radiation - 1000 + 0.5) + else: + _radiation_str = "" + + # Station equipment + _equipment_str = ".weewx-%s-%s" % (weewx.__version__, self.station_type) + + _tnc_packet = ''.join([_prefix, _time_str, _latlon_str, _wt_str, + _rain_str, _baro_str, _humid_str, + _radiation_str, _equipment_str, "\r\n"]) + + # show the packet in the logs for debug + if weewx.debug >= 2: + log.debug("CWOP: packet: '%s'", _tnc_packet.rstrip('\r\n')) + + return _tnc_packet + + def send_packet(self, login, tnc_packet): + + # Go through the list of known server:ports, looking for + # a connection that works: + for _serv_addr_str in self.server_list: + + try: + _server, _port_str = _serv_addr_str.split(":") + _port = int(_port_str) + except ValueError: + log.error("%s: Bad server address: '%s'; ignored", self.protocol_name, + _serv_addr_str) + continue + + # Try each combination up to max_tries times: + for _count in range(self.max_tries): + try: + # Get a socket connection: + _sock = self._get_connect(_server, _port) + log.debug("%s: Connected to server %s:%d", self.protocol_name, _server, _port) + try: + # Send the login ... + self._send(_sock, login, dbg_msg='login') + # ... and then the packet + response = self._send(_sock, tnc_packet, dbg_msg='tnc') + if weewx.debug >= 2: + log.debug("%s: Response to packet: '%s'", self.protocol_name, response) + return + finally: + _sock.close() + except ConnectError as e: + log.debug("%s: Attempt %d to %s:%d. Connection error: %s", + self.protocol_name, _count + 1, _server, _port, e) + except SendError as e: + log.debug("%s: Attempt %d to %s:%d. Socket send error: %s", + self.protocol_name, _count + 1, _server, _port, e) + + # If we get here, the loop terminated normally, meaning we failed + # all tries + raise FailedPost("Tried %d servers %d times each" + % (len(self.server_list), self.max_tries)) + + def _get_connect(self, server, port): + """Get a socket connection to a specific server and port.""" + + _sock = None + try: + _sock = socket.socket() + _sock.connect((server, port)) + except IOError as e: + # Unsuccessful. Close it in case it was open: + try: + _sock.close() + except (AttributeError, socket.error): + pass + raise ConnectError(e) + + return _sock + + def _send(self, sock, msg, dbg_msg): + """Send a message to a specific socket.""" + + # Convert from string to byte string + msg_bytes = msg.encode('ascii') + try: + sock.send(msg_bytes) + except IOError as e: + # Unsuccessful. Log it and go around again for another try + raise SendError("Packet %s; Error %s" % (dbg_msg, e)) + else: + # Success. Look for response from the server. + try: + _resp = sock.recv(1024).decode('ascii') + return _resp + except IOError as e: + log.debug("%s: Exception %s (%s) when looking for response to %s packet", + self.protocol_name, type(e), e, dbg_msg) + return + + +# ============================================================================== +# Station Registry +# ============================================================================== + +class StdStationRegistry(StdRESTful): + """Class for phoning home to register a weewx station. + + To enable this module, add the following to weewx.conf: + + [StdRESTful] + [[StationRegistry]] + register_this_station = True + + This will periodically do a http POST with the following information: + + station_url Should be world-accessible. Used as key. Must be unique. + description Brief synopsis of the station + latitude Station latitude in decimal + longitude Station longitude in decimal + station_type The driver name, for example Vantage, FineOffsetUSB + station_model The hardware_name property from the driver + weewx_info weewx version + python_info The version of Python. E.g., "3.7.10" + platform_info As returned by platform.platform() + config_path Where the configuration file is located. + entry_path The path to the top-level module (usually where weewxd is located) + + The station_url is the unique key by which a station is identified. + """ + + registry_url = 'https://weewx.com/api/v2/stations/' + + def __init__(self, engine, config_dict): + + super().__init__(engine, config_dict) + + _registry_dict = get_site_dict(config_dict, 'StationRegistry', 'register_this_station') + if _registry_dict is None: + return + + # Should the service be run? + if not to_bool(_registry_dict.pop('register_this_station', False)): + log.info("StationRegistry: Registration not requested.") + return + + # Registry requires a valid station url + _registry_dict.setdefault('station_url', + self.engine.stn_info.station_url) + if _registry_dict['station_url'] is None: + log.info("StationRegistry: Station will not be registered: no station_url specified.") + return + + _registry_dict.setdefault('station_type', + config_dict['Station'].get('station_type', 'Unknown')) + _registry_dict.setdefault('description', self.engine.stn_info.location) + _registry_dict.setdefault('latitude', self.engine.stn_info.latitude_f) + _registry_dict.setdefault('longitude', self.engine.stn_info.longitude_f) + _registry_dict.setdefault('station_model', self.engine.stn_info.hardware) + _registry_dict.setdefault('config_path', config_dict.get('config_path', 'Unknown')) + # Find the top-level module. This is where the entry point will be. + _registry_dict.setdefault('entry_path', getattr(sys.modules['__main__'], '__file__', + 'Unknown')) + # Delay the registration by a random amount so all stations don't hit the server + # at the same time. + _registry_dict.setdefault('delay_post', random.randint(0, 45)) + + self.archive_queue = queue.Queue() + self.archive_thread = StationRegistryThread(self.archive_queue, + **_registry_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("StationRegistry: Station will be registered.") + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +class StationRegistryThread(RESTThread): + """Concrete threaded class for posting to the weewx station registry.""" + + def __init__(self, + q, + station_url, + latitude, + longitude, + server_url=StdStationRegistry.registry_url, + description="Unknown", + station_type="Unknown", + station_model="Unknown", + config_path="Unknown", + entry_path="Unknown", + post_interval=86400, + timeout=60, + **kwargs): + """Initialize an instance of StationRegistryThread. + + Args: + + q (queue.Queue): An instance of queue.Queue where the records will appear. + station_url (str): An URL used to identify the station. This will be + used as the unique key in the registry to identify each station. + latitude (float): Latitude of the staion + longitude (float): Longitude of the station + server_url (str): The URL of the registry server. + Default is 'http://weewx.com/register/register.cgi' + description (str): A brief description of the station. + Default is 'Unknown' + station_type (str): The type of station. Generally, this is the name of + the driver used by the station. Default is 'Unknown' + config_path (str): location of the configuration file, used in system + registration to determine how weewx might have been installed. + Default is 'Unknown'. + entry_path (str): location of the top-level module that was executed. Usually this is + where 'weewxd' is located. Default is "Unknown". + station_model (str): The hardware model, typically the hardware_name property provided + by the driver. Default is 'Unknown'. + post_interval (int): How long to wait between posts. + Default is 86400 seconds (1 day). + timeout (int): How long to wait for the server to respond before giving up. + Default is 60 seconds. + """ + + super().__init__( + q, + protocol_name='StationRegistry', + post_interval=post_interval, + timeout=timeout, + **kwargs) + self.station_url = station_url + self.latitude = to_float(latitude) + self.longitude = to_float(longitude) + self.server_url = server_url + self.description = weeutil.weeutil.list_as_string(description) + self.station_type = station_type + self.station_model = station_model + self.config_path = config_path + self.entry_path = entry_path + # Don't allow registering more often than once an hour + if self.post_interval is None or self.post_interval < 86400: + log.debug("%s: Registration interval '%s' too short. Set to 86400.", + self.protocol_name, + self.post_interval) + self.post_interval = 86400 + + def format_url(self, _): + return self.server_url + + def get_post_body(self, record): + import json + + body = { + 'station_url': self.station_url, + 'description': self.description, + 'latitude': self.latitude, + 'longitude': self.longitude, + 'station_type': self.station_type, + 'station_model': self.station_model, + 'weewx_info': weewx.__version__, + 'python_info': platform.python_version(), + 'platform_info': platform.platform(), + 'config_path': self.config_path, + 'entry_path' : self.entry_path, + } + json_body = json.dumps(body) + return json_body, 'application/json' + + def check_response(self, response): + """ + Check the response from a Station Registry post. The server will + reply with a single line that starts with OK or FAIL. If a post fails + at this point, it is probably due to a configuration problem, not + communications, so retrying probably not help. So raise a FailedPost + exception, which will result in logging the failure without retrying. + """ + for line in response: + if line.startswith(b'FAIL'): + raise FailedPost(line) + + +# ============================================================================== +# AWEKAS +# ============================================================================== + +class StdAWEKAS(StdRESTful): + """Upload data to AWEKAS - Automatisches WEtterKArten System + http://www.awekas.at + + To enable this module, add the following to weewx.conf: + + [StdRESTful] + [[AWEKAS]] + enable = True + username = AWEKAS_USERNAME + password = AWEKAS_PASSWORD + + The AWEKAS server expects a single string of values delimited by + semicolons. The position of each value matters, for example position 1 + is the awekas username and position 2 is the awekas password. + + Positions 1-25 are defined for the basic API: + + Pos1: user (awekas username) + Pos2: password (awekas password MD5 Hash) + Pos3: date (dd.mm.yyyy) (varchar) + Pos4: time (hh:mm) (varchar) + Pos5: temperature (C) (float) + Pos6: humidity (%) (int) + Pos7: air pressure (hPa) (float) [22dec15. This should be SLP. -tk personal communications] + Pos8: precipitation (rain at this day) (float) + Pos9: wind speed (km/h) (float) + Pos10: wind direction (degree) (int) + Pos11: weather condition (int) + 0=clear warning + 1=clear + 2=sunny sky + 3=partly cloudy + 4=cloudy + 5=heavy cloundy + 6=overcast sky + 7=fog + 8=rain showers + 9=heavy rain showers + 10=light rain + 11=rain + 12=heavy rain + 13=light snow + 14=snow + 15=light snow showers + 16=snow showers + 17=sleet + 18=hail + 19=thunderstorm + 20=storm + 21=freezing rain + 22=warning + 23=drizzle + 24=heavy snow + 25=heavy snow showers + Pos12: warning text (varchar) + Pos13: snow high (cm) (int) if no snow leave blank + Pos14: language (varchar) + de=german; en=english; it=italian; fr=french; nl=dutch + Pos15: tendency (int) + -2 = high falling + -1 = falling + 0 = steady + 1 = rising + 2 = high rising + Pos16. wind gust (km/h) (float) + Pos17: solar radiation (W/m^2) (float) + Pos18: UV Index (float) + Pos19: brightness (LUX) (int) + Pos20: sunshine hours today (float) + Pos21: soil temperature (degree C) (float) + Pos22: rain rate (mm/h) (float) + Pos23: software flag NNNN_X.Y, for example, WLIP_2.15 + Pos24: longitude (float) + Pos25: latitude (float) + + positions 26-111 are defined for API2 + """ + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + site_dict = get_site_dict(config_dict, 'AWEKAS', 'username', 'password') + if site_dict is None: + return + + site_dict.setdefault('latitude', engine.stn_info.latitude_f) + site_dict.setdefault('longitude', engine.stn_info.longitude_f) + site_dict.setdefault('language', 'de') + + site_dict['manager_dict'] = weewx.manager.get_manager_dict_from_config( + config_dict, 'wx_binding') + + self.archive_queue = queue.Queue() + self.archive_thread = AWEKASThread(self.archive_queue, **site_dict) + self.archive_thread.start() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("AWEKAS: Data will be uploaded for user %s", site_dict['username']) + + def new_archive_record(self, event): + self.archive_queue.put(event.record) + + +# For compatibility with some early alpha versions: +AWEKAS = StdAWEKAS + + +class AWEKASThread(RESTThread): + _SERVER_URL = 'http://data.awekas.at/eingabe_pruefung.php' + _FORMATS = {'barometer': '%.3f', + 'outTemp': '%.1f', + 'outHumidity': '%.0f', + 'windSpeed': '%.1f', + 'windDir': '%.0f', + 'windGust': '%.1f', + 'dewpoint': '%.1f', + 'hourRain': '%.2f', + 'dayRain': '%.2f', + 'radiation': '%.2f', + 'UV': '%.2f', + 'rainRate': '%.2f'} + + def __init__(self, q, username, password, latitude, longitude, + manager_dict, + language='de', server_url=_SERVER_URL, + post_interval=300, max_backlog=sys.maxsize, stale=None, + log_success=True, log_failure=True, + timeout=10, max_tries=3, retry_wait=5, + retry_login=3600, retry_ssl=3600, skip_upload=False): + """Initialize an instances of AWEKASThread. + + Parameters specific to this class: + + username: AWEKAS username + + password: AWEKAS password + + language: Possible values include de, en, it, fr, nl + Default is de + + latitude: Station latitude in decimal degrees + Default is station latitude + + longitude: Station longitude in decimal degrees + Default is station longitude + + manager_dict: A dictionary holding the database manager + information. It will be used to open a connection to the archive + database. + + server_url: URL of the server + Default is the AWEKAS site + + Parameters customized for this class: + + post_interval: The interval in seconds between posts. AWEKAS requests + that uploads happen no more often than 5 minutes, so this should be + set to no less than 300. Default is 300 + """ + import hashlib + super().__init__(q, + protocol_name='AWEKAS', + manager_dict=manager_dict, + post_interval=post_interval, + max_backlog=max_backlog, + stale=stale, + log_success=log_success, + log_failure=log_failure, + timeout=timeout, + max_tries=max_tries, + retry_wait=retry_wait, + retry_login=retry_login, + retry_ssl=retry_ssl, + skip_upload=skip_upload) + self.username = username + # Calculate and save the password hash + m = hashlib.md5() + m.update(password.encode('utf-8')) + self.password_hash = m.hexdigest() + self.latitude = float(latitude) + self.longitude = float(longitude) + self.language = language + self.server_url = server_url + + def get_record(self, record, dbmanager): + """Ensure that rainRate is in the record.""" + # Have my superclass process the record first. + record = super().get_record(record, dbmanager) + + # No need to do anything if rainRate is already in the record + if 'rainRate' in record: + return record + + # If we don't have a database, we can't do anything + if dbmanager is None: + if self.log_failure: + log.debug("AWEKAS: No database specified. Augmentation from database skipped.") + return record + + # If the database does not have rainRate in its schema, an exception will be raised. + # Be prepare to catch it. + try: + rr = dbmanager.getSql('select rainRate from %s where dateTime=?' + % dbmanager.table_name, (record['dateTime'],)) + except weedb.OperationalError: + pass + else: + # If there is no record with the timestamp, None will be returned. + # In theory, this shouldn't happen, but check just in case: + if rr: + record['rainRate'] = rr[0] + + return record + + def format_url(self, in_record): + """Specialized version of format_url() for the AWEKAS protocol.""" + + # Convert to units required by awekas + record = weewx.units.to_METRIC(in_record) + if 'dayRain' in record and record['dayRain'] is not None: + record['dayRain'] *= 10 + if 'rainRate' in record and record['rainRate'] is not None: + record['rainRate'] *= 10 + + time_tt = time.gmtime(record['dateTime']) + # assemble an array of values in the proper order + values = [ + self.username, + self.password_hash, + time.strftime("%d.%m.%Y", time_tt), + time.strftime("%H:%M", time_tt), + self._format(record, 'outTemp'), # C + self._format(record, 'outHumidity'), # % + self._format(record, 'barometer'), # mbar + self._format(record, 'dayRain'), # mm + self._format(record, 'windSpeed'), # km/h + self._format(record, 'windDir'), + '', # weather condition + '', # warning text + '', # snow high + self.language, + '', # tendency + self._format(record, 'windGust'), # km/h + self._format(record, 'radiation'), # W/m^2 + self._format(record, 'UV'), # uv index + '', # brightness in lux + '', # sunshine hours + '', # soil temperature + self._format(record, 'rainRate'), # mm/h + 'weewx_%s' % weewx.__version__, + str(self.longitude), + str(self.latitude), + ] + + valstr = ';'.join(values) + url = self.server_url + '?val=' + valstr + + if weewx.debug >= 2: + # show the url in the logs for debug, but mask any credentials + log.debug('AWEKAS: url: %s', url.replace(self.password_hash, 'XXX')) + + return url + + def _format(self, record, label): + if label in record and record[label] is not None: + if label in self._FORMATS: + return self._FORMATS[label] % record[label] + return str(record[label]) + return '' + + def check_response(self, response): + """Specialized version of check_response().""" + for line in response: + # Skip blank lines: + if not line.strip(): + continue + if line.startswith(b'OK'): + return + elif line.startswith(b"Benutzer/Passwort Fehler"): + raise BadLogin(line) + else: + raise FailedPost("Server returned '%s'" % line) + + +############################################################################### + +def get_site_dict(config_dict, service, *args): + """Obtain the site options, with defaults from the StdRESTful section. + If the service is not enabled, or if one or more required parameters is + not specified, then return None.""" + + try: + site_dict = accumulateLeaves(config_dict['StdRESTful'][service], + max_level=1) + except KeyError: + log.info("%s: No config info. Skipped.", service) + return None + + # If site_dict has the key 'enable' and it is False, then + # the service is not enabled. + try: + if not to_bool(site_dict['enable']): + log.info("%s: Posting not enabled.", service) + return None + except KeyError: + pass + + # At this point, either the key 'enable' does not exist, or + # it is set to True. Check to see whether all the needed + # options exist, and none of them have been set to 'replace_me': + try: + for option in args: + if site_dict[option] == 'replace_me': + raise KeyError(option) + except KeyError as e: + log.debug("%s: Data will not be posted: Missing option %s", service, e) + return None + + # If the site dictionary does not have a log_success or log_failure, get + # them from the root dictionary + site_dict.setdefault('log_success', to_bool(config_dict.get('log_success', True))) + site_dict.setdefault('log_failure', to_bool(config_dict.get('log_failure', True))) + + # Get rid of the no longer needed key 'enable': + site_dict.pop('enable', None) + + return site_dict + + +# For backward compatibility pre 3.6.0 +check_enable = get_site_dict diff --git a/dist/weewx-5.0.2/src/weewx/station.py b/dist/weewx-5.0.2/src/weewx/station.py new file mode 100644 index 0000000..f5b702e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/station.py @@ -0,0 +1,180 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Defines (mostly static) information about a station.""" +import sys +import time + +import weeutil.weeutil +import weewx.units + +class StationInfo(object): + """Readonly class with static station information. It has no formatting information. Just a POS. + + Attributes: + + altitude_vt: Station altitude as a ValueTuple + hardware: A string holding a hardware description + rain_year_start: The start of the rain year (1=January) + latitude_f: Floating point latitude + longitude_f: Floating point longitude + location: String holding a description of the station location + week_start: The start of the week (0=Monday) + station_url: An URL with an informative website (if any) about the station + """ + + def __init__(self, console=None, **stn_dict): + """Extracts info from the console and stn_dict and stores it in self.""" + + if console and hasattr(console, "altitude_vt"): + self.altitude_vt = console.altitude_vt + else: + altitude_t = weeutil.weeutil.option_as_list(stn_dict.get('altitude', (None, None))) + try: + self.altitude_vt = weewx.units.ValueTuple(float(altitude_t[0]), altitude_t[1], "group_altitude") + except KeyError as e: + raise weewx.ViolatedPrecondition("Value 'altitude' needs a unit (%s)" % e) + + if console and hasattr(console, 'hardware_name'): + self.hardware = console.hardware_name + else: + self.hardware = stn_dict.get('station_type', 'Unknown') + + if console and hasattr(console, 'rain_year_start'): + self.rain_year_start = getattr(console, 'rain_year_start') + else: + self.rain_year_start = int(stn_dict.get('rain_year_start', 1)) + + self.latitude_f = float(stn_dict['latitude']) + self.longitude_f = float(stn_dict['longitude']) + # Locations frequently have commas in them. Guard against ConfigObj turning it into a list: + self.location = weeutil.weeutil.list_as_string(stn_dict.get('location', 'Unknown')) + self.week_start = int(stn_dict.get('week_start', 6)) + self.station_url = stn_dict.get('station_url') + # For backwards compatibility: + self.webpath = self.station_url + +class Station(object): + """Formatted version of StationInfo.""" + + def __init__(self, stn_info, formatter, converter, skin_dict): + + # Store away my instance of StationInfo + self.stn_info = stn_info + self.formatter = formatter + self.converter = converter + + # Add a bunch of formatted attributes: + label_dict = skin_dict.get('Labels', {}) + hemispheres = label_dict.get('hemispheres', ('N','S','E','W')) + latlon_formats = label_dict.get('latlon_formats') + self.latitude = weeutil.weeutil.latlon_string(stn_info.latitude_f, + hemispheres[0:2], + 'lat', latlon_formats) + self.longitude = weeutil.weeutil.latlon_string(stn_info.longitude_f, + hemispheres[2:4], + 'lon', latlon_formats) + self.altitude = weewx.units.ValueHelper(value_t=stn_info.altitude_vt, + formatter=formatter, + converter=converter) + self.rain_year_str = time.strftime("%b", (0, self.rain_year_start, 1, 0, 0, 0, 0, 0, -1)) + + self.version = weewx.__version__ + + self.python_version = "%d.%d.%d" % sys.version_info[:3] + + @property + def uptime(self): + """Lazy evaluation of weewx uptime.""" + delta_time = time.time() - weewx.launchtime_ts if weewx.launchtime_ts else None + + return weewx.units.ValueHelper(value_t=(delta_time, "second", "group_deltatime"), + context="month", + formatter=self.formatter, + converter=self.converter) + + @property + def os_uptime(self): + """Lazy evaluation of the server uptime.""" + os_uptime_secs = _os_uptime() + return weewx.units.ValueHelper(value_t=(os_uptime_secs, "second", "group_deltatime"), + context="month", + formatter=self.formatter, + converter=self.converter) + + def __getattr__(self, name): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if name in ['__call__', 'has_key']: + raise AttributeError + # For anything that is not an explicit attribute of me, try + # my instance of StationInfo. + return getattr(self.stn_info, name) + + +def _os_uptime(): + """ Get the OS uptime. Because this is highly operating system dependent, several different + strategies may have to be tried:""" + + try: + # For Python 3.7 and later, most systems + return time.clock_gettime(time.CLOCK_UPTIME) + except AttributeError: + pass + + try: + # For Python 3.3 and later, most systems + return time.clock_gettime(time.CLOCK_MONOTONIC) + except AttributeError: + pass + + try: + # For Linux, Python 2 and 3: + return float(open("/proc/uptime").read().split()[0]) + except (IOError, KeyError, OSError): + pass + + try: + # For macOS, Python 2: + from Quartz.QuartzCore import CACurrentMediaTime + return CACurrentMediaTime() + except ImportError: + pass + + try: + # for FreeBSD, Python 2 + import ctypes + from ctypes.util import find_library + + libc = ctypes.CDLL(find_library('c')) + size = ctypes.c_size_t() + buf = ctypes.c_int() + size.value = ctypes.sizeof(buf) + libc.sysctlbyname("kern.boottime", ctypes.byref(buf), ctypes.byref(size), None, 0) + os_uptime_secs = time.time() - float(buf.value) + return os_uptime_secs + except (ImportError, AttributeError, IOError, NameError): + pass + + try: + # For OpenBSD, Python 2. See issue #428. + import subprocess + from datetime import datetime + cmd = ['sysctl', 'kern.boottime'] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + o, e = proc.communicate() + # Check for errors + if e: + raise IOError + time_t = o.decode('ascii').split() + time_as_string = time_t[1] + " " + time_t[2] + " " + time_t[4][:4] + " " + time_t[3] + os_time = datetime.strptime(time_as_string, "%b %d %Y %H:%M:%S") + epoch_time = (os_time - datetime(1970, 1, 1)).total_seconds() + os_uptime_secs = time.time() - epoch_time + return os_uptime_secs + except (IOError, IndexError, ValueError): + pass + + # Nothing seems to be working. Return None + return None diff --git a/dist/weewx-5.0.2/src/weewx/tags.py b/dist/weewx-5.0.2/src/weewx/tags.py new file mode 100644 index 0000000..c2b8b05 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tags.py @@ -0,0 +1,686 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Classes for implementing the weewx tag 'code' codes.""" + +import weeutil.weeutil +import weewx.units +import weewx.xtypes +from weeutil.weeutil import to_int +from weewx.units import ValueTuple + +# Attributes we are to ignore. Cheetah calls these housekeeping functions. +IGNORE_ATTR = {'mro', 'im_func', 'func_code', '__func__', '__code__', '__init__', '__self__'} + + +# =============================================================================== +# Class TimeBinder +# =============================================================================== + +class TimeBinder(object): + """Binds to a specific time. Can be queried for time attributes, such as month. + + When a time period is given as an attribute to it, such as obj.month, the next item in the + chain is returned, in this case an instance of TimespanBinder, which binds things to a + timespan. + """ + + def __init__(self, db_lookup, report_time, + formatter=None, + converter=None, + **option_dict): + """Initialize an instance of DatabaseBinder. + + Args: + db_lookup (function|None): A function with call signature db_lookup(data_binding), + which returns a database manager and where data_binding is an optional binding + name. If not given, then a default binding will be used. + report_time(float): The time for which the report should be run. + formatter (weewx.units.Formatter|None): An instance of weewx.units.Formatter() holding + the formatting information to be used. [Optional. If not given, the default + Formatter will be used.] + converter (weewx.units.Converter|None): An instance of weewx.units.Converter() holding + the target unit information to be used. [Optional. If not given, the default + Converter will be used.] + option_dict (dict|None): Other options which can be used to customize calculations. + [Optional.] + """ + self.db_lookup = db_lookup + self.report_time = report_time + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + # What follows is the list of time period attributes: + + def trend(self, time_delta=None, time_grace=None, data_binding=None): + """Returns a TrendObj that is bound to the trend parameters.""" + if time_delta is None: + time_delta = self.option_dict['trend'].get('time_delta', 10800) + time_delta = weeutil.weeutil.nominal_spans(time_delta) + if time_grace is None: + time_grace = self.option_dict['trend'].get('time_grace', 300) + time_grace = weeutil.weeutil.nominal_spans(time_grace) + return TrendObj(time_delta, time_grace, self.db_lookup, data_binding, self.report_time, + self.formatter, self.converter, **self.option_dict) + + def hour(self, data_binding=None, hours_ago=0): + return TimespanBinder( + weeutil.weeutil.archiveHoursAgoSpan(self.report_time, hours_ago=hours_ago), + self.db_lookup, data_binding=data_binding, + context='day', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def day(self, data_binding=None, days_ago=0): + return TimespanBinder(weeutil.weeutil.archiveDaySpan(self.report_time, days_ago=days_ago), + self.db_lookup, data_binding=data_binding, + context='day', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def yesterday(self, data_binding=None): + return self.day(data_binding, days_ago=1) + + def week(self, data_binding=None, weeks_ago=0): + week_start = to_int(self.option_dict.get('week_start', 6)) + return TimespanBinder( + weeutil.weeutil.archiveWeekSpan(self.report_time, startOfWeek=week_start, weeks_ago=weeks_ago), + self.db_lookup, data_binding=data_binding, + context='week', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def month(self, data_binding=None, months_ago=0): + return TimespanBinder( + weeutil.weeutil.archiveMonthSpan(self.report_time, months_ago=months_ago), + self.db_lookup, data_binding=data_binding, + context='month', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def year(self, data_binding=None, years_ago=0): + return TimespanBinder( + weeutil.weeutil.archiveYearSpan(self.report_time, years_ago=years_ago), + self.db_lookup, data_binding=data_binding, + context='year', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def alltime(self, data_binding=None): + manager = self.db_lookup(data_binding) + # We do not need to worry about 'first' being None, because CheetahGenerator would not + # start the generation if this was the case. + first = manager.firstGoodStamp() + return TimespanBinder( + weeutil.weeutil.TimeSpan(first, self.report_time), + self.db_lookup, data_binding=data_binding, + context='year', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def rainyear(self, data_binding=None): + rain_year_start = to_int(self.option_dict.get('rain_year_start', 1)) + return TimespanBinder( + weeutil.weeutil.archiveRainYearSpan(self.report_time, rain_year_start), + self.db_lookup, data_binding=data_binding, + context='rainyear', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + def span(self, data_binding=None, time_delta=0, hour_delta=0, day_delta=0, week_delta=0, + month_delta=0, year_delta=0, boundary=None): + return TimespanBinder( + weeutil.weeutil.archiveSpanSpan(self.report_time, time_delta=time_delta, + hour_delta=hour_delta, day_delta=day_delta, + week_delta=week_delta, month_delta=month_delta, + year_delta=year_delta, boundary=boundary), + self.db_lookup, data_binding=data_binding, + context='day', formatter=self.formatter, converter=self.converter, + **self.option_dict) + + # For backwards compatiblity + hours_ago = hour + days_ago = day + + +# =============================================================================== +# Class TimespanBinder +# =============================================================================== + +class TimespanBinder(object): + """Holds a binding between a database and a timespan. + + This class is the next class in the chain of helper classes. + + When an observation type is given as an attribute to it (such as 'obj.outTemp'), the next item + in the chain is returned, in this case an instance of ObservationBinder, which binds the + database, the time period, and the statistical type all together. + + It also includes a few "special attributes" that allow iteration over certain time periods. + Example: + + # Iterate by month: + for monthStats in yearStats.months: + # Print maximum temperature for each month in the year: + print(monthStats.outTemp.max) + """ + + def __init__(self, timespan, db_lookup, data_binding=None, context='current', + formatter=None, + converter=None, + **option_dict): + """Initialize an instance of TimespanBinder. + + timespan: An instance of weeutil.Timespan with the time span over which the statistics are + to be calculated. + + db_lookup: A function with call signature db_lookup(data_binding), which returns a database + manager and where data_binding is an optional binding name. If not given, then a default + binding will be used. + + data_binding: If non-None, then use this data binding. + + context: A tag name for the timespan. This is something like 'current', 'day', 'week', etc. + This is used to pick an appropriate time label. + + formatter: An instance of weewx.units.Formatter() holding the formatting information to be + used. [Optional. If not given, the default Formatter will be used.] + + converter: An instance of weewx.units.Converter() holding the target unit information to be + used. [Optional. If not given, the default Converter will be used.] + + option_dict: Other options which can be used to customize calculations. [Optional.] + """ + + self.timespan = timespan + self.db_lookup = db_lookup + self.data_binding = data_binding + self.context = context + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + # Iterate over all records in the time period: + def records(self): + manager = self.db_lookup(self.data_binding) + for record in manager.genBatchRecords(self.timespan.start, self.timespan.stop): + yield CurrentObj(self.db_lookup, self.data_binding, record['dateTime'], self.formatter, + self.converter, record=record) + + # Iterate over custom span + def spans(self, context='day', interval=10800): + for span in weeutil.weeutil.intervalgen(self.timespan.start, self.timespan.stop, interval): + yield TimespanBinder(span, self.db_lookup, self.data_binding, + context, self.formatter, self.converter, **self.option_dict) + + # Iterate over hours in the time period: + def hours(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genHourSpans, self.timespan, + self.db_lookup, self.data_binding, + 'hour', self.formatter, self.converter, + **self.option_dict) + + # Iterate over days in the time period: + def days(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genDaySpans, self.timespan, + self.db_lookup, self.data_binding, + 'day', self.formatter, self.converter, + **self.option_dict) + + # Iterate over months in the time period: + def months(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genMonthSpans, self.timespan, + self.db_lookup, self.data_binding, + 'month', self.formatter, self.converter, + **self.option_dict) + + # Iterate over years in the time period: + def years(self): + return TimespanBinder._seqGenerator(weeutil.weeutil.genYearSpans, self.timespan, + self.db_lookup, self.data_binding, + 'year', self.formatter, self.converter, + **self.option_dict) + + # Static method used to implement the iteration: + @staticmethod + def _seqGenerator(genSpanFunc, timespan, *args, **option_dict): + """Generator function that returns TimespanBinder for the appropriate timespans""" + for span in genSpanFunc(timespan.start, timespan.stop): + yield TimespanBinder(span, *args, **option_dict) + + # Return the start time of the time period as a ValueHelper + @property + def start(self): + val = weewx.units.ValueTuple(self.timespan.start, 'unix_epoch', 'group_time') + return weewx.units.ValueHelper(val, self.context, self.formatter, self.converter) + + # Return the ending time: + @property + def end(self): + val = weewx.units.ValueTuple(self.timespan.stop, 'unix_epoch', 'group_time') + return weewx.units.ValueHelper(val, self.context, self.formatter, self.converter) + + # Return the length of the timespan + @property + def length(self): + val = weewx.units.ValueTuple(self.timespan.stop-self.timespan.start, 'second', 'group_deltatime') + return weewx.units.ValueHelper(val, self.context, self.formatter, self.converter) + + # Alias for the start time: + dateTime = start + + def check_for_data(self, sql_expr): + """Check whether the given sql expression returns any data""" + db_manager = self.db_lookup(self.data_binding) + try: + val = weewx.xtypes.get_aggregate(sql_expr, self.timespan, 'not_null', db_manager) + return bool(val[0]) + except weewx.UnknownAggregation: + return False + + def __call__(self, data_binding=None): + """The iterators return an instance of TimespanBinder. Allow them to override + data_binding""" + return TimespanBinder(self.timespan, self.db_lookup, data_binding, self.context, + self.formatter, self.converter, **self.option_dict) + + def __getattr__(self, obs_type): + """Return a helper object that binds the database, a time period, and the given observation + type. + + obs_type: An observation type, such as 'outTemp', or 'heatDeg' + + returns: An instance of class ObservationBinder.""" + + if obs_type in IGNORE_ATTR: + raise AttributeError(obs_type) + + # Return an ObservationBinder: if an attribute is + # requested from it, an aggregation value will be returned. + return ObservationBinder(obs_type, self.timespan, self.db_lookup, self.data_binding, + self.context, + self.formatter, self.converter, **self.option_dict) + + +# =============================================================================== +# Class ObservationBinder +# =============================================================================== + +class ObservationBinder(object): + """This is the next class in the chain of helper classes. It binds the + database, a time period, and an observation type all together. + + When an aggregation type (eg, 'max') is given as an attribute to it, it binds it to + an instance of AggTypeBinder and returns it. + """ + + def __init__(self, obs_type, timespan, db_lookup, data_binding, context, + formatter=None, + converter=None, + **option_dict): + """ Initialize an instance of ObservationBinder + + Args: + obs_type (str): A string with the stats type (e.g., 'outTemp') for which the query is + to be done. + timespan (weeutil.weeutil.TimeSpan): An instance of TimeSpan holding the time period + over which the query is to be run. + db_lookup (function|None): A function with call signature db_lookup(data_binding), + which returns a database manager and where data_binding is an optional binding + name. If not given, then a default binding will be used. + data_binding (str): If non-None, then use this data binding. + context (str): A tag name for the timespan. This is something like 'current', 'day', + 'week', etc. This is used to find an appropriate label, if necessary. + formatter (weewx.units.Formatter|None): An instance of weewx.units.Formatter() holding + the formatting information to be used. [Optional. If not given, the default + Formatter will be used.] + converter (weewx.units.Converter|None): An instance of weewx.units.Converter() holding + the target unit information to be used. [Optional. If not given, the default + Converter will be used.] + option_dict (dict|None): Other options which can be used to customize calculations. + [Optional.] + """ + + self.obs_type = obs_type + self.timespan = timespan + self.db_lookup = db_lookup + self.data_binding = data_binding + self.context = context + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + def __getattr__(self, aggregate_type): + """Use the specified aggregation type + + Args: + aggregate_type(str): The type of aggregation over which the summary is to be done. + This is normally something like 'sum', 'min', 'mintime', 'count', etc. However, + there are two special aggregation types that can be used to determine the + existence of data: + 'exists': Return True if the observation type exists in the database. + 'has_data': Return True if the type exists and there is a non-zero number of + entries over the aggregation period. + + Returns: + AggTypeBinder: An instance of AggTypeBinder, which is bound to the aggregation type. + """ + if aggregate_type in IGNORE_ATTR: + raise AttributeError(aggregate_type) + return AggTypeBinder(aggregate_type=aggregate_type, + obs_type=self.obs_type, + timespan=self.timespan, + db_lookup=self.db_lookup, + data_binding=self.data_binding, + context=self.context, + formatter=self.formatter, converter=self.converter, + **self.option_dict) + + @property + def exists(self): + return self.db_lookup(self.data_binding).exists(self.obs_type) + + @property + def has_data(self): + db_manager = self.db_lookup(self.data_binding) + # First see if the type exists in the database. + if db_manager.exists(self.obs_type): + # Yes. Is it non-null? + val = bool(weewx.xtypes.get_aggregate(self.obs_type, self.timespan, + 'not_null', db_manager)[0]) + else: + # Nope. Try the xtypes system. + val = weewx.xtypes.has_data(self.obs_type, self.timespan, db_manager) + return val + + def series(self, aggregate_type=None, + aggregate_interval=None, + time_series='both', + time_unit='unix_epoch'): + """Return a series with the given aggregation type and interval. + + Args: + aggregate_type (str or None): The type of aggregation to use, if any. Default is None + (no aggregation). + aggregate_interval (str or None): The aggregation interval in seconds. Default is + None (no aggregation). + time_series (str): What to include for the time series. Either 'start', 'stop', or + 'both'. + time_unit (str): Which unit to use for time. Choices are 'unix_epoch', 'unix_epoch_ms', + or 'unix_epoch_ns'. Default is 'unix_epoch'. + + Returns: + SeriesHelper. + """ + time_series = time_series.lower() + if time_series not in ['both', 'start', 'stop']: + raise ValueError("Unknown option '%s' for parameter 'time_series'" % time_series) + + db_manager = self.db_lookup(self.data_binding) + + # If we cannot calculate the series, we will get an UnknownType or UnknownAggregation + # error. Be prepared to catch it. + try: + # The returned values start_vt, stop_vt, and data_vt, will be ValueTuples. + start_vt, stop_vt, data_vt = weewx.xtypes.get_series( + self.obs_type, self.timespan, db_manager, + aggregate_type, aggregate_interval) + except (weewx.UnknownType, weewx.UnknownAggregation): + # Cannot calculate the series. Convert to AttributeError, which will signal to Cheetah + # that this type of series is unknown. + raise AttributeError(self.obs_type) + + # Figure out which time series are desired, and convert them to the desired time unit. + # If the conversion cannot be done, a KeyError will be raised. + # When done, start_vh and stop_vh will be ValueHelpers. + if time_series in ['start', 'both']: + start_vt = weewx.units.convert(start_vt, time_unit) + start_vh = weewx.units.ValueHelper(start_vt, self.context, self.formatter) + else: + start_vh = None + if time_series in ['stop', 'both']: + stop_vt = weewx.units.convert(stop_vt, time_unit) + stop_vh = weewx.units.ValueHelper(stop_vt, self.context, self.formatter) + else: + stop_vh = None + + # Form a SeriesHelper, using our existing context and formatter. For the data series, + # use the existing converter. + sh = weewx.units.SeriesHelper( + start_vh, + stop_vh, + weewx.units.ValueHelper(data_vt, self.context, self.formatter, self.converter)) + return sh + + +# =============================================================================== +# Class AggTypeBinder +# =============================================================================== + +class AggTypeBinder(object): + """This is the final class in the chain of helper classes. It binds everything needed + for a query.""" + + def __init__(self, aggregate_type, obs_type, timespan, db_lookup, data_binding, context, + formatter=None, converter=None, + **option_dict): + self.aggregate_type = aggregate_type + self.obs_type = obs_type + self.timespan = timespan + self.db_lookup = db_lookup + self.data_binding = data_binding + self.context = context + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.option_dict = option_dict + + def __call__(self, *args, **kwargs): + """Offer a call option for expressions such as $month.outTemp.max_ge((90.0, 'degree_F')). + + In this example, self.aggregate_type would be 'max_ge', and val would be the tuple + (90.0, 'degree_F'). + """ + if len(args): + self.option_dict['val'] = args[0] + self.option_dict.update(kwargs) + return self + + def __str__(self): + """Need a string representation. Force the query, return as string.""" + vh = self._do_query() + return str(vh) + + def _do_query(self): + """Run a query against the databases, using the given aggregation type.""" + try: + # Get the appropriate database manager + db_manager = self.db_lookup(self.data_binding) + except weewx.UnknownBinding: + # Don't recognize the binding. + raise AttributeError(self.data_binding) + try: + # If we cannot perform the aggregation, we will get an UnknownType or + # UnknownAggregation error. Be prepared to catch it. + result = weewx.xtypes.get_aggregate(self.obs_type, self.timespan, + self.aggregate_type, + db_manager, **self.option_dict) + except (weewx.UnknownType, weewx.UnknownAggregation): + # Signal Cheetah that we don't know how to do this by raising an AttributeError. + raise AttributeError(self.obs_type) + return weewx.units.ValueHelper(result, self.context, self.formatter, self.converter) + + def __getattr__(self, attr): + # The following is an optimization, so we avoid doing an SQL query for these kinds of + # housekeeping attribute queries done by Cheetah's NameMapper + if attr in IGNORE_ATTR: + raise AttributeError(attr) + # Do the query, getting a ValueHelper back + vh = self._do_query() + # Now seek the desired attribute of the ValueHelper and return + return getattr(vh, attr) + + +# =============================================================================== +# Class RecordBinder +# =============================================================================== + +class RecordBinder(object): + + def __init__(self, db_lookup, report_time, + formatter=None, converter=None, + record=None): + self.db_lookup = db_lookup + self.report_time = report_time + self.formatter = formatter or weewx.units.Formatter() + self.converter = converter or weewx.units.Converter() + self.record = record + + def current(self, timestamp=None, max_delta=None, data_binding=None): + """Return a CurrentObj""" + if timestamp is None: + timestamp = self.report_time + return CurrentObj(self.db_lookup, data_binding, current_time=timestamp, + max_delta=max_delta, + formatter=self.formatter, converter=self.converter, record=self.record) + + def latest(self, data_binding=None): + """Return a CurrentObj, using the last available timestamp.""" + manager = self.db_lookup(data_binding) + timestamp = manager.lastGoodStamp() + return self.current(timestamp, data_binding=data_binding) + + +# =============================================================================== +# Class CurrentObj +# =============================================================================== + +class CurrentObj(object): + """Helper class for the "Current" record. Hits the database lazily. + + This class allows tags such as: + $current.barometer + """ + + def __init__(self, db_lookup, data_binding, current_time, + formatter, converter, max_delta=None, record=None): + self.db_lookup = db_lookup + self.data_binding = data_binding + self.current_time = current_time + self.formatter = formatter + self.converter = converter + self.max_delta = max_delta + self.record = record + + def __getattr__(self, obs_type): + """Return the given observation type.""" + + if obs_type in IGNORE_ATTR: + raise AttributeError(obs_type) + + # TODO: Refactor the following to be a separate function. + + # If no data binding has been specified, and we have a current record with the right + # timestamp at hand, we don't have to hit the database. + if not self.data_binding and self.record and obs_type in self.record \ + and self.record['dateTime'] == self.current_time: + # Use the record given to us to form a ValueTuple + vt = weewx.units.as_value_tuple(self.record, obs_type) + else: + # A binding has been specified, or we don't have a record, or the observation type + # is not in the record, or the timestamp is wrong. + try: + # Get the appropriate database manager + db_manager = self.db_lookup(self.data_binding) + except weewx.UnknownBinding: + # Don't recognize the binding. + raise AttributeError(self.data_binding) + else: + # Get the record for this timestamp from the database + record = db_manager.getRecord(self.current_time, max_delta=self.max_delta) + # If there was no record at that timestamp, it will be None. If there was a record, + # check to see if the type is in it. + if not record or obs_type in record: + # If there was no record, then the value of the ValueTuple will be None. + # Otherwise, it will be value stored in the database. + vt = weewx.units.as_value_tuple(record, obs_type) + else: + # Couldn't get the value out of the record. Try the XTypes system. + try: + vt = weewx.xtypes.get_scalar(obs_type, self.record, db_manager) + except (weewx.UnknownType, weewx.CannotCalculate): + # Nothing seems to be working. It's an unknown type. + vt = weewx.units.UnknownObsType(obs_type) + + # Finally, return a ValueHelper + return weewx.units.ValueHelper(vt, 'current', self.formatter, self.converter) + + +# =============================================================================== +# Class TrendObj +# =============================================================================== + +class TrendObj(object): + """Helper class that calculates trends. + + This class allows tags such as: + $trend.barometer + """ + + def __init__(self, time_delta, time_grace, db_lookup, data_binding, + nowtime, formatter, converter, **option_dict): + """Initialize a Trend object + + time_delta: The time difference over which the trend is to be calculated + + time_grace: A time within this amount is accepted. + """ + self.time_delta_val = time_delta + self.time_grace_val = time_grace + self.db_lookup = db_lookup + self.data_binding = data_binding + self.nowtime = nowtime + self.formatter = formatter + self.converter = converter + self.time_delta = weewx.units.ValueHelper((time_delta, 'second', 'group_elapsed'), + 'current', + self.formatter, + self.converter) + self.time_grace = weewx.units.ValueHelper((time_grace, 'second', 'group_elapsed'), + 'current', + self.formatter, + self.converter) + + def __getattr__(self, obs_type): + """Return the trend for the given observation type.""" + if obs_type in IGNORE_ATTR: + raise AttributeError(obs_type) + + db_manager = self.db_lookup(self.data_binding) + # Get the current record, and one "time_delta" ago: + now_record = db_manager.getRecord(self.nowtime, self.time_grace_val) + then_record = db_manager.getRecord(self.nowtime - self.time_delta_val, self.time_grace_val) + + # Extract the ValueTuples from the records. + try: + now_vt = weewx.units.as_value_tuple(now_record, obs_type) + then_vt = weewx.units.as_value_tuple(then_record, obs_type) + except KeyError: + # One of the records does not include the type. Convert the KeyError into + # an AttributeError. + raise AttributeError(obs_type) + + # Do the unit conversion now, rather than lazily. This is because the temperature + # conversion functions are not distributive. That is, + # F_to_C(68F - 50F) + # is not equal to + # F_to_C(68F) - F_to_C(50F) + # We want the latter, not the former, so we perform the conversion immediately. + now_vtc = self.converter.convert(now_vt) + then_vtc = self.converter.convert(then_vt) + # Check to see if one of the values is None: + if now_vtc.value is None or then_vtc.value is None: + # One of the values is None, so the trend will be None. + trend = ValueTuple(None, now_vtc.unit, now_vtc.group) + else: + # All good. Calculate the trend. + trend = now_vtc - then_vtc + + # Return the results as a ValueHelper. Use the formatting and labeling options from the + # current time record. The user can always override these. + return weewx.units.ValueHelper(trend, 'current', self.formatter, self.converter) diff --git a/dist/weewx-5.0.2/src/weewx/tests/__init__.py b/dist/weewx-5.0.2/src/weewx/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-01.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-01.txt new file mode 100644 index 0000000..f2e1718 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-01.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Jan 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 0.3 20.0 15:00 -20.0 03:00 64.7 0.0 0.68 16.5 24.0 00:00 50 + 02 0.0 20.0 15:00 -20.0 03:00 65.0 0.0 0.60 16.2 24.0 00:30 131 + 03 -0.2 20.0 15:00 -20.0 03:00 65.2 0.0 0.00 3.6 11.6 00:30 204 + 04 0.2 20.1 15:00 -19.9 03:00 64.8 0.0 0.00 3.7 12.0 00:00 338 + 05 0.1 20.1 15:00 -19.9 03:00 64.9 0.0 0.68 16.5 24.0 00:00 51 + 06 0.1 20.2 15:00 -19.8 03:00 64.9 0.0 0.60 16.4 24.0 00:30 131 + 07 0.3 20.3 15:00 -19.8 03:00 64.7 0.0 0.00 3.5 11.6 00:30 204 + 08 0.3 20.3 15:00 -19.7 03:00 64.7 0.0 0.00 3.8 12.0 00:00 338 + 09 0.4 20.4 15:00 -19.6 03:00 64.6 0.0 0.68 16.4 24.0 00:00 51 + 10 0.5 20.5 15:00 -19.5 03:00 64.5 0.0 0.60 16.3 24.0 00:30 131 + 11 0.7 20.7 15:00 -19.4 03:00 64.3 0.0 0.00 3.5 11.6 00:30 204 + 12 0.9 20.8 15:00 -19.3 03:00 64.1 0.0 0.00 3.7 12.0 00:00 337 + 13 0.8 20.9 15:00 -19.1 03:00 64.2 0.0 0.68 16.5 24.0 00:00 51 + 14 1.1 21.1 15:00 -19.0 03:00 63.9 0.0 0.60 16.2 24.0 00:30 131 + 15 1.5 21.3 15:00 -18.8 03:00 63.5 0.0 0.00 3.6 11.6 00:30 205 + 16 1.2 21.4 15:00 -18.7 03:00 63.8 0.0 0.00 3.7 12.0 00:00 338 + 17 1.6 21.6 15:00 -18.5 03:00 63.4 0.0 0.68 16.5 24.0 00:00 50 + 18 2.1 21.8 15:00 -18.3 03:00 62.9 0.0 0.60 16.3 24.0 00:30 130 + 19 1.7 22.0 15:00 -18.1 03:00 63.3 0.0 0.00 3.5 11.6 00:30 204 + 20 2.2 22.3 15:00 -17.9 03:00 62.8 0.0 0.00 3.8 12.0 00:00 337 + 21 2.8 22.5 15:00 -17.6 03:00 62.2 0.0 0.68 16.4 24.0 00:00 50 + 22 2.3 22.7 15:00 -17.4 03:00 62.7 0.0 0.60 16.3 24.0 00:30 131 + 23 3.0 23.0 15:00 -17.1 03:00 62.0 0.0 0.00 3.4 11.6 00:30 204 + 24 3.6 23.3 15:00 -16.9 03:00 61.4 0.0 0.00 3.7 12.0 00:00 338 + 25 3.1 23.5 15:00 -16.6 03:00 61.9 0.0 0.68 16.5 24.0 00:00 51 + 26 3.8 23.8 15:00 -16.3 03:00 61.2 0.0 0.60 16.2 24.0 00:30 131 + 27 4.5 24.1 15:00 -15.9 02:30 60.5 0.0 0.00 3.6 11.6 00:30 204 + 28 4.0 24.4 15:00 -15.7 03:00 61.0 0.0 0.00 3.7 12.0 00:00 338 + 29 4.7 24.8 15:00 -15.4 03:00 60.3 0.0 0.68 16.6 24.0 00:00 51 + 30 5.5 25.1 15:00 -15.1 03:00 59.5 0.0 0.60 16.3 24.0 00:30 131 + 31 5.0 25.4 15:00 -14.7 03:00 60.0 0.0 0.00 3.5 11.6 00:30 204 +--------------------------------------------------------------------------------------- + 1.9 25.4 31 -20.0 01 1957.0 0.0 10.24 10.2 24.0 02 91 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-02.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-02.txt new file mode 100644 index 0000000..3a3d34c --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-02.txt @@ -0,0 +1,43 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Feb 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 5.7 25.8 15:00 -14.4 03:00 59.3 0.0 0.00 3.8 12.0 00:00 337 + 02 6.5 26.1 15:00 -14.0 03:00 58.5 0.0 0.68 16.5 24.0 00:00 51 + 03 6.1 26.5 15:00 -13.7 03:00 58.9 0.0 0.60 16.4 24.0 01:00 131 + 04 7.2 26.9 15:00 -13.3 03:00 57.8 0.0 0.00 3.5 11.6 00:30 204 + 05 7.2 27.3 15:00 -12.9 03:00 57.8 0.0 0.00 3.8 12.0 00:00 338 + 06 7.4 27.7 15:00 -12.5 03:00 57.6 0.0 0.68 16.4 24.0 00:00 50 + 07 8.3 28.1 15:00 -12.1 03:00 56.7 0.0 0.60 16.3 24.0 00:30 130 + 08 8.5 28.5 15:00 -11.7 03:00 56.5 0.0 0.00 3.5 11.6 00:30 204 + 09 8.7 29.0 15:00 -11.3 03:00 56.3 0.0 0.00 3.6 12.0 00:00 337 + 10 9.4 29.4 15:00 -10.8 03:00 55.6 0.0 0.68 16.5 24.0 00:00 51 + 11 9.8 29.8 15:00 -10.4 03:00 55.2 0.0 0.60 16.2 24.0 00:30 131 + 12 10.2 30.3 15:00 -9.9 03:00 54.8 0.0 0.00 3.6 11.6 00:30 204 + 13 10.7 30.8 15:00 -9.5 03:00 54.3 0.0 0.00 3.7 12.0 00:00 338 + 14 11.2 31.2 15:00 -9.0 03:00 53.8 0.0 0.68 16.5 24.0 00:00 51 + 15 11.7 31.7 15:00 -8.5 03:00 53.3 0.0 0.60 16.3 24.0 00:30 131 + 16 12.0 32.2 15:00 -8.0 03:00 53.0 0.0 0.00 3.5 11.6 00:30 204 + 17 12.6 32.7 15:00 -7.5 03:00 52.4 0.0 0.00 3.8 12.0 00:00 338 + 18 13.3 33.2 15:00 -7.0 03:00 51.7 0.0 0.68 16.4 24.0 00:00 51 + 19 13.5 33.7 15:00 -6.5 03:00 51.5 0.0 0.60 16.3 24.0 00:30 131 + 20 14.2 34.3 15:00 -6.0 03:00 50.8 0.0 0.00 3.5 11.6 00:30 204 + 21 15.0 34.8 15:00 -5.5 03:00 50.0 0.0 0.00 3.7 12.0 00:00 337 + 22 15.0 35.3 15:00 -4.9 03:00 50.0 0.0 0.68 16.5 24.0 00:00 51 + 23 15.8 35.9 15:00 -4.4 03:00 49.2 0.0 0.60 16.2 24.0 00:30 131 + 24 16.7 36.4 15:00 -3.9 03:00 48.3 0.0 0.00 3.6 11.6 00:30 204 + 25 16.5 37.0 15:00 -3.3 03:00 48.5 0.0 0.00 3.7 12.0 00:00 338 + 26 17.5 37.5 15:00 -2.7 03:00 47.5 0.0 0.68 16.5 24.0 00:00 50 + 27 18.4 38.1 15:00 -2.2 03:00 46.6 0.0 0.60 16.3 24.0 00:30 130 + 28 18.2 38.7 15:00 -1.6 03:00 46.8 0.0 0.00 3.5 11.6 00:30 204 +--------------------------------------------------------------------------------------- + 11.7 38.7 28 -14.4 01 1492.6 0.0 8.96 10.0 24.0 03 90 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-03.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-03.txt new file mode 100644 index 0000000..86f1129 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-03.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Mar 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 19.2 39.3 15:00 -1.0 03:00 45.8 0.0 0.00 3.8 12.0 00:00 337 + 02 20.2 39.9 15:00 -0.4 03:00 44.8 0.0 0.68 16.4 24.0 00:00 51 + 03 20.0 40.3 15:30 0.2 03:00 45.0 0.0 0.60 16.3 24.0 00:30 131 + 04 21.0 41.1 15:00 0.8 03:00 44.0 0.0 0.00 3.4 11.6 00:30 205 + 05 22.0 41.7 15:00 1.4 03:00 43.0 0.0 0.00 3.7 12.0 00:00 338 + 06 21.8 42.3 15:00 2.0 03:00 43.2 0.0 0.68 16.5 24.0 00:00 51 + 07 22.9 42.9 15:00 2.6 03:00 42.1 0.0 0.60 16.2 24.0 00:30 131 + 08 23.9 43.5 15:00 3.2 03:00 41.1 0.0 0.00 3.6 11.6 00:30 204 + 09 23.7 44.2 15:00 3.9 03:00 41.3 0.0 0.00 3.7 12.0 00:00 338 + 10 24.7 44.8 15:00 4.5 03:00 40.3 0.0 0.68 16.6 24.0 00:00 51 + 11 25.7 45.5 15:00 5.1 03:00 39.3 0.0 0.60 16.2 24.0 00:30 131 + 12 25.7 46.1 15:00 5.8 03:00 39.3 0.0 0.00 3.6 11.6 00:30 204 + 13 26.9 46.7 15:00 6.4 03:00 38.1 0.0 0.00 3.7 12.0 00:00 338 + 14 27.9 47.4 16:00 7.1 04:00 37.1 0.0 0.52 16.3 23.9 23:30 49 + 15 27.7 48.0 16:00 7.7 04:00 37.3 0.0 0.76 16.8 24.0 01:00 127 + 16 28.8 48.7 16:00 8.4 04:00 36.2 0.0 0.00 4.0 12.4 00:30 201 + 17 29.3 49.4 16:00 9.0 04:00 35.7 0.0 0.00 3.4 11.2 00:00 335 + 18 29.8 50.0 16:00 9.7 04:00 35.2 0.0 0.52 16.0 24.0 00:00 47 + 19 30.6 50.7 16:00 10.4 04:00 34.4 0.0 0.76 16.7 24.0 01:00 127 + 20 31.3 51.4 16:00 11.0 04:00 33.7 0.0 0.00 3.9 12.4 00:30 201 + 21 31.9 52.0 16:00 11.7 04:00 33.1 0.0 0.00 3.2 11.2 00:00 334 + 22 32.6 52.7 16:00 12.4 04:00 32.4 0.0 0.52 16.0 24.0 00:00 47 + 23 33.3 53.4 16:00 13.1 04:00 31.7 0.0 0.76 16.6 24.0 01:00 128 + 24 34.1 54.1 16:00 13.7 04:00 30.9 0.0 0.00 4.0 12.4 00:30 202 + 25 34.5 54.8 16:00 14.4 04:00 30.5 0.0 0.00 3.3 11.2 00:00 335 + 26 35.3 55.4 16:00 15.1 04:00 29.7 0.0 0.52 16.1 24.0 00:00 48 + 27 36.2 56.1 16:00 15.8 04:00 28.8 0.0 0.76 16.7 24.0 01:00 128 + 28 36.4 56.8 16:00 16.5 04:00 28.6 0.0 0.00 4.0 12.4 00:30 201 + 29 37.4 57.5 16:00 17.2 04:00 27.6 0.0 0.00 3.4 11.2 00:00 335 + 30 38.4 58.2 16:00 17.8 04:00 26.6 0.0 0.52 16.0 24.0 00:00 48 + 31 38.4 58.9 16:00 18.5 04:00 26.6 0.0 0.76 16.7 24.0 01:00 127 +--------------------------------------------------------------------------------------- + 28.8 58.9 31 -1.0 01 1123.1 0.0 10.24 10.2 24.0 03 89 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-04.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-04.txt new file mode 100644 index 0000000..9df0d69 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-04.txt @@ -0,0 +1,45 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Apr 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 39.5 59.6 16:00 19.2 04:00 25.5 0.0 0.00 3.9 12.4 00:30 201 + 02 40.5 60.3 16:00 19.9 04:00 24.5 0.0 0.00 3.3 11.2 00:00 335 + 03 40.4 60.9 16:00 20.6 04:00 24.6 0.0 0.52 16.0 24.0 00:00 48 + 04 41.5 61.6 16:00 21.3 04:00 23.5 0.0 0.76 16.6 24.0 01:00 128 + 05 42.6 62.3 16:00 22.0 04:00 22.4 0.0 0.00 4.0 12.4 00:30 201 + 06 42.5 63.0 16:00 22.7 04:00 22.5 0.0 0.00 3.3 11.2 00:00 335 + 07 43.6 63.7 16:00 23.4 04:00 21.4 0.0 0.52 16.1 24.0 00:00 47 + 08 44.7 64.4 16:00 24.2 03:30 20.3 0.0 0.76 16.7 24.0 01:00 127 + 09 44.5 65.1 16:00 24.7 04:00 20.5 0.0 0.00 4.0 12.4 00:30 201 + 10 45.6 65.7 16:00 25.4 04:00 19.4 0.0 0.00 3.4 11.2 00:00 334 + 11 46.7 66.4 16:00 26.1 04:00 18.3 0.0 0.52 16.0 24.0 00:00 47 + 12 46.6 67.1 16:00 26.8 04:00 18.4 0.0 0.76 16.7 24.0 01:00 128 + 13 47.7 67.8 16:00 27.4 04:00 17.3 0.0 0.00 3.8 12.4 00:30 202 + 14 48.7 68.5 16:00 28.1 04:00 16.3 0.0 0.00 3.3 11.2 00:00 335 + 15 48.7 69.1 16:00 28.8 04:00 16.3 0.0 0.52 16.0 24.0 00:00 48 + 16 49.7 69.8 16:00 29.5 04:00 15.3 0.0 0.76 16.6 24.0 01:00 128 + 17 50.7 70.5 16:00 30.1 04:00 14.3 0.0 0.00 4.0 12.4 00:30 201 + 18 50.8 71.1 16:00 30.8 04:00 14.2 0.0 0.00 3.2 11.2 00:00 334 + 19 51.9 71.8 16:00 31.5 04:00 13.1 0.0 0.52 16.0 24.0 00:00 47 + 20 52.3 72.4 16:00 32.1 04:00 12.7 0.0 0.76 16.6 24.0 01:00 128 + 21 52.8 73.1 16:00 32.8 04:00 12.2 0.0 0.00 4.0 12.4 00:30 201 + 22 53.7 73.7 16:00 33.4 04:00 11.3 0.0 0.00 3.3 11.2 00:00 335 + 23 54.3 74.4 16:00 34.1 04:00 10.7 0.0 0.52 16.1 24.0 00:00 48 + 24 54.9 75.0 16:00 34.7 04:00 10.1 0.0 0.76 16.8 24.0 01:00 128 + 25 55.6 75.7 16:00 35.3 04:00 9.4 0.0 0.00 4.0 12.4 00:30 201 + 26 56.2 76.3 16:00 36.0 04:00 8.8 0.0 0.00 3.4 11.2 00:00 335 + 27 56.9 76.9 16:00 36.6 04:00 8.1 0.0 0.52 16.0 24.0 00:00 47 + 28 57.3 77.5 16:00 37.2 04:00 7.7 0.0 0.76 16.7 24.0 01:00 127 + 29 58.1 78.2 16:00 37.9 04:00 6.9 0.0 0.00 3.9 12.4 00:30 201 + 30 58.8 78.8 16:00 38.5 04:00 6.2 0.0 0.00 3.3 11.2 00:00 334 +--------------------------------------------------------------------------------------- + 49.3 78.8 30 19.2 01 472.1 0.0 8.96 9.6 24.0 04 90 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-05.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-05.txt new file mode 100644 index 0000000..6eaeea6 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-05.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for May 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 59.1 79.4 16:00 39.1 04:00 5.9 0.0 0.52 16.0 24.0 00:00 47 + 02 59.9 80.0 16:00 39.7 04:00 5.1 0.0 0.76 16.6 24.0 01:00 128 + 03 60.7 80.6 16:00 40.3 04:00 4.3 0.0 0.00 4.0 12.4 00:30 202 + 04 60.8 81.2 16:00 40.9 04:00 4.2 0.0 0.00 3.3 11.2 00:00 335 + 05 61.6 81.7 16:00 41.4 04:00 3.4 0.0 0.52 16.1 24.0 00:00 48 + 06 62.6 82.3 16:00 42.0 04:00 2.4 0.0 0.76 16.7 24.0 01:00 128 + 07 62.4 82.9 16:00 42.6 04:00 2.6 0.0 0.00 4.0 12.4 00:30 201 + 08 63.4 83.4 16:00 43.2 04:00 1.6 0.0 0.00 3.4 11.2 00:00 334 + 09 64.3 84.0 16:00 43.7 04:00 0.7 0.0 0.52 16.0 24.0 00:00 47 + 10 64.0 84.5 16:00 44.3 04:00 1.0 0.0 0.76 16.7 24.0 01:00 128 + 11 65.0 85.1 16:00 44.8 04:00 0.0 0.0 0.00 3.9 12.4 00:30 202 + 12 66.0 85.6 16:00 45.4 04:00 0.0 1.0 0.00 3.3 11.2 00:00 335 + 13 65.6 86.0 16:30 45.9 04:00 0.0 0.6 0.52 16.0 24.0 00:00 48 + 14 66.6 86.7 16:00 46.4 04:00 0.0 1.6 0.76 16.6 24.0 01:00 128 + 15 67.5 87.2 16:00 46.9 04:00 0.0 2.5 0.00 4.0 12.4 00:30 201 + 16 67.2 87.7 16:00 47.4 04:00 0.0 2.2 0.00 3.3 11.2 00:00 335 + 17 68.1 88.2 16:00 47.9 04:00 0.0 3.1 0.52 16.2 24.0 00:00 47 + 18 69.0 88.6 16:00 48.4 04:00 0.0 4.0 0.76 16.7 24.0 01:00 127 + 19 68.7 89.1 16:00 48.9 04:00 0.0 3.7 0.00 4.0 12.4 00:30 201 + 20 69.5 89.6 16:00 49.4 04:00 0.0 4.5 0.00 3.4 11.2 00:00 335 + 21 70.3 90.0 16:00 49.8 04:00 0.0 5.3 0.52 16.0 24.0 00:00 48 + 22 70.1 90.5 16:00 50.3 04:00 0.0 5.1 0.76 16.7 24.0 00:30 128 + 23 70.9 90.9 16:00 50.7 04:00 0.0 5.9 0.00 3.8 12.4 00:30 202 + 24 71.6 91.4 16:00 51.2 04:00 0.0 6.6 0.00 3.3 10.8 23:30 335 + 25 71.5 91.8 16:00 51.6 04:00 0.0 6.5 0.52 16.0 24.0 00:00 47 + 26 72.3 92.2 16:00 52.0 04:00 0.0 7.3 0.76 16.7 24.0 01:00 127 + 27 72.5 92.6 16:00 52.4 04:00 0.0 7.5 0.00 4.0 12.4 00:30 201 + 28 72.8 93.0 16:00 52.8 04:00 0.0 7.8 0.00 3.2 11.2 00:00 334 + 29 73.4 93.4 16:00 53.2 04:00 0.0 8.4 0.52 16.0 24.0 00:00 47 + 30 73.7 93.8 16:00 53.6 04:00 0.0 8.7 0.76 16.6 24.0 01:00 128 + 31 74.1 94.1 16:00 53.9 04:00 0.0 9.1 0.00 4.0 12.4 00:30 202 +--------------------------------------------------------------------------------------- + 67.3 94.1 31 39.1 01 31.2 101.4 10.24 10.2 24.0 02 91 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-06.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-06.txt new file mode 100644 index 0000000..40d61a7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-06.txt @@ -0,0 +1,45 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Jun 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 74.4 94.5 16:00 54.3 04:00 0.0 9.4 0.00 3.3 11.2 00:00 335 + 02 74.8 94.8 16:00 54.7 04:00 0.0 9.8 0.52 16.1 24.0 00:00 48 + 03 75.2 95.2 16:00 55.0 04:00 0.0 10.2 0.76 16.8 24.0 01:00 128 + 04 75.3 95.5 16:00 55.3 04:00 0.0 10.3 0.00 4.0 12.4 00:30 201 + 05 75.7 95.8 16:00 55.6 04:00 0.0 10.7 0.00 3.4 11.2 00:00 335 + 06 76.3 96.1 16:00 55.9 04:00 0.0 11.3 0.52 16.0 24.0 00:00 48 + 07 76.1 96.4 16:00 56.2 04:00 0.0 11.1 0.76 16.7 24.0 01:00 127 + 08 76.6 96.7 16:00 56.5 04:00 0.0 11.6 0.00 3.9 12.4 00:30 201 + 09 77.2 96.9 16:00 56.8 04:00 0.0 12.2 0.00 3.3 11.2 00:00 335 + 10 76.8 97.2 16:00 57.1 04:00 0.0 11.8 0.52 16.0 24.0 00:00 48 + 11 77.4 97.4 16:00 57.3 04:00 0.0 12.4 0.76 16.6 24.0 01:00 127 + 12 78.0 97.7 16:00 57.6 04:00 0.0 13.0 0.00 4.0 12.4 00:30 201 + 13 77.5 97.9 16:00 57.8 04:00 0.0 12.5 0.00 3.3 11.2 00:00 335 + 14 78.1 98.1 16:00 58.0 04:00 0.0 13.1 0.52 16.1 24.0 00:00 47 + 15 78.7 98.3 16:00 58.2 04:00 0.0 13.7 0.76 16.7 24.0 01:00 127 + 16 78.1 98.5 16:00 58.4 04:00 0.0 13.1 0.00 4.0 12.4 00:30 201 + 17 78.7 98.7 16:00 58.6 04:00 0.0 13.7 0.00 3.4 11.2 00:00 334 + 18 79.3 98.9 16:00 58.9 03:30 0.0 14.3 0.52 16.0 24.0 00:00 47 + 19 78.6 99.0 16:00 58.9 04:00 0.0 13.6 0.76 16.7 24.0 01:00 128 + 20 79.1 99.2 16:00 59.1 04:00 0.0 14.1 0.00 3.9 12.4 00:30 202 + 21 79.7 99.3 16:00 59.2 04:00 0.0 14.7 0.00 3.3 11.2 00:00 335 + 22 79.0 99.4 16:00 59.4 04:00 0.0 14.0 0.52 16.0 24.0 00:00 48 + 23 79.5 99.5 16:00 59.5 04:00 0.0 14.5 0.76 16.6 24.0 01:00 128 + 24 80.0 99.6 16:00 59.6 04:00 0.0 15.0 0.00 4.0 12.4 00:30 201 + 25 79.4 99.7 16:00 59.7 04:00 0.0 14.4 0.00 3.3 11.2 00:00 335 + 26 79.8 99.8 16:00 59.8 04:00 0.0 14.8 0.52 16.2 24.0 00:00 48 + 27 80.2 99.9 16:00 59.8 04:00 0.0 15.2 0.76 16.7 24.0 01:00 127 + 28 79.6 99.9 16:00 59.9 04:00 0.0 14.6 0.00 4.0 12.0 01:00 201 + 29 80.2 100.0 16:00 59.9 04:00 0.0 15.2 0.00 3.3 11.2 00:00 335 + 30 80.0 100.0 16:00 60.0 04:00 0.0 15.0 0.52 16.0 24.0 00:00 48 +--------------------------------------------------------------------------------------- + 78.0 100.0 30 54.3 01 0.0 389.1 9.48 10.0 24.0 03 85 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-07.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-07.txt new file mode 100644 index 0000000..03f22e9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-07.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Jul 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 79.8 100.0 16:00 60.0 04:00 0.0 14.8 0.76 16.8 24.0 01:00 127 + 02 80.1 100.0 16:00 60.0 04:00 0.0 15.1 0.00 4.0 12.4 00:30 201 + 03 80.0 100.0 16:00 60.0 04:00 0.0 15.0 0.00 3.4 11.2 00:00 335 + 04 79.9 100.0 16:00 60.0 04:00 0.0 14.9 0.52 16.0 24.0 00:00 47 + 05 79.9 99.9 16:00 60.0 04:00 0.0 14.9 0.76 16.7 24.0 01:00 127 + 06 79.9 99.9 16:00 59.9 04:00 0.0 14.9 0.00 3.9 12.4 00:30 201 + 07 79.9 99.8 16:00 59.9 04:00 0.0 14.9 0.00 3.2 11.2 00:00 334 + 08 79.7 99.8 16:00 59.8 04:00 0.0 14.7 0.52 16.0 24.0 00:00 47 + 09 79.7 99.7 16:00 59.7 04:00 0.0 14.7 0.76 16.6 24.0 01:00 128 + 10 79.8 99.6 16:00 59.7 04:00 0.0 14.8 0.00 4.0 12.4 00:30 202 + 11 79.3 99.5 16:00 59.6 04:00 0.0 14.3 0.00 3.3 11.2 00:00 335 + 12 79.4 99.4 16:00 59.5 04:00 0.0 14.4 0.52 16.1 24.0 00:00 48 + 13 79.5 99.3 16:00 59.3 04:00 0.0 14.5 0.76 16.7 24.0 01:00 128 + 14 78.9 99.1 16:00 59.2 04:00 0.0 13.9 0.00 4.0 12.4 00:30 201 + 15 79.0 99.0 16:00 59.1 04:00 0.0 14.0 0.00 3.4 11.2 00:00 335 + 16 79.2 98.8 16:00 58.9 04:00 0.0 14.2 0.52 16.0 24.0 00:00 48 + 17 78.3 98.7 16:00 58.7 04:00 0.0 13.3 0.76 16.7 24.0 01:00 127 + 18 78.5 98.5 16:00 58.6 04:00 0.0 13.5 0.00 3.9 12.4 00:30 201 + 19 78.7 98.3 16:00 58.4 04:00 0.0 13.7 0.00 3.3 11.2 00:00 335 + 20 77.7 98.1 16:00 58.2 04:00 0.0 12.7 0.52 16.0 24.0 00:00 48 + 21 77.9 97.9 16:00 58.0 04:00 0.0 12.9 0.76 16.6 24.0 01:00 128 + 22 78.1 97.6 16:00 57.7 04:00 0.0 13.1 0.00 4.0 12.4 00:30 201 + 23 77.0 97.2 15:30 57.5 04:00 0.0 12.0 0.00 3.3 11.2 00:00 335 + 24 77.2 97.1 16:00 57.3 04:00 0.0 12.2 0.52 16.1 24.0 00:00 47 + 25 77.3 96.9 16:00 57.0 04:00 0.0 12.3 0.76 16.7 24.0 01:00 127 + 26 76.2 96.6 16:00 56.7 04:00 0.0 11.2 0.00 4.0 12.4 00:30 201 + 27 76.4 96.3 16:00 56.5 04:00 0.0 11.4 0.00 3.4 11.2 00:00 334 + 28 76.5 96.0 16:00 56.2 04:00 0.0 11.5 0.52 16.0 24.0 00:00 47 + 29 75.4 95.7 16:00 55.9 04:00 0.0 10.4 0.76 16.7 24.0 01:00 128 + 30 75.5 95.4 16:00 55.6 04:00 0.0 10.5 0.00 3.8 12.4 00:30 202 + 31 75.5 95.1 16:00 55.2 04:00 0.0 10.5 0.00 3.3 11.2 00:00 335 +--------------------------------------------------------------------------------------- + 78.4 100.0 02 55.2 31 0.0 415.1 9.72 9.8 24.0 01 94 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-08.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-08.txt new file mode 100644 index 0000000..a7b08fb --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-08.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Aug 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 74.5 94.7 16:00 54.9 04:00 0.0 9.5 0.52 16.0 24.0 00:00 48 + 02 74.4 94.4 16:00 54.6 04:00 0.0 9.4 0.76 16.6 24.0 01:00 128 + 03 74.3 94.0 16:00 54.2 04:00 0.0 9.3 0.00 4.0 12.4 00:30 201 + 04 73.5 93.7 16:00 53.9 04:00 0.0 8.5 0.00 3.2 11.2 00:00 334 + 05 73.5 93.3 16:00 53.5 04:00 0.0 8.5 0.52 16.0 24.0 00:00 47 + 06 73.0 92.9 16:00 53.1 04:00 0.0 8.0 0.76 16.6 24.0 01:00 128 + 07 72.5 92.5 16:00 52.7 04:00 0.0 7.5 0.00 4.0 12.4 00:30 201 + 08 72.2 92.1 16:00 52.3 04:00 0.0 7.2 0.00 3.3 11.2 00:00 335 + 09 71.8 91.7 16:00 51.9 04:00 0.0 6.8 0.52 16.1 24.0 00:00 48 + 10 71.3 91.3 16:00 51.5 04:00 0.0 6.3 0.76 16.8 24.0 01:00 128 + 11 70.8 90.8 16:00 51.0 04:00 0.0 5.8 0.00 4.0 12.4 00:30 201 + 12 70.5 90.4 16:00 50.6 04:00 0.0 5.5 0.00 3.4 11.2 00:00 335 + 13 70.1 89.9 16:00 50.2 04:00 0.0 5.1 0.52 16.0 24.0 00:00 47 + 14 69.4 89.5 16:00 49.7 04:00 0.0 4.4 0.76 16.7 24.0 01:00 127 + 15 69.1 89.0 16:00 49.2 04:00 0.0 4.1 0.00 3.9 12.4 00:30 201 + 16 68.8 88.5 16:00 48.8 04:00 0.0 3.8 0.00 3.3 11.2 00:00 335 + 17 67.9 88.0 16:00 48.3 04:00 0.0 2.9 0.52 16.0 24.0 00:00 48 + 18 67.6 87.5 16:00 47.8 04:00 0.0 2.6 0.76 16.6 24.0 01:00 128 + 19 67.4 87.0 16:00 47.3 04:00 0.0 2.4 0.00 4.0 12.4 00:30 202 + 20 66.3 86.5 16:00 46.8 04:00 0.0 1.3 0.00 3.3 11.2 00:00 335 + 21 66.1 86.0 16:00 46.3 04:00 0.0 1.1 0.52 16.1 24.0 00:00 47 + 22 65.9 85.5 16:00 45.7 04:00 0.0 0.9 0.76 16.7 24.0 01:00 127 + 23 64.6 84.9 16:00 45.2 04:00 0.4 0.0 0.00 4.0 12.4 00:30 201 + 24 64.5 84.4 16:00 44.7 04:00 0.5 0.0 0.00 3.4 11.2 00:00 334 + 25 64.4 83.9 16:00 44.1 04:00 0.6 0.0 0.52 16.0 24.0 00:00 47 + 26 63.0 83.3 16:00 43.6 04:00 2.0 0.0 0.76 16.7 24.0 01:00 128 + 27 62.8 82.7 16:00 43.0 04:00 2.2 0.0 0.00 3.9 12.4 00:30 202 + 28 62.7 82.2 16:00 42.6 04:30 2.3 0.0 0.00 3.3 11.2 00:00 335 + 29 61.3 81.6 16:00 41.9 04:00 3.7 0.0 0.52 16.0 24.0 00:00 48 + 30 61.1 81.0 16:00 41.3 04:00 3.9 0.0 0.76 16.6 24.0 01:00 128 + 31 60.9 80.4 16:00 40.7 04:00 4.1 0.0 0.00 4.0 12.4 00:30 201 +--------------------------------------------------------------------------------------- + 68.3 94.7 01 40.7 31 19.8 121.0 10.24 10.2 24.0 02 91 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-09.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-09.txt new file mode 100644 index 0000000..b5b7253 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010-09.txt @@ -0,0 +1,45 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Sep 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F), RAIN (in), WIND SPEED (mph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 59.5 79.8 16:00 40.1 04:00 5.5 0.0 0.00 3.3 11.2 00:00 335 + 02 59.3 79.2 16:00 39.5 04:00 5.7 0.0 0.52 16.2 24.0 00:00 47 + 03 46.7 63.9 11:00 38.9 04:00 18.3 0.0 0.76 19.3 24.0 01:00 107 + 04 + 05 + 06 + 07 + 08 + 09 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 +--------------------------------------------------------------------------------------- + 57.1 79.8 01 38.9 03 29.4 0.0 1.28 11.5 24.0 03 61 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010.txt new file mode 100644 index 0000000..d99f757 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/NOAA/NOAA-2010.txt @@ -0,0 +1,70 @@ + CLIMATOLOGICAL SUMMARY for year 2010 + + +NAME: Location with UTF8 characters +ELEV: 328 feet LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (F) + + HEAT COOL MAX MAX MIN MIN + MEAN MEAN DEG DEG >= <= <= <= + YR MO MAX MIN MEAN DAYS DAYS HI DAY LOW DAY 90 32 32 0 +------------------------------------------------------------------------------------------------ +2010 01 21.9 -18.2 1.9 1957.0 0.0 25.4 31 -20.0 01 0 31 31 31 +2010 02 31.7 -8.5 11.7 1492.6 0.0 38.7 28 -14.4 01 0 15 28 28 +2010 03 48.8 8.5 28.8 1123.1 0.0 58.9 31 -1.0 01 0 0 31 2 +2010 04 69.4 29.0 49.3 472.1 0.0 78.8 30 19.2 01 0 0 19 0 +2010 05 87.3 47.1 67.3 31.2 101.4 94.1 31 39.1 01 11 0 0 0 +2010 06 98.0 57.9 78.0 0.0 389.1 100.0 30 54.3 01 30 0 0 0 +2010 07 98.4 58.4 78.4 0.0 415.1 100.0 02 55.2 31 31 0 0 0 +2010 08 88.2 48.4 68.3 19.8 121.0 94.7 01 40.7 31 12 0 0 0 +2010 09 74.3 39.5 57.1 29.4 0.0 79.8 01 38.9 03 0 0 0 0 +2010 10 +2010 11 +2010 12 +------------------------------------------------------------------------------------------------ + 68.4 28.3 48.3 5125.1 1026.5 100.0 Jul -20.0 Jan 84 46 109 61 + + + PRECIPITATION (in) + + MAX ---DAYS OF RAIN--- + OBS. OVER + YR MO TOTAL DAY DATE 0.01 0.10 1.00 +------------------------------------------------ +2010 01 10.24 0.68 01 16 16 0 +2010 02 8.96 0.68 02 14 14 0 +2010 03 10.24 0.76 15 16 16 0 +2010 04 8.96 0.76 04 14 14 0 +2010 05 10.24 0.76 02 16 16 0 +2010 06 9.48 0.76 03 15 15 0 +2010 07 9.72 0.76 01 15 15 0 +2010 08 10.24 0.76 02 16 16 0 +2010 09 1.28 0.76 03 2 2 0 +2010 10 +2010 11 +2010 12 +------------------------------------------------ + 79.36 0.76 Mar 124 124 0 + + + WIND SPEED (mph) + + DOM + YR MO AVG HI DATE DIR +----------------------------------- +2010 01 10.2 24.0 02 91 +2010 02 10.0 24.0 03 90 +2010 03 10.2 24.0 03 89 +2010 04 9.6 24.0 04 90 +2010 05 10.2 24.0 02 91 +2010 06 10.0 24.0 03 85 +2010 07 9.8 24.0 01 94 +2010 08 10.2 24.0 02 91 +2010 09 11.5 24.0 03 61 +2010 10 +2010 11 +2010 12 +----------------------------------- + 10.0 24.0 Jan 90 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/almanac.html b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/almanac.html new file mode 100644 index 0000000..4ddd1a3 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/almanac.html @@ -0,0 +1,155 @@ + + + + + Test almanac + + + +

Test for $almanac. Requires pyephem

+

Sun

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$almanac.sun.az (old-style)134.962
$almanac.sun.alt (old-style)43.127
$almanac.sun.azimuth135°
$almanac.sun.azimuth.format("%03.2f")134.96°
$almanac.sun.altitude43°
$almanac.sun.altitude.format("%02.2f")43.13°
$almanac.sun.altitude.radian0.753 rad
$almanac.sun.astro_ra162°
$almanac.sun.astro_dec07°
$almanac.sun.geo_ra163°
$almanac.sun.geo_dec07°
$almanac.sun.topo_ra163°
$almanac.sun.topo_dec07°
$almanac.sidereal_time131.19896418033775
$almanac.sidereal_angle131°
+ +

Jupiter

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$almanac.jupiter.az (old-style)300.152
$almanac.jupiter.alt (old-style)-27.598
$almanac.jupiter.azimuth300°
$almanac.jupiter.altitude-28°
$almanac.jupiter.astro_ra001°
$almanac.jupiter.astro_dec-1°
$almanac.jupiter.geo_ra001°
$almanac.jupiter.geo_dec-1°
$almanac.jupiter.topo_ra001°
$almanac.jupiter.topo_dec-1°
$almanac.jupiter.topo_dec.radian-0.021 rad
+ +

Venus

+

Example from the PyEphem manual:

+ + + + + + + + + +
$almanac(lon=-84.39733, lat=33.775867, altitude=320, almanac_time=454782176).venus.altitude72.33°
$almanac(lon=-84.39733, lat=33.775867, altitude=320, almanac_time=454782176).venus.azimuth134.24°
+ +

Example from the docs

+Current time is 03-Sep-2010 11:00 +
+    Sunrise, transit, sunset: 06:29 13:05 19:40
+    Moonrise, transit, moonset: 00:29 08:37 16:39
+    Mars rise, transit, set: 10:12 15:38 21:04
+    Azimuth, altitude of Mars: 111° 08°
+    Next new, full moon: 08-Sep-2010 03:29; 23-Sep-2010 02:17
+    Next summer, winter solstice: 21-Jun-2011 10:16; 21-Dec-2010 15:38
+    
+ + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byhour.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byhour.txt new file mode 100644 index 0000000..a4f075d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byhour.txt @@ -0,0 +1,56 @@ + TEST HOURS ITERABLE +--------------------------------------------------------------------------------------- +01:00 46.8F +02:00 43.1F +03:00 40.5F +04:00 39.1F +05:00 39.6F +06:00 41.6F +07:00 44.7F +08:00 48.8F +09:00 53.6F +10:00 58.8F +11:00 63.9F +12:00 N/A +13:00 N/A +14:00 N/A +15:00 N/A +16:00 N/A +17:00 N/A +18:00 N/A +19:00 N/A +20:00 N/A +21:00 N/A +22:00 N/A +23:00 N/A +00:00 N/A +--------------------------------------------------------------------------------------- + + TEST USING ALTERNATE BINDING +--------------------------------------------------------------------------------------- +01:00 12.1F +02:00 10.8F +03:00 10.1F +04:00 10.4F +05:00 11.4F +06:00 13.0F +07:00 15.1F +08:00 17.5F +09:00 20.1F +10:00 22.6F +11:00 25.1F +12:00 N/A +13:00 N/A +14:00 N/A +15:00 N/A +16:00 N/A +17:00 N/A +18:00 N/A +19:00 N/A +20:00 N/A +21:00 N/A +22:00 N/A +23:00 N/A +00:00 N/A +--------------------------------------------------------------------------------------- + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byrecord.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byrecord.txt new file mode 100644 index 0000000..3b2e814 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byrecord.txt @@ -0,0 +1,26 @@ + TEST ITERATING BY RECORDS +--------------------------------------------------------------------------------------- +03-Sep-2010 00:30 46.8F +03-Sep-2010 01:00 44.9F +03-Sep-2010 01:30 43.1F +03-Sep-2010 02:00 N/A +03-Sep-2010 02:30 40.5F +03-Sep-2010 03:00 39.6F +03-Sep-2010 03:30 39.1F +03-Sep-2010 04:00 38.9F +03-Sep-2010 04:30 39.1F +03-Sep-2010 05:00 39.6F +03-Sep-2010 05:30 40.4F +03-Sep-2010 06:00 41.6F +03-Sep-2010 06:30 43.0F +03-Sep-2010 07:00 44.7F +03-Sep-2010 07:30 46.7F +03-Sep-2010 08:00 48.8F +03-Sep-2010 08:30 51.2F +03-Sep-2010 09:00 53.6F +03-Sep-2010 09:30 56.2F +03-Sep-2010 10:00 58.8F +03-Sep-2010 10:30 61.4F +03-Sep-2010 11:00 63.9F +--------------------------------------------------------------------------------------- + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byspan.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byspan.txt new file mode 100644 index 0000000..76e13f5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/byspan.txt @@ -0,0 +1,143 @@ +Current time is 03-Sep-2010 11:00 (1283536800) + +Hourly Top Temperatures in Last Day +Thu 09/02 00:00-01:00: 47.4F at 00:30 +Thu 09/02 01:00-02:00: 43.7F at 01:30 +Thu 09/02 02:00-03:00: 41.1F at 02:30 +Thu 09/02 03:00-04:00: 39.7F at 03:30 +Thu 09/02 04:00-05:00: 40.2F at 05:00 +Thu 09/02 05:00-06:00: 42.2F at 06:00 +Thu 09/02 06:00-07:00: 45.3F at 07:00 +Thu 09/02 07:00-08:00: 49.4F at 08:00 +Thu 09/02 08:00-09:00: 54.2F at 09:00 +Thu 09/02 09:00-10:00: 59.4F at 10:00 +Thu 09/02 10:00-11:00: 64.5F at 11:00 +Thu 09/02 11:00-12:00: 69.3F at 12:00 +Thu 09/02 12:00-13:00: 73.4F at 13:00 +Thu 09/02 13:00-14:00: 76.6F at 14:00 +Thu 09/02 14:00-15:00: 78.6F at 15:00 +Thu 09/02 15:00-16:00: 79.2F at 16:00 +Thu 09/02 16:00-17:00: 79.0F at 16:30 +Thu 09/02 17:00-18:00: 77.7F at 17:30 +Thu 09/02 18:00-19:00: 75.0F at 18:30 +Thu 09/02 19:00-20:00: 71.3F at 19:30 +Thu 09/02 20:00-21:00: 66.8F at 20:30 +Thu 09/02 21:00-22:00: 61.7F at 21:30 +Thu 09/02 22:00-23:00: 56.5F at 22:30 +Thu 09/02 23:00-00:00: 51.4F at 23:30 + +Daily Top Temperatures in Last Week +Sun 08/22: 85.5F at 16:00 +Mon 08/23: 84.9F at 16:00 +Tue 08/24: 84.4F at 16:00 +Wed 08/25: 83.9F at 16:00 +Thu 08/26: 83.3F at 16:00 +Fri 08/27: 82.7F at 16:00 +Sat 08/28: 82.2F at 16:00 + +Daily Top Temperatures in Last Month +Sun 08/01: 94.7F at 16:00 +Mon 08/02: 94.4F at 16:00 +Tue 08/03: 94.0F at 16:00 +Wed 08/04: 93.7F at 16:00 +Thu 08/05: 93.3F at 16:00 +Fri 08/06: 92.9F at 16:00 +Sat 08/07: 92.5F at 16:00 +Sun 08/08: 92.1F at 16:00 +Mon 08/09: 91.7F at 16:00 +Tue 08/10: 91.3F at 16:00 +Wed 08/11: 90.8F at 16:00 +Thu 08/12: 90.4F at 16:00 +Fri 08/13: 89.9F at 16:00 +Sat 08/14: 89.5F at 16:00 +Sun 08/15: 89.0F at 16:00 +Mon 08/16: 88.5F at 16:00 +Tue 08/17: 88.0F at 16:00 +Wed 08/18: 87.5F at 16:00 +Thu 08/19: 87.0F at 16:00 +Fri 08/20: 86.5F at 16:00 +Sat 08/21: 86.0F at 16:00 +Sun 08/22: 85.5F at 16:00 +Mon 08/23: 84.9F at 16:00 +Tue 08/24: 84.4F at 16:00 +Wed 08/25: 83.9F at 16:00 +Thu 08/26: 83.3F at 16:00 +Fri 08/27: 82.7F at 16:00 +Sat 08/28: 82.2F at 16:00 +Sun 08/29: 81.6F at 16:00 +Mon 08/30: 81.0F at 16:00 +Tue 08/31: 80.4F at 16:00 + +Monthly Top Temperatures this Year +Jan 2010: 25.4F at 31-Jan-2010 15:00 +Feb 2010: 38.7F at 28-Feb-2010 15:00 +Mar 2010: 58.9F at 31-Mar-2010 16:00 +Apr 2010: 78.8F at 30-Apr-2010 16:00 +May 2010: 94.1F at 31-May-2010 16:00 +Jun 2010: 100.0F at 30-Jun-2010 16:00 +Jul 2010: 100.0F at 02-Jul-2010 16:00 +Aug 2010: 94.7F at 01-Aug-2010 16:00 +Sep 2010: 79.8F at 01-Sep-2010 16:00 +Oct 2010: N/A at N/A +Nov 2010: N/A at N/A +Dec 2010: N/A at N/A + +3 hour averages over yesterday +09/02 00:00-03:00: 43.4F +09/02 03:00-06:00: 40.4F +09/02 06:00-09:00: 48.6F +09/02 09:00-12:00: 63.2F +09/02 12:00-15:00: 75.5F +09/02 15:00-18:00: 78.3F +09/02 18:00-21:00: 70.0F +09/02 21:00-00:00: 55.2F + +All temperature records for yesterday +09/02 00:30: 47.4F +09/02 01:00: 45.5F +09/02 01:30: 43.7F +09/02 02:00: 42.3F +09/02 02:30: 41.1F +09/02 03:00: 40.2F +09/02 03:30: 39.7F +09/02 04:00: 39.5F +09/02 04:30: 39.7F +09/02 05:00: 40.2F +09/02 05:30: 41.0F +09/02 06:00: 42.2F +09/02 06:30: 43.6F +09/02 07:00: 45.3F +09/02 07:30: 47.3F +09/02 08:00: 49.4F +09/02 08:30: 51.8F +09/02 09:00: 54.2F +09/02 09:30: 56.8F +09/02 10:00: 59.4F +09/02 10:30: 62.0F +09/02 11:00: 64.5F +09/02 11:30: 67.0F +09/02 12:00: 69.3F +09/02 12:30: 71.5F +09/02 13:00: 73.4F +09/02 13:30: 75.2F +09/02 14:00: 76.6F +09/02 14:30: 77.7F +09/02 15:00: 78.6F +09/02 15:30: 79.1F +09/02 16:00: 79.2F +09/02 16:30: 79.0F +09/02 17:00: 78.5F +09/02 17:30: 77.7F +09/02 18:00: 76.5F +09/02 18:30: 75.0F +09/02 19:00: 73.3F +09/02 19:30: 71.3F +09/02 20:00: 69.1F +09/02 20:30: 66.8F +09/02 21:00: 64.3F +09/02 21:30: 61.7F +09/02 22:00: 59.1F +09/02 22:30: 56.5F +09/02 23:00: 53.9F +09/02 23:30: 51.4F +09/03 00:00: 49.0F diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/index.html b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/index.html new file mode 100644 index 0000000..501655e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/index.html @@ -0,0 +1,1036 @@ + + + + TEST: Current Weather Conditions + + + + +

Tests for tag $station

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Station location:Ĺōćāţĩőń with UTF8 characters
Latitude:45° 41.16' N
Longitude:121° 33.96' W
Altitude (default unit):328 feet
Altitude (feet):328 feet
Altitude (meters):100 meters
+ +
+ +

Tests for tag $current

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current dateTime:03-Sep-2010 11:00
Current dateTime with formatting:11:00
Raw dateTime:1283536800
Outside Temperature (normal formatting)63.9°F
Outside Temperature (explicit unit conversion to Celsius)17.7°C
Outside Temperature (explicit unit conversion to Fahrenheit)63.9°F
Outside Temperature (explicit unit conversion to Celsius, plus formatting)17.734°C
Outside Temperature (explicit unit conversion to Fahrenheit, plus formatting)63.921°F
Outside Temperature (with explicit binding to 'wx_binding')63.9°F
Outside Temperature (with explicit binding to 'alt_binding')25.1°F
Outside Temperature with nonsense binding $current($data_binding='foo_binding').outTemp$current($data_binding='foo_binding').outTemp
Outside Temperature with explicit time63.9°F
Outside Temperature with nonsense time N/A
Outside Temperature trend (3 hours)15.1°F
Outside Temperature trend with explicit time_delta (3600 seconds)5.2°F
Trend with nonsense type$trend.foobar
Barometer (normal)29.207 inHg
Barometer trend where previous value is known to be None (3 hours) N/A
Barometer using $latest29.207 inHg
Barometer using $latest and explicit data binding29.670 inHg at 1283536800
Wind Chill (normal)63.9°F
Heat Index (normal)63.9°F
Heat Index (in Celsius)17.7°C
Heat Index (in Fahrenheit)63.9°F
Dewpoint58.2°F
Humidity82%
Wind18 mph from 128°
Wind (beaufort)4
Rain Rate0.00 in/h
Inside Temperature68.0°F
Test tag "exists" for an existent type: $current.outTemp.existsTRUE
Test tag "exists" for a nonsense type: $current.nonsense.existsFALSE
Test tag "has_data" for an existing type with data: $current.outTemp.has_dataTRUE
Test tag "has_data" for an existing type without data: $current.hail.has_dataFALSE
Test tag "has_data" for a nonsense type: $current.nonsense.has_dataFALSE
Test tag "has_data" for the last hour: $hour.outTemp.has_dataTRUE
Test tag "has_data" for the last hour of a nonsense type: $hour.nonsense.has_dataFALSE
Test for a bad observation type on a $current tag: $current.foobar?'foobar'?
+ +
+ +

Tests for tag $hour

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Start of hour:09/03/2010 10:00:00
Start of hour (unix epoch time):1283533200
Max Temperature63.9°F
Min Temperature61.4°F
Time of max temperature:11:00
Time of min temperature:10:30
+ +

Wind related tags

+

$current

+ + + + + + + + + + + + + + + + +
$current.windSpeed18 mph
$current.windDir128°
$current.windGust22 mph
$current.windGustDir128°
+ +

$hour

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Hour start, stop1283533200
1283536800
$hour.wind.vecdir127°
$hour.wind.vecavg18 mph
$hour.wind.max22 mph
$hour.wind.gustdir126°
$hour.wind.maxtime10:30
+ +

$month

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$month.start.raw1283324400
$month.end.raw1285916400
$month.length.raw2592000
$month.start01-Sep-2010 00:00
$month.end01-Oct-2010 00:00
$month.length2592000 seconds
$month.length.long_form("%(day)d %(day_label)s")30 days
$month.windSpeed.avg11 mph
$month.wind.avg11 mph
$month.wind.vecavg8 mph
$month.wind.vecdir061°
$month.windGust.max24 mph
$month.wind.max24 mph
$month.wind.gustdir090°
$month.windGust.maxtime03-Sep-2010 01:00
$month.wind.maxtime03-Sep-2010 01:00
$month.windSpeed.max20 mph
$month.windDir.avg168°
+ +

Iterate over three hours:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of hourMin temperatureWhen
07:0046.7°F07:30
08:0051.2°F08:30
09:0056.2°F09:30
10:0061.4°F10:30
+ +
+ +

Tests for tag $span

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current dateTime:03-Sep-2010 11:00
Min Temperature in last hour (via $span($time_delta=3600)):61.4°F
Min Temperature in last hour (via $span($hour_delta=1)):61.4°F
Min Temperature in last 90 min (via $span($time_delta=5400)):58.8°F
Min Temperature in last 90 min (via $span($hour_delta=1.5)):58.8°F
Max Temperature in last 24 hours (via $span($time_delta=86400)):79.2°F
Max Temperature in last 24 hours (via $span($hour_delta=24)):79.2°F
Max Temperature in last 24 hours (via $span($day_delta=1)):79.2°F
Min Temperature in last 7 days (via $span($time_delta=604800)):38.9°F
Min Temperature in last 7 days (via $span($hour_delta=168)):38.9°F
Min Temperature in last 7 days (via $span($day_delta=7)):38.9°F
Min Temperature in last 7 days (via $span($week_delta=1)):38.9°F
Rainfall in last 24 hours (via $span($time_delta=86400)):1.28 in
Rainfall in last 24 hours (via $span($hour_delta=24)):1.28 in
Rainfall in last 24 hours (via $span($day_delta=1)):1.28 in
Max Windchill in last hour (existing obs type that is None):63.9°F
+ +
+ +

Tests for tag $day

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of day:09/03/2010 00:00:00
Start of day (unix epoch time):1283497200
End of day (unix epoch time):1283583600
Max Temperature63.9°F
Min Temperature38.9°F
Time of max temperature:11:00
Time of min temperature:04:00
Last temperature of the day63.9°F
Time of the last temperature of the day09/03/2010 11:00:00
First temperature of the day46.8°F
Time of the first temperature of the day09/03/2010 00:30:00
Max Temperature in alt_binding25.1°F
Max Temperature with bogus binding$day($data_binding='foo_binding').outTemp.max
Min temp with explicit conversion to Celsius3.8°C
Min temp with explicit conversion to Fahrenheit38.9°F
Min temp with explicit conversion to nonsense type$day.outTemp.min.badtype
Min temperature with inappropriate conversion: $day.outTemp.min.mbar$day.outTemp.min.mbar
Max value for a type with no data: $day.UV.max N/A
Sum aggregation (rain)0.76 in
Aggregation of xtype: $day.humidex.avg47.8°F
Aggregation of xtype: $day.humidex.min38.9°F
Aggregation of xtype: $day.humidex.mintime04:00
Aggregation of xtype: $day.humidex.max70.6°F
Aggregation of xtype: $day.humidex.maxtime11:00
High Wind from "$day.wind.max"24 mph from 090° at 01:00
High Wind from "$day.windGust.max"24 mph
High Wind from "$day.windSpeed.max"20 mph
Average wind from "$day.wind.avg"19 mph
Average wind from "$day.windSpeed.avg"19 mph
Average wind w/alt_binding: "$day(data_binding='alt_binding').wind.avg"7 mph
RMS aggregation: "$day.wind.rms"19 mph
Vector average: "$day.wind.vecavg"19 mph
Aggregation Vector Direction (wind)107°
Test tag "has_data" with nonsense type $day.nonsense.has_dataFALSE
Test tag "exists" with an existing type that has no data $day.UV.existsTRUE
Test tag "has_data" with existent type that has no data $day.UV.has_dataFALSE
Test tag "has_data" with existent type that has data $day.outTemp.has_dataTRUE
Test tag "not_null" with existent type that has no data $day.UV.not_null0
Test tag "not_null" with existent type that has data $day.outTemp.not_null1
Test for a bad observation type on a $day tag: $day.foobar.min$day.foobar.min
Test for a bad aggregation type on a $day tag: $day.outTemp.foo$day.outTemp.foo
Test tag "has_data" for an xtype: $day.humidex.has_dataTRUE
Test tag "has_data" for another xtype: $month.heatdeg.has_dataTRUE
Test for sunshineDur: $day.sunshineDur.sum25200 seconds
Test for sunshineDur: $day.sunshineDur.sum.long_form7 hours, 0 minutes, 0 seconds
Test for sunshineDur, custom format:7 hours, 0 minutes
sunshineDur in hours:7.00 hours
$day.stringData.first.rawS1283499000
$day.stringData.last.rawS1283536800
$day.stringData.first.format("%s")S1283499000
+ +
+ +

Tests for tag $yesterday

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of yesterday:09/02/2010 00:00:00
Start of yesterday (unix epoch time):1283410800
Max Temperature yesterday79.2°F
Min Temperature yesterday39.5°F
Time of max temperature yesterday:16:00
Time of min temperature yesterday:04:00
Yesterday's last temperature49.0°F
Time of yesterday's last temperature09/03/2010 00:00:00
+ +

Tests for tag $rainyear

+ + + + + +
Rainyear total79.36 in
+ +

Test for tag $alltime

+ + + + + + + + + +
Max temp from $alltime.outTemp.max100.0°F
+ at 02-Jul-2010 16:00
High Wind from "$alltime.wind.max" + 24 mph
+ from 090°
+ at 02-Jan-2010 00:00 +
+ +

Test for tag $seven_day

+ + + + + + + + + +
Max temp from $seven_day.outTemp.max82.7°F
+ at 27-Aug-2010 16:00
High Wind from "$seven_day.wind.max" + 24 mph
+ from 090°
+ at 30-Aug-2010 01:00 +
+ +

Test for various versions of $colorize

+ + + + + + + + + + + + + +
Current temperature using $colorize_163.9°F
Current temperature using $colorize_263.9°F
Current temperature using $colorize_363.9°F
+

Tests at timestamp 1280692800, when the temperature is null:

+ + + + + + + + + + + + + +
Null temperature using $colorize_1 N/A
Null temperature using $colorize_2 N/A
Null temperature using $colorize_3 N/A
+ +
+

Tests for tag $Extras

+ + + + + +
Radar URL"http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif"
+ +
+ +

Tests for tag $almanac

+ + + + + + + + + + + + + + + + + + + + + +
Sunrise:06:29
Sunset:19:40
Moon:Waning crescent (29%full)
$almanac.sun.visible:13 hours, 10 minutes, 42 seconds
(47442 seconds)
$almanac.sun.visible_change:3 minutes, 6 seconds (-186 seconds)
+ +
+ +

Test for tag $unit

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tag "$unit.unit_type.outTemp"degree_F
Tag "$unit.label.outTemp"°F
Tag "$unit.format.outTemp"%.1f
Example from customizing guide
+ ("$day.outTemp.max.format(add_label=False)$unit.label.outTemp")
63.9°F
Test the above with backwards compatibility
+ ("$day.outTemp.max.formatted$unit.label.outTemp")
63.9°F
Add a new unit type, existing groupdegree_F
Check its label°F
Check its format%.1f
Add a new unit type, new groupamp
Check its label A
Check its format%.1f
+ +

Test for tag $obs

+ + + + + +
Tag "$obs.label.outTemp"Outside Temperature
+ +
+ +

Test for formatting examples in the Customizing Guide

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$current.outTemp63.9°F
$current.outTemp.format63.9°F
$current.outTemp.format()63.9°F
$current.outTemp.format(format_string="%.3f")63.921°F
$current.outTemp.format("%.3f")63.921°F
$current.outTemp.format(add_label=False)63.9
$current.UV N/A
$current.UV.format(None_string="No UV")No UV
$current.windDir128°
$current.windDir.ordinal_compassSE
$current.dateTime03-Sep-2010 11:00
$current.dateTime.format(format_string="%H:%M")11:00
$current.dateTime.format("%H:%M")11:00
$current.dateTime.raw1283536800
$current.outTemp.raw63.92146
+ +

Test for aggregates

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$month.outTemp.maxmin40.1°F
$month.outTemp.maxmintime01-Sep-2010 04:00
$month.outTemp.minmax63.9°F
$month.outTemp.minmaxtime03-Sep-2010 11:00
$month.outTemp.minsum981.7°F
$month.outTemp.minsumtime03-Sep-2010 00:00
$month.outTemp.maxsumtime02-Sep-2010 00:00
$month.heatdeg.sum29.4°F-day
$month.cooldeg.sum0.0°F-day
$month.growdeg.sum18.8°F-day
$year.rain.sum_le((0.1, 'inch', 'group_rain'))122
$year.outTemp.avg_ge((41, 'degree_F', 'group_temperature'))153
$year.outTemp.avg_le((32, 'degree_F', 'group_temperature'))80
+ +
+

Tests for $gettext

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$langen
$pagetest
$gettext("Foo")Foo
$gettext("Today")Today
$pgettext("test","Plots")test
$pgettext($page,"Plots")History
$pgettext($page,"Something")Something
$pgettext("Foo","Plots")Plots
$pgettext("Foo","Bar")Bar
+
+ + + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-01.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-01.txt new file mode 100644 index 0000000..ae1c549 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-01.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Jan 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 -17.6 -6.7 15:00 -28.9 03:00 36.0 0.0 17.3 26.5 38.6 00:00 50 + 02 -17.8 -6.7 15:00 -28.9 03:00 36.1 0.0 15.2 26.1 38.6 00:30 131 + 03 -17.9 -6.6 15:00 -28.9 03:00 36.2 0.0 0.0 5.8 18.7 00:30 204 + 04 -17.6 -6.6 15:00 -28.9 03:00 36.0 0.0 0.0 6.0 19.3 00:00 338 + 05 -17.7 -6.6 15:00 -28.8 03:00 36.0 0.0 17.3 26.5 38.6 00:00 51 + 06 -17.7 -6.6 15:00 -28.8 03:00 36.1 0.0 15.2 26.3 38.6 00:30 131 + 07 -17.6 -6.5 15:00 -28.8 03:00 35.9 0.0 0.0 5.7 18.7 00:30 204 + 08 -17.6 -6.5 15:00 -28.7 03:00 35.9 0.0 0.0 6.1 19.3 00:00 338 + 09 -17.5 -6.4 15:00 -28.7 03:00 35.9 0.0 17.3 26.4 38.6 00:00 51 + 10 -17.5 -6.4 15:00 -28.6 03:00 35.8 0.0 15.2 26.2 38.6 00:30 131 + 11 -17.4 -6.3 15:00 -28.6 03:00 35.7 0.0 0.0 5.6 18.7 00:30 204 + 12 -17.3 -6.2 15:00 -28.5 03:00 35.6 0.0 0.0 5.9 19.3 00:00 337 + 13 -17.4 -6.1 15:00 -28.4 03:00 35.7 0.0 17.3 26.5 38.6 00:00 51 + 14 -17.2 -6.1 15:00 -28.3 03:00 35.5 0.0 15.2 26.1 38.6 00:30 131 + 15 -17.0 -6.0 15:00 -28.2 03:00 35.3 0.0 0.0 5.8 18.7 00:30 205 + 16 -17.1 -5.9 15:00 -28.1 03:00 35.5 0.0 0.0 6.0 19.3 00:00 338 + 17 -16.9 -5.8 15:00 -28.0 03:00 35.2 0.0 17.3 26.6 38.6 00:00 50 + 18 -16.6 -5.7 15:00 -27.9 03:00 34.9 0.0 15.2 26.2 38.6 00:30 130 + 19 -16.8 -5.5 15:00 -27.8 03:00 35.2 0.0 0.0 5.7 18.7 00:30 204 + 20 -16.5 -5.4 15:00 -27.7 03:00 34.9 0.0 0.0 6.1 19.3 00:00 337 + 21 -16.2 -5.3 15:00 -27.6 03:00 34.5 0.0 17.3 26.4 38.6 00:00 50 + 22 -16.5 -5.1 15:00 -27.4 03:00 34.8 0.0 15.2 26.2 38.6 00:30 131 + 23 -16.1 -5.0 15:00 -27.3 03:00 34.5 0.0 0.0 5.5 18.7 00:30 204 + 24 -15.8 -4.9 15:00 -27.2 03:00 34.1 0.0 0.0 6.0 19.3 00:00 338 + 25 -16.1 -4.7 15:00 -27.0 03:00 34.4 0.0 17.3 26.5 38.6 00:00 51 + 26 -15.7 -4.5 15:00 -26.8 03:00 34.0 0.0 15.2 26.0 38.6 00:30 131 + 27 -15.3 -4.4 15:00 -26.6 02:30 33.6 0.0 0.0 5.7 18.7 00:30 204 + 28 -15.6 -4.2 15:00 -26.5 03:00 33.9 0.0 0.0 6.0 19.3 00:00 338 + 29 -15.2 -4.0 15:00 -26.3 03:00 33.5 0.0 17.3 26.7 38.6 00:00 51 + 30 -14.7 -3.8 15:00 -26.2 03:00 33.1 0.0 15.2 26.2 38.6 00:30 131 + 31 -15.0 -3.6 15:00 -26.0 03:00 33.3 0.0 0.0 5.7 18.7 00:30 204 +--------------------------------------------------------------------------------------- + -16.7 -3.6 31 -28.9 01 1087.2 0.0 260.1 16.4 38.6 02 91 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-02.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-02.txt new file mode 100644 index 0000000..bfdfa55 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-02.txt @@ -0,0 +1,43 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Feb 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 -14.6 -3.5 15:00 -25.8 03:00 32.9 0.0 0.0 6.1 19.3 00:00 337 + 02 -14.2 -3.3 15:00 -25.6 03:00 32.5 0.0 17.3 26.5 38.6 00:00 51 + 03 -14.4 -3.0 15:00 -25.4 03:00 32.7 0.0 15.2 26.4 38.6 01:00 131 + 04 -13.8 -2.8 15:00 -25.2 03:00 32.1 0.0 0.0 5.7 18.7 00:30 204 + 05 -13.8 -2.6 15:00 -24.9 03:00 32.1 0.0 0.0 6.1 19.3 00:00 338 + 06 -13.7 -2.4 15:00 -24.7 03:00 32.0 0.0 17.3 26.4 38.6 00:00 50 + 07 -13.2 -2.2 15:00 -24.5 03:00 31.5 0.0 15.2 26.2 38.6 00:30 130 + 08 -13.1 -1.9 15:00 -24.3 03:00 31.4 0.0 0.0 5.7 18.7 00:30 204 + 09 -12.9 -1.7 15:00 -24.0 03:00 31.3 0.0 0.0 5.8 19.3 00:00 337 + 10 -12.5 -1.5 15:00 -23.8 03:00 30.9 0.0 17.3 26.5 38.6 00:00 51 + 11 -12.3 -1.2 15:00 -23.5 03:00 30.7 0.0 15.2 26.1 38.6 00:30 131 + 12 -12.1 -0.9 15:00 -23.3 03:00 30.5 0.0 0.0 5.8 18.7 00:30 204 + 13 -11.8 -0.7 15:00 -23.0 03:00 30.2 0.0 0.0 6.0 19.3 00:00 338 + 14 -11.6 -0.4 15:00 -22.8 03:00 29.9 0.0 17.3 26.5 38.6 00:00 51 + 15 -11.3 -0.2 15:00 -22.5 03:00 29.6 0.0 15.2 26.3 38.6 00:30 131 + 16 -11.1 0.1 15:00 -22.2 03:00 29.4 0.0 0.0 5.7 18.7 00:30 204 + 17 -10.8 0.4 15:00 -22.0 03:00 29.1 0.0 0.0 6.1 19.3 00:00 338 + 18 -10.4 0.7 15:00 -21.7 03:00 28.7 0.0 17.3 26.4 38.6 00:00 51 + 19 -10.3 1.0 15:00 -21.4 03:00 28.6 0.0 15.2 26.2 38.6 00:30 131 + 20 -9.9 1.3 15:00 -21.1 03:00 28.2 0.0 0.0 5.6 18.7 00:30 204 + 21 -9.5 1.5 15:00 -20.8 03:00 27.8 0.0 0.0 5.9 19.3 00:00 337 + 22 -9.5 1.8 15:00 -20.5 03:00 27.8 0.0 17.3 26.5 38.6 00:00 51 + 23 -9.0 2.1 15:00 -20.2 03:00 27.3 0.0 15.2 26.1 38.6 00:30 131 + 24 -8.5 2.5 15:00 -19.9 03:00 26.8 0.0 0.0 5.8 18.7 00:30 204 + 25 -8.6 2.8 15:00 -19.6 03:00 26.9 0.0 0.0 6.0 19.3 00:00 338 + 26 -8.1 3.1 15:00 -19.3 03:00 26.4 0.0 17.3 26.6 38.6 00:00 50 + 27 -7.5 3.4 15:00 -19.0 03:00 25.9 0.0 15.2 26.2 38.6 00:30 130 + 28 -7.7 3.7 15:00 -18.7 03:00 26.0 0.0 0.0 5.7 18.7 00:30 204 +--------------------------------------------------------------------------------------- + -11.3 3.7 28 -25.8 01 829.2 0.0 227.6 16.1 38.6 03 90 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-03.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-03.txt new file mode 100644 index 0000000..4b303b7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-03.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Mar 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 -7.1 4.0 15:00 -18.3 03:00 25.4 0.0 0.0 6.1 19.3 00:00 337 + 02 -6.5 4.4 15:00 -18.0 03:00 24.9 0.0 17.3 26.4 38.6 00:00 51 + 03 -6.7 4.6 15:30 -17.7 03:00 25.0 0.0 15.2 26.2 38.6 00:30 131 + 04 -6.1 5.0 15:00 -17.3 03:00 24.4 0.0 0.0 5.5 18.7 00:30 205 + 05 -5.5 5.4 15:00 -17.0 03:00 23.9 0.0 0.0 6.0 19.3 00:00 338 + 06 -5.7 5.7 15:00 -16.7 03:00 24.0 0.0 17.3 26.5 38.6 00:00 51 + 07 -5.1 6.1 15:00 -16.3 03:00 23.4 0.0 15.2 26.0 38.6 00:30 131 + 08 -4.5 6.4 15:00 -16.0 03:00 22.8 0.0 0.0 5.7 18.7 00:30 204 + 09 -4.6 6.8 15:00 -15.6 03:00 22.9 0.0 0.0 6.0 19.3 00:00 338 + 10 -4.0 7.1 15:00 -15.3 03:00 22.4 0.0 17.3 26.7 38.6 00:00 51 + 11 -3.5 7.5 15:00 -14.9 03:00 21.8 0.0 15.2 26.1 38.6 00:30 131 + 12 -3.5 7.8 15:00 -14.6 03:00 21.8 0.0 0.0 5.8 18.7 00:30 204 + 13 -2.8 8.2 15:00 -14.2 03:00 21.2 0.0 0.0 6.0 19.3 00:00 338 + 14 -2.3 8.6 16:00 -13.9 04:00 20.6 0.0 13.2 26.2 38.5 23:30 49 + 15 -2.4 8.9 16:00 -13.5 04:00 20.7 0.0 19.3 27.0 38.6 01:00 127 + 16 -1.8 9.3 16:00 -13.1 04:00 20.1 0.0 0.0 6.4 19.9 00:30 201 + 17 -1.5 9.7 16:00 -12.8 04:00 19.9 0.0 0.0 5.4 18.0 00:00 335 + 18 -1.2 10.0 16:00 -12.4 04:00 19.5 0.0 13.2 25.7 38.6 00:00 47 + 19 -0.8 10.4 16:00 -12.0 04:00 19.1 0.0 19.3 26.8 38.6 01:00 127 + 20 -0.4 10.8 16:00 -11.6 04:00 18.7 0.0 0.0 6.3 19.9 00:30 201 + 21 -0.0 11.1 16:00 -11.3 04:00 18.4 0.0 0.0 5.2 18.0 00:00 334 + 22 0.3 11.5 16:00 -10.9 04:00 18.0 0.0 13.2 25.8 38.6 00:00 47 + 23 0.7 11.9 16:00 -10.5 04:00 17.6 0.0 19.3 26.8 38.6 01:00 128 + 24 1.2 12.3 16:00 -10.1 04:00 17.2 0.0 0.0 6.5 19.9 00:30 202 + 25 1.4 12.6 16:00 -9.8 04:00 16.9 0.0 0.0 5.4 18.0 00:00 335 + 26 1.9 13.0 16:00 -9.4 04:00 16.5 0.0 13.2 25.9 38.6 00:00 48 + 27 2.4 13.4 16:00 -9.0 04:00 16.0 0.0 19.3 26.9 38.6 01:00 128 + 28 2.5 13.8 16:00 -8.6 04:00 15.9 0.0 0.0 6.4 19.9 00:30 201 + 29 3.0 14.2 16:00 -8.2 04:00 15.3 0.0 0.0 5.4 18.0 00:00 335 + 30 3.5 14.6 16:00 -7.9 04:00 14.8 0.0 13.2 25.7 38.6 00:00 48 + 31 3.6 14.9 16:00 -7.5 04:00 14.8 0.0 19.3 26.8 38.6 01:00 127 +--------------------------------------------------------------------------------------- + -1.8 14.9 31 -18.3 01 624.0 0.0 260.1 16.4 38.6 03 89 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-04.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-04.txt new file mode 100644 index 0000000..8e67ef4 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-04.txt @@ -0,0 +1,45 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Apr 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 4.1 15.3 16:00 -7.1 04:00 14.2 0.0 0.0 6.3 19.9 00:30 201 + 02 4.7 15.7 16:00 -6.7 04:00 13.6 0.0 0.0 5.3 18.0 00:00 335 + 03 4.7 16.1 16:00 -6.3 04:00 13.6 0.0 13.2 25.8 38.6 00:00 48 + 04 5.3 16.5 16:00 -5.9 04:00 13.0 0.0 19.3 26.7 38.6 01:00 128 + 05 5.9 16.8 16:00 -5.6 04:00 12.4 0.0 0.0 6.4 19.9 00:30 201 + 06 5.8 17.2 16:00 -5.2 04:00 12.5 0.0 0.0 5.4 18.0 00:00 335 + 07 6.4 17.6 16:00 -4.8 04:00 11.9 0.0 13.2 26.0 38.6 00:00 47 + 08 7.1 18.0 16:00 -4.3 03:30 11.3 0.0 19.3 26.9 38.6 01:00 127 + 09 7.0 18.4 16:00 -4.0 04:00 11.4 0.0 0.0 6.4 19.9 00:30 201 + 10 7.6 18.7 16:00 -3.7 04:00 10.8 0.0 0.0 5.5 18.0 00:00 334 + 11 8.2 19.1 16:00 -3.3 04:00 10.1 0.0 13.2 25.8 38.6 00:00 47 + 12 8.1 19.5 16:00 -2.9 04:00 10.2 0.0 19.3 26.8 38.6 01:00 128 + 13 8.7 19.9 16:00 -2.5 04:00 9.6 0.0 0.0 6.2 19.9 00:30 202 + 14 9.3 20.3 16:00 -2.2 04:00 9.0 0.0 0.0 5.4 18.0 00:00 335 + 15 9.3 20.6 16:00 -1.8 04:00 9.1 0.0 13.2 25.8 38.6 00:00 48 + 16 9.8 21.0 16:00 -1.4 04:00 8.5 0.0 19.3 26.7 38.6 01:00 128 + 17 10.4 21.4 16:00 -1.0 04:00 8.0 0.0 0.0 6.4 19.9 00:30 201 + 18 10.4 21.7 16:00 -0.7 04:00 7.9 0.0 0.0 5.2 18.0 00:00 334 + 19 11.0 22.1 16:00 -0.3 04:00 7.3 0.0 13.2 25.8 38.6 00:00 47 + 20 11.3 22.5 16:00 0.1 04:00 7.0 0.0 19.3 26.8 38.6 01:00 128 + 21 11.6 22.8 16:00 0.4 04:00 6.8 0.0 0.0 6.5 19.9 00:30 201 + 22 12.1 23.2 16:00 0.8 04:00 6.3 0.0 0.0 5.4 18.0 00:00 335 + 23 12.4 23.5 16:00 1.1 04:00 6.0 0.0 13.2 25.8 38.6 00:00 48 + 24 12.7 23.9 16:00 1.5 04:00 5.6 0.0 19.3 27.0 38.6 01:00 128 + 25 13.1 24.3 16:00 1.9 04:00 5.2 0.0 0.0 6.4 19.9 00:30 201 + 26 13.4 24.6 16:00 2.2 04:00 4.9 0.0 0.0 5.4 18.0 00:00 335 + 27 13.8 25.0 16:00 2.6 04:00 4.5 0.0 13.2 25.7 38.6 00:00 47 + 28 14.1 25.3 16:00 2.9 04:00 4.3 0.0 19.3 26.8 38.6 01:00 127 + 29 14.5 25.6 16:00 3.3 04:00 3.9 0.0 0.0 6.3 19.9 00:30 201 + 30 14.9 26.0 16:00 3.6 04:00 3.4 0.0 0.0 5.2 18.0 00:00 334 +--------------------------------------------------------------------------------------- + 9.6 26.0 30 -7.1 01 262.3 0.0 227.6 15.4 38.6 04 90 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-05.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-05.txt new file mode 100644 index 0000000..1744e6a --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-05.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for May 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 15.0 26.3 16:00 3.9 04:00 3.3 0.0 13.2 25.8 38.6 00:00 47 + 02 15.5 26.7 16:00 4.3 04:00 2.8 0.0 19.3 26.7 38.6 01:00 128 + 03 16.0 27.0 16:00 4.6 04:00 2.4 0.0 0.0 6.5 19.9 00:30 202 + 04 16.0 27.3 16:00 4.9 04:00 2.4 0.0 0.0 5.4 18.0 00:00 335 + 05 16.5 27.6 16:00 5.2 04:00 1.9 0.0 13.2 25.9 38.6 00:00 48 + 06 17.0 28.0 16:00 5.6 04:00 1.4 0.0 19.3 26.9 38.6 01:00 128 + 07 16.9 28.3 16:00 5.9 04:00 1.4 0.0 0.0 6.4 19.9 00:30 201 + 08 17.4 28.6 16:00 6.2 04:00 0.9 0.0 0.0 5.5 18.0 00:00 334 + 09 17.9 28.9 16:00 6.5 04:00 0.4 0.0 13.2 25.7 38.6 00:00 47 + 10 17.8 29.2 16:00 6.8 04:00 0.5 0.0 19.3 26.8 38.6 01:00 128 + 11 18.3 29.5 16:00 7.1 04:00 0.0 0.0 0.0 6.2 19.9 00:30 202 + 12 18.9 29.8 16:00 7.4 04:00 0.0 0.5 0.0 5.3 18.0 00:00 335 + 13 18.7 30.0 16:30 7.7 04:00 0.0 0.4 13.2 25.8 38.6 00:00 48 + 14 19.2 30.4 16:00 8.0 04:00 0.0 0.9 19.3 26.7 38.6 01:00 128 + 15 19.7 30.6 16:00 8.3 04:00 0.0 1.4 0.0 6.4 19.9 00:30 201 + 16 19.5 30.9 16:00 8.6 04:00 0.0 1.2 0.0 5.4 18.0 00:00 335 + 17 20.0 31.2 16:00 8.8 04:00 0.0 1.7 13.2 26.0 38.6 00:00 47 + 18 20.5 31.5 16:00 9.1 04:00 0.0 2.2 19.3 26.8 38.6 01:00 127 + 19 20.4 31.7 16:00 9.4 04:00 0.0 2.0 0.0 6.4 19.9 00:30 201 + 20 20.8 32.0 16:00 9.6 04:00 0.0 2.5 0.0 5.5 18.0 00:00 335 + 21 21.3 32.2 16:00 9.9 04:00 0.0 3.0 13.2 25.8 38.6 00:00 48 + 22 21.2 32.5 16:00 10.2 04:00 0.0 2.8 19.3 26.8 38.6 00:30 128 + 23 21.6 32.7 16:00 10.4 04:00 0.0 3.3 0.0 6.1 19.9 00:30 202 + 24 22.0 33.0 16:00 10.6 04:00 0.0 3.6 0.0 5.4 17.4 23:30 335 + 25 22.0 33.2 16:00 10.9 04:00 0.0 3.6 13.2 25.7 38.6 00:00 47 + 26 22.4 33.4 16:00 11.1 04:00 0.0 4.1 19.3 26.8 38.6 01:00 127 + 27 22.5 33.7 16:00 11.3 04:00 0.0 4.2 0.0 6.4 19.9 00:30 201 + 28 22.7 33.9 16:00 11.6 04:00 0.0 4.4 0.0 5.2 18.0 00:00 334 + 29 23.0 34.1 16:00 11.8 04:00 0.0 4.7 13.2 25.8 38.6 00:00 47 + 30 23.2 34.3 16:00 12.0 04:00 0.0 4.8 19.3 26.8 38.6 01:00 128 + 31 23.4 34.5 16:00 12.2 04:00 0.0 5.0 0.0 6.5 19.9 00:30 202 +--------------------------------------------------------------------------------------- + 19.6 34.5 31 3.9 01 17.3 56.3 260.1 16.4 38.6 02 91 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-06.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-06.txt new file mode 100644 index 0000000..828b382 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-06.txt @@ -0,0 +1,45 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Jun 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 23.5 34.7 16:00 12.4 04:00 0.0 5.2 0.0 5.4 18.0 00:00 335 + 02 23.8 34.9 16:00 12.6 04:00 0.0 5.4 13.2 25.9 38.6 00:00 48 + 03 24.0 35.1 16:00 12.8 04:00 0.0 5.7 19.3 27.0 38.6 01:00 128 + 04 24.0 35.3 16:00 13.0 04:00 0.0 5.7 0.0 6.4 19.9 00:30 201 + 05 24.3 35.4 16:00 13.1 04:00 0.0 6.0 0.0 5.4 18.0 00:00 335 + 06 24.6 35.6 16:00 13.3 04:00 0.0 6.3 13.2 25.7 38.6 00:00 48 + 07 24.5 35.8 16:00 13.5 04:00 0.0 6.2 19.3 26.8 38.6 01:00 127 + 08 24.8 35.9 16:00 13.6 04:00 0.0 6.5 0.0 6.3 19.9 00:30 201 + 09 25.1 36.1 16:00 13.8 04:00 0.0 6.8 0.0 5.3 18.0 00:00 335 + 10 24.9 36.2 16:00 13.9 04:00 0.0 6.6 13.2 25.8 38.6 00:00 48 + 11 25.2 36.4 16:00 14.1 04:00 0.0 6.9 19.3 26.7 38.6 01:00 127 + 12 25.6 36.5 16:00 14.2 04:00 0.0 7.2 0.0 6.5 19.9 00:30 201 + 13 25.3 36.6 16:00 14.3 04:00 0.0 6.9 0.0 5.4 18.0 00:00 335 + 14 25.6 36.7 16:00 14.5 04:00 0.0 7.3 13.2 25.9 38.6 00:00 47 + 15 25.9 36.8 16:00 14.6 04:00 0.0 7.6 19.3 26.9 38.6 01:00 127 + 16 25.6 37.0 16:00 14.7 04:00 0.0 7.3 0.0 6.4 19.9 00:30 201 + 17 25.9 37.1 16:00 14.8 04:00 0.0 7.6 0.0 5.5 18.0 00:00 334 + 18 26.3 37.1 16:00 15.0 03:30 0.0 7.9 13.2 25.8 38.6 00:00 47 + 19 25.9 37.2 16:00 15.0 04:00 0.0 7.5 19.3 26.8 38.6 01:00 128 + 20 26.2 37.3 16:00 15.1 04:00 0.0 7.9 0.0 6.2 19.9 00:30 202 + 21 26.5 37.4 16:00 15.1 04:00 0.0 8.2 0.0 5.3 18.0 00:00 335 + 22 26.1 37.5 16:00 15.2 04:00 0.0 7.8 13.2 25.8 38.6 00:00 48 + 23 26.4 37.5 16:00 15.3 04:00 0.0 8.1 19.3 26.7 38.6 01:00 128 + 24 26.7 37.6 16:00 15.3 04:00 0.0 8.3 0.0 6.4 19.9 00:30 201 + 25 26.3 37.6 16:00 15.4 04:00 0.0 8.0 0.0 5.4 18.0 00:00 335 + 26 26.5 37.7 16:00 15.4 04:00 0.0 8.2 13.2 26.0 38.6 00:00 48 + 27 26.8 37.7 16:00 15.5 04:00 0.0 8.4 19.3 26.8 38.6 01:00 127 + 28 26.5 37.7 16:00 15.5 04:00 0.0 8.1 0.0 6.5 19.3 01:00 201 + 29 26.8 37.8 16:00 15.5 04:00 0.0 8.4 0.0 5.4 18.0 00:00 335 + 30 26.7 37.8 16:00 15.5 04:00 0.0 8.3 13.2 25.8 38.6 00:00 48 +--------------------------------------------------------------------------------------- + 25.5 37.8 30 12.4 01 0.0 216.1 240.8 16.1 38.6 03 85 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-07.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-07.txt new file mode 100644 index 0000000..3e562c9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-07.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Jul 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 26.6 37.8 16:00 15.5 04:00 0.0 8.2 19.3 27.0 38.6 01:00 127 + 02 26.7 37.8 16:00 15.6 04:00 0.0 8.4 0.0 6.4 19.9 00:30 201 + 03 26.7 37.8 16:00 15.6 04:00 0.0 8.3 0.0 5.4 18.0 00:00 335 + 04 26.6 37.8 16:00 15.5 04:00 0.0 8.3 13.2 25.7 38.6 00:00 47 + 05 26.6 37.7 16:00 15.5 04:00 0.0 8.3 19.3 26.8 38.6 01:00 127 + 06 26.6 37.7 16:00 15.5 04:00 0.0 8.3 0.0 6.3 19.9 00:30 201 + 07 26.6 37.7 16:00 15.5 04:00 0.0 8.3 0.0 5.2 18.0 00:00 334 + 08 26.5 37.7 16:00 15.5 04:00 0.0 8.2 13.2 25.8 38.6 00:00 47 + 09 26.5 37.6 16:00 15.4 04:00 0.0 8.2 19.3 26.8 38.6 01:00 128 + 10 26.5 37.6 16:00 15.4 04:00 0.0 8.2 0.0 6.5 19.9 00:30 202 + 11 26.3 37.5 16:00 15.3 04:00 0.0 8.0 0.0 5.4 18.0 00:00 335 + 12 26.3 37.4 16:00 15.3 04:00 0.0 8.0 13.2 25.9 38.6 00:00 48 + 13 26.4 37.4 16:00 15.2 04:00 0.0 8.1 19.3 26.9 38.6 01:00 128 + 14 26.0 37.3 16:00 15.1 04:00 0.0 7.7 0.0 6.4 19.9 00:30 201 + 15 26.1 37.2 16:00 15.0 04:00 0.0 7.8 0.0 5.4 18.0 00:00 335 + 16 26.2 37.1 16:00 14.9 04:00 0.0 7.9 13.2 25.7 38.6 00:00 48 + 17 25.7 37.0 16:00 14.9 04:00 0.0 7.4 19.3 26.8 38.6 01:00 127 + 18 25.8 36.9 16:00 14.8 04:00 0.0 7.5 0.0 6.3 19.9 00:30 201 + 19 25.9 36.8 16:00 14.7 04:00 0.0 7.6 0.0 5.3 18.0 00:00 335 + 20 25.4 36.7 16:00 14.5 04:00 0.0 7.1 13.2 25.8 38.6 00:00 48 + 21 25.5 36.6 16:00 14.4 04:00 0.0 7.2 19.3 26.7 38.6 01:00 128 + 22 25.6 36.5 16:00 14.3 04:00 0.0 7.3 0.0 6.4 19.9 00:30 201 + 23 25.0 36.2 15:30 14.2 04:00 0.0 6.7 0.0 5.4 18.0 00:00 335 + 24 25.1 36.2 16:00 14.0 04:00 0.0 6.8 13.2 26.0 38.6 00:00 47 + 25 25.2 36.0 16:00 13.9 04:00 0.0 6.9 19.3 26.8 38.6 01:00 127 + 26 24.6 35.9 16:00 13.7 04:00 0.0 6.2 0.0 6.4 19.9 00:30 201 + 27 24.6 35.7 16:00 13.6 04:00 0.0 6.3 0.0 5.5 18.0 00:00 334 + 28 24.7 35.6 16:00 13.4 04:00 0.0 6.4 13.2 25.8 38.6 00:00 47 + 29 24.1 35.4 16:00 13.3 04:00 0.0 5.8 19.3 26.8 38.6 01:00 128 + 30 24.1 35.2 16:00 13.1 04:00 0.0 5.8 0.0 6.2 19.9 00:30 202 + 31 24.1 35.0 16:00 12.9 04:00 0.0 5.8 0.0 5.4 18.0 00:00 335 +--------------------------------------------------------------------------------------- + 25.8 37.8 02 12.9 31 0.0 230.6 246.9 15.8 38.6 01 94 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-08.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-08.txt new file mode 100644 index 0000000..dac70ab --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-08.txt @@ -0,0 +1,46 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Aug 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 23.6 34.9 16:00 12.7 04:00 0.0 5.3 13.2 25.8 38.6 00:00 48 + 02 23.6 34.7 16:00 12.5 04:00 0.0 5.2 19.3 26.7 38.6 01:00 128 + 03 23.5 34.5 16:00 12.3 04:00 0.0 5.2 0.0 6.4 19.9 00:30 201 + 04 23.1 34.3 16:00 12.1 04:00 0.0 4.7 0.0 5.2 18.0 00:00 334 + 05 23.1 34.1 16:00 11.9 04:00 0.0 4.7 13.2 25.8 38.6 00:00 47 + 06 22.8 33.8 16:00 11.7 04:00 0.0 4.4 19.3 26.8 38.6 01:00 128 + 07 22.5 33.6 16:00 11.5 04:00 0.0 4.1 0.0 6.5 19.9 00:30 201 + 08 22.3 33.4 16:00 11.3 04:00 0.0 4.0 0.0 5.4 18.0 00:00 335 + 09 22.1 33.2 16:00 11.1 04:00 0.0 3.8 13.2 25.8 38.6 00:00 48 + 10 21.9 32.9 16:00 10.8 04:00 0.0 3.5 19.3 27.0 38.6 01:00 128 + 11 21.6 32.7 16:00 10.6 04:00 0.0 3.2 0.0 6.4 19.9 00:30 201 + 12 21.4 32.4 16:00 10.3 04:00 0.0 3.0 0.0 5.4 18.0 00:00 335 + 13 21.2 32.2 16:00 10.1 04:00 0.0 2.8 13.2 25.7 38.6 00:00 47 + 14 20.8 31.9 16:00 9.8 04:00 0.0 2.4 19.3 26.8 38.6 01:00 127 + 15 20.6 31.7 16:00 9.6 04:00 0.0 2.3 0.0 6.3 19.9 00:30 201 + 16 20.5 31.4 16:00 9.3 04:00 0.0 2.1 0.0 5.2 18.0 00:00 335 + 17 19.9 31.1 16:00 9.0 04:00 0.0 1.6 13.2 25.8 38.6 00:00 48 + 18 19.8 30.9 16:00 8.8 04:00 0.0 1.5 19.3 26.7 38.6 01:00 128 + 19 19.7 30.6 16:00 8.5 04:00 0.0 1.3 0.0 6.5 19.9 00:30 202 + 20 19.0 30.3 16:00 8.2 04:00 0.0 0.7 0.0 5.4 18.0 00:00 335 + 21 18.9 30.0 16:00 7.9 04:00 0.0 0.6 13.2 25.9 38.6 00:00 47 + 22 18.9 29.7 16:00 7.6 04:00 0.0 0.5 19.3 26.9 38.6 01:00 127 + 23 18.1 29.4 16:00 7.3 04:00 0.2 0.0 0.0 6.4 19.9 00:30 201 + 24 18.1 29.1 16:00 7.0 04:00 0.3 0.0 0.0 5.5 18.0 00:00 334 + 25 18.0 28.8 16:00 6.7 04:00 0.4 0.0 13.2 25.7 38.6 00:00 47 + 26 17.2 28.5 16:00 6.4 04:00 1.1 0.0 19.3 26.8 38.6 01:00 128 + 27 17.1 28.2 16:00 6.1 04:00 1.2 0.0 0.0 6.2 19.9 00:30 202 + 28 17.0 27.9 16:00 5.9 04:30 1.3 0.0 0.0 5.3 18.0 00:00 335 + 29 16.3 27.6 16:00 5.5 04:00 2.1 0.0 13.2 25.8 38.6 00:00 48 + 30 16.2 27.2 16:00 5.2 04:00 2.2 0.0 19.3 26.7 38.6 01:00 128 + 31 16.1 26.9 16:00 4.8 04:00 2.3 0.0 0.0 6.4 19.9 00:30 201 +--------------------------------------------------------------------------------------- + 20.1 34.9 01 4.8 31 11.0 67.2 260.1 16.4 38.6 02 91 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-09.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-09.txt new file mode 100644 index 0000000..d34b8bf --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010-09.txt @@ -0,0 +1,45 @@ + MONTHLY CLIMATOLOGICAL SUMMARY for Sep 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C), RAIN (mm), WIND SPEED (kph) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- + 01 15.3 26.6 16:00 4.5 04:00 3.0 0.0 0.0 5.4 18.0 00:00 335 + 02 15.2 26.2 16:00 4.2 04:00 3.2 0.0 13.2 26.0 38.6 00:00 47 + 03 8.2 17.7 11:00 3.8 04:00 10.1 0.0 19.3 31.1 38.6 01:00 107 + 04 + 05 + 06 + 07 + 08 + 09 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 +--------------------------------------------------------------------------------------- + 14.0 26.6 01 3.8 03 16.3 0.0 32.5 18.5 38.6 03 61 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010.txt new file mode 100644 index 0000000..51cfbe2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/NOAA/NOAA-2010.txt @@ -0,0 +1,70 @@ + CLIMATOLOGICAL SUMMARY for year 2010 + + +NAME: Location with UTF8 characters +ELEV: 100 m LAT: 45-41.16 N LONG: 121-33.96 W + + + TEMPERATURE (C) + + HEAT COOL MAX MAX MIN MIN + MEAN MEAN DEG DEG >= <= <= <= + YR MO MAX MIN MEAN DAYS DAYS HI DAY LOW DAY 30 0 0 -20 +------------------------------------------------------------------------------------------------ +2010 01 -5.6 -27.9 -16.7 1087.2 0.0 -3.6 31 -28.9 01 0 31 31 31 +2010 02 -0.1 -22.5 -11.3 829.2 0.0 3.7 28 -25.8 01 0 15 28 23 +2010 03 9.4 -13.0 -1.8 624.0 0.0 14.9 31 -18.3 01 0 0 31 0 +2010 04 20.8 -1.6 9.6 262.3 0.0 26.0 30 -7.1 01 0 0 19 0 +2010 05 30.7 8.4 19.6 17.3 56.3 34.5 31 3.9 01 18 0 0 0 +2010 06 36.7 14.4 25.5 0.0 216.1 37.8 30 12.4 01 30 0 0 0 +2010 07 36.9 14.7 25.8 0.0 230.6 37.8 02 12.9 31 31 0 0 0 +2010 08 31.2 9.1 20.1 11.0 67.2 34.9 01 4.8 31 21 0 0 0 +2010 09 23.5 4.2 14.0 16.3 0.0 26.6 01 3.8 03 0 0 0 0 +2010 10 +2010 11 +2010 12 +------------------------------------------------------------------------------------------------ + 20.2 -2.1 9.1 2847.3 570.3 37.8 Jul -28.9 Jan 100 46 109 54 + + + PRECIPITATION (mm) + + MAX ---DAYS OF RAIN--- + OBS. OVER + YR MO TOTAL DAY DATE 0.30 3.00 30.00 +------------------------------------------------ +2010 01 260.1 17.3 01 16 16 0 +2010 02 227.6 17.3 02 14 14 0 +2010 03 260.1 19.3 15 16 16 0 +2010 04 227.6 19.3 04 14 14 0 +2010 05 260.1 19.3 02 16 16 0 +2010 06 240.8 19.3 03 15 15 0 +2010 07 246.9 19.3 01 15 15 0 +2010 08 260.1 19.3 02 16 16 0 +2010 09 32.5 19.3 03 2 2 0 +2010 10 +2010 11 +2010 12 +------------------------------------------------ + 2015.7 19.3 Mar 124 124 0 + + + WIND SPEED (kph) + + DOM + YR MO AVG HI DATE DIR +----------------------------------- +2010 01 16.4 38.6 02 91 +2010 02 16.1 38.6 03 90 +2010 03 16.4 38.6 03 89 +2010 04 15.4 38.6 04 90 +2010 05 16.4 38.6 02 91 +2010 06 16.1 38.6 03 85 +2010 07 15.8 38.6 01 94 +2010 08 16.4 38.6 02 91 +2010 09 18.5 38.6 03 61 +2010 10 +2010 11 +2010 12 +----------------------------------- + 16.2 38.6 Jan 90 diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byhour.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byhour.txt new file mode 100644 index 0000000..d4a8c7d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byhour.txt @@ -0,0 +1,56 @@ + TEST HOURS ITERABLE +--------------------------------------------------------------------------------------- +01:00 8.2C +02:00 6.2C +03:00 4.7C +04:00 3.9C +05:00 4.2C +06:00 5.3C +07:00 7.1C +08:00 9.3C +09:00 12.0C +10:00 14.9C +11:00 17.7C +12:00 N/A +13:00 N/A +14:00 N/A +15:00 N/A +16:00 N/A +17:00 N/A +18:00 N/A +19:00 N/A +20:00 N/A +21:00 N/A +22:00 N/A +23:00 N/A +00:00 N/A +--------------------------------------------------------------------------------------- + + TEST USING ALTERNATE BINDING +--------------------------------------------------------------------------------------- +01:00 -11.0C +02:00 -11.8C +03:00 -12.1C +04:00 -12.0C +05:00 -11.4C +06:00 -10.6C +07:00 -9.4C +08:00 -8.1C +09:00 -6.6C +10:00 -5.2C +11:00 -3.9C +12:00 N/A +13:00 N/A +14:00 N/A +15:00 N/A +16:00 N/A +17:00 N/A +18:00 N/A +19:00 N/A +20:00 N/A +21:00 N/A +22:00 N/A +23:00 N/A +00:00 N/A +--------------------------------------------------------------------------------------- + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byrecord.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byrecord.txt new file mode 100644 index 0000000..b185aef --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byrecord.txt @@ -0,0 +1,26 @@ + TEST ITERATING BY RECORDS +--------------------------------------------------------------------------------------- +03-Sep-2010 00:30 8.2C +03-Sep-2010 01:00 7.1C +03-Sep-2010 01:30 6.2C +03-Sep-2010 02:00 N/A +03-Sep-2010 02:30 4.7C +03-Sep-2010 03:00 4.2C +03-Sep-2010 03:30 3.9C +03-Sep-2010 04:00 3.8C +03-Sep-2010 04:30 3.9C +03-Sep-2010 05:00 4.2C +03-Sep-2010 05:30 4.7C +03-Sep-2010 06:00 5.3C +03-Sep-2010 06:30 6.1C +03-Sep-2010 07:00 7.1C +03-Sep-2010 07:30 8.1C +03-Sep-2010 08:00 9.3C +03-Sep-2010 08:30 10.6C +03-Sep-2010 09:00 12.0C +03-Sep-2010 09:30 13.4C +03-Sep-2010 10:00 14.9C +03-Sep-2010 10:30 16.3C +03-Sep-2010 11:00 17.7C +--------------------------------------------------------------------------------------- + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byspan.txt b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byspan.txt new file mode 100644 index 0000000..45fa8e3 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/byspan.txt @@ -0,0 +1,143 @@ +Current time is 03-Sep-2010 11:00 (1283536800) + +Hourly Top Temperatures in Last Day +Thu 09/02 00:00-01:00: 8.6C at 00:30 +Thu 09/02 01:00-02:00: 6.5C at 01:30 +Thu 09/02 02:00-03:00: 5.0C at 02:30 +Thu 09/02 03:00-04:00: 4.3C at 03:30 +Thu 09/02 04:00-05:00: 4.5C at 05:00 +Thu 09/02 05:00-06:00: 5.6C at 06:00 +Thu 09/02 06:00-07:00: 7.4C at 07:00 +Thu 09/02 07:00-08:00: 9.7C at 08:00 +Thu 09/02 08:00-09:00: 12.3C at 09:00 +Thu 09/02 09:00-10:00: 15.2C at 10:00 +Thu 09/02 10:00-11:00: 18.1C at 11:00 +Thu 09/02 11:00-12:00: 20.7C at 12:00 +Thu 09/02 12:00-13:00: 23.0C at 13:00 +Thu 09/02 13:00-14:00: 24.8C at 14:00 +Thu 09/02 14:00-15:00: 25.9C at 15:00 +Thu 09/02 15:00-16:00: 26.2C at 16:00 +Thu 09/02 16:00-17:00: 26.1C at 16:30 +Thu 09/02 17:00-18:00: 25.4C at 17:30 +Thu 09/02 18:00-19:00: 23.9C at 18:30 +Thu 09/02 19:00-20:00: 21.8C at 19:30 +Thu 09/02 20:00-21:00: 19.3C at 20:30 +Thu 09/02 21:00-22:00: 16.5C at 21:30 +Thu 09/02 22:00-23:00: 13.6C at 22:30 +Thu 09/02 23:00-00:00: 10.8C at 23:30 + +Daily Top Temperatures in Last Week +Sun 08/22: 29.7C at 16:00 +Mon 08/23: 29.4C at 16:00 +Tue 08/24: 29.1C at 16:00 +Wed 08/25: 28.8C at 16:00 +Thu 08/26: 28.5C at 16:00 +Fri 08/27: 28.2C at 16:00 +Sat 08/28: 27.9C at 16:00 + +Daily Top Temperatures in Last Month +Sun 08/01: 34.9C at 16:00 +Mon 08/02: 34.7C at 16:00 +Tue 08/03: 34.5C at 16:00 +Wed 08/04: 34.3C at 16:00 +Thu 08/05: 34.1C at 16:00 +Fri 08/06: 33.8C at 16:00 +Sat 08/07: 33.6C at 16:00 +Sun 08/08: 33.4C at 16:00 +Mon 08/09: 33.2C at 16:00 +Tue 08/10: 32.9C at 16:00 +Wed 08/11: 32.7C at 16:00 +Thu 08/12: 32.4C at 16:00 +Fri 08/13: 32.2C at 16:00 +Sat 08/14: 31.9C at 16:00 +Sun 08/15: 31.7C at 16:00 +Mon 08/16: 31.4C at 16:00 +Tue 08/17: 31.1C at 16:00 +Wed 08/18: 30.9C at 16:00 +Thu 08/19: 30.6C at 16:00 +Fri 08/20: 30.3C at 16:00 +Sat 08/21: 30.0C at 16:00 +Sun 08/22: 29.7C at 16:00 +Mon 08/23: 29.4C at 16:00 +Tue 08/24: 29.1C at 16:00 +Wed 08/25: 28.8C at 16:00 +Thu 08/26: 28.5C at 16:00 +Fri 08/27: 28.2C at 16:00 +Sat 08/28: 27.9C at 16:00 +Sun 08/29: 27.6C at 16:00 +Mon 08/30: 27.2C at 16:00 +Tue 08/31: 26.9C at 16:00 + +Monthly Top Temperatures this Year +Jan 2010: -3.6C at 31-Jan-2010 15:00 +Feb 2010: 3.7C at 28-Feb-2010 15:00 +Mar 2010: 14.9C at 31-Mar-2010 16:00 +Apr 2010: 26.0C at 30-Apr-2010 16:00 +May 2010: 34.5C at 31-May-2010 16:00 +Jun 2010: 37.8C at 30-Jun-2010 16:00 +Jul 2010: 37.8C at 02-Jul-2010 16:00 +Aug 2010: 34.9C at 01-Aug-2010 16:00 +Sep 2010: 26.6C at 01-Sep-2010 16:00 +Oct 2010: N/A at N/A +Nov 2010: N/A at N/A +Dec 2010: N/A at N/A + +3 hour averages over yesterday +09/02 00:00-03:00: 6.3C +09/02 03:00-06:00: 4.7C +09/02 06:00-09:00: 9.2C +09/02 09:00-12:00: 17.3C +09/02 12:00-15:00: 24.2C +09/02 15:00-18:00: 25.7C +09/02 18:00-21:00: 21.1C +09/02 21:00-00:00: 12.9C + +All temperature records for yesterday +09/02 00:30: 8.6C +09/02 01:00: 7.5C +09/02 01:30: 6.5C +09/02 02:00: 5.7C +09/02 02:30: 5.0C +09/02 03:00: 4.6C +09/02 03:30: 4.3C +09/02 04:00: 4.2C +09/02 04:30: 4.3C +09/02 05:00: 4.5C +09/02 05:30: 5.0C +09/02 06:00: 5.6C +09/02 06:30: 6.4C +09/02 07:00: 7.4C +09/02 07:30: 8.5C +09/02 08:00: 9.7C +09/02 08:30: 11.0C +09/02 09:00: 12.3C +09/02 09:30: 13.8C +09/02 10:00: 15.2C +09/02 10:30: 16.7C +09/02 11:00: 18.1C +09/02 11:30: 19.4C +09/02 12:00: 20.7C +09/02 12:30: 21.9C +09/02 13:00: 23.0C +09/02 13:30: 24.0C +09/02 14:00: 24.8C +09/02 14:30: 25.4C +09/02 15:00: 25.9C +09/02 15:30: 26.1C +09/02 16:00: 26.2C +09/02 16:30: 26.1C +09/02 17:00: 25.8C +09/02 17:30: 25.4C +09/02 18:00: 24.7C +09/02 18:30: 23.9C +09/02 19:00: 22.9C +09/02 19:30: 21.8C +09/02 20:00: 20.6C +09/02 20:30: 19.3C +09/02 21:00: 17.9C +09/02 21:30: 16.5C +09/02 22:00: 15.0C +09/02 22:30: 13.6C +09/02 23:00: 12.2C +09/02 23:30: 10.8C +09/03 00:00: 9.5C diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/index.html b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/index.html new file mode 100644 index 0000000..c46a9e6 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/index.html @@ -0,0 +1,1036 @@ + + + + TEST: Current Weather Conditions + + + + +

Tests for tag $station

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Station location:Ĺōćāţĩőń with UTF8 characters
Latitude:45° 41.16' N
Longitude:121° 33.96' W
Altitude (default unit):100 m
Altitude (feet):328 feet
Altitude (meters):100 m
+ +
+ +

Tests for tag $current

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current dateTime:03-Sep-2010 11:00
Current dateTime with formatting:11:00
Raw dateTime:1283536800
Outside Temperature (normal formatting)17.7°C
Outside Temperature (explicit unit conversion to Celsius)17.7°C
Outside Temperature (explicit unit conversion to Fahrenheit)63.9°F
Outside Temperature (explicit unit conversion to Celsius, plus formatting)17.734°C
Outside Temperature (explicit unit conversion to Fahrenheit, plus formatting)63.921°F
Outside Temperature (with explicit binding to 'wx_binding')17.7°C
Outside Temperature (with explicit binding to 'alt_binding')-3.9°C
Outside Temperature with nonsense binding $current($data_binding='foo_binding').outTemp$current($data_binding='foo_binding').outTemp
Outside Temperature with explicit time17.7°C
Outside Temperature with nonsense time N/A
Outside Temperature trend (3 Stunden)8.4°C
Outside Temperature trend with explicit time_delta (3600 seconds)2.9°C
Trend with nonsense type$trend.foobar
Barometer (normal)989.1 mbar
Barometer trend where previous value is known to be None (3 Stunden) N/A
Barometer using $latest989.1 mbar
Barometer using $latest and explicit data binding1004.8 mbar at 1283536800
Wind Chill (normal)17.7°C
Heat Index (normal)17.7°C
Heat Index (in Celsius)17.7°C
Heat Index (in Fahrenheit)63.9°F
Dewpoint14.6°C
Humidity82%
Wind29 kph from 128°
Wind (beaufort)4
Rain Rate0.0 mm/h
Inside Temperature20.0°C
Test tag "exists" for an existent type: $current.outTemp.existsTRUE
Test tag "exists" for a nonsense type: $current.nonsense.existsFALSE
Test tag "has_data" for an existing type with data: $current.outTemp.has_dataTRUE
Test tag "has_data" for an existing type without data: $current.hail.has_dataFALSE
Test tag "has_data" for a nonsense type: $current.nonsense.has_dataFALSE
Test tag "has_data" for the last hour: $hour.outTemp.has_dataTRUE
Test tag "has_data" for the last hour of a nonsense type: $hour.nonsense.has_dataFALSE
Test for a bad observation type on a $current tag: $current.foobar?'foobar'?
+ +
+ +

Tests for tag $hour

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Start of hour:09/03/2010 10:00:00
Start of hour (unix epoch time):1283533200
Max Temperature17.7°C
Min Temperature16.3°C
Time of max temperature:11:00
Time of min temperature:10:30
+ +

Wind related tags

+

$current

+ + + + + + + + + + + + + + + + +
$current.windSpeed29 kph
$current.windDir128°
$current.windGust35 kph
$current.windGustDir128°
+ +

$hour

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Hour start, stop1283533200
1283536800
$hour.wind.vecdir127°
$hour.wind.vecavg29 kph
$hour.wind.max35 kph
$hour.wind.gustdir126°
$hour.wind.maxtime10:30
+ +

$month

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$month.start.raw1283324400
$month.end.raw1285916400
$month.length.raw2592000
$month.start01-Sep-2010 00:00
$month.end01-Oct-2010 00:00
$month.length2592000 Sekunden
$month.length.long_form("%(day)d %(day_label)s")30 Tage
$month.windSpeed.avg18 kph
$month.wind.avg18 kph
$month.wind.vecavg13 kph
$month.wind.vecdir061°
$month.windGust.max39 kph
$month.wind.max39 kph
$month.wind.gustdir090°
$month.windGust.maxtime03-Sep-2010 01:00
$month.wind.maxtime03-Sep-2010 01:00
$month.windSpeed.max32 kph
$month.windDir.avg168°
+ +

Iterate over three hours:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of hourMin temperatureWhen
07:008.1°C07:30
08:0010.6°C08:30
09:0013.4°C09:30
10:0016.3°C10:30
+ +
+ +

Tests for tag $span

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current dateTime:03-Sep-2010 11:00
Min Temperature in last hour (via $span($time_delta=3600)):16.3°C
Min Temperature in last hour (via $span($hour_delta=1)):16.3°C
Min Temperature in last 90 min (via $span($time_delta=5400)):14.9°C
Min Temperature in last 90 min (via $span($hour_delta=1.5)):14.9°C
Max Temperature in last 24 hours (via $span($time_delta=86400)):26.2°C
Max Temperature in last 24 hours (via $span($hour_delta=24)):26.2°C
Max Temperature in last 24 hours (via $span($day_delta=1)):26.2°C
Min Temperature in last 7 days (via $span($time_delta=604800)):3.8°C
Min Temperature in last 7 days (via $span($hour_delta=168)):3.8°C
Min Temperature in last 7 days (via $span($day_delta=7)):3.8°C
Min Temperature in last 7 days (via $span($week_delta=1)):3.8°C
Rainfall in last 24 hours (via $span($time_delta=86400)):32.5 mm
Rainfall in last 24 hours (via $span($hour_delta=24)):32.5 mm
Rainfall in last 24 hours (via $span($day_delta=1)):32.5 mm
Max Windchill in last hour (existing obs type that is None):17.7°C
+ +
+ +

Tests for tag $day

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of day:09/03/2010 00:00:00
Start of day (unix epoch time):1283497200
End of day (unix epoch time):1283583600
Max Temperature17.7°C
Min Temperature3.8°C
Time of max temperature:11:00
Time of min temperature:04:00
Last temperature of the day17.7°C
Time of the last temperature of the day09/03/2010 11:00:00
First temperature of the day8.2°C
Time of the first temperature of the day09/03/2010 00:30:00
Max Temperature in alt_binding-3.9°C
Max Temperature with bogus binding$day($data_binding='foo_binding').outTemp.max
Min temp with explicit conversion to Celsius3.8°C
Min temp with explicit conversion to Fahrenheit38.9°F
Min temp with explicit conversion to nonsense type$day.outTemp.min.badtype
Min temperature with inappropriate conversion: $day.outTemp.min.mbar$day.outTemp.min.mbar
Max value for a type with no data: $day.UV.max N/A
Sum aggregation (rain)19.3 mm
Aggregation of xtype: $day.humidex.avg8.8°C
Aggregation of xtype: $day.humidex.min3.8°C
Aggregation of xtype: $day.humidex.mintime04:00
Aggregation of xtype: $day.humidex.max21.4°C
Aggregation of xtype: $day.humidex.maxtime11:00
High Wind from "$day.wind.max"39 kph from 090° at 01:00
High Wind from "$day.windGust.max"39 kph
High Wind from "$day.windSpeed.max"32 kph
Average wind from "$day.wind.avg"31 kph
Average wind from "$day.windSpeed.avg"31 kph
Average wind w/alt_binding: "$day(data_binding='alt_binding').wind.avg"11 kph
RMS aggregation: "$day.wind.rms"31 kph
Vector average: "$day.wind.vecavg"30 kph
Aggregation Vector Direction (wind)107°
Test tag "has_data" with nonsense type $day.nonsense.has_dataFALSE
Test tag "exists" with an existing type that has no data $day.UV.existsTRUE
Test tag "has_data" with existent type that has no data $day.UV.has_dataFALSE
Test tag "has_data" with existent type that has data $day.outTemp.has_dataTRUE
Test tag "not_null" with existent type that has no data $day.UV.not_null0
Test tag "not_null" with existent type that has data $day.outTemp.not_null1
Test for a bad observation type on a $day tag: $day.foobar.min$day.foobar.min
Test for a bad aggregation type on a $day tag: $day.outTemp.foo$day.outTemp.foo
Test tag "has_data" for an xtype: $day.humidex.has_dataTRUE
Test tag "has_data" for another xtype: $month.heatdeg.has_dataTRUE
Test for sunshineDur: $day.sunshineDur.sum25200 Sekunden
Test for sunshineDur: $day.sunshineDur.sum.long_form7 Stunden, 0 Minuten, 0 Sekunden
Test for sunshineDur, custom format:7 Stunden, 0 Minuten
sunshineDur in hours:7.00 Stunden
$day.stringData.first.rawS1283499000
$day.stringData.last.rawS1283536800
$day.stringData.first.format("%s")S1283499000
+ +
+ +

Tests for tag $yesterday

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of yesterday:09/02/2010 00:00:00
Start of yesterday (unix epoch time):1283410800
Max Temperature yesterday26.2°C
Min Temperature yesterday4.2°C
Time of max temperature yesterday:16:00
Time of min temperature yesterday:04:00
Yesterday's last temperature9.5°C
Time of yesterday's last temperature09/03/2010 00:00:00
+ +

Tests for tag $rainyear

+ + + + + +
Rainyear total2015.7 mm
+ +

Test for tag $alltime

+ + + + + + + + + +
Max temp from $alltime.outTemp.max37.8°C
+ at 02-Jul-2010 16:00
High Wind from "$alltime.wind.max" + 39 kph
+ from 090°
+ at 02-Jan-2010 00:00 +
+ +

Test for tag $seven_day

+ + + + + + + + + +
Max temp from $seven_day.outTemp.max28.2°C
+ at 27-Aug-2010 16:00
High Wind from "$seven_day.wind.max" + 39 kph
+ from 090°
+ at 30-Aug-2010 01:00 +
+ +

Test for various versions of $colorize

+ + + + + + + + + + + + + +
Current temperature using $colorize_117.7°C
Current temperature using $colorize_217.7°C
Current temperature using $colorize_317.7°C
+

Tests at timestamp 1280692800, when the temperature is null:

+ + + + + + + + + + + + + +
Null temperature using $colorize_1 N/A
Null temperature using $colorize_2 N/A
Null temperature using $colorize_3 N/A
+ +
+

Tests for tag $Extras

+ + + + + +
Radar URL"http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif"
+ +
+ +

Tests for tag $almanac

+ + + + + + + + + + + + + + + + + + + + + +
Sunrise:06:29
Sunset:19:40
Moon:abnehmend (29%full)
$almanac.sun.visible:13 Stunden, 10 Minuten, 42 Sekunden
(47442 Sekunden)
$almanac.sun.visible_change:3 Minuten, 6 Sekunden (-186 Sekunden)
+ +
+ +

Test for tag $unit

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tag "$unit.unit_type.outTemp"degree_C
Tag "$unit.label.outTemp"°C
Tag "$unit.format.outTemp"%.1f
Example from customizing guide
+ ("$day.outTemp.max.format(add_label=False)$unit.label.outTemp")
17.7°C
Test the above with backwards compatibility
+ ("$day.outTemp.max.formatted$unit.label.outTemp")
17.7°C
Add a new unit type, existing groupdegree_C
Check its label°C
Check its format%.1f
Add a new unit type, new groupamp
Check its label A
Check its format%.1f
+ +

Test for tag $obs

+ + + + + +
Tag "$obs.label.outTemp"Außentemperatur
+ +
+ +

Test for formatting examples in the Customizing Guide

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$current.outTemp17.7°C
$current.outTemp.format17.7°C
$current.outTemp.format()17.7°C
$current.outTemp.format(format_string="%.3f")17.734°C
$current.outTemp.format("%.3f")17.734°C
$current.outTemp.format(add_label=False)17.7
$current.UV N/A
$current.UV.format(None_string="No UV")No UV
$current.windDir128°
$current.windDir.ordinal_compassSO
$current.dateTime03-Sep-2010 11:00
$current.dateTime.format(format_string="%H:%M")11:00
$current.dateTime.format("%H:%M")11:00
$current.dateTime.raw1283536800
$current.outTemp.raw17.73414
+ +

Test for aggregates

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$month.outTemp.maxmin4.5°C
$month.outTemp.maxmintime01-Sep-2010 04:00
$month.outTemp.minmax17.7°C
$month.outTemp.minmaxtime03-Sep-2010 11:00
$month.outTemp.minsum527.6°C
$month.outTemp.minsumtime03-Sep-2010 00:00
$month.outTemp.maxsumtime02-Sep-2010 00:00
$month.heatdeg.sum16.3°C-day
$month.cooldeg.sum0.0°C-day
$month.growdeg.sum10.5°C-day
$year.rain.sum_le((0.1, 'inch', 'group_rain'))122
$year.outTemp.avg_ge((41, 'degree_F', 'group_temperature'))153
$year.outTemp.avg_le((32, 'degree_F', 'group_temperature'))80
+ +
+

Tests for $gettext

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$langde
$pagetest
$gettext("Foo")Foo
$gettext("Today")Heute
$pgettext("test","Plots")test
$pgettext($page,"Plots")Diagramme
$pgettext($page,"Something")Something
$pgettext("Foo","Plots")Plots
$pgettext("Foo","Bar")Bar
+
+ + + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/series.html b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/series.html new file mode 100644 index 0000000..1857a09 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/metric/series.html @@ -0,0 +1,233 @@ + + + + + Test the ".series" tag + + + + +

Unaggregated series

+

Unaggregated series in json: $day.outTemp.series.round(5).json():

+
[[1283497200, 1283499000, 8.24191], [1283499000, 1283500800, 7.14217], [1283500800, 1283502600, 6.17686], [1283502600, 1283504400, null], [1283504400, 1283506200, 4.71254], [1283506200, 1283508000, 4.23834], [1283508000, 1283509800, 3.94778], [1283509800, 1283511600, 3.8457], [1283511600, 1283513400, 3.93373], [1283513400, 1283515200, 4.21025], [1283515200, 1283517000, 4.67041], [1283517000, 1283518800, 5.30621], [1283518800, 1283520600, 6.10665], [1283520600, 1283522400, 7.05791], [1283522400, 1283524200, 8.14361], [1283524200, 1283526000, 9.34504], [1283526000, 1283527800, 10.64152], [1283527800, 1283529600, 12.01076], [1283529600, 1283531400, 13.4292], [1283531400, 1283533200, 14.87245], [1283533200, 1283535000, 16.31571], [1283535000, 1283536800, 17.73414]]
+ +

Unaggregated series, in json with just start times: $day.outTemp.series(time_series='start').round(5).json:

+
[[1283497200, 8.24191], [1283499000, 7.14217], [1283500800, 6.17686], [1283502600, null], [1283504400, 4.71254], [1283506200, 4.23834], [1283508000, 3.94778], [1283509800, 3.8457], [1283511600, 3.93373], [1283513400, 4.21025], [1283515200, 4.67041], [1283517000, 5.30621], [1283518800, 6.10665], [1283520600, 7.05791], [1283522400, 8.14361], [1283524200, 9.34504], [1283526000, 10.64152], [1283527800, 12.01076], [1283529600, 13.4292], [1283531400, 14.87245], [1283533200, 16.31571], [1283535000, 17.73414]]
+ +

Unaggregated series, in json with start times in milliseconds: $day.outTemp.series(time_series='start', time_unit='unix_epoch_ms').round(5).json:

+
[[1283497200000, 8.24191], [1283499000000, 7.14217], [1283500800000, 6.17686], [1283502600000, null], [1283504400000, 4.71254], [1283506200000, 4.23834], [1283508000000, 3.94778], [1283509800000, 3.8457], [1283511600000, 3.93373], [1283513400000, 4.21025], [1283515200000, 4.67041], [1283517000000, 5.30621], [1283518800000, 6.10665], [1283520600000, 7.05791], [1283522400000, 8.14361], [1283524200000, 9.34504], [1283526000000, 10.64152], [1283527800000, 12.01076], [1283529600000, 13.4292], [1283531400000, 14.87245], [1283533200000, 16.31571], [1283535000000, 17.73414]]
+ +

Unaggregated series, in json, in degrees C, rounded to 5 decimal places: $day.outTemp.series.degree_C.round(5).json

+
[[1283497200, 1283499000, 8.24191], [1283499000, 1283500800, 7.14217], [1283500800, 1283502600, 6.17686], [1283502600, 1283504400, null], [1283504400, 1283506200, 4.71254], [1283506200, 1283508000, 4.23834], [1283508000, 1283509800, 3.94778], [1283509800, 1283511600, 3.8457], [1283511600, 1283513400, 3.93373], [1283513400, 1283515200, 4.21025], [1283515200, 1283517000, 4.67041], [1283517000, 1283518800, 5.30621], [1283518800, 1283520600, 6.10665], [1283520600, 1283522400, 7.05791], [1283522400, 1283524200, 8.14361], [1283524200, 1283526000, 9.34504], [1283526000, 1283527800, 10.64152], [1283527800, 1283529600, 12.01076], [1283529600, 1283531400, 13.4292], [1283531400, 1283533200, 14.87245], [1283533200, 1283535000, 16.31571], [1283535000, 1283536800, 17.73414]]
+ +

Unaggregated series, as a formatted string (not JSON) : $day.outTemp.series:

+
00:00, 00:30, 8.2°C
+00:30, 01:00, 7.1°C
+01:00, 01:30, 6.2°C
+01:30, 02:00,    N/A
+02:00, 02:30, 4.7°C
+02:30, 03:00, 4.2°C
+03:00, 03:30, 3.9°C
+03:30, 04:00, 3.8°C
+04:00, 04:30, 3.9°C
+04:30, 05:00, 4.2°C
+05:00, 05:30, 4.7°C
+05:30, 06:00, 5.3°C
+06:00, 06:30, 6.1°C
+06:30, 07:00, 7.1°C
+07:00, 07:30, 8.1°C
+07:30, 08:00, 9.3°C
+08:00, 08:30, 10.6°C
+08:30, 09:00, 12.0°C
+09:00, 09:30, 13.4°C
+09:30, 10:00, 14.9°C
+10:00, 10:30, 16.3°C
+10:30, 11:00, 17.7°C
+ +

Unaggregated series, start time only, as a formatted string (not JSON) : $day.outTemp.series(time_series='start'):

+
00:00, 8.2°C
+00:30, 7.1°C
+01:00, 6.2°C
+01:30,    N/A
+02:00, 4.7°C
+02:30, 4.2°C
+03:00, 3.9°C
+03:30, 3.8°C
+04:00, 3.9°C
+04:30, 4.2°C
+05:00, 4.7°C
+05:30, 5.3°C
+06:00, 6.1°C
+06:30, 7.1°C
+07:00, 8.1°C
+07:30, 9.3°C
+08:00, 10.6°C
+08:30, 12.0°C
+09:00, 13.4°C
+09:30, 14.9°C
+10:00, 16.3°C
+10:30, 17.7°C
+ +

Unaggregated series, stop time only, as a formatted string (not JSON) : $day.outTemp.series(time_series='stop'):

+
00:30, 8.2°C
+01:00, 7.1°C
+01:30, 6.2°C
+02:00,    N/A
+02:30, 4.7°C
+03:00, 4.2°C
+03:30, 3.9°C
+04:00, 3.8°C
+04:30, 3.9°C
+05:00, 4.2°C
+05:30, 4.7°C
+06:00, 5.3°C
+06:30, 6.1°C
+07:00, 7.1°C
+07:30, 8.1°C
+08:00, 9.3°C
+08:30, 10.6°C
+09:00, 12.0°C
+09:30, 13.4°C
+10:00, 14.9°C
+10:30, 16.3°C
+11:00, 17.7°C
+ +

Unaggregated series, by column, as a formatted string (not JSON) $day.outTemp.series.format(order_by='column'):

+
00:00, 00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30
+00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30, 11:00
+8.2°C, 7.1°C, 6.2°C,    N/A, 4.7°C, 4.2°C, 3.9°C, 3.8°C, 3.9°C, 4.2°C, 4.7°C, 5.3°C, 6.1°C, 7.1°C, 8.1°C, 9.3°C, 10.6°C, 12.0°C, 13.4°C, 14.9°C, 16.3°C, 17.7°C
+ +

Unaggregated series, by column, start times only, as a formatted string (not JSON) $day.outTemp.series(time_series='start').format(order_by='column'):

+
00:00, 00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30
+8.2°C, 7.1°C, 6.2°C,    N/A, 4.7°C, 4.2°C, 3.9°C, 3.8°C, 3.9°C, 4.2°C, 4.7°C, 5.3°C, 6.1°C, 7.1°C, 8.1°C, 9.3°C, 10.6°C, 12.0°C, 13.4°C, 14.9°C, 16.3°C, 17.7°C
+ +

Unaggregated series, by column, stop times only, as a formatted string (not JSON) $day.outTemp.series(time_series='stop').format(order_by='column'):

+
00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30, 11:00
+8.2°C, 7.1°C, 6.2°C,    N/A, 4.7°C, 4.2°C, 3.9°C, 3.8°C, 3.9°C, 4.2°C, 4.7°C, 5.3°C, 6.1°C, 7.1°C, 8.1°C, 9.3°C, 10.6°C, 12.0°C, 13.4°C, 14.9°C, 16.3°C, 17.7°C
+ +
+ +

Aggregated series

+

Aggregated series: $month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json():

+
[[1283324400, 1283410800, 26.5699], [1283410800, 1283497200, 26.23604], [1283497200, 1283583600, 17.73414]]
+ +

Using shortcut 'day': $month.outTemp.series(aggregate_type='max', aggregate_interval='day').round(5).json():

+
[[1283324400, 1283410800, 26.5699], [1283410800, 1283497200, 26.23604], [1283497200, 1283583600, 17.73414]]
+ +

Order by column: $month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json(order_by="column"):

+
[[1283324400, 1283410800, 1283497200], [1283410800, 1283497200, 1283583600], [26.5699, 26.23604, 17.73414]]
+ +

Aggregated series, using $jsonize(): + $jsonize($zip($min.start.unix_epoch_ms.raw, $min.data.degree_C.round(2).raw, $max.data.degree_C.round(2).raw)) +

+    [[1283324400000, 4.51, 26.57], [1283410800000, 4.18, 26.24], [1283497200000, 3.85, 17.73]]
+    
+ + +
+ +

Aggregated series of wind vectors

+ +

Aggregated wind series (not JSON)), complex notation + $month.windvec.series(aggregate_type='max', aggregate_interval='day')

+
01-Sep-2010 00:00, 02-Sep-2010 00:00, (-1, 15) kph
+02-Sep-2010 00:00, 03-Sep-2010 00:00, (32, 2) kph
+03-Sep-2010 00:00, 04-Sep-2010 00:00, (32, 0) kph
+ +

Aggregated wind series (not JSON)), complex notation with formatting + $month.windvec.series(aggregate_type='max', aggregate_interval='day').format("%.5f")

+
01-Sep-2010 00:00, 02-Sep-2010 00:00, (-0.98372, 15.00868) kph
+02-Sep-2010 00:00, 03-Sep-2010 00:00, (32.08358, 2.10287) kph
+03-Sep-2010 00:00, 04-Sep-2010 00:00, (32.18688, 0.00000) kph
+ +

Aggregated wind series (not JSON), polar notation + $month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.format("%.5f")

+
01-Sep-2010 00:00, 02-Sep-2010 00:00, (15.04088 kph, 356°)
+02-Sep-2010 00:00, 03-Sep-2010 00:00, (32.15242 kph, 086°)
+03-Sep-2010 00:00, 04-Sep-2010 00:00, (32.18688 kph, 090°)
+ +
+ +

Aggregated series of wind vectors with conversions

+ +

Starting series: $month.windvec.series(aggregate_type='max', aggregate_interval='day').json()

+
+    [[1283324400, 1283410800, [-0.9837205617579349, 15.0086754575505]], [1283410800, 1283497200, [32.08358153133061, 2.1028690330784316]], [1283497200, 1283583600, [32.18688, 0.0]]]
+    
+ +

X-component: $month.windvec.series(aggregate_type='max', aggregate_interval='day').x.json()

+
+    [[1283324400, 1283410800, -0.9837205617579349], [1283410800, 1283497200, 32.08358153133061], [1283497200, 1283583600, 32.18688]]
+    
+ +

x-component, in knots: $month.windvec.series(aggregate_type='max', aggregate_interval='day').x.knot.json()

+
+    [[1283324400, 1283410800, -0.5311666095721786], [1283410800, 1283497200, 17.32374811244712], [1283497200, 1283583600, 17.379524823344642]]
+    
+ +

Y-component: $month.windvec.series(aggregate_type='max', aggregate_interval='day').y.json()

+
+    [[1283324400, 1283410800, 15.0086754575505], [1283410800, 1283497200, 2.1028690330784316], [1283497200, 1283583600, 0.0]]
+    
+ +

magnitude: $month.windvec.series(aggregate_type='max', aggregate_interval='day').magnitude.json()

+
+    [[1283324400, 1283410800, 15.040879134336], [1283410800, 1283497200, 32.152422335616], [1283497200, 1283583600, 32.18688]]
+    
+ +

direction: $month.windvec.series(aggregate_type='max', aggregate_interval='day').direction.json()

+
+    [[1283324400, 1283410800, 356.25], [1283410800, 1283497200, 86.25], [1283497200, 1283583600, 90.0]]
+    
+ +

polar: $month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.json()

+
+    [[1283324400, 1283410800, [15.040879134336, 356.25]], [1283410800, 1283497200, [32.152422335616, 86.25]], [1283497200, 1283583600, [32.18688, 90.0]]]
+    
+ +

polar in knots: $month.windvec.series(aggregate_type='max', aggregate_interval='day').knot.polar.round(5).json()

+
+    [[1283324400, 1283410800, [8.12143, 356.25]], [1283410800, 1283497200, [17.36092, 86.25]], [1283497200, 1283583600, [17.37952, 90.0]]]
+    
+ +
+

Iterate over an aggregated series

+
+    #for ($start, $stop, $data) in $month.outTemp.series(aggregate_type='max', aggregate_interval='day')
+      ...
+    #end for
+  
+ + + + + + + + + + + + + + + + + + + + + + +
Start dateStop dateMax temperature
2010-09-012010-09-0226.57°C
2010-09-022010-09-0326.24°C
2010-09-032010-09-0417.73°C
+ + + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/series.html b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/series.html new file mode 100644 index 0000000..5637114 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/expected/StandardTest/series.html @@ -0,0 +1,233 @@ + + + + + Test the ".series" tag + + + + +

Unaggregated series

+

Unaggregated series in json: $day.outTemp.series.round(5).json():

+
[[1283497200, 1283499000, 46.83544], [1283499000, 1283500800, 44.85591], [1283500800, 1283502600, 43.11835], [1283502600, 1283504400, null], [1283504400, 1283506200, 40.48257], [1283506200, 1283508000, 39.62901], [1283508000, 1283509800, 39.106], [1283509800, 1283511600, 38.92225], [1283511600, 1283513400, 39.08072], [1283513400, 1283515200, 39.57846], [1283515200, 1283517000, 40.40674], [1283517000, 1283518800, 41.55117], [1283518800, 1283520600, 42.99197], [1283520600, 1283522400, 44.70425], [1283522400, 1283524200, 46.6585], [1283524200, 1283526000, 48.82107], [1283526000, 1283527800, 51.15474], [1283527800, 1283529600, 53.61937], [1283529600, 1283531400, 56.17256], [1283531400, 1283533200, 58.77042], [1283533200, 1283535000, 61.36827], [1283535000, 1283536800, 63.92146]]
+ +

Unaggregated series, in json with just start times: $day.outTemp.series(time_series='start').round(5).json:

+
[[1283497200, 46.83544], [1283499000, 44.85591], [1283500800, 43.11835], [1283502600, null], [1283504400, 40.48257], [1283506200, 39.62901], [1283508000, 39.106], [1283509800, 38.92225], [1283511600, 39.08072], [1283513400, 39.57846], [1283515200, 40.40674], [1283517000, 41.55117], [1283518800, 42.99197], [1283520600, 44.70425], [1283522400, 46.6585], [1283524200, 48.82107], [1283526000, 51.15474], [1283527800, 53.61937], [1283529600, 56.17256], [1283531400, 58.77042], [1283533200, 61.36827], [1283535000, 63.92146]]
+ +

Unaggregated series, in json with start times in milliseconds: $day.outTemp.series(time_series='start', time_unit='unix_epoch_ms').round(5).json:

+
[[1283497200000, 46.83544], [1283499000000, 44.85591], [1283500800000, 43.11835], [1283502600000, null], [1283504400000, 40.48257], [1283506200000, 39.62901], [1283508000000, 39.106], [1283509800000, 38.92225], [1283511600000, 39.08072], [1283513400000, 39.57846], [1283515200000, 40.40674], [1283517000000, 41.55117], [1283518800000, 42.99197], [1283520600000, 44.70425], [1283522400000, 46.6585], [1283524200000, 48.82107], [1283526000000, 51.15474], [1283527800000, 53.61937], [1283529600000, 56.17256], [1283531400000, 58.77042], [1283533200000, 61.36827], [1283535000000, 63.92146]]
+ +

Unaggregated series, in json, in degrees C, rounded to 5 decimal places: $day.outTemp.series.degree_C.round(5).json

+
[[1283497200, 1283499000, 8.24191], [1283499000, 1283500800, 7.14217], [1283500800, 1283502600, 6.17686], [1283502600, 1283504400, null], [1283504400, 1283506200, 4.71254], [1283506200, 1283508000, 4.23834], [1283508000, 1283509800, 3.94778], [1283509800, 1283511600, 3.8457], [1283511600, 1283513400, 3.93373], [1283513400, 1283515200, 4.21025], [1283515200, 1283517000, 4.67041], [1283517000, 1283518800, 5.30621], [1283518800, 1283520600, 6.10665], [1283520600, 1283522400, 7.05791], [1283522400, 1283524200, 8.14361], [1283524200, 1283526000, 9.34504], [1283526000, 1283527800, 10.64152], [1283527800, 1283529600, 12.01076], [1283529600, 1283531400, 13.4292], [1283531400, 1283533200, 14.87245], [1283533200, 1283535000, 16.31571], [1283535000, 1283536800, 17.73414]]
+ +

Unaggregated series, as a formatted string (not JSON) : $day.outTemp.series:

+
00:00, 00:30, 46.8°F
+00:30, 01:00, 44.9°F
+01:00, 01:30, 43.1°F
+01:30, 02:00,    N/A
+02:00, 02:30, 40.5°F
+02:30, 03:00, 39.6°F
+03:00, 03:30, 39.1°F
+03:30, 04:00, 38.9°F
+04:00, 04:30, 39.1°F
+04:30, 05:00, 39.6°F
+05:00, 05:30, 40.4°F
+05:30, 06:00, 41.6°F
+06:00, 06:30, 43.0°F
+06:30, 07:00, 44.7°F
+07:00, 07:30, 46.7°F
+07:30, 08:00, 48.8°F
+08:00, 08:30, 51.2°F
+08:30, 09:00, 53.6°F
+09:00, 09:30, 56.2°F
+09:30, 10:00, 58.8°F
+10:00, 10:30, 61.4°F
+10:30, 11:00, 63.9°F
+ +

Unaggregated series, start time only, as a formatted string (not JSON) : $day.outTemp.series(time_series='start'):

+
00:00, 46.8°F
+00:30, 44.9°F
+01:00, 43.1°F
+01:30,    N/A
+02:00, 40.5°F
+02:30, 39.6°F
+03:00, 39.1°F
+03:30, 38.9°F
+04:00, 39.1°F
+04:30, 39.6°F
+05:00, 40.4°F
+05:30, 41.6°F
+06:00, 43.0°F
+06:30, 44.7°F
+07:00, 46.7°F
+07:30, 48.8°F
+08:00, 51.2°F
+08:30, 53.6°F
+09:00, 56.2°F
+09:30, 58.8°F
+10:00, 61.4°F
+10:30, 63.9°F
+ +

Unaggregated series, stop time only, as a formatted string (not JSON) : $day.outTemp.series(time_series='stop'):

+
00:30, 46.8°F
+01:00, 44.9°F
+01:30, 43.1°F
+02:00,    N/A
+02:30, 40.5°F
+03:00, 39.6°F
+03:30, 39.1°F
+04:00, 38.9°F
+04:30, 39.1°F
+05:00, 39.6°F
+05:30, 40.4°F
+06:00, 41.6°F
+06:30, 43.0°F
+07:00, 44.7°F
+07:30, 46.7°F
+08:00, 48.8°F
+08:30, 51.2°F
+09:00, 53.6°F
+09:30, 56.2°F
+10:00, 58.8°F
+10:30, 61.4°F
+11:00, 63.9°F
+ +

Unaggregated series, by column, as a formatted string (not JSON) $day.outTemp.series.format(order_by='column'):

+
00:00, 00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30
+00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30, 11:00
+46.8°F, 44.9°F, 43.1°F,    N/A, 40.5°F, 39.6°F, 39.1°F, 38.9°F, 39.1°F, 39.6°F, 40.4°F, 41.6°F, 43.0°F, 44.7°F, 46.7°F, 48.8°F, 51.2°F, 53.6°F, 56.2°F, 58.8°F, 61.4°F, 63.9°F
+ +

Unaggregated series, by column, start times only, as a formatted string (not JSON) $day.outTemp.series(time_series='start').format(order_by='column'):

+
00:00, 00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30
+46.8°F, 44.9°F, 43.1°F,    N/A, 40.5°F, 39.6°F, 39.1°F, 38.9°F, 39.1°F, 39.6°F, 40.4°F, 41.6°F, 43.0°F, 44.7°F, 46.7°F, 48.8°F, 51.2°F, 53.6°F, 56.2°F, 58.8°F, 61.4°F, 63.9°F
+ +

Unaggregated series, by column, stop times only, as a formatted string (not JSON) $day.outTemp.series(time_series='stop').format(order_by='column'):

+
00:30, 01:00, 01:30, 02:00, 02:30, 03:00, 03:30, 04:00, 04:30, 05:00, 05:30, 06:00, 06:30, 07:00, 07:30, 08:00, 08:30, 09:00, 09:30, 10:00, 10:30, 11:00
+46.8°F, 44.9°F, 43.1°F,    N/A, 40.5°F, 39.6°F, 39.1°F, 38.9°F, 39.1°F, 39.6°F, 40.4°F, 41.6°F, 43.0°F, 44.7°F, 46.7°F, 48.8°F, 51.2°F, 53.6°F, 56.2°F, 58.8°F, 61.4°F, 63.9°F
+ +
+ +

Aggregated series

+

Aggregated series: $month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json():

+
[[1283324400, 1283410800, 79.82582], [1283410800, 1283497200, 79.22488], [1283497200, 1283583600, 63.92146]]
+ +

Using shortcut 'day': $month.outTemp.series(aggregate_type='max', aggregate_interval='day').round(5).json():

+
[[1283324400, 1283410800, 79.82582], [1283410800, 1283497200, 79.22488], [1283497200, 1283583600, 63.92146]]
+ +

Order by column: $month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json(order_by="column"):

+
[[1283324400, 1283410800, 1283497200], [1283410800, 1283497200, 1283583600], [79.82582, 79.22488, 63.92146]]
+ +

Aggregated series, using $jsonize(): + $jsonize($zip($min.start.unix_epoch_ms.raw, $min.data.degree_C.round(2).raw, $max.data.degree_C.round(2).raw)) +

+    [[1283324400000, 4.51, 26.57], [1283410800000, 4.18, 26.24], [1283497200000, 3.85, 17.73]]
+    
+ + +
+ +

Aggregated series of wind vectors

+ +

Aggregated wind series (not JSON)), complex notation + $month.windvec.series(aggregate_type='max', aggregate_interval='day')

+
01-Sep-2010 00:00, 02-Sep-2010 00:00, (-1, 9) mph
+02-Sep-2010 00:00, 03-Sep-2010 00:00, (20, 1) mph
+03-Sep-2010 00:00, 04-Sep-2010 00:00, (20, 0) mph
+ +

Aggregated wind series (not JSON)), complex notation with formatting + $month.windvec.series(aggregate_type='max', aggregate_interval='day').format("%.5f")

+
01-Sep-2010 00:00, 02-Sep-2010 00:00, (-0.61126, 9.32596) mph
+02-Sep-2010 00:00, 03-Sep-2010 00:00, (19.93581, 1.30666) mph
+03-Sep-2010 00:00, 04-Sep-2010 00:00, (20.00000, 0.00000) mph
+ +

Aggregated wind series (not JSON), polar notation + $month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.format("%.5f")

+
01-Sep-2010 00:00, 02-Sep-2010 00:00, (9.34597 mph, 356°)
+02-Sep-2010 00:00, 03-Sep-2010 00:00, (19.97859 mph, 086°)
+03-Sep-2010 00:00, 04-Sep-2010 00:00, (20.00000 mph, 090°)
+ +
+ +

Aggregated series of wind vectors with conversions

+ +

Starting series: $month.windvec.series(aggregate_type='max', aggregate_interval='day').json()

+
+    [[1283324400, 1283410800, [-0.6112556182879079, 9.325958562961368]], [1283410800, 1283497200, [19.935813307366608, 1.3066622382029147]], [1283497200, 1283583600, [20.0, 0.0]]]
+    
+ +

X-component: $month.windvec.series(aggregate_type='max', aggregate_interval='day').x.json()

+
+    [[1283324400, 1283410800, -0.6112556182879079], [1283410800, 1283497200, 19.935813307366608], [1283497200, 1283583600, 20.0]]
+    
+ +

x-component, in knots: $month.windvec.series(aggregate_type='max', aggregate_interval='day').x.knot.json()

+
+    [[1283324400, 1283410800, -0.5311666100812127], [1283410800, 1283497200, 17.323748129049026], [1283497200, 1283583600, 17.379524840000002]]
+    
+ +

Y-component: $month.windvec.series(aggregate_type='max', aggregate_interval='day').y.json()

+
+    [[1283324400, 1283410800, 9.325958562961368], [1283410800, 1283497200, 1.3066622382029147], [1283497200, 1283583600, 0.0]]
+    
+ +

magnitude: $month.windvec.series(aggregate_type='max', aggregate_interval='day').magnitude.json()

+
+    [[1283324400, 1283410800, 9.345969], [1283410800, 1283497200, 19.978589], [1283497200, 1283583600, 20.0]]
+    
+ +

direction: $month.windvec.series(aggregate_type='max', aggregate_interval='day').direction.json()

+
+    [[1283324400, 1283410800, 356.25], [1283410800, 1283497200, 86.25], [1283497200, 1283583600, 90.0]]
+    
+ +

polar: $month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.json()

+
+    [[1283324400, 1283410800, [9.345969, 356.25]], [1283410800, 1283497200, [19.978589, 86.25]], [1283497200, 1283583600, [20.0, 90.0]]]
+    
+ +

polar in knots: $month.windvec.series(aggregate_type='max', aggregate_interval='day').knot.polar.round(5).json()

+
+    [[1283324400, 1283410800, [8.12143, 356.25]], [1283410800, 1283497200, [17.36092, 86.25]], [1283497200, 1283583600, [17.37952, 90.0]]]
+    
+ +
+

Iterate over an aggregated series

+
+    #for ($start, $stop, $data) in $month.outTemp.series(aggregate_type='max', aggregate_interval='day')
+      ...
+    #end for
+  
+ + + + + + + + + + + + + + + + + + + + + + +
Start dateStop dateMax temperature
2010-09-012010-09-0279.83°F
2010-09-022010-09-0379.22°F
2010-09-032010-09-0463.92°F
+ + + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/gen_fake_data.py b/dist/weewx-5.0.2/src/weewx/tests/gen_fake_data.py new file mode 100644 index 0000000..ea7887c --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/gen_fake_data.py @@ -0,0 +1,240 @@ +# +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Generate fake data used by the tests. + +The idea is to create a deterministic database that reports +can be run against, resulting in predictable, expected results""" + +import logging +import math +import os +import time + +import weecfg.database +import weedb +import weewx.manager + +log = logging.getLogger(__name__) + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +# The start of the 'solar year' for 2009-2010 +year_start_tt = (2009, 12, 21, 9, 47, 0, 0, 0, 0) +year_start = int(time.mktime(year_start_tt)) + +# Roughly nine months of data: +start_tt = (2010, 1, 1, 0, 0, 0, 0, 0, -1) # 2010-01-01 00:00 +stop_tt = (2010, 9, 3, 11, 0, 0, 0, 0, -1) # 2010-09-03 11:00 +alt_start_tt = (2010, 8, 30, 0, 0, 0, 0, 0, -1) +start_ts = int(time.mktime(start_tt)) +stop_ts = int(time.mktime(stop_tt)) +alt_start = int(time.mktime(alt_start_tt)) + +# At one half-hour archive intervals: +interval = 1800 + +altitude_vt = (700, 'foot', 'group_altitude') +latitude = 45 +longitude = -125 + +daily_temp_range = 40.0 +annual_temp_range = 80.0 +avg_temp = 40.0 + +# Four day weather cycle: +weather_cycle = 3600 * 24.0 * 4 +weather_baro_range = 2.0 +weather_wind_range = 10.0 +weather_rain_total = 0.5 # This is inches per weather cycle +avg_baro = 30.0 + + +def make_nulls_filter(record): + """Values of inTemp for the first half of the database will have nulls, + the second half will not.""" + if record['dateTime'] > (start_ts + stop_ts)/2: + record['inTemp'] = 68.0 + return record + + +def configDatabases(config_dict, database_type, record_filter=make_nulls_filter): + config_dict['DataBindings']['wx_binding']['database'] = "archive_" + database_type + configDatabase(config_dict, 'wx_binding', record_filter=record_filter) + config_dict['DataBindings']['alt_binding']['database'] = "alt_" + database_type + configDatabase(config_dict, 'alt_binding', start_ts=alt_start, amplitude=0.5, record_filter=record_filter) + + +def configDatabase(config_dict, binding, start_ts=start_ts, stop_ts=stop_ts, interval=interval, amplitude=1.0, + day_phase_offset=math.pi / 4.0, annual_phase_offset=0.0, + weather_phase_offset=0.0, year_start=start_ts, record_filter=lambda r: r): + """Configures the archive databases.""" + + # Check to see if it already exists and is configured correctly. + try: + with weewx.manager.open_manager_with_config(config_dict, binding) as manager: + if manager.firstGoodStamp() == start_ts and manager.lastGoodStamp() == stop_ts: + # Before weewx V4, the test database had interval in seconds. + # Check that this one has been corrected. + last_record = manager.getRecord(stop_ts) + if last_record['interval'] == interval / 60: + # Database exists, and it has the right value for interval. We're done. + return + else: + log.info("Interval value is wrong. Rebuilding test databases.") + + except weedb.DatabaseError: + pass + + # Delete anything that might already be there. + try: + log.info("Dropping database %s" % config_dict['DataBindings'][binding]['database']) + weewx.manager.drop_database_with_config(config_dict, binding) + except weedb.DatabaseError: + pass + + # Need to build a new synthetic database. General strategy is to create the + # archive data, THEN backfill with the daily summaries. This is faster than + # creating the daily summaries on the fly. + # First, we need to modify the configuration dictionary that was passed in + # so it uses the DBManager, instead of the daily summary manager + monkey_dict = config_dict.dict() + monkey_dict['DataBindings'][binding]['manager'] = 'weewx.manager.Manager' + + with weewx.manager.open_manager_with_config(monkey_dict, binding, initialize=True) as archive: + + log.info("Creating synthetic database %s" % config_dict['DataBindings'][binding]['database']) + + # Because this can generate voluminous log information, + # suppress all but the essentials: + logging.disable(logging.INFO) + + # Now generate and add the fake records to populate the database: + t1 = time.time() + archive.addRecord( + filter(record_filter, + genFakeRecords(start_ts=start_ts, stop_ts=stop_ts, + interval=interval, + amplitude=amplitude, + day_phase_offset=day_phase_offset, + annual_phase_offset=annual_phase_offset, + weather_phase_offset=weather_phase_offset, + year_start=start_ts, + db_manager=archive) + ) + ) + t2 = time.time() + delta = t2 - t1 + print("\nTime to create synthetic database '%s' = %6.2fs" + % (config_dict['DataBindings'][binding]['database'], delta)) + + # Restore the logging + logging.disable(logging.NOTSET) + + with weewx.manager.open_manager_with_config(config_dict, binding, initialize=True) as archive: + + # Backfill with daily summaries: + t1 = time.time() + nrecs, ndays = archive.backfill_day_summary() + tdiff = time.time() - t1 + if nrecs: + print("\nProcessed %d records to backfill %d day summaries in database '%s' in %.2f seconds" + % (nrecs, ndays, config_dict['DataBindings'][binding]['database'], tdiff)) + else: + print("Daily summaries in database '%s' up to date." + % config_dict['DataBindings'][binding]['database']) + + t1 = time.time() + patch_database(config_dict, binding) + tdiff = time.time() - t1 + print("\nTime to patch database with derived types: %.2f seconds" % tdiff) + + +def genFakeRecords(start_ts=start_ts, stop_ts=stop_ts, interval=interval, + amplitude=1.0, day_phase_offset=0.0, annual_phase_offset=0.0, + weather_phase_offset=0.0, year_start=start_ts, db_manager=None): + """Generate records from start_ts to stop_ts, inclusive. + start_ts: Starting timestamp in unix epoch time. This timestamp will be included in the results. + + stop_ts: Stopping timestamp in unix epoch time. This timestamp will be included in the results. + + interval: The interval between timestamps IN SECONDS! + """ + count = 0 + + for ts in range(start_ts, stop_ts + interval, interval): + daily_phase = ((ts - year_start) * 2.0 * math.pi) / (3600 * 24.0) + day_phase_offset + annual_phase = ((ts - year_start) * 2.0 * math.pi) / (3600 * 24.0 * 365.0) + annual_phase_offset + weather_phase = ((ts - year_start) * 2.0 * math.pi) / weather_cycle + weather_phase_offset + record = { + 'dateTime': ts, + 'usUnits': weewx.US, + 'interval': int(interval / 60), + 'outTemp': 0.5 * amplitude * (-daily_temp_range * math.sin(daily_phase) + - annual_temp_range * math.cos(annual_phase)) + avg_temp, + 'barometer': -0.5 * amplitude * weather_baro_range * math.sin(weather_phase) + avg_baro, + 'windSpeed': abs(amplitude * weather_wind_range * (1.0 + math.sin(weather_phase))), + 'windDir': math.degrees(weather_phase) % 360.0, + 'outHumidity': 40 * math.sin(weather_phase) + 50, + 'stringData' : "S%d" % ts, + } + record['windGust'] = 1.2 * record['windSpeed'] + record['windGustDir'] = record['windDir'] + if math.sin(weather_phase) > .95: + record['rain'] = 0.08 * amplitude if math.sin(weather_phase) > 0.98 else 0.04 * amplitude + else: + record['rain'] = 0.0 + record['radiation'] = max(amplitude * 800 * math.sin(daily_phase - math.pi / 2.0), 0) + record['radiation'] *= 0.5 * (math.cos(annual_phase + math.pi) + 1.5) + record['sunshineDur'] = interval if record['radiation'] else 0.0 + + # Make every 71st observation (a prime number) a null. This is a deterministic algorithm, so it + # will produce the same results every time. + for obs_type in ['barometer', 'outTemp', 'windDir', 'windGust', 'windGustDir', 'windSpeed']: + count += 1 + if count % 71 == 0: + record[obs_type] = None + + # Round the values slightly so we don't get small assertion errors from SQL arithmetic. + for obs_type in ['barometer', 'outTemp', 'windDir', 'windGust', 'windGustDir', 'windSpeed']: + if record.get(obs_type) is not None: + record[obs_type] = round(record[obs_type], 6) + yield record + + + +def patch_database(config_dict, binding='wx_binding'): + calc_missing_config_dict = { + 'name': 'Patch gen_fake_data', + 'binding': binding, + 'start_ts': start_ts, + 'stop_ts': stop_ts, + 'dry_run': False, + } + calc_missing = weecfg.database.CalcMissing(config_dict, calc_missing_config_dict) + calc_missing.run() + + +if __name__ == '__main__': + count = 0 + for rec in genFakeRecords(): + if count % 30 == 0: + print("Time outTemp windSpeed barometer rain radiation stringData") + count += 1 + outTemp = "%10.1f" % rec['outTemp'] if rec['outTemp'] is not None else " N/A" + windSpeed = "%10.1f" % rec['windSpeed'] if rec['windSpeed'] is not None else " N/A" + barometer = "%10.1f" % rec['barometer'] if rec['barometer'] is not None else " N/A" + rain = "%10.2f" % rec['rain'] if rec['rain'] is not None else " N/A" + radiation = "%10.0f" % rec['radiation'] if rec['radiation'] is not None else " N/A" + string_data = " %s" % rec['stringData'] + print(7 * "%s" % (time.ctime(rec['dateTime']), + outTemp, + windSpeed, + barometer, + rain, + radiation, + string_data)) diff --git a/dist/weewx-5.0.2/src/weewx/tests/restful.md b/dist/weewx-5.0.2/src/weewx/tests/restful.md new file mode 100644 index 0000000..328e507 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/restful.md @@ -0,0 +1,113 @@ +# Known behavior of various RESTful services + +## Wunderground (checked 5-Mar-2019) +* If all is OK, it responds with a code of 200, and a response body of `success`. + +* If either the station ID, or the password is bad, it responds with a code of 401, and a response body of `unauthorized`. + +* If the GET statement is malformed (for example, the date is garbled), it responds +with a code of 400, and a response body of `bad request`. + +## PWS (checked 6-Mar-2019) +* If all is OK, it responds with a code of 200 and a response body with the following: + + ``` + + + PWS Weather Station Update + + + Data Logged and posted in METAR mirror. + + + + ``` + +* If a bad station ID is given, it responds with a code of 200, and a response body with the following: + ``` + + + PWS Weather Station Update + + + ERROR: Not a vailid Station ID + ``` + +* If a valid station ID is given, but a bad password, it responds with a code of 200, and a +response body with the following: + ``` + + + PWS Weather Station Update + + + ERROR: Not a vailid Station ID/Password + ``` + +* If the date is garbled, it responds with a code of 200, and a response body with the following: + ``` + + + PWS Weather Station Update + + + dateutc parameter is not in proper format: YYYY-MM-DD HH:ii:ss
+ Data parameters invalid, NOT logged. + + + + ``` + +## WOW (checked 6-Mar-2019) +* If all is OK, it responds with a code of 200, and an empty JSON response body: + ``` + {} + ``` + +* If a valid station ID is given, but a bad password, it responds with a code of 403, and a +response body with the following: + ``` + You do not have permission to view this directory or page. + ``` + +* If the GET is garbled (e.g., a bad date), it responds with code 400, and response body +with the following: + ``` + Bad Request + ``` + +* If a post is done too soon (or, has already been seen, or is out of date --- not sure), it responds with code 429 ("Too many requests"), and a response body with the +following + ``` + The custom error module does not recognize this error. + ``` + +## AWEKAS (checked 6-Mar-2019) +* If all is OK, it responds with a code of 200, and a response body with a simple `OK`. + +* If a bad user ID or password is given, it responds with a code of 200, and a +response body with the following: +``` +Benutzer/Passwort Fehler +``` + +* If a post is done too soon, it responds with a code of 200, and a response body with +the following: +``` +too many requests - try again later +``` + +## Windy (checked 19 April 2019) +* If all is OK, it responds with a code of 200, and a response body with a simple `SUCCESS`. + +* If a bad API key is given, it responds with a code of 400, and a response body with +the following +``` +Invalid API key +``` + +* If the JSON payload is garbled, it responds with a code of 500, and a response body with +the following +``` +We are sorry, but something broke. +``` \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx/tests/simgen.conf b/dist/weewx-5.0.2/src/weewx/tests/simgen.conf new file mode 100644 index 0000000..e257e20 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/simgen.conf @@ -0,0 +1,237 @@ +############################################################################### +# # +# # +# WEEWX SYM CONFIGURATION FILE # +# # +# # +############################################################################### +# # +# Copyright (c) 2009, 2010, 2011, 2012 Tom Keffer # +# # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################### + +# +# This section is for general configuration information +# + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 1 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /home/weewx + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Current version +version = SIMGEN + +############################################################################################ + +[Station] + + # + # This section is for information about your station + # + + location = "Sim City" + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in. Normally this is + # downloaded from the station, but not all hardware supports this. + altitude = 100, meter # Choose 'foot' or 'meter' for unit + + # The start of the rain year (1=January; 10=October, etc.). Normally + # this is downloaded from the station, but not all hardware supports this. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + + # Set to type of station hardware (e.g., 'Vantage'). + # Must match a section name below. + station_type = Simulator + +############################################################################################ + +[Simulator] + + # + # This section for the weewx weather station simulator + # + + # The time (in seconds) between LOOP packets. + loop_interval = 15 + + # One of either: + #mode = simulator # Real-time simulator. It will sleep between emitting LOOP packets. + mode = generator # Emit packets as fast as it can (useful for testing). + + # The start time. [Optional. Default is to use the present time] + start = 2011-01-01T00:00 + + driver = weewx.drivers.simulator + +############################################################################################ + +[StdReport] + + # + # This section specifies what reports, using which skins, are to be generated. + # + + # Where the skins reside, relative to WEEWX_ROOT: + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = public_html + + # Each subsection represents a report you wish to run: + [[StandardReport]] + + # What skin this report should be based on: + skin = Standard + + # You can override values in the skin configuration file from here. + # For example, uncommenting the next 3 lines would have pressure reported + # in millibars, irregardless of what was in the skin configuration file + # [[[Units]]] + # [[[[Groups]]]] + # group_pressure=mbar + + # + # Here is an example where we create a custom report, still using the standard + # skin, but where the image size is overridden, and the results are put in a + # separate subdirectory 'public_html/big' + # + #[[BigReport]] + # skin = Standard + # HTML_ROOT = public_html/big + # [[[Images]]] + # image_width = 600 + # image_height = 360 + + [[FTP]] + skin = Ftp + + # + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + # + # If you wish to use FTP, uncomment and fill out the next four lines: + # user = replace with your username + # password = replace with your password + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination root directory on your server (e.g., '/weather) + + # Set to 1 to use passive mode, zero for active mode: + passive = 1 + + # How many times to try to transfer a file before giving up: + max_tries = 3 + + # If you wish to upload files from something other than what HTML_ROOT is set to + # above, then reset it here: + # HTML_ROOT = public_html + + [[RSYNC]] + skin = Rsync + + # + # rsync'ing the results to a webserver is treated as just another report, + # much like the FTP report. + # + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx runs + # as to the user account on the remote machine where the files will be copied. + # user = replace with your username + # The following configure what system and remote path the files are sent to: + # server = replace with your server name, e.g, www.threefools.org + # path = replace with the destination root directory on your server (e.g., '/weather) + +############################################################################################ + +[StdArchive] + + # + # This section is for configuring the archive databases. + # + + # If your station hardware supports data logging (such as the Davis Vantage + # series), then the archive interval will be downloaded off the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 600 + + # How long to wait (in seconds) before processing new archive data + archive_delay = 1 + + # Generally, if possible, new records are downloaded from the console hardware. + # If the console does not support this, then software record generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + + # The database binding to be used: + data_binding = wx_binding + +############################################################################################ + +[DataBindings] + # This section contains database bindings. + + # + # This section lists bindings + # + + [[wx_binding]] + # The database to be used - it should match one of the sections in [Databases] + database = archive_sqlite + # The name of the table within the database + table_name = archive + # The class to manage the database + manager = weewx.manager.DaySummaryManager + # The schema defines to structure of the database contents + schema = schemas.wview_extended.schema + +[Databases] + + # + # This section lists possible databases. + # + + [[archive_sqlite]] + root = /var/tmp/weewx_test + database_name = sim.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx1 + password = weewx1 + database_name = test_sim + driver = weedb.mysql + +############################################################################################ + +[Engine] + + # + # This section configures the internal weewx engine. It is for advanced customization. + # + + [[Services]] + # The list of services the main weewx engine should run: + prep_services = + process_services = + archive_services = weewx.engine.StdArchive + restful_services = + report_services = stopper.Stopper + +[Stopper] + # How long to run the simulator in hours + run_length = 48.0 \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx/tests/stopper.py b/dist/weewx-5.0.2/src/weewx/tests/stopper.py new file mode 100644 index 0000000..ad62215 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/stopper.py @@ -0,0 +1,48 @@ +# +# Copyright (c) 2009-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Helper class for stopping the engine after a specified period of time. + +Originally, this class was included in test_engine.py. When test_engine.py was run, it would set +up the logger. Then it would run the engine, which would dynamically load "test_engine.Stopper", +which caused test_engine to be loaded *again*, resulting in the logger getting set up a 2nd time. +This resulted in "ResourceWarning" errors because the syslog socket connection from the first +setup was not closed. + +So, class Stopper was moved to its own module. Now you know. +""" + +import sys +import time +from weeutil.weeutil import to_int + +import weewx.engine + + +class Stopper(weewx.engine.StdService): + """Special service which stops the engine when it gets to a certain time.""" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + # Fail hard if "run_length" or the "start" time are missing. + run_length = to_int(config_dict['Stopper']['run_length']) + start_tt = time.strptime(config_dict['Simulator']['start'], "%Y-%m-%dT%H:%M") + + start_ts = time.mktime(start_tt) + self.last_ts = start_ts + run_length * 3600.0 + + self.count = 0 + + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_archive_record(self, event): + self.count += 1 + print("~", end='') + if self.count % 80 == 0: + print("") + sys.stdout.flush() + if event.record['dateTime'] >= self.last_ts: + raise weewx.StopNow("Time to stop!") diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_accum.py b/dist/weewx-5.0.2/src/weewx/tests/test_accum.py new file mode 100644 index 0000000..d441ed4 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_accum.py @@ -0,0 +1,206 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test module weewx.accum""" +import math +import time +import unittest + +import gen_fake_data +import weewx.accum +from gen_fake_data import genFakeRecords +from weeutil.weeutil import TimeSpan + +# 30 minutes worth of data: +start_ts = int(time.mktime((2009, 1, 1, 0, 0, 0, 0, 0, -1))) +stop_ts = int(time.mktime((2009, 1, 1, 0, 30, 0, 0, 0, -1))) + + +class StatsTest(unittest.TestCase): + + def setUp(self): + + # The data set is a list of faked records at 5 second intervals + self.dataset = list(genFakeRecords(start_ts=start_ts + 5, stop_ts=stop_ts, interval=5)) + + def test_scalarStats(self): + + ss = weewx.accum.ScalarStats() + + # Make sure the default values work: + self.assertEqual(ss.min, None) + self.assertEqual(ss.last, None) + + tmin = tmintime = None + tsum = tcount = 0 + N = 0 + for rec in self.dataset: + # Make a copy. We may be changing it. + record = dict(rec) + if record['outTemp'] is not None: + tsum += record['outTemp'] + tcount += 1 + if tmin is None or record['outTemp'] < tmin: + tmin = record['outTemp'] + tmintime = record['dateTime'] + + # Every once in a while, try to insert a string. How often should not be divisible into + # the number of records, otherwise the last value will be a string. + N += 1 + if N % 17 == 0 and record['outTemp'] is not None: + record['outTemp'] = str(record['outTemp']) + + ss.addHiLo(record['outTemp'], record['dateTime']) + ss.addSum(record['outTemp']) + + # Some of these tests look for "almost equal", because of the rounding errors introduced by + # conversion to a string and back + self.assertAlmostEqual(ss.min, tmin, 6) + self.assertEqual(ss.mintime, tmintime) + + # Assumes the last data point is not None: + self.assertAlmostEqual(ss.last, self.dataset[-1]['outTemp'], 6) + self.assertEqual(ss.lasttime, self.dataset[-1]['dateTime']) + + self.assertAlmostEqual(ss.sum, tsum, 6) + self.assertEqual(ss.count, tcount) + self.assertAlmostEqual(ss.avg, tsum / tcount, 6) + + # Merge ss into its self. Should leave highs and lows unchanged, but double counts: + ss.mergeHiLo(ss) + ss.mergeSum(ss) + + self.assertAlmostEqual(ss.min, tmin, 6) + self.assertEqual(ss.mintime, tmintime) + + self.assertAlmostEqual(ss.last, self.dataset[-1]['outTemp'], 6) + self.assertEqual(ss.lasttime, self.dataset[-1]['dateTime']) + + self.assertAlmostEqual(ss.sum, 2 * tsum, 6) + self.assertEqual(ss.count, 2 * tcount) + + def test_null_wind_gust_dir(self): + # If LOOP packets windGustDir=None, the accumulator should not substitute windDir. + # This is a regression test that tests that. + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + + # Add the dataset to the accumulator. Null out windGustDir first. + for record in self.dataset: + record_test = dict(record) + record_test['windGustDir'] = None + accum.addRecord(record_test) + + # Extract the record out of the accumulator + accum_record = accum.getRecord() + # windGustDir should match the windDir seen at max wind: + self.assertIsNone(accum_record['windGustDir']) + + def test_no_wind_gust_dir(self): + # If LOOP packets do not have windGustDir at all, then the accumulator is supposed to + # substitute windDir. This is a regression test that tests that. + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + + windMax = None + windMaxDir = None + # Add the dataset to the accumulator. Null out windGustDir first. + for record in self.dataset: + record_test = dict(record) + del record_test['windGustDir'] + if windMax is None \ + or (record_test['windSpeed'] is not None + and record_test['windSpeed'] > windMax): + windMax = record_test['windSpeed'] + windMaxDir = record_test['windDir'] + accum.addRecord(record_test) + + # Extract the record out of the accumulator + accum_record = accum.getRecord() + # windGustDir should match the windDir seen at max wind: + self.assertEqual(accum_record['windGustDir'], windMaxDir) + + def test_issue_737(self): + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + for packet in self.dataset: + packet['windrun'] = None + accum.addRecord(packet) + # Extract the record out of the accumulator + record = accum.getRecord() + self.assertIsNone(record['windrun']) + + +class AccumTest(unittest.TestCase): + + def setUp(self): + # The data set is a list of faked records at 5 second intervals. The stage of the weather cycle + # is set so that some rain will appear. + self.dataset = list(genFakeRecords(start_ts=start_ts + 5, stop_ts=stop_ts, interval=5, + weather_phase_offset=gen_fake_data.weather_cycle * math.pi / 2.0)) + + def test_Accum_getRecord(self): + """Test extraction of record from an accumulator.""" + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + for record in self.dataset: + accum.addRecord(record) + extracted = accum.getRecord() + + self.assertEqual(extracted['dateTime'], self.dataset[-1]['dateTime']) + self.assertEqual(extracted['usUnits'], weewx.US) + + sum_t = 0 + count_t = 0 + for rec in self.dataset: + if rec['outTemp'] is not None: + sum_t += rec['outTemp'] + count_t += 1 + self.assertEqual(extracted['outTemp'], sum_t / count_t) + + max_wind = 0 + max_dir = None + for rec in self.dataset: + if rec['windGust'] is not None and rec['windGust'] > max_wind: + max_wind = rec['windGust'] + max_dir = rec['windGustDir'] + self.assertEqual(extracted['windGust'], max_wind) + self.assertEqual(extracted['windGustDir'], max_dir) + + rain_sum = 0 + for rec in self.dataset: + if rec['rain'] is not None: + rain_sum += rec['rain'] + self.assertEqual(extracted['rain'], rain_sum) + + def test_Accum_with_string(self): + """Test records with string literals in them.""" + for i, record in enumerate(self.dataset): + record['stringType'] = "AString%d" % i + + # As of V4.6, adding a string to the default accumulators should no longer result in an + # exception + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + for record in self.dataset: + accum.addRecord(record) + + # Try it again, but this time specifying a FirstLast accumulator for type 'stringType': + weewx.accum.accum_dict.extend({'stringType': {'accumulator': 'firstlast', 'extractor': 'last'}}) + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + for record in self.dataset: + accum.addRecord(record) + # The value extracted for the string should be the last one seen + rec = accum.getRecord() + self.assertEqual(rec['stringType'], "AString%d" % (len(self.dataset) - 1)) + + def test_Accum_unit_change(self): + + # Change the units used by a record mid-stream + self.dataset[5]['usUnits'] = weewx.METRICWX + # This should result in a ValueError + with self.assertRaises(ValueError): + accum = weewx.accum.Accum(TimeSpan(start_ts, stop_ts)) + for record in self.dataset: + accum.addRecord(record) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_aggregate.py b/dist/weewx-5.0.2/src/weewx/tests/test_aggregate.py new file mode 100644 index 0000000..8437c2f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_aggregate.py @@ -0,0 +1,369 @@ +# +# Copyright (c) 2019-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test aggregate functions.""" + +import logging +import math +import os.path +import sys +import time +import unittest + +import configobj + +import weedb +import gen_fake_data +import weeutil.logger +import weewx +import weewx.manager +import weewx.xtypes +from weeutil.weeutil import TimeSpan +from weewx.units import ValueTuple + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +weewx.debug = 1 + +log = logging.getLogger(__name__) +# Set up logging using the defaults. +weeutil.logger.setup('weetest_aggregate') + +# Find the configuration file. It's assumed to be in the same directory as me: +config_path = os.path.join(os.path.dirname(__file__), "testgen.conf") + + +class TestAggregate(unittest.TestCase): + + def setUp(self): + global config_path + + try: + self.config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') + except IOError: + sys.stderr.write("Unable to open configuration file %s" % config_path) + # Reraise the exception (this will eventually cause the program to exit) + raise + except configobj.ConfigObjError: + sys.stderr.write("Error while parsing configuration file %s" % config_path) + raise + + # This will generate the test databases if necessary. Use the SQLite database: it's faster. + gen_fake_data.configDatabases(self.config_dict, database_type='sqlite') + + def tearDown(self): + pass + + def test_get_aggregate(self): + # Use the same function to test calculating aggregations from the main archive file, as + # well as from the daily summaries: + self.examine_object(weewx.xtypes.ArchiveTable) + self.examine_object(weewx.xtypes.DailySummaries) + + def examine_object(self, aggregate_obj): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + month_start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) + month_stop_tt = (2010, 4, 1, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(month_start_tt) + stop_ts = time.mktime(month_stop_tt) + + avg_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), 'avg', + db_manager) + self.assertAlmostEqual(avg_vt[0], 28.77, 2) + self.assertEqual(avg_vt[1], 'degree_F') + self.assertEqual(avg_vt[2], 'group_temperature') + + max_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'max', db_manager) + self.assertAlmostEqual(max_vt[0], 58.88, 2) + maxtime_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'maxtime', db_manager) + self.assertEqual(maxtime_vt[0], 1270076400) + + min_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'min', db_manager) + self.assertAlmostEqual(min_vt[0], -1.01, 2) + mintime_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'mintime', db_manager) + self.assertEqual(mintime_vt[0], 1267441200) + + count_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'count', db_manager) + self.assertEqual(count_vt[0], 1465) + + sum_vt = aggregate_obj.get_aggregate('rain', TimeSpan(start_ts, stop_ts), + 'sum', db_manager) + self.assertAlmostEqual(sum_vt[0], 10.24, 2) + + not_null_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'not_null', db_manager) + self.assertTrue(not_null_vt[0]) + self.assertEqual(not_null_vt[1], 'boolean') + self.assertEqual(not_null_vt[2], 'group_boolean') + + null_vt = aggregate_obj.get_aggregate('inTemp', TimeSpan(start_ts, stop_ts), + 'not_null', db_manager) + self.assertFalse(null_vt[0]) + + # Values for inTemp in the test database are null for early May, but not null for later + # in the month. So, for all of May, the aggregate 'not_null' should be True. + null_start_ts = time.mktime((2010, 5, 1, 0, 0, 0, 0, 0, -1)) + null_stop_ts = time.mktime((2010, 6, 1, 0, 0, 0, 0, 0, -1)) + null_vt = aggregate_obj.get_aggregate('inTemp', TimeSpan(null_start_ts, null_stop_ts), + 'not_null', db_manager) + self.assertTrue(null_vt[0]) + + # The ArchiveTable version has a few extra aggregate types: + if aggregate_obj == weewx.xtypes.ArchiveTable: + first_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'first', db_manager) + # Get the timestamp of the first record inside the month + ts = start_ts + gen_fake_data.interval + rec = db_manager.getRecord(ts) + self.assertEqual(first_vt[0], rec['outTemp']) + + first_time_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'firsttime', + db_manager) + self.assertEqual(first_time_vt[0], ts) + + last_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'last', db_manager) + # Get the timestamp of the last record of the month + rec = db_manager.getRecord(stop_ts) + self.assertEqual(last_vt[0], rec['outTemp']) + + last_time_vt = aggregate_obj.get_aggregate('outTemp', TimeSpan(start_ts, stop_ts), + 'lasttime', db_manager) + self.assertEqual(last_time_vt[0], stop_ts) + + # Use 'dateTime' to check 'diff' and 'tderiv'. The calculations are super easy. + diff_vt = aggregate_obj.get_aggregate('dateTime', TimeSpan(start_ts, stop_ts), + 'diff', db_manager) + self.assertEqual(diff_vt[0], stop_ts - start_ts) + + tderiv_vt = aggregate_obj.get_aggregate('dateTime', TimeSpan(start_ts, stop_ts), + 'tderiv', db_manager) + self.assertAlmostEqual(tderiv_vt[0], 1.0) + + def test_AggregateDaily(self): + """Test special aggregates that can be used against the daily summaries.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + month_start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) + month_stop_tt = (2010, 4, 1, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(month_start_tt) + stop_ts = time.mktime(month_stop_tt) + + min_ge_vt = weewx.xtypes.DailySummaries.get_aggregate('outTemp', + TimeSpan(start_ts, stop_ts), + 'min_ge', + db_manager, + val=ValueTuple(15, + 'degree_F', + 'group_temperature')) + self.assertEqual(min_ge_vt[0], 6) + + min_le_vt = weewx.xtypes.DailySummaries.get_aggregate('outTemp', + TimeSpan(start_ts, stop_ts), + 'min_le', + db_manager, + val=ValueTuple(0, + 'degree_F', + 'group_temperature')) + self.assertEqual(min_le_vt[0], 2) + + minmax_vt = weewx.xtypes.DailySummaries.get_aggregate('outTemp', + TimeSpan(start_ts, stop_ts), + 'minmax', + db_manager) + self.assertAlmostEqual(minmax_vt[0], 39.28, 2) + + max_wind_vt = weewx.xtypes.DailySummaries.get_aggregate('wind', + TimeSpan(start_ts, stop_ts), + 'max', + db_manager) + self.assertAlmostEqual(max_wind_vt[0], 24.0, 2) + + avg_wind_vt = weewx.xtypes.DailySummaries.get_aggregate('wind', + TimeSpan(start_ts, stop_ts), + 'avg', + db_manager) + self.assertAlmostEqual(avg_wind_vt[0], 10.21, 2) + # Double check this last one against the average calculated from the archive + avg_wind_vt = weewx.xtypes.ArchiveTable.get_aggregate('windSpeed', + TimeSpan(start_ts, stop_ts), + 'avg', + db_manager) + self.assertAlmostEqual(avg_wind_vt[0], 10.21, 2) + + vecavg_wind_vt = weewx.xtypes.DailySummaries.get_aggregate('wind', + TimeSpan(start_ts, stop_ts), + 'vecavg', + db_manager) + self.assertAlmostEqual(vecavg_wind_vt[0], 5.14, 2) + + vecdir_wind_vt = weewx.xtypes.DailySummaries.get_aggregate('wind', + TimeSpan(start_ts, stop_ts), + 'vecdir', + db_manager) + self.assertAlmostEqual(vecdir_wind_vt[0], 88.77, 2) + + def test_get_aggregate_heatcool(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + month_start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) + month_stop_tt = (2010, 4, 1, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(month_start_tt) + stop_ts = time.mktime(month_stop_tt) + + # First, with the default heating base: + heatdeg = weewx.xtypes.AggregateHeatCool.get_aggregate('heatdeg', + TimeSpan(start_ts, stop_ts), + 'sum', + db_manager) + self.assertAlmostEqual(heatdeg[0], 1123.12, 2) + # Now with an explicit heating base: + heatdeg = weewx.xtypes.AggregateHeatCool.get_aggregate('heatdeg', + TimeSpan(start_ts, stop_ts), + 'sum', + db_manager, + skin_dict={ + 'Units': {'DegreeDays': { + 'heating_base': ( + 60.0, "degree_F", + "group_temperature") + }}}) + self.assertAlmostEqual(heatdeg[0], 968.12, 2) + + def test_get_aggregate_windvec(self): + """Test calculating special type 'windvec' using a variety of methods.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + month_start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) + month_stop_tt = (2010, 3, 2, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(month_start_tt) + stop_ts = time.mktime(month_stop_tt) + + # Calculate the daily wind for 1-March-2010 using the daily summaries, the main archive + # table, and letting get_aggregate() choose. + for func in [ + weewx.xtypes.WindVecDaily.get_aggregate, + weewx.xtypes.WindVec.get_aggregate, + weewx.xtypes.get_aggregate + ]: + windvec = func('windvec', TimeSpan(start_ts, stop_ts), 'avg', db_manager) + self.assertAlmostEqual(windvec[0].real, -1.390, 3) + self.assertAlmostEqual(windvec[0].imag, 3.250, 3) + self.assertEqual(windvec[1:3], ('mile_per_hour', 'group_speed')) + + # Calculate the wind vector for the hour starting at 1-06-2010 15:00 + hour_start_tt = (2010, 1, 6, 15, 0, 0, 0, 0, -1) + hour_stop_tt = (2010, 1, 6, 16, 0, 0, 0, 0, -1) + hour_start_ts = time.mktime(hour_start_tt) + hour_stop_ts = time.mktime(hour_stop_tt) + vt = weewx.xtypes.WindVec.get_aggregate('windvec', + TimeSpan(hour_start_ts, hour_stop_ts), + 'max', db_manager) + self.assertAlmostEqual(abs(vt[0]), 15.281, 3) + self.assertAlmostEqual(vt[0].real, 8.069, 3) + self.assertAlmostEqual(vt[0].imag, -12.976, 3) + vt = weewx.xtypes.WindVec.get_aggregate('windgustvec', + TimeSpan(hour_start_ts, hour_stop_ts), + 'max', db_manager) + self.assertAlmostEqual(abs(vt[0]), 18.337, 3) + self.assertAlmostEqual(vt[0].real, 9.683, 3) + self.assertAlmostEqual(vt[0].imag, -15.572, 3) + + vt = weewx.xtypes.WindVec.get_aggregate('windvec', + TimeSpan(hour_start_ts, hour_stop_ts), + 'not_null', db_manager) + self.assertTrue(vt[0]) + self.assertEqual(vt[1], 'boolean') + self.assertEqual(vt[2], 'group_boolean') + + def test_get_aggregate_expression(self): + """Test using an expression in an aggregate""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + month_start_tt = (2010, 7, 1, 0, 0, 0, 0, 0, -1) + month_stop_tt = (2010, 8, 1, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(month_start_tt) + stop_ts = time.mktime(month_stop_tt) + + # This one is a valid expression: + value = weewx.xtypes.get_aggregate('rain-ET', TimeSpan(start_ts, stop_ts), + 'sum', db_manager) + self.assertAlmostEqual(value[0], 2.94, 2) + + # This one uses a nonsense variable: + with self.assertRaises(weewx.UnknownAggregation): + value = weewx.xtypes.get_aggregate('rain-foo', TimeSpan(start_ts, stop_ts), + 'sum', db_manager) + + # A valid function + value = weewx.xtypes.get_aggregate('max(rain-ET, 0)', TimeSpan(start_ts, stop_ts), + 'sum', db_manager) + self.assertAlmostEqual(value[0], 9.57, 2) + + # This one uses a nonsense function + with self.assertRaises(weedb.OperationalError): + value = weewx.xtypes.get_aggregate('foo(rain-ET)', TimeSpan(start_ts, stop_ts), + 'sum', db_manager) + + def test_first_wind(self): + """Test getting the first non-null wind record in a time range.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Get the first value for 2-Aug-2010. This date was chosen because the wind speed of + # the very first record of the day (at 00:30:00) is actually null, so the next value + # (at 01:00:00) should be the one chosen. + day_start_tt = (2010, 8, 2, 0, 0, 0, 0, 0, -1) + day_stop_tt = (2010, 8, 3, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(day_start_tt) + stop_ts = time.mktime(day_stop_tt) + # Check the premise of the test, aas well as get the expected results + results = [x for x in db_manager.genSql("SELECT windSpeed, windDir FROM archive " + "WHERE dateTime > ? " + "ORDER BY dateTime ASC LIMIT 2", (start_ts,))] + # We expect the first datum to be null + self.assertIsNone(results[0][0]) + # This is the expected value: the 2nd datum + windSpeed, windDir = results[1] + expected = complex(windSpeed * math.cos(math.radians(90.0 - windDir)), + windSpeed * math.sin(math.radians(90.0 - windDir))) + value = weewx.xtypes.WindVec.get_aggregate('windvec', + TimeSpan(start_ts, stop_ts), + 'first', db_manager) + self.assertEqual(value[0], expected) + self.assertEqual(value[1], 'mile_per_hour') + self.assertEqual(value[2], 'group_speed') + + def test_last_wind(self): + """Test getting the last non-null wind record in a time range.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Get the last value for 18-Apr-2010. This date was chosen because the wind speed of + # the very last record of the day (at 19-Apr-2010 00:00:00) is actually null, so the + # previous value (at 18-Apr-2010 23:30:00) should be the one chosen. + day_start_tt = (2010, 4, 18, 0, 0, 0, 0, 0, -1) + day_stop_tt = (2010, 4, 19, 0, 0, 0, 0, 0, -1) + start_ts = time.mktime(day_start_tt) + stop_ts = time.mktime(day_stop_tt) + # Check the premise of the test, as well as get the expected results + results = [x for x in db_manager.genSql("SELECT windSpeed, windDir FROM archive " + "WHERE dateTime <= ? " + "ORDER BY dateTime DESC LIMIT 2", (stop_ts,))] + # We expect the first record (which is the last record of the day) to be null + self.assertIsNone(results[0][0]) + # This is the expected value: the 2nd record + windSpeed, windDir = results[1] + expected = complex(windSpeed * math.cos(math.radians(90.0 - windDir)), + windSpeed * math.sin(math.radians(90.0 - windDir))) + value = weewx.xtypes.WindVec.get_aggregate('windvec', + TimeSpan(start_ts, stop_ts), + 'last', db_manager) + self.assertAlmostEqual(value[0], expected) + self.assertEqual(value[1], 'mile_per_hour') + self.assertEqual(value[2], 'group_speed') + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_almanac.py b/dist/weewx-5.0.2/src/weewx/tests/test_almanac.py new file mode 100644 index 0000000..a75a851 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_almanac.py @@ -0,0 +1,182 @@ +# +# Copyright (c) 2021-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test module weewx.almanac""" +import locale +import os +import unittest + +from weewx.almanac import * + +locale.setlocale(locale.LC_ALL, 'C') +os.environ['LANG'] = 'C' + +LATITUDE = 46.0 +LONGITUDE = -122.0 +SPRING_TIMESTAMP = 1238180400 # 2009-03-27 12:00:00 PDT +FALL_TIMESTAMP = 1254078000 # 2009-09-27 12:00:00 PDT + +default_formatter = weewx.units.get_default_formatter() + +try: + import ephem +except ImportError: + pyephem_installed = False +else: + pyephem_installed = True + +class AlmanacTest(unittest.TestCase): + + def setUp(self): + os.environ['TZ'] = 'America/Los_Angeles' + time.tzset() + # Unix epoch time + self.ts_ue = SPRING_TIMESTAMP + self.almanac = Almanac(self.ts_ue, LATITUDE, LONGITUDE, formatter=default_formatter) + + def test_Dublin(self): + # Dublin julian days + t_djd = timestamp_to_djd(self.ts_ue) + # Test forward conversion + self.assertAlmostEqual(t_djd, 39898.29167, 5) + # Test back conversion + self.assertAlmostEqual(djd_to_timestamp(t_djd), self.ts_ue, 5) + + def test_moon(self): + # Test backwards compatiblity with the attribute _moon_fullness + self.assertAlmostEqual(self.almanac._moon_fullness, 3, 2) + self.assertEqual(self.almanac.moon_phase, 'new (totally dark)') + + @unittest.skipIf(not pyephem_installed, "Skipping test_moon_extended: no pyephem") + def test_moon_extended(self): + # Now test a more precise result for fullness of the moon: + self.assertAlmostEqual(self.almanac.moon.moon_fullness, 1.70, 2) + self.assertEqual(str(self.almanac.moon.rise), "06:59:14") + self.assertEqual(str(self.almanac.moon.transit), "14:01:57") + self.assertEqual(str(self.almanac.moon.set), "21:20:06") + + # Location of the moon + self.assertAlmostEqual(self.almanac.moon.az, 133.55, 2) + self.assertAlmostEqual(self.almanac.moon.alt, 47.89, 2) + + self.assertEqual(str(self.almanac.next_full_moon), "04/09/09 07:55:49") + self.assertEqual(str(self.almanac.next_new_moon), "04/24/09 20:22:33") + self.assertEqual(str(self.almanac.next_first_quarter_moon), "04/02/09 07:33:42") + + @unittest.skipIf(pyephem_installed, "Skipping test_sun: using extended test instead") + def test_sun(self): + # Test backwards compatibility + self.assertEqual(str(self.almanac.sunrise), "06:55:59") + self.assertEqual(str(self.almanac.sunset), "19:30:22") + + @unittest.skipIf(not pyephem_installed, "Skipping test_sun_extended: no pyephem") + def test_sun_extended(self): + # Test backwards compatibility + self.assertEqual(str(self.almanac.sunrise), "06:56:36") + self.assertEqual(str(self.almanac.sunset), "19:30:41") + + # Use pyephem + self.assertEqual(str(self.almanac.sun.rise), "06:56:36") + self.assertEqual(str(self.almanac.sun.transit), "13:13:13") + self.assertEqual(str(self.almanac.sun.set), "19:30:41") + + # Equinox / solstice + self.assertEqual(str(self.almanac.next_vernal_equinox), "03/20/10 10:32:11") + self.assertEqual(str(self.almanac.next_autumnal_equinox), "09/22/09 14:18:39") + self.assertEqual(str(self.almanac.next_summer_solstice), "06/20/09 22:45:40") + self.assertEqual(str(self.almanac.previous_winter_solstice), "12/21/08 04:03:36") + self.assertEqual(str(self.almanac.next_winter_solstice), "12/21/09 09:46:38") + + # Location of the sun + self.assertAlmostEqual(self.almanac.sun.az, 154.14, 2) + self.assertAlmostEqual(self.almanac.sun.alt, 44.02, 2) + + # Visible time + self.assertEqual(self.almanac.sun.visible.long_form(), "12 hours, 34 minutes, 4 seconds") + # Change in visible time: + self.assertEqual(str(self.almanac.sun.visible_change().long_form()), "3 minutes, 15 seconds") + # Do it again, but in the fall when daylight is decreasing: + almanac = Almanac(FALL_TIMESTAMP, LATITUDE, LONGITUDE, formatter=default_formatter) + self.assertEqual(str(almanac.sun.visible_change().long_form()), "3 minutes, 13 seconds") + + @unittest.skipIf(not pyephem_installed, "Skipping test_mars: no pyephem") + def test_mars(self): + self.assertEqual(str(self.almanac.mars.rise), "06:08:57") + self.assertEqual(str(self.almanac.mars.transit), "11:34:13") + self.assertEqual(str(self.almanac.mars.set), "17:00:04") + self.assertAlmostEqual(self.almanac.mars.sun_distance, 1.3857, 4) + + @unittest.skipIf(not pyephem_installed, "Skipping test_jupiter: no pyephem") + def test_jupiter(self): + # Specialized attribute for Jupiter: + self.assertEqual(str(self.almanac.jupiter.cmlI), "310:55:32.7") + # Should fail if applied to a different body + with self.assertRaises(AttributeError): + self.almanac.venus.cmlI + + @unittest.skipIf(not pyephem_installed, "Skipping test_star: no pyephem") + def test_star(self): + self.assertAlmostEqual(self.almanac.castor.rise.raw, 1238178997, 0) + self.assertAlmostEqual(self.almanac.castor.transit.raw, 1238210429, 0) + self.assertAlmostEqual(self.almanac.castor.set.raw, 1238155697, 0) + + @unittest.skipIf(not pyephem_installed, "Skipping test_sidereal: no pyephem") + def test_sidereal(self): + self.assertAlmostEqual(self.almanac.sidereal_time, 348.3400, 4) + + @unittest.skipIf(not pyephem_installed, "Skipping test_always_up: no pyephem") + def test_always_up(self): + # Time and location where the sun is always up + t = 1371044003 # 2013-06-12 06:33:23 PDT (1371044003) + almanac = Almanac(t, 74.0, 0.0, formatter=default_formatter) + self.assertIsNone(almanac(horizon=-6).sun(use_center=1).rise.raw) + self.assertIsNone(almanac(horizon=-6).sun(use_center=1).set.raw) + self.assertEqual(almanac(horizon=-6).sun(use_center=1).visible.raw, 86400) + self.assertEqual(almanac(horizon=-6).sun(use_center=1).visible.long_form(), + "24 hours, 0 minutes, 0 seconds") + + # Now where the sun is always down: + almanac = Almanac(t, -74.0, 0.0, formatter=default_formatter) + self.assertIsNone(almanac(horizon=-6).sun(use_center=1).rise.raw) + self.assertIsNone(almanac(horizon=-6).sun(use_center=1).set.raw) + self.assertEqual(almanac(horizon=-6).sun(use_center=1).visible.raw, 0) + self.assertEqual(almanac(horizon=-6).sun(use_center=1).visible.long_form(), + "0 hours, 0 minutes, 0 seconds") + + @unittest.skipIf(not pyephem_installed, "Skipping test_naval_observatory: no pyephem") + def test_naval_observatory(self): + # + # pyephem "Naval Observatory" example. + t = 1252256400 # 2009-09-06 17:00:00 UTC (1252256400) + atlanta = Almanac(t, 33.8, -84.4, pressure=0, horizon=-34.0 / 60.0, + formatter=default_formatter) + self.assertAlmostEqual(atlanta.sun.previous_rising.raw, 1252235697, 0) + self.assertAlmostEqual(atlanta.moon.next_setting.raw, 1252332329, 0) + + # Civil twilight examples: + self.assertAlmostEqual(atlanta(horizon=-6).sun(use_center=1).previous_rising.raw, 1252234180, 0) + self.assertAlmostEqual(atlanta(horizon=-6).sun(use_center=1).next_setting.raw, 1252282883, 0) + + # Try sun rise again, to make sure the horizon value cleared: + self.assertAlmostEqual(atlanta.sun.previous_rising.raw, 1252235697, 0) + + @unittest.skipIf(pyephem_installed, "Skipping test_exceptions: using pyephem version instead") + def test_exceptions(self): + # Try a nonsense tag + with self.assertRaises(AttributeError): + self.almanac.sun.foo + + @unittest.skipIf(not pyephem_installed, "Skipping test_exceptions_pyephem: no pyephem") + def test_exceptions_pyephem(self): + # Try a nonsense body + with self.assertRaises(KeyError): + self.almanac.bar.rise + # Try a nonsense tag + with self.assertRaises(AttributeError): + self.almanac.sun.foo + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_cheetah.py b/dist/weewx-5.0.2/src/weewx/tests/test_cheetah.py new file mode 100644 index 0000000..14a8bf9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_cheetah.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test functions in cheetahgenerator""" + +import logging +import unittest + +import weeutil.logger +import weeutil.weeutil +import weewx +import weewx.cheetahgenerator +from weewx.units import ValueTuple, ValueHelper + +weewx.debug = 1 + +log = logging.getLogger(__name__) +# Set up logging using the defaults. +weeutil.logger.setup('weetest_cheetah') + + +class RaiseException(object): + def __str__(self): + raise AttributeError("Fine mess you got me in!") + + +class TestFilter(unittest.TestCase): + "Test the function filter() in AssureUnicode" + def test_none(self): + val = None + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(val) + self.assertEqual(filtered_value, u'') + + def test_str(self): + val = "abcdé" + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(val) + self.assertEqual(filtered_value, u"abcdé") + + def test_byte_str(self): + val = b'abcd\xc3\xa9' + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(val) + self.assertEqual(filtered_value, u"abcdé") + + def test_int(self): + val = 27 + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(val) + self.assertEqual(filtered_value, u"27") + + def test_float(self): + val = 27.9 + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(val) + self.assertEqual(filtered_value, u"27.9") + + def test_ValueHelper(self): + val_vh = ValueHelper(ValueTuple(20.0, 'degree_C', 'group_temperature'), + formatter=weewx.units.get_default_formatter()) + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(val_vh) + self.assertEqual(filtered_value, u"20.0°C") + + def test_RaiseException(self): + r = RaiseException() + au = weewx.cheetahgenerator.AssureUnicode() + filtered_value = au.filter(r) + self.assertEqual(filtered_value, u'Fine mess you got me in!?') + + +class TestHelpers(unittest.TestCase): + "Test the helper functions" + + def test_jsonize(self): + full = zip([0, None, 4], [1, 3, 5]) + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.jsonize(full), + '[[0, 1], [null, 3], [4, 5]]') + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.jsonize([complex(1,2), complex(3,4)]), + '[[1.0, 2.0], [3.0, 4.0]]') + + def test_rnd(self): + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.rnd(1.2345, 2), 1.23) + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.rnd(-1.2345, 2), -1.23) + self.assertIsNone(weewx.cheetahgenerator.JSONHelpers.rnd(None, 2)) + + def test_to_int(self): + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.to_int(1.2345), 1) + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.to_int('1.2345'), 1) + self.assertEqual(weewx.cheetahgenerator.JSONHelpers.to_int(-1.2345), -1) + self.assertIsNone(weewx.cheetahgenerator.JSONHelpers.to_int(None)) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_daily.py b/dist/weewx-5.0.2/src/weewx/tests/test_daily.py new file mode 100644 index 0000000..84c1361 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_daily.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Unit test module weewx.wxstats""" + +import datetime +import logging +import math +import os.path +import shutil +import sys +import time +import unittest + +import configobj + +import gen_fake_data +import tst_schema +import weeutil.logger +import weeutil.weeutil +import weewx.manager +import weewx.tags +from weewx.units import ValueHelper + +weewx.debug = 1 + +log = logging.getLogger(__name__) +# Set up logging using the defaults. +weeutil.logger.setup('weetest_daily') + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +day_keys = [x[0] for x in tst_schema.schema['day_summaries']] + +# Find the configuration file. It's assumed to be in the same directory as me: +config_path = os.path.join(os.path.dirname(__file__), "testgen.conf") + +cwd = None + +skin_dict = {'Units': {'Trend': {'time_delta': 3600, 'time_grace': 300}, + 'DegreeDay': {'heating_base': "65, degree_F", + 'cooling_base': "65, degree_C"}}} + +default_formatter = weewx.units.get_default_formatter() + +class Common(object): + + def setUp(self): + global config_path + global cwd + + # Save and set the current working directory in case some service changes it. + if not cwd: + cwd = os.getcwd() + else: + os.chdir(cwd) + + try: + self.config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') + except IOError: + sys.stderr.write("Unable to open configuration file %s" % self.config_path) + # Reraise the exception (this will eventually cause the program to exit) + raise + except configobj.ConfigObjError: + sys.stderr.write("Error while parsing configuration file %s" % config_path) + raise + + # Remove the old directory: + try: + test_html_dir = os.path.join(self.config_dict['WEEWX_ROOT'], self.config_dict['StdReport']['HTML_ROOT']) + shutil.rmtree(test_html_dir) + except OSError as e: + if os.path.exists(test_html_dir): + print("\nUnable to remove old test directory %s", test_html_dir, file=sys.stderr) + print("Reason:", e, file=sys.stderr) + print("Aborting", file=sys.stderr) + exit(1) + + # This will generate the test databases if necessary: + gen_fake_data.configDatabases(self.config_dict, database_type=self.database_type) + + def tearDown(self): + pass + + def test_create_stats(self): + global day_keys + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + self.assertEqual(sorted(manager.daykeys), sorted(day_keys)) + self.assertEqual(manager.connection.columnsOf('archive_day_barometer'), + ['dateTime', 'min', 'mintime', 'max', 'maxtime', 'sum', 'count', 'wsum', 'sumtime']) + self.assertEqual(manager.connection.columnsOf('archive_day_wind'), + ['dateTime', 'min', 'mintime', 'max', 'maxtime', 'sum', 'count', 'wsum', 'sumtime', + 'max_dir', 'xsum', 'ysum', 'dirsumtime', 'squaresum', 'wsquaresum']) + + def testScalarTally(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + # Pick a random day, say 15 March: + start_ts = int(time.mktime((2010, 3, 15, 0, 0, 0, 0, 0, -1))) + stop_ts = int(time.mktime((2010, 3, 16, 0, 0, 0, 0, 0, -1))) + # Sanity check that this is truly the start of day: + self.assertEqual(start_ts, weeutil.weeutil.startOfDay(start_ts)) + + # Get a day's stats from the daily summaries: + allStats = manager._get_day_summary(start_ts) + + # Now calculate the same summaries from the raw data in the archive. + # Here are some random observation types: + for stats_type in ['barometer', 'outTemp', 'rain']: + + # Now test all the aggregates: + for aggregate in ['min', 'max', 'sum', 'count', 'avg']: + # Compare to the main archive: + res = manager.getSql( + "SELECT %s(%s) FROM archive WHERE dateTime>? AND dateTime <=?;" % (aggregate, stats_type), + (start_ts, stop_ts)) + # The results from the daily summaries for this aggregation + allStats_res = getattr(allStats[stats_type], aggregate) + self.assertAlmostEqual(allStats_res, res[0], + msg="Value check. Failing type %s, aggregate: %s" % (stats_type, aggregate)) + + # Check the times of min and max as well: + if aggregate in ['min', 'max']: + res2 = manager.getSql( + "SELECT dateTime FROM archive WHERE %s = ? AND dateTime>? AND dateTime <=?" % (stats_type,), + (res[0], start_ts, stop_ts)) + stats_time = getattr(allStats[stats_type], aggregate + 'time') + self.assertEqual(stats_time, res2[0], + "Time check. Failing type %s, aggregate: %s" % (stats_type, aggregate)) + + def testWindTally(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + # Pick a random day, say 15 March: + start_ts = int(time.mktime((2010, 3, 15, 0, 0, 0, 0, 0, -1))) + stop_ts = int(time.mktime((2010, 3, 16, 0, 0, 0, 0, 0, -1))) + # Sanity check that this is truly the start of day: + self.assertEqual(start_ts, weeutil.weeutil.startOfDay(start_ts)) + + allStats = manager._get_day_summary(start_ts) + + # Test all the aggregates: + for aggregate in ['min', 'max', 'sum', 'count', 'avg']: + if aggregate == 'max': + res = manager.getSql("SELECT MAX(windGust) FROM archive WHERE dateTime>? AND dateTime <=?;", + (start_ts, stop_ts)) + else: + res = manager.getSql( + "SELECT %s(windSpeed) FROM archive WHERE dateTime>? AND dateTime <=?;" % (aggregate,), + (start_ts, stop_ts)) + + # From StatsDb: + allStats_res = getattr(allStats['wind'], aggregate) + self.assertAlmostEqual(allStats_res, res[0]) + + # Check the times of min and max as well: + if aggregate == 'min': + resmin = manager.getSql( + "SELECT dateTime FROM archive WHERE windSpeed = ? AND dateTime>? AND dateTime <=?", + (res[0], start_ts, stop_ts)) + self.assertEqual(allStats['wind'].mintime, resmin[0]) + elif aggregate == 'max': + resmax = manager.getSql( + "SELECT dateTime FROM archive WHERE windGust = ? AND dateTime>? AND dateTime <=?", + (res[0], start_ts, stop_ts)) + self.assertEqual(allStats['wind'].maxtime, resmax[0]) + + # Check RMS: + (squaresum, count) = manager.getSql( + "SELECT SUM(windSpeed*windSpeed), COUNT(windSpeed) from archive where dateTime>? AND dateTime<=?;", + (start_ts, stop_ts)) + rms = math.sqrt(squaresum / count) if count else None + self.assertAlmostEqual(allStats['wind'].rms, rms) + + def testRebuild(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + # Pick a random day, say 15 March: + start_d = datetime.date(2010, 3, 15) + stop_d = datetime.date(2010, 3, 15) + start_ts = int(time.mktime(start_d.timetuple())) + + # Get the day's statistics: + origStats = manager._get_day_summary(start_ts) + + # Rebuild that day: + manager.backfill_day_summary(start_d=start_d, stop_d=stop_d) + + # Get the new statistics + newStats = manager._get_day_summary(start_ts) + + # Check for equality + for obstype in ('outTemp', 'barometer', 'windSpeed'): + self.assertTrue(all([getattr(origStats[obstype], prop) == \ + getattr(newStats[obstype], prop) \ + for prop in ('min', 'mintime', 'max', 'maxtime', + 'sum', 'count', 'wsum', 'sumtime', + 'last', 'lasttime')])) + + def testTags(self): + """Test common tags.""" + global skin_dict + db_binder = weewx.manager.DBBinder(self.config_dict) + db_lookup = db_binder.bind_default() + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + + spans = {'day': weeutil.weeutil.TimeSpan(time.mktime((2010, 3, 15, 0, 0, 0, 0, 0, -1)), + time.mktime((2010, 3, 16, 0, 0, 0, 0, 0, -1))), + 'week': weeutil.weeutil.TimeSpan(time.mktime((2010, 3, 14, 0, 0, 0, 0, 0, -1)), + time.mktime((2010, 3, 21, 0, 0, 0, 0, 0, -1))), + 'month': weeutil.weeutil.TimeSpan(time.mktime((2010, 3, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2010, 4, 1, 0, 0, 0, 0, 0, -1))), + 'year': weeutil.weeutil.TimeSpan(time.mktime((2010, 1, 1, 0, 0, 0, 0, 0, -1)), + time.mktime((2011, 1, 1, 0, 0, 0, 0, 0, -1)))} + + # This may not necessarily execute in the order specified above: + for span in spans: + + start_ts = spans[span].start + stop_ts = spans[span].stop + tagStats = weewx.tags.TimeBinder(db_lookup, stop_ts, + formatter=default_formatter, + rain_year_start=1, + skin_dict=skin_dict) + + # Cycle over the statistical types: + for stats_type in ('barometer', 'outTemp', 'rain'): + + # Now test all the aggregates: + for aggregate in ('min', 'max', 'sum', 'count', 'avg'): + # Compare to the main archive: + res = manager.getSql( + "SELECT %s(%s) FROM archive WHERE dateTime>? AND dateTime <=?;" % (aggregate, stats_type), + (start_ts, stop_ts)) + archive_result = res[0] + value_helper = getattr(getattr(getattr(tagStats, span)(), stats_type), aggregate) + self.assertAlmostEqual(float(str(value_helper.formatted)), archive_result, places=1) + + # Check the times of min and max as well: + if aggregate in ('min', 'max'): + res2 = manager.getSql( + "SELECT dateTime FROM archive WHERE %s = ? AND dateTime>? AND dateTime <=?" % ( + stats_type,), (archive_result, start_ts, stop_ts)) + stats_value_helper = getattr(getattr(getattr(tagStats, span)(), stats_type), + aggregate + 'time') + self.assertEqual(stats_value_helper.raw, res2[0]) + + # Do the tests for a report time of midnight, 1-Apr-2010 + tagStats = weewx.tags.TimeBinder(db_lookup, spans['month'].stop, + formatter=default_formatter, + rain_year_start=1, + skin_dict=skin_dict) + self.assertEqual(str(tagStats.day().barometer.avg), "29.333 inHg") + self.assertEqual(str(tagStats.day().barometer.min), "29.000 inHg") + self.assertEqual(str(tagStats.day().barometer.max), "29.935 inHg") + self.assertEqual(str(tagStats.day().barometer.mintime), "01:00:00") + self.assertEqual(str(tagStats.day().barometer.maxtime), "00:00:00") + self.assertEqual(str(tagStats.week().barometer.avg), "30.097 inHg") + self.assertEqual(str(tagStats.week().barometer.min), "29.000 inHg") + self.assertEqual(str(tagStats.week().barometer.max), "31.000 inHg") + self.assertEqual(str(tagStats.week().barometer.mintime), "01:00:00 (Wednesday)") + self.assertEqual(str(tagStats.week().barometer.maxtime), "01:00:00 (Monday)") + self.assertEqual(str(tagStats.month().barometer.avg), "29.979 inHg") + self.assertEqual(str(tagStats.month().barometer.min), "29.000 inHg") + self.assertEqual(str(tagStats.month().barometer.max), "31.000 inHg") + self.assertEqual(str(tagStats.month().barometer.mintime), "03/03/10 00:00:00") + self.assertEqual(str(tagStats.month().barometer.maxtime), "03/05/10 00:00:00") + self.assertEqual(str(tagStats.year().barometer.avg), "29.996 inHg") + self.assertEqual(str(tagStats.year().barometer.min), "29.000 inHg") + self.assertEqual(str(tagStats.year().barometer.max), "31.000 inHg") + self.assertEqual(str(tagStats.year().barometer.mintime), "01/02/10 00:00:00") + self.assertEqual(str(tagStats.year().barometer.maxtime), "01/04/10 00:00:00") + self.assertEqual(str(tagStats.day().outTemp.avg), "38.4°F") + self.assertEqual(str(tagStats.day().outTemp.min), "18.5°F") + self.assertEqual(str(tagStats.day().outTemp.max), "58.9°F") + self.assertEqual(str(tagStats.day().outTemp.mintime), "04:00:00") + self.assertEqual(str(tagStats.day().outTemp.maxtime), "16:00:00") + self.assertEqual(str(tagStats.week().outTemp.avg), "38.7°F") + self.assertEqual(str(tagStats.week().outTemp.min), "16.5°F") + self.assertEqual(str(tagStats.week().outTemp.max), "60.9°F") + self.assertEqual(str(tagStats.week().outTemp.mintime), "04:00:00 (Sunday)") + self.assertEqual(str(tagStats.week().outTemp.maxtime), "16:00:00 (Saturday)") + self.assertEqual(str(tagStats.month().outTemp.avg), "28.8°F") + self.assertEqual(str(tagStats.month().outTemp.min), "-1.0°F") + self.assertEqual(str(tagStats.month().outTemp.max), "58.9°F") + self.assertEqual(str(tagStats.month().outTemp.mintime), "03/01/10 03:00:00") + self.assertEqual(str(tagStats.month().outTemp.maxtime), "03/31/10 16:00:00") + self.assertEqual(str(tagStats.year().outTemp.avg), "48.3°F") + self.assertEqual(str(tagStats.year().outTemp.min), "-20.0°F") + self.assertEqual(str(tagStats.year().outTemp.max), "100.0°F") + self.assertEqual(str(tagStats.year().outTemp.mintime), "01/01/10 03:00:00") + self.assertEqual(str(tagStats.year().outTemp.maxtime), "07/02/10 16:00:00") + + # Check the special aggregate types "exists" and "has_data": + self.assertTrue(tagStats.year().barometer.exists) + self.assertTrue(tagStats.year().barometer.has_data) + self.assertFalse(tagStats.year().bar.exists) + self.assertFalse(tagStats.year().bar.has_data) + self.assertTrue(tagStats.year().inHumidity.exists) + self.assertFalse(tagStats.year().inHumidity.has_data) + + def test_agg_intervals(self): + """Test aggregation spans that do not span a day""" + db_binder = weewx.manager.DBBinder(self.config_dict) + db_lookup = db_binder.bind_default() + + # note that this spans the spring DST boundary: + six_hour_span = weeutil.weeutil.TimeSpan(time.mktime((2010, 3, 14, 1, 0, 0, 0, 0, -1)), + time.mktime((2010, 3, 14, 8, 0, 0, 0, 0, -1))) + + tsb = weewx.tags.TimespanBinder(six_hour_span, + db_lookup, + formatter=default_formatter) + self.assertEqual(str(tsb.outTemp.max), "17.2°F") + self.assertEqual(str(tsb.outTemp.maxtime), "03/14/10 08:00:00") + self.assertEqual(str(tsb.outTemp.min), "7.1°F") + self.assertEqual(str(tsb.outTemp.mintime), "03/14/10 04:00:00") + self.assertEqual(str(tsb.outTemp.avg), "10.0°F") + + rain_span = weeutil.weeutil.TimeSpan(time.mktime((2010, 3, 14, 20, 10, 0, 0, 0, -1)), + time.mktime((2010, 3, 14, 23, 10, 0, 0, 0, -1))) + tsb = weewx.tags.TimespanBinder(rain_span, + db_lookup, + formatter=default_formatter) + self.assertEqual(str(tsb.rain.sum), "0.36 in") + + def test_agg(self): + """Test aggregation in the archive table against aggregation in the daily summary""" + + week_start_ts = time.mktime((2010, 3, 14, 0, 0, 0, 0, 0, -1)) + week_stop_ts = time.mktime((2010, 3, 21, 0, 0, 0, 0, 0, -1)) + + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + for day_span in weeutil.weeutil.genDaySpans(week_start_ts, week_stop_ts): + for aggregation in ['min', 'max', 'mintime', 'maxtime', 'avg']: + # Get the answer using the raw archive table: + table_answer = ValueHelper( + weewx.manager.Manager.getAggregate(manager, day_span, 'outTemp', aggregation)) + daily_answer = ValueHelper( + weewx.manager.DaySummaryManager.getAggregate(manager, day_span, 'outTemp', aggregation)) + self.assertEqual(str(table_answer), str(daily_answer), + msg="aggregation=%s; %s vs %s" % (aggregation, table_answer, daily_answer)) + + def test_rainYear(self): + db_binder = weewx.manager.DBBinder(self.config_dict) + db_lookup = db_binder.bind_default() + + stop_ts = time.mktime((2011, 1, 1, 0, 0, 0, 0, 0, -1)) + # Check for a rain year starting 1-Jan + tagStats = weewx.tags.TimeBinder(db_lookup, stop_ts, + formatter=default_formatter, + rain_year_start=1) + + self.assertEqual(str(tagStats.rainyear().rain.sum), "79.36 in") + + # Do it again, for starting 1-Oct: + tagStats = weewx.tags.TimeBinder(db_lookup, stop_ts, + formatter=default_formatter, + rain_year_start=6) + self.assertEqual(str(tagStats.rainyear().rain.sum), "30.72 in") + + def test_heatcool(self): + db_binder = weewx.manager.DBBinder(self.config_dict) + db_lookup = db_binder.bind_default() + # Test heating and cooling degree days: + stop_ts = time.mktime((2011, 1, 1, 0, 0, 0, 0, 0, -1)) + + tagStats = weewx.tags.TimeBinder(db_lookup, stop_ts, + formatter=default_formatter, + skin_dict=skin_dict) + + self.assertEqual(str(tagStats.year().heatdeg.sum), "5125.1°F-day") + self.assertEqual(str(tagStats.year().cooldeg.sum), "1026.5°F-day") + + +class TestSqlite(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "sqlite" + super().__init__(*args, **kwargs) + + +class TestMySQL(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "mysql" + super().__init__(*args, **kwargs) + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + +def suite(): + tests = ['test_create_stats', 'testScalarTally', 'testWindTally', 'testRebuild', + 'testTags', 'test_rainYear', 'test_agg_intervals', 'test_agg', 'test_heatcool'] + + # Test both sqlite and MySQL: + return unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_database.py b/dist/weewx-5.0.2/src/weewx/tests/test_database.py new file mode 100644 index 0000000..b2f56d5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_database.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test archive and stats database modules""" +import unittest +import time + +from io import StringIO + +import configobj + +import weewx.manager +import weedb +import weeutil.weeutil +import weeutil.logger + +weeutil.logger.setup('weetest_database') + +archive_sqlite = {'database_name': '/var/tmp/weewx_test/weedb.sdb', 'driver':'weedb.sqlite'} +archive_mysql = {'database_name': 'test_weedb', 'user':'weewx1', 'password':'weewx1', 'driver':'weedb.mysql'} + +archive_schema = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('barometer', 'REAL'), + ('inTemp', 'REAL'), + ('outTemp', 'REAL'), + ('windSpeed', 'REAL')] + +std_unit_system = 1 +interval = 3600 # One hour +nrecs = 48 # Two days +start_ts = int(time.mktime((2012, 7, 1, 00, 00, 0, 0, 0, -1))) # 1 July 2012 +stop_ts = start_ts + interval * (nrecs-1) +timevec = [start_ts+i*interval for i in range(nrecs)] + +def timefunc(i): + return start_ts + i*interval +def barfunc(i): + return 30.0 + 0.01*i +def temperfunc(i): + return 68.0 + 0.1*i + +def expected_record(irec): + _record = {'dateTime': timefunc(irec), 'interval': int(interval/60), 'usUnits' : 1, + 'outTemp': temperfunc(irec), 'barometer': barfunc(irec), 'inTemp': 70.0 + 0.1*irec} + return _record + +def gen_included_recs(timevec, start_ts, stop_ts, agg_interval): + """Generator function that marches down a set of aggregation intervals. Each yield returns + the set of records included in that interval.""" + for span in weeutil.weeutil.intervalgen(start_ts, stop_ts, agg_interval): + included = set() + for (irec, ts) in enumerate(timevec): + if span[0] < ts <= span[1]: + included.add(irec) + yield included + +def genRecords(): + for irec in range(nrecs): + _record = expected_record(irec) + yield _record + + +class TestDatabaseDict(unittest.TestCase): + + def test_get_database_dict(self): + config_snippet = ''' + WEEWX_ROOT = /home/weewx + [DatabaseTypes] + [[SQLite]] + driver = weedb.sqlite + SQLITE_ROOT = %(WEEWX_ROOT)s/archive + [Databases] + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite''' + config_dict = configobj.ConfigObj(StringIO(config_snippet)) + database_dict = weewx.manager.get_database_dict_from_config(config_dict, 'archive_sqlite') + self.assertEqual(database_dict, {'SQLITE_ROOT': '/home/weewx/archive', + 'database_name': 'weewx.sdb', + 'driver': 'weedb.sqlite'}) + + +class Common(object): + + def setUp(self): + try: + weedb.drop(self.archive_db_dict) + except: + pass + + def tearDown(self): + try: + weedb.drop(self.archive_db_dict) + except: + pass + + def populate_database(self): + # Use a 'with' statement: + with weewx.manager.Manager.open_with_create(self.archive_db_dict, schema=archive_schema) as archive: + archive.addRecord(genRecords()) + + def test_open_no_archive(self): + # Attempt to open a non-existent database results in an exception: + with self.assertRaises(weedb.NoDatabaseError): + db_manager = weewx.manager.Manager.open(self.archive_db_dict) + + def test_open_unitialized_archive(self): + """Test creating the database, but not initializing it. Then try to open it.""" + weedb.create(self.archive_db_dict) + with self.assertRaises(weedb.ProgrammingError): + db_manager = weewx.manager.Manager.open(self.archive_db_dict) + + def test_open_with_create_no_archive(self): + """Test open_with_create of a non-existent database and without supplying a schema.""" + with self.assertRaises(weedb.NoDatabaseError): + db_manager = weewx.manager.Manager.open_with_create(self.archive_db_dict) + + def test_open_with_create_uninitialized(self): + """Test open_with_create with a database that exists, but has not been initialized and + no schema has been supplied.""" + weedb.create(self.archive_db_dict) + with self.assertRaises(weedb.ProgrammingError): + db_manager = weewx.manager.Manager.open_with_create(self.archive_db_dict) + + def test_create_archive(self): + """Test open_with_create with a database that does not exist, while supplying a schema""" + archive = weewx.manager.Manager.open_with_create(self.archive_db_dict, schema=archive_schema) + self.assertEqual(archive.connection.tables(), ['archive']) + self.assertEqual(archive.connection.columnsOf('archive'), ['dateTime', 'usUnits', 'interval', 'barometer', 'inTemp', 'outTemp', 'windSpeed']) + archive.close() + + # Now that the database exists, these should also succeed: + archive = weewx.manager.Manager.open(self.archive_db_dict) + self.assertEqual(archive.connection.tables(), ['archive']) + self.assertEqual(archive.connection.columnsOf('archive'), ['dateTime', 'usUnits', 'interval', 'barometer', 'inTemp', 'outTemp', 'windSpeed']) + self.assertEqual(archive.sqlkeys, ['dateTime', 'usUnits', 'interval', 'barometer', 'inTemp', 'outTemp', 'windSpeed']) + self.assertEqual(archive.std_unit_system, None) + archive.close() + + def test_empty_archive(self): + archive = weewx.manager.Manager.open_with_create(self.archive_db_dict, schema=archive_schema) + self.assertEqual(archive.firstGoodStamp(), None) + self.assertEqual(archive.lastGoodStamp(), None) + self.assertEqual(archive.getRecord(123456789), None) + self.assertEqual(archive.getRecord(123456789, max_delta=1800), None) + archive.close() + + def test_add_archive_records(self): + # Add a bunch of records + self.populate_database() + + # Now test to see what's in there: + with weewx.manager.Manager.open(self.archive_db_dict) as archive: + self.assertEqual(archive.firstGoodStamp(), start_ts) + self.assertEqual(archive.lastGoodStamp(), stop_ts) + self.assertEqual(archive.std_unit_system, std_unit_system) + + expected_iterator = genRecords() + for _rec in archive.genBatchRecords(): + try: + _expected_rec = next(expected_iterator) + except StopIteration: + break + # Check that the missing windSpeed is None, then remove it in order to do the compare: + self.assertEqual(_rec.pop('windSpeed'), None) + self.assertEqual(_expected_rec, _rec) + + # Test adding an existing record. It should just quietly swallow it: + existing_record = {'dateTime': start_ts, 'interval': interval, 'usUnits' : 1, 'outTemp': 68.0} + archive.addRecord(existing_record) + + # Test changing the unit system. It should raise a UnitError exception: + metric_record = {'dateTime': stop_ts + interval, 'interval': interval, 'usUnits' : 16, 'outTemp': 20.0} + self.assertRaises(weewx.UnitError, archive.addRecord, metric_record) + + def test_get_records(self): + # Add a bunch of records + self.populate_database() + + # Now fetch them: + with weewx.manager.Manager.open(self.archive_db_dict) as archive: + # Test getSql on existing type: + bar0 = archive.getSql("SELECT barometer FROM archive WHERE dateTime=?", (start_ts,)) + self.assertEqual(bar0[0], barfunc(0)) + + # Test getSql on existing type, no record: + bar0 = archive.getSql("SELECT barometer FROM archive WHERE dateTime=?", (start_ts + 1,)) + self.assertEqual(bar0, None) + + # Try getSql on non-existing types + self.assertRaises(weedb.OperationalError, archive.getSql, "SELECT foo FROM archive WHERE dateTime=?", + (start_ts,)) + self.assertRaises(weedb.ProgrammingError, archive.getSql, "SELECT barometer FROM foo WHERE dateTime=?", + (start_ts,)) + + # Test genSql: + for (irec,_row) in enumerate(archive.genSql("SELECT barometer FROM archive;")): + self.assertEqual(_row[0], barfunc(irec)) + + itest = int(nrecs/2) + # Try getRecord(): + target_ts = timevec[itest] + _rec = archive.getRecord(target_ts) + # Check that the missing windSpeed is None, then remove it in order to do the compare: + self.assertEqual(_rec.pop('windSpeed'), None) + self.assertEqual(expected_record(itest), _rec) + + # Try finding the nearest neighbor below + target_ts = timevec[itest] + interval/100 + _rec = archive.getRecord(target_ts, max_delta=interval/50) + # Check that the missing windSpeed is None, then remove it in order to do the compare: + self.assertEqual(_rec.pop('windSpeed'), None) + self.assertEqual(expected_record(itest), _rec) + + # Try finding the nearest neighbor above + target_ts = timevec[itest] - interval/100 + _rec = archive.getRecord(target_ts, max_delta=interval/50) + # Check that the missing windSpeed is None, then remove it in order to do the compare: + self.assertEqual(_rec.pop('windSpeed'), None) + self.assertEqual(expected_record(itest), _rec) + + # Try finding a neighbor too far away: + target_ts = timevec[itest] - interval/2 + _rec = archive.getRecord(target_ts, max_delta=interval/50) + self.assertEqual(_rec, None) + + # Try finding a non-existent record: + target_ts = timevec[itest] + 1 + _rec = archive.getRecord(target_ts) + self.assertEqual(_rec, None) + + # Now try fetching them as vectors: + with weewx.manager.Manager.open(self.archive_db_dict) as archive: + # Return the values between start_ts and stop_ts, exclusive on the left, + # inclusive on the right. + # Recall that barvec returns a 3-way tuple of VectorTuples. + start_vt, stop_vt, data_vt = archive.getSqlVectors((start_ts, stop_ts), 'barometer') + # Build the expected series of stop times and data values. Note that the very first + # value in the database (at timestamp start_ts) is not included, so it should not be + # included in the expected results either. + expected_stop = [timefunc(irec) for irec in range(1, nrecs)] + expected_data = [barfunc(irec) for irec in range(1, nrecs)] + self.assertEqual(stop_vt, (expected_stop, "unix_epoch", "group_time")) + self.assertEqual(data_vt, (expected_data, "inHg", "group_pressure")) + + # Now try fetching the vectora gain, but using aggregation. + # Start by setting up a generator function that will return the records to be + # included in each aggregation + gen = gen_included_recs(timevec, start_ts, stop_ts, 6*interval) + with weewx.manager.Manager.open(self.archive_db_dict) as archive: + barvec = archive.getSqlVectors((start_ts, stop_ts), 'barometer', aggregate_type='avg', aggregate_interval=6*interval) + n_expected = int(nrecs / 6) + self.assertEqual(n_expected, len(barvec[0][0])) + for irec in range(n_expected): + # Get the set of records to be included in this aggregation: + recs = next(gen) + # Make sure the timestamp of the aggregation interval is the same as the last + # record to be included in the aggregation: + self.assertEqual(timevec[max(recs)], barvec[1][0][irec]) + # Calculate the expected average of the records included in the aggregation. + expected_avg = sum((barfunc(i) for i in recs)) / len(recs) + # Compare them. + self.assertAlmostEqual(expected_avg, barvec[2][0][irec]) + + def test_update(self): + # Add a bunch of records + self.populate_database() + expected_rec = expected_record(3) + with weewx.manager.Manager.open_with_create(self.archive_db_dict, schema=archive_schema) as archive: + archive.updateValue(expected_rec['dateTime'], 'outTemp', -1.0) + with weewx.manager.Manager.open_with_create(self.archive_db_dict, schema=archive_schema) as archive: + rec = archive.getRecord(expected_rec['dateTime']) + self.assertEqual(rec['outTemp'], -1.0) + + +class TestSqlite(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.archive_db_dict = archive_sqlite + super().__init__(*args, **kwargs) + + +class TestMySQL(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.archive_db_dict = archive_mysql + super().__init__(*args, **kwargs) + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + +def suite(): + tests = ['test_open_no_archive', 'test_open_unitialized_archive', + 'test_open_with_create_no_archive', 'test_open_with_create_uninitialized', + 'test_empty_archive', 'test_add_archive_records', 'test_get_records', 'test_update'] + suite = unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + suite.addTest(TestDatabaseDict('test_get_database_dict')) + return suite + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_engine.py b/dist/weewx-5.0.2/src/weewx/tests/test_engine.py new file mode 100644 index 0000000..4369fbd --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_engine.py @@ -0,0 +1,135 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the accumulators by using the simulator wx station""" + +import logging +import os.path +import sys +import time +import unittest + +import configobj + +import weedb +import weeutil.config +import weeutil.weeutil +import weewx.drivers.simulator +import weewx.engine +import weewx.manager +from weeutil.weeutil import to_int + +weewx.debug = 1 + +log = logging.getLogger(__name__) + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +# The types to actually test: +TEST_TYPES = ['outTemp', 'inTemp', 'barometer', 'windSpeed'] + +# Find the configuration file. It's assumed to be in the same directory as me: +config_path = os.path.join(os.path.dirname(__file__), "simgen.conf") + +try: + config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') +except IOError: + print("Unable to open configuration file %s" % config_path, file=sys.stderr) + # Reraise the exception (this will eventually cause the program to exit) + raise +except configobj.ConfigObjError: + print("Error while parsing configuration file %s" % config_path, file=sys.stderr) + raise + +weeutil.logger.setup('weetest_engine', config_dict) + + +class TestEngine(unittest.TestCase): + """Test the engine and accumulators using the simulator.""" + + def setUp(self): + global config_dict + self.config_dict = weeutil.config.deep_copy(config_dict) + + first_ts, last_ts = _get_first_last(self.config_dict) + + try: + with weewx.manager.open_manager_with_config(self.config_dict, + 'wx_binding') as dbmanager: + if dbmanager.firstGoodStamp() == first_ts and dbmanager.lastGoodStamp() == last_ts: + print("\nSimulator need not be run") + return + except weedb.OperationalError: + pass + + engine = weewx.engine.StdEngine(self.config_dict) + try: + # This will generate the simulator data: + engine.run() + except weewx.StopNow: + pass + + def test_archive_data(self): + + global TEST_TYPES + archive_interval = self.config_dict['StdArchive'].as_int('archive_interval') + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as archive: + for record in archive.genBatchRecords(): + start_ts = record['dateTime'] - archive_interval + # Calculate the average (throw away min and max): + _, _, obs_avg = calc_stats(self.config_dict, start_ts, record['dateTime']) + for obs_type in TEST_TYPES: + self.assertAlmostEqual(obs_avg[obs_type], record[obs_type], 2) + + +def _get_first_last(config_dict): + """Get the first and last archive record timestamps.""" + run_length = to_int(config_dict['Stopper']['run_length']) + start_tt = time.strptime(config_dict['Simulator']['start'], "%Y-%m-%dT%H:%M") + start_ts = time.mktime(start_tt) + first_ts = start_ts + config_dict['StdArchive'].as_int('archive_interval') + last_ts = start_ts + run_length * 3600.0 + return first_ts, last_ts + + +def calc_stats(config_dict, start_ts, stop_ts): + """Calculate the statistics directly from the simulator output.""" + global TEST_TYPES + + sim_start_tt = time.strptime(config_dict['Simulator']['start'], "%Y-%m-%dT%H:%M") + sim_start_ts = time.mktime(sim_start_tt) + + simulator = weewx.drivers.simulator.Simulator( + loop_interval=config_dict['Simulator'].as_int('loop_interval'), + mode='generator', + start_time=sim_start_ts, + resume_time=start_ts) + + obs_sum = dict(zip(TEST_TYPES, len(TEST_TYPES) * (0,))) + obs_min = dict(zip(TEST_TYPES, len(TEST_TYPES) * (None,))) + obs_max = dict(zip(TEST_TYPES, len(TEST_TYPES) * (None,))) + count = 0 + + for packet in simulator.genLoopPackets(): + if packet['dateTime'] > stop_ts: + break + for obs_type in TEST_TYPES: + obs_sum[obs_type] += packet[obs_type] + obs_min[obs_type] = packet[obs_type] if obs_min[obs_type] is None \ + else min(obs_min[obs_type], packet[obs_type]) + obs_max[obs_type] = packet[obs_type] if obs_max[obs_type] is None \ + else max(obs_max[obs_type], packet[obs_type]) + count += 1 + + obs_avg = {} + for obs_type in obs_sum: + obs_avg[obs_type] = obs_sum[obs_type] / count + + return obs_min, obs_max, obs_avg + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_manager.py b/dist/weewx-5.0.2/src/weewx/tests/test_manager.py new file mode 100644 index 0000000..c228802 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_manager.py @@ -0,0 +1,171 @@ +# +# Copyright (c) 2009-2021 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the weighted sums in the daily summary. Most of tests for the daily summaries are in module +test_daily. However, gen_fake_data.configDatabase() speeds things up by inserting a bunch of +records in the archive table *then* building the daily summary, which means it does not test +building the daily summaries by using addRecord(). + +The tests in this module take a different strategy by using addRecord() directly. This takes a lot +longer, so the tests use a more abbreviated database. + +This file also tests reweighting the weighted sums. + +It also tests the V4.3 and v4.4 patches. +""" +import datetime +import logging +import os +import time +import unittest + +import gen_fake_data +import schemas.wview_small +import weedb +import weeutil.logger +import weewx.manager + +log = logging.getLogger(__name__) + +weeutil.logger.setup('weetest_manager') +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +# Things go *much* faster if we use an abbreviated schema +schema = schemas.wview_small.schema + +# Archive interval of one hour +interval_secs = 3600 +# Twelve days worth of data. +start_d = datetime.date(2020, 10, 26) +stop_d = datetime.date(2020, 11, 7) +# This starts and ends on day boundaries: +start_ts = int(time.mktime(start_d.timetuple())) + interval_secs +stop_ts = int(time.mktime(stop_d.timetuple())) +# Add a little data to both ends, so the data do not start and end on day boundaries: +start_ts -= 4 * interval_secs +stop_ts += 4 * interval_secs + +# Find something roughly near the half way mark +mid_d = datetime.date(2020, 11, 1) +mid_ts = int(time.mktime(mid_d.timetuple())) + +db_dict_sqlite = { + 'driver': 'weedb.sqlite', + # Use an in-memory database: + 'database_name': ':memory:', + # Can be useful for testing: + # 'SQLITE_ROOT': '/var/tmp/weewx_test', + # 'database_name': 'testmgr.sdb', +} + +db_dict_mysql = { + 'host': 'localhost', + 'user': 'weewx', + 'password': 'weewx', + 'database_name': 'test_scratch', + 'driver': 'weedb.mysql', +} + + +class CommonWeightTests(object): + """Test that inserting records get the weighted sums right. Regression test for issue #623. """ + + def test_weights(self): + """Check that the weighted sums were done correctly.""" + self.check_weights() + + def test_reweight(self): + """Check that recalculating the weighted sums was done correctly""" + self.db_manager.recalculate_weights() + self.check_weights() + + def check_weights(self): + # check weights for scalar types + for key in self.db_manager.daykeys: + archive_key = key if key != 'wind' else 'windSpeed' + result1 = self.db_manager.getSql("SELECT COUNT(%s) FROM archive" % archive_key) + result2 = self.db_manager.getSql("SELECT SUM(count) FROM archive_day_%s;" % key) + self.assertEqual(result1, result2) + result3 = self.db_manager.getSql("SELECT COUNT(%s) * %d FROM archive" + % (archive_key, interval_secs)) + result4 = self.db_manager.getSql("SELECT SUM(sumtime) FROM archive_day_%s" % key) + self.assertEqual(result3, result4) + + result5 = self.db_manager.getSql("SELECT SUM(%s * `interval` * 60) FROM archive" + % archive_key) + result6 = self.db_manager.getSql("SELECT SUM(wsum) FROM archive_day_%s" % key) + if result5[0] is None: + self.assertEqual(result6[0], 0.0) + else: + self.assertAlmostEqual(result5[0], result6[0], 3) + # check weights for vector types, for now that is just type wind + result7 = self.db_manager.getSql("SELECT SUM(xsum), SUM(ysum), SUM(dirsumtime) FROM archive_day_wind") + self.assertAlmostEqual(result7[0], 5032317.021, 3) + self.assertAlmostEqual(result7[1], -2600.126, 3) + self.assertEqual(result7[2], 1040400) + + +class TestSqliteWeights(CommonWeightTests, unittest.TestCase): + """Test using the SQLite database""" + + def setUp(self): + self.db_manager = setup_database(db_dict_sqlite) + + # The patch test is done with sqlite only, because it is so much faster + def test_patch(self): + # Sanity check that the original database is at V4.0 + self.assertEqual(self.db_manager.version, weewx.manager.DaySummaryManager.version) + + # Bugger up roughly half the database + with weedb.Transaction(self.db_manager.connection) as cursor: + for key in self.db_manager.daykeys: + sql_update = "UPDATE %s_day_%s SET wsum=sum, sumtime=count WHERE dateTime >?" \ + % (self.db_manager.table_name, key) + cursor.execute(sql_update, (mid_ts,)) + + # Force the patch (could use '2.0' or '3.0': + self.db_manager.version = '2.0' + + self.db_manager.patch_sums() + self.check_weights() + + # Make sure the version was set to V4.0 after the patch + self.assertEqual(self.db_manager.version, weewx.manager.DaySummaryManager.version) + + +class TestMySQLWeights(CommonWeightTests, unittest.TestCase): + """Test using the MySQL database""" + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + + self.db_manager = setup_database(db_dict_mysql) + + +def setup_database(db_dict): + """Set up a database by using addRecord()""" + try: + # Drop the old database + weedb.drop(db_dict) + except weedb.NoDatabaseError: + pass + # Get a new database by initializing with the schema + db_manager = weewx.manager.DaySummaryManager.open_with_create(db_dict, schema=schema) + + # Populate the database. By passing in a generator, it is all done as one transaction. + db_manager.addRecord(gen_fake_data.genFakeRecords(start_ts, stop_ts, interval=interval_secs)) + + return db_manager + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_reportengine.py b/dist/weewx-5.0.2/src/weewx/tests/test_reportengine.py new file mode 100644 index 0000000..be8f8a2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_reportengine.py @@ -0,0 +1,203 @@ +# +# Copyright (c) 2021-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test algorithms in the Report Engine""" + +import logging +import os.path +import unittest + +import weeutil.config +import weeutil.logger +import weeutil.weeutil +import weewx +from weewx.reportengine import build_skin_dict + +log = logging.getLogger(__name__) +weewx.debug = 1 + +# Find where the skins are stored. Unfortunately, the following strategy won't work if the +# resources are stored as a zip file. But, the alternative is too messy. After all, this is just +# for testing. +with weeutil.weeutil.get_resource_path('weewx_data', 'skins') as skin_resource: + SKIN_DIR = skin_resource + +CONFIG_DICT_INI = f""" +WEEWX_ROOT = '../../..' + +[StdReport] + SKIN_ROOT = {SKIN_DIR} + [[SeasonsReport]] + skin = Seasons + [[Defaults]] +""" +# Find WEEWX_ROOT by working up from this file's location: +CONFIG_DICT = weeutil.config.config_from_str(CONFIG_DICT_INI) +CONFIG_DICT['WEEWX_ROOT'] = os.path.normpath(os.path.join(os.path.dirname(__file__), '../../..')) + +weeutil.logger.setup('weetest_reportengine', CONFIG_DICT) + + +class TestReportEngine(unittest.TestCase): + """Test elements of StdReportEngine""" + + def setUp(self): + self.config_dict = weeutil.config.deep_copy(CONFIG_DICT) + + def test_default(self): + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'inHg') + self.assertEqual(skin_dict['Units']['Labels']['day'], [" day", " days"]) + # Without a 'lang' entry, there should not be a 'Texts' section: + self.assertNotIn('Texts', skin_dict) + + def test_defaults_with_defaults_override(self): + """Test override in [[Defaults]]""" + # Override the units to be used for group pressure + self.config_dict['StdReport']['Defaults'].update( + {'Units': {'Groups': {'group_pressure': 'mbar'}}}) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'mbar') + + def test_defaults_with_reports_override(self): + """Test override for a specific report""" + # Override the units to be used for group pressure + self.config_dict['StdReport']['SeasonsReport'].update( + {'Units': {'Groups': {'group_pressure': 'mbar'}}}) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'mbar') + + def test_override_unit_system_in_defaults(self): + """Test specifying a unit system in [[Defaults]]""" + # Specify that metric be used by default + self.config_dict['StdReport']['Defaults']['unit_system'] = 'metricwx' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'mbar') + + def test_override_unit_system_in_report(self): + """Test specifying a unit system for a specific report""" + # Specify that metric be used for this specific report + self.config_dict['StdReport']['SeasonsReport']['unit_system'] = 'metricwx' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'mbar') + + def test_override_unit_system_in_report_and_defaults(self): + """Test specifying a unit system for a specific report, versus overriding a unit + in the [[Defaults]] section""" + # Specify that US be used for this specific report + self.config_dict['StdReport']['SeasonsReport']['unit_system'] = 'us' + # But override the default unit to be used for pressure + self.config_dict['StdReport']['Defaults'].update( + {'Units': {'Groups': {'group_pressure': 'mbar'}}}) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # Because the override for the specific report has precedence, + # the units should be unchanged. + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'inHg') + + def test_override_unit_system_in_report_and_unit_in_defaults(self): + """Test a default unit system, versus overriding a unit for a specific report. + This is basically the inverse of the above.""" + # Specify that US be used as a default + self.config_dict['StdReport']['Defaults']['unit_system'] = 'us' + # But override the default unit to be used for pressure for a specific report + self.config_dict['StdReport']['SeasonsReport'].update( + {'Units': {'Groups': {'group_pressure': 'mbar'}}}) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # The override for the specific report should take precedence. + self.assertEqual(skin_dict['Units']['Groups']['group_pressure'], 'mbar') + + def test_defaults_lang(self): + """Test adding a lang spec to a report""" + # Specify that the default language is German + self.config_dict['StdReport']['Defaults']['lang'] = 'de' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # That should change the unit system, as well as make translation texts available. + self.assertEqual(skin_dict['Units']['Groups']['group_rain'], 'mm') + self.assertEqual(skin_dict['Units']['Labels']['day'], [" Tag", " Tage"]) + + def test_report_lang(self): + """Test adding a lang spec to a specific report""" + # Specify that the default should be French... + self.config_dict['StdReport']['Defaults']['lang'] = 'fr' + # ... but ask for German for this report. + self.config_dict['StdReport']['SeasonsReport']['lang'] = 'de' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # The results should reflect German + self.assertEqual(skin_dict['Units']['Groups']['group_rain'], 'mm') + self.assertEqual(skin_dict['Units']['Labels']['day'], [" Tag", " Tage"]) + + def test_override_lang(self): + """Test using a language spec in Defaults, as well as overriding a label.""" + self.config_dict['StdReport']['Defaults']['lang'] = 'de' + self.config_dict['StdReport']['Defaults'].update( + {'Labels': {'Generic': {'inTemp': 'foo'}}} + ) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Labels']['Generic']['inTemp'], 'foo') + + def test_override_lang2(self): + """Test using a language spec in Defaults, then override for a specific report""" + self.config_dict['StdReport']['Defaults']['lang'] = 'de' + self.config_dict['StdReport']['SeasonsReport'].update( + {'Labels': {'Generic': {'inTemp': 'foo'}}} + ) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['Labels']['Generic']['inTemp'], 'foo') + + def test_override_lang3(self): + """Test using a language spec in a report, then override a label in Defaults.""" + self.config_dict['StdReport']['SeasonsReport']['lang'] = 'de' + self.config_dict['StdReport']['Defaults'].update( + {'Labels': {'Generic': {'inTemp': 'foo'}}} + ) + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # Because the override for the specific report has precedence, we should stay with German. + self.assertEqual(skin_dict['Labels']['Generic']['inTemp'], 'Raumtemperatur') + + def test_lang_override_unit_system_defaults(self): + """Specifying a language can specify a unit system. Test overriding it. + Test [[Defaults]]""" + self.config_dict['StdReport']['Defaults']['lang'] = 'de' + self.config_dict['StdReport']['Defaults']['unit_system'] = 'metric' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # The unit_system override should win. NB: 'metric' uses cm for rain. 'metricwx' uses mm. + self.assertEqual(skin_dict['Units']['Groups']['group_rain'], 'cm') + + def test_lang_override_unit_system_report(self): + """Specifying a language can specify a unit system. Test overriding it. + Test a specific report""" + self.config_dict['StdReport']['SeasonsReport']['lang'] = 'de' + self.config_dict['StdReport']['SeasonsReport']['unit_system'] = 'metric' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + # The unit_system override should win. NB: 'metric' uses cm for rain. 'metricwx' uses mm. + self.assertEqual(skin_dict['Units']['Groups']['group_rain'], 'cm') + + def test_override_root(self): + self.config_dict['StdReport']['SeasonsReport']['SKIN_ROOT'] = 'alt_skins' + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertEqual(skin_dict['SKIN_ROOT'], 'alt_skins') + + def test_default_log_specs(self): + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertTrue(skin_dict['log_success']) + + def test_override_defaults_log_specs(self): + self.config_dict['StdReport']['Defaults']['log_success'] = False + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertFalse(skin_dict['log_success']) + + def test_override_report_log_specs(self): + self.config_dict['StdReport']['log_success'] = False + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertFalse(skin_dict['log_success']) + + def test_global_override_log_specs(self): + self.config_dict['log_success'] = False + skin_dict = build_skin_dict(self.config_dict, 'SeasonsReport') + self.assertFalse(skin_dict['log_success']) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_restx.py b/dist/weewx-5.0.2/src/weewx/tests/test_restx.py new file mode 100644 index 0000000..cd02874 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_restx.py @@ -0,0 +1,251 @@ +# +# Copyright (c) 2019 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test restx services""" + +import http.client +import os +import queue +import time +import unittest +import urllib.parse +from unittest import mock + +import weewx +import weewx.restx + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + + +class MatchRequest(object): + """Allows equality testing between Request objects""" + + def __init__(self, url, user_agent): + # This is what I'm expecting: + self.url = url + self.user_agent = user_agent + + def __eq__(self, req): + """Check for equality between myself and a Request object""" + + other_split = urllib.parse.urlsplit(req.get_full_url()) + other_query = urllib.parse.parse_qs(other_split.query) + my_split = urllib.parse.urlsplit(self.url) + my_query = urllib.parse.parse_qs(my_split.query) + + # In what follows, we could just return 'False', but raising the AssertionError directly + # gives more useful error messages. + + if other_split.hostname != my_split.hostname: + raise AssertionError( + "Mismatched hostnames: \nActual:'%s'\nExpect:'%s' " % (other_split.hostname, my_split.hostname)) + if other_query != my_query: + raise AssertionError("Mismatched queries: \nActual:'%s'\nExpect:'%s' " % (other_query, my_query)) + if req.headers.get('User-agent') != self.user_agent: + raise AssertionError("Mismatched user-agent: \nActual:'%s'\nExpect:'%s'" + % (req.headers.get('User-agent'), self.user_agent)) + return True + + +def get_record(): + """Get a record that is to be posted to the RESTful service.""" + ts = time.mktime(time.strptime("2018-03-22", "%Y-%m-%d")) + record = {'dateTime': ts, + 'usUnits': weewx.US, + 'interval': 5, + 'outTemp': 20.0, + 'inTemp': 70.0, + 'barometer': 30.1 + } + return record + + +class TestAmbient(unittest.TestCase): + """Test the Ambient RESTful protocol""" + + # These don't really matter. We'll be testing that the URL we end up with is + # the one we were expecting. + station = 'KBZABCDEF3' + password = 'somepassword' + server_url = 'http://www.testserver.com/testapi' + protocol_name = 'Test-Ambient' + + def get_openurl_patcher(self, code=200, response_body=[], side_effect=None): + """Get a patch object for a post to urllib.request.urlopen + + code: The response code that should be returned by the mock urlopen(). + + response_body: The response body that should be returned by the mock urlopen(). + Should be an iterable. + + side_effect: Any side effect to be done. Set to an exception class to have + an exception raised by the mock urlopen(). + """ + # Mock up the urlopen + patcher = mock.patch('weewx.restx.urllib.request.urlopen') + mock_urlopen = patcher.start() + # Set up its return value. It will be a MagicMock object. + mock_urlopen.return_value = mock.MagicMock() + # Add a return code + mock_urlopen.return_value.code = code + # And something we can iterate over for the response body + mock_urlopen.return_value.__iter__.return_value = iter(response_body) + if side_effect: + mock_urlopen.side_effect = side_effect + # This will insure that patcher.stop() method will get called after a test + self.addCleanup(patcher.stop) + return mock_urlopen + + def test_request(self): + """Test of a normal GET post to an Ambient uploading service""" + + # Get the mock urlopen() + mock_urlopen = self.get_openurl_patcher() + q = queue.Queue() + obj = weewx.restx.AmbientThread(q, + manager_dict=None, + station=TestAmbient.station, + password=TestAmbient.password, + server_url=TestAmbient.server_url, + protocol_name=TestAmbient.protocol_name, + max_tries=1, + log_success=True, + log_failure=True, + ) + record = get_record() + q.put(record) + q.put(None) + # Set up mocks of log.debug() and log.info(). Then we'll check that they got called as we expected + with mock.patch('weewx.restx.log.debug') as mock_logdbg: + with mock.patch('weewx.restx.log.info') as mock_loginf: + obj.run() + mock_logdbg.assert_not_called() + # loginf() should have been called once with the success + mock_loginf.assert_called_once_with('Test-Ambient: Published record ' + '2018-03-22 00:00:00 PDT (1521702000)') + + # Now check that our mock urlopen() was called with the parameters we expected. + matcher = TestAmbient.get_matcher(TestAmbient.server_url, TestAmbient.station, TestAmbient.password) + mock_urlopen.assert_called_once_with(matcher, data=None, timeout=10) + + def test_request_with_indoor(self): + """Test of a normal GET post to an Ambient uploading service, but include indoor temperature""" + + mock_urlopen = self.get_openurl_patcher() + q = queue.Queue() + obj = weewx.restx.AmbientThread(q, + manager_dict=None, + station=TestAmbient.station, + password=TestAmbient.password, + server_url=TestAmbient.server_url, + protocol_name=TestAmbient.protocol_name, + max_tries=1, + log_success=True, + log_failure=True, + post_indoor_observations=True # Set to True this time! + ) + record = get_record() + q.put(record) + q.put(None) + # Set up mocks of log.debug() and log.info(). Then we'll check that they got called as we expected + with mock.patch('weewx.restx.log.debug') as mock_logdbg: + with mock.patch('weewx.restx.log.info') as mock_loginf: + obj.run() + mock_logdbg.assert_not_called() + # loginf() should have been called once with the success + mock_loginf.assert_called_once_with('Test-Ambient: Published record ' + '2018-03-22 00:00:00 PDT (1521702000)') + + # Now check that our mock urlopen() was called with the parameters we expected. + matcher = TestAmbient.get_matcher(TestAmbient.server_url, TestAmbient.station, TestAmbient.password, + include_indoor=True) + mock_urlopen.assert_called_once_with(matcher, data=None, timeout=10) + + def test_bad_response_request(self): + """Test response to a bad request""" + + # This will get a mocked version of urlopen, which will return a 401 code + mock_urlopen = self.get_openurl_patcher(code=401, response_body=['unauthorized']) + q = queue.Queue() + obj = weewx.restx.AmbientThread(q, + manager_dict=None, + station=TestAmbient.station, + password=TestAmbient.password, + server_url=TestAmbient.server_url, + protocol_name=TestAmbient.protocol_name, + max_tries=1, + log_success=True, + log_failure=True, + ) + record = get_record() + q.put(record) + q.put(None) + # Set up mocks of log.debug() and log.error(). Then we'll check that they got called as we expected + with mock.patch('weewx.restx.log.debug') as mock_logdbg: + with mock.patch('weewx.restx.log.error') as mock_logerr: + obj.run() + # log.debug() should have been called twice... + mock_logdbg.assert_called_once_with('Test-Ambient: Failed upload attempt 1: Code 401') + # ... and log.error() once with the failed post. + mock_logerr.assert_called_once_with('Test-Ambient: Failed to publish record ' + '2018-03-22 00:00:00 PDT (1521702000): ' + 'Failed upload after 1 tries') + + # Now check that our mock urlopen() was called with the parameters we expected. + matcher = TestAmbient.get_matcher(TestAmbient.server_url, TestAmbient.station, TestAmbient.password) + mock_urlopen.assert_called_once_with(matcher, data=None, timeout=10) + + def test_bad_http_request(self): + """Test response to raising an exception during a post""" + + # Get a mock version of urlopen(), but with the side effect of having an exception of + # type http.client.HTTPException raised when it's called + mock_urlopen = self.get_openurl_patcher(side_effect=http.client.HTTPException("oops")) + + q = queue.Queue() + obj = weewx.restx.AmbientThread(q, + manager_dict=None, + station=TestAmbient.station, + password=TestAmbient.password, + server_url=TestAmbient.server_url, + protocol_name=TestAmbient.protocol_name, + max_tries=1, + log_success=True, + log_failure=True, + ) + record = get_record() + q.put(record) + q.put(None) + # Set up mocks of log.debug() and log.error(). Then we'll check that they got called as we expected + with mock.patch('weewx.restx.log.debug') as mock_logdbg: + with mock.patch('weewx.restx.log.error') as mock_logerr: + obj.run() + # log.debug() should have been called twice... + mock_logdbg.assert_called_once_with('Test-Ambient: Failed upload attempt 1: oops') + # ... and log.error() once with the failed post. + mock_logerr.assert_called_once_with('Test-Ambient: Failed to publish record ' + '2018-03-22 00:00:00 PDT (1521702000): ' + 'Failed upload after 1 tries') + + # Now check that our mock urlopen() was called with the parameters we expected. + matcher = TestAmbient.get_matcher(TestAmbient.server_url, TestAmbient.station, TestAmbient.password) + mock_urlopen.assert_called_once_with(matcher, data=None, timeout=10) + + @staticmethod + def get_matcher(server_url, station, password, include_indoor=False): + """Return a MatchRequest object that will test against what we expected""" + url = '%s?action=updateraw&ID=%s&PASSWORD=%s&softwaretype=weewx-%s' \ + '&dateutc=2018-03-22%%2007%%3A00%%3A00' \ + '&baromin=30.100&tempf=20.0' \ + % (server_url, station, password, weewx.__version__) + if include_indoor: + url += "&indoortempf=70.0" + matcher = MatchRequest(url, 'weewx/%s' % weewx.__version__) + return matcher + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_series.py b/dist/weewx-5.0.2/src/weewx/tests/test_series.py new file mode 100644 index 0000000..d1fa166 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_series.py @@ -0,0 +1,358 @@ +# +# Copyright (c) 2018-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test weewx.xtypes.get_series""" + +import functools +import math +import os.path +import sys +import time +import unittest + +import configobj + +import gen_fake_data +import weewx +import weewx.units +import weewx.wxformulas +import weewx.xtypes +from weeutil.weeutil import TimeSpan + +weewx.debug = 1 + +# Find the configuration file. It's assumed to be in the same directory as me: +config_path = os.path.join(os.path.dirname(__file__), "testgen.conf") +cwd = None + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() +month_start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) +month_stop_tt = (2010, 4, 1, 0, 0, 0, 0, 0, -1) +start_ts = time.mktime(month_start_tt) +stop_ts = time.mktime(month_stop_tt) + + +class VaporPressure(weewx.xtypes.XType): + """Calculate VaporPressure. Used to test generating series of a user-defined type.""" + + def get_scalar(self, obs_type, record, db_manager): + # We only know how to calculate 'vapor_p'. For everything else, raise an exception + if obs_type != 'vapor_p': + raise weewx.UnknownType(obs_type) + + # We need outTemp in order to do the calculation. + if 'outTemp' not in record or record['outTemp'] is None: + raise weewx.CannotCalculate(obs_type) + + # We have everything we need. Start by forming a ValueTuple for the outside temperature. + # To do this, figure out what unit and group the record is in ... + unit_and_group = weewx.units.getStandardUnitType(record['usUnits'], 'outTemp') + # ... then form the ValueTuple. + outTemp_vt = weewx.units.ValueTuple(record['outTemp'], *unit_and_group) + + # We need the temperature in Kelvin + outTemp_K_vt = weewx.units.convert(outTemp_vt, 'degree_K') + + # Now we can use the formula. Results will be in mmHg. Create a ValueTuple out of it: + p_vt = weewx.units.ValueTuple(math.exp(20.386 - 5132.0 / outTemp_K_vt[0]), + 'mmHg', + 'group_pressure') + + # We have the vapor pressure as a ValueTuple. Convert it back to the units used by + # the incoming record and return it + return weewx.units.convertStd(p_vt, record['usUnits']) + + +# Register vapor pressure +weewx.units.obs_group_dict['vapor_p'] = "group_pressure" +# Instantiate and register an instance of VaporPressure: +weewx.xtypes.xtypes.append(VaporPressure()) + + +class Common(object): + # These are the expected results for March 2010 + expected_daily_rain_sum = [0.00, 0.68, 0.60, 0.00, 0.00, 0.68, 0.60, 0.00, 0.00, 0.68, 0.60, + 0.00, 0.00, 0.52, 0.76, 0.00, 0.00, 0.52, 0.76, 0.00, 0.00, 0.52, + 0.76, 0.00, 0.00, 0.52, 0.76, 0.00, 0.00, 0.52, 0.76] + + expected_daily_wind_avg = [(-1.39, 3.25), (11.50, 9.43), (11.07, -9.64), (-1.39, -3.03), + (-1.34, 3.29), (11.66, 9.37), (11.13, -9.76), (-1.35, -3.11), + (-1.38, 3.35), (11.68, 9.48), (11.14, -9.60), (-1.37, -3.09), + (-1.34, 3.24), (11.21, 9.78), (12.08, -9.24), (-1.37, -3.57), + (-1.35, 2.91), (10.70, 9.93), (12.07, -9.13), (-1.33, -3.50), + (-1.38, 2.84), (10.65, 9.82), (11.89, -9.21), (-1.38, -3.47), + (-1.34, 2.88), (10.83, 9.77), (11.97, -9.31), (-1.35, -3.54), + (-1.37, 2.93), (10.82, 9.88), (11.97, -9.16)] + + expected_daily_wind_last = [(0.00, 10.00), (20.00, 0.00), (0.00, -10.00), (0.00, 0.00), + (0.00, 10.00), (20.00, 0.00), (0.00, -10.00), (0.00, 0.00), + (0.00, 10.00), (20.00, 0.00), (0.00, -10.00), (0.00, 0.00), + (0.00, 10.00), (19.94, 1.31), (0.70, -10.63), (-0.02, 0.00), + (-0.61, 9.33), (19.94, 1.31), (0.70, -10.63), (-0.02, 0.00), + (-0.61, 9.33), (19.94, 1.31), (0.70, -10.63), (-0.02, 0.00), + (-0.61, 9.33), (19.94, 1.31), (0.70, -10.63), (-0.02, 0.00), + (-0.61, 9.33), (19.94, 1.31), (0.70, -10.63)] + + expected_vapor_pressures = [0.052, 0.052, None, 0.053, 0.055, 0.058] + expected_aggregate_vapor_pressures = [0.055, 0.130, 0.238, 0.119, None, 0.133, 0.243, 0.122, + 0.058, 0.136, None, 0.125, 0.060, 0.139, 0.254, 0.128, + None, 0.143, 0.260, 0.131, 0.063, 0.146, None, 0.135, + 0.064, 0.150, 0.272, 0.138, None, 0.153, 0.279, 0.141, + 0.068, 0.157, None, 0.145, 0.070, 0.161, 0.292, 0.149, + None, 0.165, 0.299, 0.152, 0.074, None, 0.306, 0.156, + 0.076, 0.173, 0.313, None, 0.076, 0.148, 0.317, 0.196, + 0.081, None, 0.325, 0.201, 0.083, 0.156, 0.333, None, + 0.086, 0.160, 0.341, 0.211, 0.088, None, 0.349, 0.217, + 0.091, 0.168, 0.357, None, 0.093, 0.173, 0.366, 0.228, + 0.096, None, 0.375, 0.234, 0.098, 0.182, 0.384, None, + 0.101, 0.187, 0.393, 0.246, 0.104, None, 0.403, 0.252, + 0.107, 0.197, 0.413, None, 0.110, 0.202, 0.423, 0.265, + 0.113, None, 0.433, 0.272, 0.116, 0.212, 0.444, None, + 0.120, 0.218, 0.454, 0.286, 0.123, None, 0.465, 0.293, + 0.126, 0.229, 0.477, None] + + def setUp(self): + global config_path + global cwd + + # Save and set the current working directory in case some service changes it. + if cwd: + os.chdir(cwd) + else: + cwd = os.getcwd() + + try: + self.config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') + except IOError: + sys.stderr.write("Unable to open configuration file %s" % config_path) + # Reraise the exception (this will eventually cause the program to exit) + raise + except configobj.ConfigObjError: + sys.stderr.write("Error while parsing configuration file %s" % config_path) + raise + + # This will generate the test databases if necessary: + gen_fake_data.configDatabases(self.config_dict, database_type=self.database_type) + + def tearDown(self): + pass + + def test_get_series_archive_outTemp(self): + """Test a series of outTemp with no aggregation, run against the archive table.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + start_vec, stop_vec, data_vec \ + = weewx.xtypes.ArchiveTable.get_series('outTemp', + TimeSpan(start_ts, stop_ts), + db_manager) + self.assertEqual(len(start_vec[0]), (stop_ts - start_ts) / gen_fake_data.interval) + self.assertEqual(len(stop_vec[0]), (stop_ts - start_ts) / gen_fake_data.interval) + self.assertEqual(len(data_vec[0]), (stop_ts - start_ts) / gen_fake_data.interval) + + def test_get_series_daily_agg_rain_sum(self): + """Test a series of daily aggregated rain totals, run against the daily summaries""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Calculate the total daily rain + start_vec, stop_vec, data_vec \ + = weewx.xtypes.DailySummaries.get_series('rain', + TimeSpan(start_ts, stop_ts), + db_manager, + 'sum', + 'day') + # March has 30 days. + self.assertEqual(len(start_vec[0]), 30 + 1) + self.assertEqual(len(stop_vec[0]), 30 + 1) + self.assertEqual((["%.2f" % d for d in data_vec[0]], data_vec[1], data_vec[2]), + (["%.2f" % d for d in Common.expected_daily_rain_sum], 'inch', + 'group_rain')) + + def test_get_series_archive_agg_rain_sum(self): + """Test a series of daily aggregated rain totals, run against the main archive table""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Calculate the total daily rain + start_vec, stop_vec, data_vec \ + = weewx.xtypes.ArchiveTable.get_series('rain', + TimeSpan(start_ts, stop_ts), + db_manager, + 'sum', + 'day') + # March has 30 days. + self.assertEqual(len(start_vec[0]), 30 + 1) + self.assertEqual(len(stop_vec[0]), 30 + 1) + self.assertEqual((["%.2f" % d for d in data_vec[0]], data_vec[1], data_vec[2]), + (["%.2f" % d for d in Common.expected_daily_rain_sum], 'inch', + 'group_rain')) + + def test_get_series_archive_agg_rain_cum(self): + """Test a series of daily cumulative rain totals, run against the main archive table.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Calculate the cumulative total daily rain + start_vec, stop_vec, data_vec \ + = weewx.xtypes.ArchiveTable.get_series('rain', + TimeSpan(start_ts, stop_ts), + db_manager, + 'cumulative', + 24 * 3600) + # March has 30 days. + self.assertEqual(len(start_vec[0]), 30 + 1) + self.assertEqual(len(stop_vec[0]), 30 + 1) + right_answer = functools.reduce(lambda v, x: v + [v[-1] + x], + Common.expected_daily_rain_sum, [0])[1:] + self.assertEqual((["%.2f" % d for d in data_vec[0]], data_vec[1], data_vec[2]), + (["%.2f" % d for d in right_answer], 'inch', 'group_rain')) + + def test_get_series_archive_windvec(self): + """Test a series of 'windvec', with no aggregation, run against the main archive table""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Get a series of wind values + start_vec, stop_vec, data_vec \ + = weewx.xtypes.WindVec.get_series('windvec', + TimeSpan(start_ts, stop_ts), + db_manager) + self.assertEqual(len(start_vec[0]), (stop_ts - start_ts) / gen_fake_data.interval + 1) + self.assertEqual(len(stop_vec[0]), (stop_ts - start_ts) / gen_fake_data.interval + 1) + self.assertEqual(len(data_vec[0]), (stop_ts - start_ts) / gen_fake_data.interval + 1) + + def test_get_series_archive_agg_windvec_avg(self): + """Test a series of 'windvec', with 'avg' aggregation. This will exercise + WindVec.get_series(0), which, in turn, will call WindVecDaily.get_aggregate() to get each + individual aggregate value.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Get a series of wind values + start_vec, stop_vec, data_vec \ + = weewx.xtypes.WindVec.get_series('windvec', + TimeSpan(start_ts, stop_ts), + db_manager, + 'avg', + 24 * 3600) + # March has 30 days. + self.assertEqual(len(start_vec[0]), 30 + 1) + self.assertEqual(len(stop_vec[0]), 30 + 1) + self.assertEqual((["(%.2f, %.2f)" % (x.real, x.imag) for x in data_vec[0]]), + (["(%.2f, %.2f)" % (x[0], x[1]) for x in Common.expected_daily_wind_avg])) + + def test_get_series_archive_agg_windvec_last(self): + """Test a series of 'windvec', with 'last' aggregation. This will exercise + WindVec.get_series(), which, in turn, will call WindVec.get_aggregate() to get each + individual aggregate value.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Get a series of wind values + start_vec, stop_vec, data_vec = weewx.xtypes.get_series('windvec', + TimeSpan(start_ts, stop_ts), + db_manager, + 'last', + 24 * 3600) + # March has 30 days. + self.assertEqual(len(start_vec[0]), 30 + 1) + self.assertEqual(len(stop_vec[0]), 30 + 1) + # The round(x, 2) + 0 is necessary to avoid 0.00 comparing different from -0.00. + self.assertEqual( + (["(%.2f, %.2f)" % (round(x.real, 2) + 0, round(x.imag, 2) + 0) for x in data_vec[0]]), + (["(%.2f, %.2f)" % (x[0], x[1]) for x in Common.expected_daily_wind_last])) + + def test_get_aggregate_windvec_last(self): + """Test getting a windvec aggregation over a period that does not fall on midnight + boundaries.""" + # This time span was chosen because it includes a null value. + start_tt = (2010, 3, 2, 12, 0, 0, 0, 0, -1) + start = time.mktime(start_tt) # = 1267560000 + stop = start + 6 * 3600 + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + # Get a simple 'avg' aggregation over this period + val_t = weewx.xtypes.WindVec.get_aggregate('windvec', + TimeSpan(start, stop), + 'avg', + db_manager) + self.assertEqual(type(val_t[0]), complex) + self.assertAlmostEqual(val_t[0].real, 15.37441, 5) + self.assertAlmostEqual(val_t[0].imag, 9.79138, 5) + self.assertEqual(val_t[1], 'mile_per_hour') + self.assertEqual(val_t[2], 'group_speed') + + def test_get_series_on_the_fly(self): + """Test a series of a user-defined type with no aggregation, + run against the archive table.""" + # This time span was chosen because it includes a null for outTemp at 0330 + start_tt = (2010, 3, 2, 2, 0, 0, 0, 0, -1) + stop_tt = (2010, 3, 2, 5, 0, 0, 0, 0, -1) + start = time.mktime(start_tt) + stop = time.mktime(stop_tt) + + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + start_vec, stop_vec, data_vec \ + = weewx.xtypes.get_series('vapor_p', + TimeSpan(start, stop), + db_manager) + + for actual, expected in zip(data_vec[0], Common.expected_vapor_pressures): + self.assertAlmostEqual(actual, expected, 3) + self.assertEqual(data_vec[1], 'inHg') + self.assertEqual(data_vec[2], 'group_pressure') + + def test_get_aggregate_series_on_the_fly(self): + """Test a series of a user-defined type with aggregation, run against the archive table.""" + + start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) + stop_tt = (2010, 4, 1, 0, 0, 0, 0, 0, -1) + start = time.mktime(start_tt) + stop = time.mktime(stop_tt) + + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + start_vec, stop_vec, data_vec \ + = weewx.xtypes.get_series('vapor_p', + TimeSpan(start, stop), + db_manager, + aggregate_type='avg', + aggregate_interval=6 * 3600) + + for actual, expected in zip(data_vec[0], Common.expected_aggregate_vapor_pressures): + self.assertAlmostEqual(actual, expected, 3) + self.assertEqual(data_vec[1], 'inHg') + self.assertEqual(data_vec[2], 'group_pressure') + + +class TestSqlite(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "sqlite" + super().__init__(*args, **kwargs) + + +class TestMySQL(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "mysql" + super().__init__(*args, **kwargs) + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + +def suite(): + tests = [ + 'test_get_series_archive_outTemp', + 'test_get_series_daily_agg_rain_sum', + 'test_get_series_archive_agg_rain_sum', + 'test_get_series_archive_agg_rain_cum', + 'test_get_series_archive_windvec', + 'test_get_series_archive_agg_windvec_avg', + 'test_get_series_archive_agg_windvec_last', + 'test_get_aggregate_windvec_last', + 'test_get_series_on_the_fly', + 'test_get_aggregate_series_on_the_fly', + ] + return unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + # return unittest.TestSuite(list(map(TestSqlite, tests))) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/NOAA b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/NOAA new file mode 120000 index 0000000..e3eca4f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/NOAA @@ -0,0 +1 @@ +../../../../weewx_data/skins/Seasons/NOAA \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/almanac.html.tmpl b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/almanac.html.tmpl new file mode 100644 index 0000000..3290d2f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/almanac.html.tmpl @@ -0,0 +1,161 @@ +#errorCatcher Echo +#encoding UTF-8 + + + + + Test almanac + + + +

Test for \$almanac. Requires pyephem

+

Sun

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\$almanac.sun.az (old-style)$("%.3f" % $almanac.sun.az)
\$almanac.sun.alt (old-style)$("%.3f" % $almanac.sun.alt)
\$almanac.sun.azimuth$almanac.sun.azimuth
\$almanac.sun.azimuth.format("%03.2f")$almanac.sun.azimuth.format("%03.2f")
\$almanac.sun.altitude$almanac.sun.altitude
\$almanac.sun.altitude.format("%02.2f")$almanac.sun.altitude.format("%02.2f")
\$almanac.sun.altitude.radian$almanac.sun.altitude.radian
\$almanac.sun.astro_ra$almanac.sun.astro_ra
\$almanac.sun.astro_dec$almanac.sun.astro_dec
\$almanac.sun.geo_ra$almanac.sun.geo_ra
\$almanac.sun.geo_dec$almanac.sun.geo_dec
\$almanac.sun.topo_ra$almanac.sun.topo_ra
\$almanac.sun.topo_dec$almanac.sun.topo_dec
\$almanac.sidereal_time$almanac.sidereal_time
\$almanac.sidereal_angle$almanac.sidereal_angle
+ +

Jupiter

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\$almanac.jupiter.az (old-style)$("%.3f" % $almanac.jupiter.az)
\$almanac.jupiter.alt (old-style)$("%.3f" % $almanac.jupiter.alt)
\$almanac.jupiter.azimuth$almanac.jupiter.azimuth
\$almanac.jupiter.altitude$almanac.jupiter.altitude
\$almanac.jupiter.astro_ra$almanac.jupiter.astro_ra
\$almanac.jupiter.astro_dec$almanac.jupiter.astro_dec
\$almanac.jupiter.geo_ra$almanac.jupiter.geo_ra
\$almanac.jupiter.geo_dec$almanac.jupiter.geo_dec
\$almanac.jupiter.topo_ra$almanac.jupiter.topo_ra
\$almanac.jupiter.topo_dec$almanac.jupiter.topo_dec
\$almanac.jupiter.topo_dec.radian$almanac.jupiter.topo_dec.radian
+ +

Venus

+

Example from the PyEphem manual:

+ + + + + + + + + +
\$almanac(lon=-84.39733, lat=33.775867, altitude=320, almanac_time=454782176).venus.altitude$almanac(lon=-84.39733, lat=33.775867, altitude=320, almanac_time=454782176).venus.altitude.format("%02.2f")
\$almanac(lon=-84.39733, lat=33.775867, altitude=320, almanac_time=454782176).venus.azimuth$almanac(lon=-84.39733, lat=33.775867, altitude=320, almanac_time=454782176).venus.azimuth.format("%03.2f")
+ +

Example from the docs

+Current time is $current.dateTime +#if $almanac.hasExtras +
+    Sunrise, transit, sunset: $almanac.sun.rise $almanac.sun.transit $almanac.sun.set
+    Moonrise, transit, moonset: $almanac.moon.rise $almanac.moon.transit $almanac.moon.set
+    Mars rise, transit, set: $almanac.mars.rise $almanac.mars.transit $almanac.mars.set
+    Azimuth, altitude of Mars: $almanac.mars.azimuth $almanac.mars.altitude
+    Next new, full moon: $almanac.next_new_moon; $almanac.next_full_moon
+    Next summer, winter solstice: $almanac.next_summer_solstice; $almanac.next_winter_solstice
+    
+#else + Sunrise, sunset: $almanac.sunrise $almanac.sunset +#end if + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byhour.txt.tmpl b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byhour.txt.tmpl new file mode 100644 index 0000000..ccb01b7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byhour.txt.tmpl @@ -0,0 +1,15 @@ +#errorCatcher Echo + TEST HOURS ITERABLE +--------------------------------------------------------------------------------------- +#for $_hour in $day.hours +$_hour.end $_hour.outTemp.max +#end for +--------------------------------------------------------------------------------------- + + TEST USING ALTERNATE BINDING +--------------------------------------------------------------------------------------- +#for $_hour in $day.hours +$_hour($data_binding='alt_binding').end $_hour($data_binding='alt_binding').outTemp.max +#end for +--------------------------------------------------------------------------------------- + diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byrecord.txt.tmpl b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byrecord.txt.tmpl new file mode 100644 index 0000000..6fa7915 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byrecord.txt.tmpl @@ -0,0 +1,8 @@ +#errorCatcher Echo + TEST ITERATING BY RECORDS +--------------------------------------------------------------------------------------- +#for $_record in $day.records +$_record.dateTime $_record.outTemp +#end for +--------------------------------------------------------------------------------------- + diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byspan.txt.tmpl b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byspan.txt.tmpl new file mode 100644 index 0000000..6f1076c --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/byspan.txt.tmpl @@ -0,0 +1,31 @@ +Current time is $current.dateTime ($current.dateTime.raw) + +Hourly Top Temperatures in Last Day +#for $_hour in $day(days_ago=1).hours +$_hour.start.format("%a %m/%d %H:%M")-$_hour.end.format("%H:%M"): $_hour.outTemp.max at $_hour.outTemp.maxtime +#end for + +Daily Top Temperatures in Last Week +#for $_day in $week(weeks_ago=1).days +$_day.dateTime.format("%a %m/%d"): $_day.outTemp.max at $_day.outTemp.maxtime +#end for + +Daily Top Temperatures in Last Month +#for $_day in $month(months_ago=1).days +$_day.dateTime.format("%a %m/%d"): $_day.outTemp.max at $_day.outTemp.maxtime +#end for + +Monthly Top Temperatures this Year +#for $_month in $year.months +$_month.dateTime.format("%b %Y"): $_month.outTemp.max at $_month.outTemp.maxtime +#end for + +3 hour averages over yesterday +#for $span in $yesterday.spans(interval=10800) +$span.dateTime.format("%m/%d %H:%M")-$span.end.format("%H:%M"): $span.outTemp.avg +#end for + +All temperature records for yesterday +#for $record in $yesterday.records +$record.dateTime.format("%m/%d %H:%M"): $record.outTemp +#end for diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/index.html.tmpl b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/index.html.tmpl new file mode 100644 index 0000000..abbb68c --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/index.html.tmpl @@ -0,0 +1,1040 @@ +#errorCatcher Echo +#def emit_boolean(val) + #if val +TRUE#slurp + #else +FALSE#slurp + #end if +#end def + + + + #if $encoding == 'utf8' + + #end if + TEST: Current Weather Conditions + + + + +

Tests for tag \$station

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Station location:$station.location
Latitude:$station.latitude[0]° $station.latitude[1]' $station.latitude[2]
Longitude:$station.longitude[0]° $station.longitude[1]' $station.longitude[2]
Altitude (default unit):$station.altitude
Altitude (feet):$station.altitude.foot
Altitude (meters):$station.altitude.meter
+ +
+ +

Tests for tag \$current

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #set $now=$current.dateTime.raw + + + + + + + + + + + + + + + + + + + + + + + + ## This test uses a previous value known to be None: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current dateTime:$current.dateTime
Current dateTime with formatting:$current.dateTime.format("%H:%M")
Raw dateTime:$current.dateTime.raw
Outside Temperature (normal formatting)$current.outTemp
Outside Temperature (explicit unit conversion to Celsius)$current.outTemp.degree_C
Outside Temperature (explicit unit conversion to Fahrenheit)$current.outTemp.degree_F
Outside Temperature (explicit unit conversion to Celsius, plus formatting)$current.outTemp.degree_C.format("%.3f")
Outside Temperature (explicit unit conversion to Fahrenheit, plus formatting)$current.outTemp.degree_F.format("%.3f")
Outside Temperature (with explicit binding to 'wx_binding')$current($data_binding='wx_binding').outTemp
Outside Temperature (with explicit binding to 'alt_binding')$current($data_binding='alt_binding').outTemp
Outside Temperature with nonsense binding \$current($data_binding='foo_binding').outTemp$current($data_binding='foo_binding').outTemp
Outside Temperature with explicit time$current($timestamp=$now).outTemp
Outside Temperature with nonsense time$current($timestamp=$now - 3100).outTemp
Outside Temperature trend ($trend.time_delta.hour.format("%.0f"))$trend.outTemp
Outside Temperature trend with explicit time_delta (3600 seconds)$trend($time_delta='1h').outTemp
Trend with nonsense type$trend.foobar
Barometer (normal)$current.barometer
Barometer trend where previous value is known to be None ($trend.time_delta.hour.format("%.0f"))$trend.barometer
Barometer using \$latest$latest.barometer
Barometer using \$latest and explicit data binding$latest($data_binding='alt_binding').barometer at $latest($data_binding='alt_binding').dateTime.raw
Wind Chill (normal)$current.windchill
Heat Index (normal)$current.heatindex
Heat Index (in Celsius)$current.heatindex.degree_C
Heat Index (in Fahrenheit)$current.heatindex.degree_F
Dewpoint$current.dewpoint
Humidity$current.outHumidity
Wind$current.windSpeed from $current.windDir
Wind (beaufort)$current.windSpeed.beaufort
Rain Rate$current.rainRate
Inside Temperature$current.inTemp
Test tag "exists" for an existent type: \$current.outTemp.exists$emit_boolean($current.outTemp.exists)
Test tag "exists" for a nonsense type: \$current.nonsense.exists$emit_boolean($current.nonsense.exists)
Test tag "has_data" for an existing type with data: \$current.outTemp.has_data$emit_boolean($current.outTemp.has_data)
Test tag "has_data" for an existing type without data: \$current.hail.has_data$emit_boolean($current.hail.has_data)
Test tag "has_data" for a nonsense type: \$current.nonsense.has_data$emit_boolean($current.nonsense.has_data)
Test tag "has_data" for the last hour: \$hour.outTemp.has_data$emit_boolean($hour.outTemp.has_data)
Test tag "has_data" for the last hour of a nonsense type: \$hour.nonsense.has_data$emit_boolean($hour.nonsense.has_data)
Test for a bad observation type on a \$current tag: \$current.foobar$current.foobar
+ +
+ +

Tests for tag \$hour

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Start of hour:$hour.dateTime.format("%m/%d/%Y %H:%M:%S")
Start of hour (unix epoch time):$hour.dateTime.raw
Max Temperature$hour.outTemp.max
Min Temperature$hour.outTemp.min
Time of max temperature:$hour.outTemp.maxtime
Time of min temperature:$hour.outTemp.mintime
+ +

Wind related tags

+

\$current

+ + + + + + + + + + + + + + + + +
\$current.windSpeed$current.windSpeed
\$current.windDir$current.windDir
\$current.windGust$current.windGust
\$current.windGustDir$current.windGustDir
+ +

\$hour

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Hour start, stop$hour.start.raw
$hour.end.raw
\$hour.wind.vecdir$hour.wind.vecdir
\$hour.wind.vecavg$hour.wind.vecavg
\$hour.wind.max$hour.wind.max
\$hour.wind.gustdir$hour.wind.gustdir
\$hour.wind.maxtime$hour.wind.maxtime
+ +

\$month

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\$month.start.raw$month.start.raw
\$month.end.raw$month.end.raw
\$month.length.raw$month.length.raw
\$month.start$month.start
\$month.end$month.end
\$month.length$month.length
\$month.length.long_form("%(day)d %(day_label)s")$month.length.long_form("%(day)d %(day_label)s")
\$month.windSpeed.avg$month.windSpeed.avg
\$month.wind.avg$month.wind.avg
\$month.wind.vecavg$month.wind.vecavg
\$month.wind.vecdir$month.wind.vecdir
\$month.windGust.max$month.windGust.max
\$month.wind.max$month.wind.max
\$month.wind.gustdir$month.wind.gustdir
\$month.windGust.maxtime$month.windGust.maxtime
\$month.wind.maxtime$month.wind.maxtime
\$month.windSpeed.max$month.windSpeed.max
\$month.windDir.avg$month.windDir.avg
+ +

Iterate over three hours:

+ + + + + + + #for $i in range(3,-1,-1) + + + + + + #end for +
Start of hourMin temperatureWhen
$hours_ago($hours_ago=$i).dateTime$hours_ago($hours_ago=$i).outTemp.min$hours_ago($hours_ago=$i).outTemp.mintime
+ +
+ +

Tests for tag \$span

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current dateTime:$current.dateTime
Min Temperature in last hour (via \$span(\$time_delta=3600)):$span($time_delta=3600).outTemp.min
Min Temperature in last hour (via \$span(\$hour_delta=1)):$span($hour_delta=1).outTemp.min
Min Temperature in last 90 min (via \$span(\$time_delta=5400)):$span($time_delta=5400).outTemp.min
Min Temperature in last 90 min (via \$span(\$hour_delta=1.5)):$span($hour_delta=1.5).outTemp.min
Max Temperature in last 24 hours (via \$span(\$time_delta=86400)):$span($time_delta=86400).outTemp.max
Max Temperature in last 24 hours (via \$span(\$hour_delta=24)):$span($hour_delta=24).outTemp.max
Max Temperature in last 24 hours (via \$span(\$day_delta=1)):$span($day_delta=1).outTemp.max
Min Temperature in last 7 days (via \$span(\$time_delta=604800)):$span($time_delta=86400).outTemp.min
Min Temperature in last 7 days (via \$span(\$hour_delta=168)):$span($hour_delta=24).outTemp.min
Min Temperature in last 7 days (via \$span(\$day_delta=7)):$span($day_delta=1).outTemp.min
Min Temperature in last 7 days (via \$span(\$week_delta=1)):$span($week_delta=1).outTemp.min
Rainfall in last 24 hours (via \$span(\$time_delta=86400)):$span($time_delta=86400).rain.sum
Rainfall in last 24 hours (via \$span(\$hour_delta=24)):$span($hour_delta=24).rain.sum
Rainfall in last 24 hours (via \$span(\$day_delta=1)):$span($day_delta=1).rain.sum
Max Windchill in last hour (existing obs type that is None):$span($time_delta=3600).windchill.max
+ +
+ +

Tests for tag \$day

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of day:$day.dateTime.format("%m/%d/%Y %H:%M:%S")
Start of day (unix epoch time):$day.dateTime.raw
End of day (unix epoch time):$($day.dateTime.raw + 24*3600)
Max Temperature$day.outTemp.max
Min Temperature$day.outTemp.min
Time of max temperature:$day.outTemp.maxtime
Time of min temperature:$day.outTemp.mintime
Last temperature of the day$day.outTemp.last
Time of the last temperature of the day$day.outTemp.lasttime.format("%m/%d/%Y %H:%M:%S")
First temperature of the day$day.outTemp.first
Time of the first temperature of the day$day.outTemp.firsttime.format("%m/%d/%Y %H:%M:%S")
Max Temperature in alt_binding$day($data_binding='alt_binding').outTemp.max
Max Temperature with bogus binding$day($data_binding='foo_binding').outTemp.max
Min temp with explicit conversion to Celsius$day.outTemp.min.degree_C
Min temp with explicit conversion to Fahrenheit$day.outTemp.min.degree_F
Min temp with explicit conversion to nonsense type$day.outTemp.min.badtype
Min temperature with inappropriate conversion: \$day.outTemp.min.mbar$day.outTemp.min.mbar
Max value for a type with no data: \$day.UV.max$day.UV.max
Sum aggregation (rain)$day.rain.sum
Aggregation of xtype: \$day.humidex.avg$day.humidex.avg
Aggregation of xtype: \$day.humidex.min$day.humidex.min
Aggregation of xtype: \$day.humidex.mintime$day.humidex.mintime
Aggregation of xtype: \$day.humidex.max$day.humidex.max
Aggregation of xtype: \$day.humidex.maxtime$day.humidex.maxtime
High Wind from "\$day.wind.max"$day.wind.max from $day.wind.gustdir at $day.wind.maxtime
High Wind from "\$day.windGust.max"$day.windGust.max
High Wind from "\$day.windSpeed.max"$day.windSpeed.max
Average wind from "\$day.wind.avg"$day.wind.avg
Average wind from "\$day.windSpeed.avg"$day.windSpeed.avg
Average wind w/alt_binding: "\$day(data_binding='alt_binding').wind.avg"$day(data_binding='alt_binding').wind.avg
RMS aggregation: "\$day.wind.rms"$day.wind.rms
Vector average: "\$day.wind.vecavg"$day.wind.vecavg
Aggregation Vector Direction (wind)$day.wind.vecdir
Test tag "has_data" with nonsense type \$day.nonsense.has_data$emit_boolean($day.nonsense.has_data)
Test tag "exists" with an existing type that has no data \$day.UV.exists$emit_boolean($day.UV.exists)
Test tag "has_data" with existent type that has no data \$day.UV.has_data$emit_boolean($day.UV.has_data)
Test tag "has_data" with existent type that has data \$day.outTemp.has_data$emit_boolean($day.outTemp.has_data)
Test tag "not_null" with existent type that has no data \$day.UV.not_null$day.UV.not_null
Test tag "not_null" with existent type that has data \$day.outTemp.not_null$day.outTemp.not_null
Test for a bad observation type on a \$day tag: \$day.foobar.min$day.foobar.min
Test for a bad aggregation type on a \$day tag: \$day.outTemp.foo$day.outTemp.foo
Test tag "has_data" for an xtype: \$day.humidex.has_data$emit_boolean($day.humidex.has_data)
Test tag "has_data" for another xtype: \$month.heatdeg.has_data$emit_boolean($day.heatdeg.has_data)
Test for sunshineDur: \$day.sunshineDur.sum$day.sunshineDur.sum
Test for sunshineDur: \$day.sunshineDur.sum.long_form$day.sunshineDur.sum.long_form
Test for sunshineDur, custom format:$day.sunshineDur.sum.long_form("%(hour)d%(hour_label)s, %(minute)d%(minute_label)s")
sunshineDur in hours:$day.sunshineDur.sum.hour.format("%.2f")
\$day.stringData.first.raw$day.stringData.first.raw
\$day.stringData.last.raw$day.stringData.last.raw
\$day.stringData.first.format("%s")$day.stringData.first.format("%s")
+ +
+ +

Tests for tag \$yesterday

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start of yesterday:$yesterday.dateTime.format("%m/%d/%Y %H:%M:%S")
Start of yesterday (unix epoch time):$yesterday.dateTime.raw
Max Temperature yesterday$yesterday.outTemp.max
Min Temperature yesterday$yesterday.outTemp.min
Time of max temperature yesterday:$yesterday.outTemp.maxtime
Time of min temperature yesterday:$yesterday.outTemp.mintime
Yesterday's last temperature$yesterday.outTemp.last
Time of yesterday's last temperature$yesterday.outTemp.lasttime.format("%m/%d/%Y %H:%M:%S")
+ +

Tests for tag \$rainyear

+ + + + + +
Rainyear total$rainyear.rain.sum
+ +

Test for tag \$alltime

+ + + + + + + + + +
Max temp from \$alltime.outTemp.max$alltime.outTemp.max
+ at $alltime.outTemp.maxtime
High Wind from "\$alltime.wind.max" + $alltime.wind.max
+ from $alltime.wind.gustdir
+ at $alltime.wind.maxtime +
+ +

Test for tag \$seven_day

+ + + + + + + + + +
Max temp from \$seven_day.outTemp.max$seven_day.outTemp.max
+ at $seven_day.outTemp.maxtime
High Wind from "\$seven_day.wind.max" + $seven_day.wind.max
+ from $seven_day.wind.gustdir
+ at $seven_day.wind.maxtime +
+ +

Test for various versions of \$colorize

+ + + + + + + + + + + + + +
Current temperature using \$colorize_1$current.outTemp
Current temperature using \$colorize_2$current.outTemp
Current temperature using \$colorize_3$current.outTemp
+

Tests at timestamp 1280692800, when the temperature is null:

+ + + + + + + + + + + + + +
Null temperature using \$colorize_1$current(timestamp=1280692800).outTemp
Null temperature using \$colorize_2$current(timestamp=1280692800).outTemp
Null temperature using \$colorize_3$current(timestamp=1280692800).outTemp
+ +
+

Tests for tag \$Extras

+ + + #if 'radar_url' in $Extras + + + #else + +
Radar URL"$Extras.radar_url"FAIL + #end if +
+ +
+ +

Tests for tag \$almanac

+ + + + + + + + + + + + + + + + + + + + + +
Sunrise:$almanac.sunrise
Sunset:$almanac.sunset
Moon:$almanac.moon_phase ($almanac.moon_fullness%full)
\$almanac.sun.visible:$almanac.sun.visible.long_form
($almanac.sun.visible)
\$almanac.sun.visible_change:$almanac.sun.visible_change.long_form ($almanac.sun.visible_change)
+ +
+ +

Test for tag \$unit

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tag "\$unit.unit_type.outTemp"$unit.unit_type.outTemp
Tag "\$unit.label.outTemp"$unit.label.outTemp
Tag "\$unit.format.outTemp"$unit.format.outTemp
Example from customizing guide
+ ("\$day.outTemp.max.format(add_label=False)\$unit.label.outTemp")
$day.outTemp.max.format(add_label=False)$unit.label.outTemp
Test the above with backwards compatibility
+ ("\$day.outTemp.max.formatted\$unit.label.outTemp")
$day.outTemp.max.formatted$unit.label.outTemp
Add a new unit type, existing group$unit.unit_type.fooTemp
Check its label$unit.label.fooTemp
Check its format$unit.format.fooTemp
Add a new unit type, new group$unit.unit_type.current0
Check its label$unit.label.current0
Check its format$unit.format.current0
+ +

Test for tag \$obs

+ + + + + +
Tag "\$obs.label.outTemp"$obs.label.outTemp
+ +
+ +

Test for formatting examples in the Customizing Guide

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\$current.outTemp$current.outTemp
\$current.outTemp.format$current.outTemp.format
\$current.outTemp.format()$current.outTemp.format()
\$current.outTemp.format(format_string="%.3f")$current.outTemp.format(format_string="%.3f")
\$current.outTemp.format("%.3f")$current.outTemp.format("%.3f")
\$current.outTemp.format(add_label=False)$current.outTemp.format(add_label=False)
\$current.UV$current.UV
\$current.UV.format(None_string="No UV")$current.UV.format(None_string="No UV")
\$current.windDir$current.windDir
\$current.windDir.ordinal_compass$current.windDir.ordinal_compass
\$current.dateTime$current.dateTime
\$current.dateTime.format(format_string="%H:%M")$current.dateTime.format(format_string="%H:%M")
\$current.dateTime.format("%H:%M")$current.dateTime.format("%H:%M")
\$current.dateTime.raw$current.dateTime.raw
\$current.outTemp.raw$("%.5f" % $current.outTemp.raw)
+ +

Test for aggregates

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\$month.outTemp.maxmin$month.outTemp.maxmin
\$month.outTemp.maxmintime$month.outTemp.maxmintime
\$month.outTemp.minmax$month.outTemp.minmax
\$month.outTemp.minmaxtime$month.outTemp.minmaxtime
\$month.outTemp.minsum$month.outTemp.minsum
\$month.outTemp.minsumtime$month.outTemp.minsumtime
\$month.outTemp.maxsumtime$month.outTemp.maxsumtime
\$month.heatdeg.sum$month.heatdeg.sum
\$month.cooldeg.sum$month.cooldeg.sum
\$month.growdeg.sum$month.growdeg.sum
\$year.rain.sum_le((0.1, 'inch', 'group_rain'))$year.rain.sum_le((0.1, 'inch', 'group_rain'))
\$year.outTemp.avg_ge((41, 'degree_F', 'group_temperature'))$year.outTemp.avg_ge((41, 'degree_F', 'group_temperature'))
\$year.outTemp.avg_le((32, 'degree_F', 'group_temperature'))$year.outTemp.avg_le((32, 'degree_F', 'group_temperature'))
+ +
+

Tests for \$gettext

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\$lang$lang
\$page$page
\$gettext("Foo")$gettext("Foo")
\$gettext("Today")$gettext("Today")
\$pgettext("test","Plots")$pgettext("Plots", "test")
\$pgettext(\$page,"Plots")$pgettext($page,"Plots")
\$pgettext(\$page,"Something")$pgettext($page,"Something")
\$pgettext("Foo","Plots")$pgettext("Foo","Plots")
\$pgettext("Foo","Bar")$pgettext("Foo","Bar")
+
+ + + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/lang/de.conf b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/lang/de.conf new file mode 100644 index 0000000..2e6268b --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/lang/de.conf @@ -0,0 +1,94 @@ +############################################################################### +# Localization File # +# German # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Units] + + # The following section overrides the label used for each type of unit + [[Labels]] + meter = " m", " m" + day = " Tag", " Tage" + hour = " Stunde", " Stunden" + minute = " Minute", " Minuten" + second = " Sekunde", " Sekunden" + NONE = "" + + [[Ordinates]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = "Datum/Zeit" + interval = Intervall + altimeter = Luftdruck (QNH) # QNH + altimeterRate = Luftdruckänderung + barometer = Luftdruck # QFF + barometerRate = Luftdruckänderung + pressure = abs. Luftdruck # QFE + pressureRate = Luftdruckänderung + dewpoint = Taupunkt + ET = ET + heatindex = Hitzeindex + inHumidity = Raumluftfeuchte + inTemp = Raumtemperatur + inDewpoint = Raumtaupunkt + outHumidity = Luftfeuchte + outTemp = Außentemperatur + radiation = Sonnenstrahlung + rain = Regen + rainRate = Regen-Rate + UV = UV-Index + wind = Wind + windDir = Windrichtung + windGust = Böen Geschwindigkeit + windGustDir = Böen Richtung + windSpeed = Windgeschwindigkeit + windchill = Windchill + windgustvec = Böen-Vektor + windvec = Wind-Vektor + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + appTemp = gefühlte Temperatur + appTemp1 = gefühlte Temperatur + THSW = THSW-Index + lightning_distance = Blitzentfernung + lightning_strike_count = Blitzanzahl + cloudbase = Wolkenuntergrenze + + # used in Seasons skin but not defined + feel = gefühlte Temperatur + + # Sensor status indicators + rxCheckPercent = Signalqualität + txBatteryStatus = Übertragerbatteriestatus + windBatteryStatus = Anemometerbatteriestatus + rainBatteryStatus = Regenmesserbatteriestatus + outTempBatteryStatus = Außentemperatursensorbatteriestatus + inTempBatteryStatus = Innentemperatursensorbatteriestatus + consBatteryVoltage = Konsolenbatteriestatus + heatingVoltage = Heizungsspannung + supplyVoltage = Versorgungsspannung + referenceVoltage = Referenzspannung + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Neumond, zunehmend, Halbmond, zunehmend, Vollmond, abnehmend, Halbmond, abnehmend + +[Texts] + "Today" = Heute + [[test]] + "Plots" = Diagramme diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/lang/en.conf b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/lang/en.conf new file mode 100644 index 0000000..cdb5da4 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/lang/en.conf @@ -0,0 +1,93 @@ +############################################################################### +# Localization File # +# English # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Units] + + [[Labels]] + + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Time + interval = Interval + altimeter = Altimeter # QNH + altimeterRate = Altimeter Change Rate + barometer = Barometer # QFF + barometerRate = Barometer Change Rate + pressure = Pressure # QFE + pressureRate = Pressure Change Rate + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + inDewpoint = Inside Dew Point + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + appTemp = Apparent Temperature + appTemp1 = Apparent Temperature + THSW = THSW Index + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + cloudbase = Cloud Base + + # used in Seasons skin, but not defined + feel = apparent temperature + + # Sensor status indicators + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +[Texts] + "Today" = Today + [[test]] + "Plots" = History diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/series.html.tmpl b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/series.html.tmpl new file mode 100644 index 0000000..ccb7f34 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/series.html.tmpl @@ -0,0 +1,164 @@ +#errorCatcher Echo +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +## +## This template tests tags used for generating series +## +## +## Specifying an encoding of UTF-8 is usually safe: +#encoding UTF-8 +## + + + + + Test the ".series" tag + + + + +

Unaggregated series

+

Unaggregated series in json: \$day.outTemp.series.round(5).json():

+
$day.outTemp.series.round(5).json()
+ +

Unaggregated series, in json with just start times: \$day.outTemp.series(time_series='start').round(5).json:

+
$day.outTemp.series(time_series='start').round(5).json
+ +

Unaggregated series, in json with start times in milliseconds: \$day.outTemp.series(time_series='start', time_unit='unix_epoch_ms').round(5).json:

+
$day.outTemp.series(time_series='start', time_unit='unix_epoch_ms').round(5).json
+ +

Unaggregated series, in json, in degrees C, rounded to 5 decimal places: \$day.outTemp.series.degree_C.round(5).json

+
$day.outTemp.series.degree_C.round(5).json
+ +

Unaggregated series, as a formatted string (not JSON) : \$day.outTemp.series:

+
$day.outTemp.series
+ +

Unaggregated series, start time only, as a formatted string (not JSON) : \$day.outTemp.series(time_series='start'):

+
$day.outTemp.series(time_series='start')
+ +

Unaggregated series, stop time only, as a formatted string (not JSON) : \$day.outTemp.series(time_series='stop'):

+
$day.outTemp.series(time_series='stop')
+ +

Unaggregated series, by column, as a formatted string (not JSON) \$day.outTemp.series.format(order_by='column'):

+
$day.outTemp.series.format(order_by='column')
+ +

Unaggregated series, by column, start times only, as a formatted string (not JSON) \$day.outTemp.series(time_series='start').format(order_by='column'):

+
$day.outTemp.series(time_series='start').format(order_by='column')
+ +

Unaggregated series, by column, stop times only, as a formatted string (not JSON) \$day.outTemp.series(time_series='stop').format(order_by='column'):

+
$day.outTemp.series(time_series='stop').format(order_by='column')
+ +
+ +

Aggregated series

+

Aggregated series: \$month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json():

+
$month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5)json()
+ +

Using shortcut 'day': \$month.outTemp.series(aggregate_type='max', aggregate_interval='day').round(5).json():

+
$month.outTemp.series(aggregate_type='max', aggregate_interval='day').round(5).json()
+ +

Order by column: \$month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json(order_by="column"):

+
$month.outTemp.series(aggregate_type='max', aggregate_interval=86400).round(5).json(order_by="column")
+ +

Aggregated series, using \$jsonize(): + \$jsonize(\$zip(\$min.start.unix_epoch_ms.raw, \$min.data.degree_C.round(2).raw, \$max.data.degree_C.round(2).raw)) + #set $min = $month.outTemp.series(aggregate_type='min', aggregate_interval='day') + #set $max = $month.outTemp.series(aggregate_type='max', aggregate_interval='day') +

+    $jsonize($zip($min.start.unix_epoch_ms.raw, $min.data.degree_C.round(2).raw, $max.data.degree_C.round(2).raw))
+    
+ + +
+ +

Aggregated series of wind vectors

+ +

Aggregated wind series (not JSON)), complex notation + \$month.windvec.series(aggregate_type='max', aggregate_interval='day')

+
$month.windvec.series(aggregate_type='max', aggregate_interval='day')
+ +

Aggregated wind series (not JSON)), complex notation with formatting + \$month.windvec.series(aggregate_type='max', aggregate_interval='day').format("%.5f")

+
$month.windvec.series(aggregate_type='max', aggregate_interval='day').format("%.5f")
+ +

Aggregated wind series (not JSON), polar notation + \$month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.format("%.5f")

+
$month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.format("%.5f")
+ +
+ +

Aggregated series of wind vectors with conversions

+ +

Starting series: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').json()
+    
+ +

X-component: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').x.json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').x.json()
+    
+ +

x-component, in knots: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').x.knot.json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').x.knot.json()
+    
+ +

Y-component: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').y.json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').y.json()
+    
+ +

magnitude: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').magnitude.json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').magnitude.json()
+    
+ +

direction: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').direction.json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').direction.json()
+    
+ +

polar: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').polar.json()
+    
+ +

polar in knots: \$month.windvec.series(aggregate_type='max', aggregate_interval='day').knot.polar.round(5).json()

+
+    $month.windvec.series(aggregate_type='max', aggregate_interval='day').knot.polar.round(5).json()
+    
+ +
+

Iterate over an aggregated series

+
+    \#for (\$start, \$stop, \$data) in \$month.outTemp.series(aggregate_type='max', aggregate_interval='day')
+      ...
+    \#end for
+  
+ + + + + + + + #for ($start, $stop, $data) in $month.outTemp.series(aggregate_type='max', aggregate_interval='day') + + + + + + #end for +
Start dateStop dateMax temperature
$start.format("%Y-%m-%d")$stop.format("%Y-%m-%d")$data.format("%.2f")
+ + + + diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/skin.conf b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/skin.conf new file mode 100644 index 0000000..67ee559 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_skins/StandardTest/skin.conf @@ -0,0 +1,384 @@ +############################################################################### +# # +# # +# TEST SKIN CONFIGURATION FILE # +# # +# # +############################################################################### +# # +# Copyright (c) 2010, 2011, 2018 Tom Keffer # +# # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################### + +[Extras] + + # + # Put any extra tags here that you want to be available in the templates + # + + # Here's an example. This radar URL would be available as $Extras.radar_url + # (Comment the line out if you don't want to include the radar image) + radar_url = http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif + + # Here's another. If you have a Google Analytics ID, uncomment and edit + # the next line, and the analytics code will automatically be included + # in your generated HTML files: + #googleAnalyticsId = UA-12345678-1 + +############################################################################################ + +[Units] + + # + # This section is for managing the selection and formatting of units. + # + + # Use some special formats to test overriding defaults.py in skin.conf + + [[Labels]] + # + # This section sets a label to be used for each type of unit. + # + + km_per_hour = " kph" + km_per_hour2 = " kph" + + [[TimeFormats]] + # + # This section sets the string format to be used + # each time scale. + # + hour = %H:%M + day = %H:%M + week = %H:%M on %A + month = %d-%b-%Y %H:%M + year = %d-%b-%Y %H:%M + rainyear = %d-%b-%Y %H:%M + current = %d-%b-%Y %H:%M + ephem_day = %H:%M + ephem_year = %d-%b-%Y %H:%M + +############################################################################################ + +# Use the deprecated 'FileGenerator', to test backwards compatibility +[FileGenerator] + + # + # This section is used by the generator FileGenerator, and specifies which + # files are to be generated from which template. + # + + encoding = html_entities # Possible encodings are 'html_entities', 'utf8', or 'strict_ascii' + + # We will be testing SLEs as well. Might as well test the example included in the manual: + search_list_extensions = xstats.ExtendedStatistics, colorize_1.Colorize, colorize_2.Colorize, colorize_3.Colorize + + [[SummaryByHours]] + # + # Reports that summarize "by hour" + # + [[[by_hour]]] + encoding = strict_ascii + template = byhour.txt.tmpl + + [[SummaryByMonth]] + # Reports that summarize "by month" + [[[NOAA_month]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m.txt.tmpl + + [[SummaryByYear]] + # Reports that summarize "by year" + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y.txt.tmpl + + [[ToDate]] + # + # Reports that show statistics "to date", such as day-to-date, + # week-to-date, month-to-date, etc. + # + [[[test]]] + template = index.html.tmpl + [[[almanac]]] + template = almanac.html.tmpl + + [[[by_span]]] + encoding = strict_ascii + template = byspan.txt.tmpl + + [[[series]]] + template = series.html.tmpl + +# Used by the "colorize_3" example: +[Colorize] + [[group_temperature]] + unit_system = metricwx + default = tomato + None = lightgray + [[[upper_bounds]]] + -10 = magenta + 0 = violet + 10 = lavender + 20 = moccasin + 30 = yellow + 40 = coral + [[group_uv]] + unit_system = metricwx + default = darkviolet + [[[upper_bounds]]] + 2.4 = limegreen + 5.4 = yellow + 7.4 = orange + 10.4 = red + +############################################################################################ + +[ImageGenerator] + + # + # THE TEST SUITES DO NOT TEST IMAGE GENERATION CURRENTLY. + # Nevertheless, this section is left intact in case this changes later. + # + + image_width = 300 + image_height = 180 + image_background_color = "#f5f5f5" + + chart_background_color = "#d8d8d8" + chart_gridline_color = "#a0a0a0" + + top_label_font_path = /usr/share/fonts/truetype/freefont/FreeMonoBold.ttf + top_label_font_size = 10 + + unit_label_font_path = /usr/share/fonts/truetype/freefont/FreeMonoBold.ttf + unit_label_font_size = 10 + unit_label_font_color = "#000000" + + bottom_label_font_path = /usr/share/fonts/truetype/freefont/FreeMonoBold.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#000000" + + axis_label_font_path = /usr/share/fonts/truetype/freefont/FreeMonoBold.ttf + axis_label_font_size = 10 + axis_label_font_color = "#000000" + + # Options for the compass rose, used for progressive vector plots + rose_label = N + rose_label_font_path = /usr/share/fonts/truetype/freefont/FreeMonoBold.ttf + rose_label_font_size = 10 + rose_label_font_color = "#000000" + + + # Default colors for the plot lines. These can be overridden for + # individual lines using option 'color' + chart_line_colors = "#4282b4", "#b44242", "#42b442" + + ## + ## What follows is a list of subsections, each specifying a time span, such + ## as a day, week, month, or year. There's nothing special about them or + ## their names: it's just a convenient way to group plots with a time span + ## in common. You could add a time span [[biweek_images]] and add the + ## appropriate time length, aggregation strategy, etc., without changing any + ## code. + ## + ## Within each time span, each sub-subsection is the name of a plot to be + ## generated for that time span. The generated plot will be stored using + ## that name, in whatever directory was specified by option 'HTML_ROOT' + ## in weewx.conf. + ## + ## With one final nesting (four brackets!) is the sql type of each line to + ## be included within that plot. + ## + ## Unless overridden, leaf nodes inherit options from their parent + ## + + # Default plot and aggregation. Can get overridden at any level. + plot_type = line + aggregate_type = none + width = 1 + time_length = 24h + + # The following option merits an explanation. The y-axis scale used for plotting + # can be controlled using option 'yscale'. It is a 3-way tuple, with + # values (ylow, yhigh, min_interval). If set to "None", a parameter is + # set automatically, otherwise the value is used. However, in the case of + # min_interval, what is set is the *minimum* y-axis tick interval. + yscale = None, None, None + + # For progressive vector plots, you can choose to rotate the vectors. + # Positive is clockwise. + # For my area, westerlies overwhelmingly predominate, so by rotating + # positive 90 degrees, the average vector will point straight up. + vector_rotate = 90 + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %m/%d/%y %H:%M + time_length = 24h + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + # Test plotting a derived type without aggregation, using the main archive table + [[[dayhumidex]]] + [[[[humidex]]]] + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot: + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Rain (hourly avg) + + [[[dayraintot]]] + [[[[rain]]]] + aggregate_type = cumulative + aggregate_interval = 600 + label = Rain (cumulative) + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[daywinddir]]] + # Hardwire in the y-axis scale for wind direction: + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[windDir]]]] + + [[[daywindvec]]] + [[[[windvec]]]] + plot_type = vector + + #Test using an arbitrary expression for the type: + [[[daytempdiff]]] + [[[[diff]]]] + data_type = outTemp-dewpoint + label = Difference + [[[[baseline]]]] + data_type = 0.0 + label = ' ' + color = "#ff0000" + width = 1 + + [[week_images]] + # Test for a missing aggregate_interval + time_length = 1w + aggregate_type = avg + [[[weekbarometer]]] + [[[[barometer]]]] + + [[month_images]] + x_label_format = %d + bottom_label_format = %m/%d/%y %H:%M + time_length = 30d + aggregate_type = avg + aggregate_interval = 3h + + [[[monthbarometer]]] + [[[[barometer]]]] + + [[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + # Test plotting a derived type with aggregation, using the main archive table + [[[monthhumidex]]] + [[[[humidex]]]] + + [[[monthrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily avg) + + [[[monthET_rain]]] + data_binding = alt_binding + [[[[ET]]]] + aggregate_type = cumulative + aggregate_interval = 3h + label = 'Wasser-Verdunstung /' + [[[[data]]]] + data_binding = wx_binding + data_type = ET + aggregate_type = cumulative + aggregate_interval = 3h + label = 'Wasserbilanz ges.' + + [[[monthwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[monthwinddir]]] + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[wind]]]] + aggregate_type = vecdir + + [[[monthwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[year_images]] + x_label_format = %m/%d + bottom_label_format = %m/%d/%y + time_length = 365d + aggregate_type = avg + aggregate_interval = 1d + + [[[yearbarometer]]] + [[[[barometer]]]] + + + [[[yeartempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[yearwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[yearrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1w + label = Rain (weekly avg) + + [[[yearwinddir]]] + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[wind]]]] + aggregate_type = vecdir + + [[[yearwindvec]]] + [[[[windvec]]]] + plot_type = vector + +############################################################################################ + +# +# The list of generators that are to be run: +# +[Generators] + generator_list = weewx.filegenerator.FileGenerator, weewx.imagegenerator.ImageGenerator diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_templates.py b/dist/weewx-5.0.2/src/weewx/tests/test_templates.py new file mode 100644 index 0000000..8e21368 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_templates.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test tag notation for template generation.""" + +import locale +import logging +import os.path +import shutil +import sys +import time +import unittest + +import configobj + +import gen_fake_data +import weeutil.config +import weeutil.logger +import weeutil.weeutil +import weewx +import weewx.accum +import weewx.manager +import weewx.reportengine +import weewx.station +import weewx.units +import weewx.wxxtypes + +weewx.debug = 1 + +log = logging.getLogger(__name__) +# Set up logging using the defaults. +weeutil.logger.setup('weetest_templates') + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +# This will use the locale specified by the environment variable 'LANG' +# Other options are possible. See: +# http://docs.python.org/2/library/locale.html#locale.setlocale +locale.setlocale(locale.LC_ALL, '') + +# Find the configuration file. It's assumed to be in the same directory as me, so first figure +# out where that is. +my_dir = os.path.normpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) +# The full path to the configuration file: +config_path = os.path.join(my_dir, "testgen.conf") + +try: + config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') +except IOError: + sys.stderr.write("Unable to open configuration file %s" % config_path) + # Reraise the exception (this will eventually cause the program to exit) + raise +except configobj.ConfigObjError: + sys.stderr.write("Error while parsing configuration file %s" % config_path) + raise + +altitude_vt = weewx.units.ValueTuple(float(config_dict['Station']['altitude'][0]), + config_dict['Station']['altitude'][1], + 'group_altitude') +latitude = float(config_dict['Station']['latitude']) +longitude = float(config_dict['Station']['longitude']) +# Initialize the xtypes system for derived weather types: +weewx.xtypes.xtypes.append(weewx.wxxtypes.WXXTypes(altitude_vt, latitude, longitude)) + +# We test accumulator configurations as well, so initialize the Accumulator dictionary: +weewx.accum.initialize(config_dict) + +# These tests also test the examples in the 'example' subdirectory. +# Patch PYTHONPATH to find them. +import weewx_data +example_dir = os.path.normpath(os.path.join(os.path.dirname(weewx_data.__file__), + './examples')) +sys.path.append(os.path.join(example_dir, './colorize')) +sys.path.append(os.path.join(example_dir, './xstats/bin/user')) + +import colorize_1 +import colorize_2 +import colorize_3 + +# Monkey patch to create SLEs with unambiguous names. We will test using these names. +colorize_1.Colorize.colorize_1 = colorize_1.Colorize.colorize +colorize_2.Colorize.colorize_2 = colorize_2.Colorize.colorize +colorize_3.Colorize.colorize_3 = colorize_3.Colorize.colorize + + +# We will be testing the ability to extend the unit system, so set that up first: +class ExtraUnits(object): + def __getitem__(self, obs_type): + if obs_type.endswith('Temp'): + # Anything that ends with "Temp" is assumed to be in group_temperature + return "group_temperature" + elif obs_type.startswith('current'): + # Anything that starts with "current" is in group_amperage: + return "group_amperage" + else: + raise KeyError(obs_type) + + def __contains__(self, obs_type): + return obs_type.endswith('Temp') or obs_type.startswith('current') + + +extra_units = ExtraUnits() +weewx.units.obs_group_dict.extend(extra_units) + +# Add the new group group_amperage to the standard unit systems: +weewx.units.USUnits["group_amperage"] = "amp" +weewx.units.MetricUnits["group_amperage"] = "amp" +weewx.units.MetricWXUnits["group_amperage"] = "amp" + +weewx.units.default_unit_format_dict["amp"] = "%.1f" +weewx.units.default_unit_label_dict["amp"] = " A" + + +class Common(object): + + def setUp(self): + global config_dict + + self.config_dict = weeutil.config.deep_copy(config_dict) + + # Remove the old directory: + try: + test_html_dir = os.path.join(self.config_dict['WEEWX_ROOT'], + self.config_dict['StdReport']['HTML_ROOT']) + shutil.rmtree(test_html_dir) + except OSError as e: + if os.path.exists(test_html_dir): + print("\nUnable to remove old test directory %s", test_html_dir, file=sys.stderr) + print("Reason:", e, file=sys.stderr) + print("Aborting", file=sys.stderr) + exit(1) + + # This will generate the test databases if necessary: + gen_fake_data.configDatabases(self.config_dict, database_type=self.database_type) + + def tearDown(self): + pass + + def test_report_engine(self): + + # The generation time should be the same as the last record in the test database: + testtime_ts = gen_fake_data.stop_ts + print("\ntest time is %s" % weeutil.weeutil.timestamp_to_string(testtime_ts)) + + stn_info = weewx.station.StationInfo(**self.config_dict['Station']) + + # First run the engine without a current record. + self.run_engine(stn_info, None, testtime_ts) + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as manager: + record = manager.getRecord(testtime_ts) + # Now run the engine again, but this time with a current record: + self.run_engine(stn_info, record, testtime_ts) + + def run_engine(self, stn_info, record, testtime_ts): + t = weewx.reportengine.StdReportEngine(self.config_dict, stn_info, record, testtime_ts) + + # Find the test skins and then have SKIN_ROOT point to it: + test_dir = sys.path[0] + t.config_dict['StdReport']['SKIN_ROOT'] = os.path.join(test_dir, 'test_skins') + + # Although the report engine inherits from Thread, we can just run it in the main thread: + print("Starting report engine test") + t.run() + print("Done.") + + test_html_dir = os.path.join(t.config_dict['WEEWX_ROOT'], + t.config_dict['StdReport']['HTML_ROOT']) + expected_dir = os.path.join(test_dir, 'expected') + + # Walk the directory of expected results to discover all the generated files we should + # be checking + for dirpath, _, dirfilenames in os.walk(expected_dir): + for dirfilename in dirfilenames: + expected_filename_abs = os.path.join(dirpath, dirfilename) + # Get the file path relative to the directory of expected results + filename_rel = os.path.relpath(expected_filename_abs, expected_dir) + # Use that to figure out where the actual results ended up + actual_filename_abs = os.path.join(test_html_dir, filename_rel) + + with open(actual_filename_abs, 'r') as actual: + with open(expected_filename_abs, 'r') as expected: + n = 0 + while True: + actual_line = actual.readline() + expected_line = expected.readline() + if actual_line == '' and expected_line == '': + break + n += 1 + self.assertEqual(actual_line, + expected_line, + msg="%s[%d]:\n%r vs\n%r" + % (actual_filename_abs, n, actual_line, + expected_line)) + + print(f"Checked {n:d} lines in {filename_rel}") + + +class TestSqlite(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "sqlite" + super().__init__(*args, **kwargs) + + +class TestMySQL(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "mysql" + super().__init__(*args, **kwargs) + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + +def suite(): + tests = ['test_report_engine'] + return unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + # return unittest.TestSuite(list(map(TestSqlite, tests)) ) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_units.py b/dist/weewx-5.0.2/src/weewx/tests/test_units.py new file mode 100644 index 0000000..10bbf96 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_units.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test module weewx.units""" + +import unittest +import operator + +import weewx.units +from weewx.units import ValueTuple + + +default_formatter = weewx.units.get_default_formatter() + + +class ConstantsTest(unittest.TestCase): + + def test_std_unit_systems(self): + self.assertEqual(weewx.units.MetricUnits['group_rain'], 'cm') + self.assertEqual(weewx.units.MetricUnits['group_rainrate'], 'cm_per_hour') + self.assertEqual(weewx.units.MetricUnits['group_speed'], 'km_per_hour') + self.assertEqual(weewx.units.MetricUnits['group_speed2'], 'km_per_hour2') + + self.assertEqual(weewx.units.MetricWXUnits['group_rain'], 'mm') + self.assertEqual(weewx.units.MetricWXUnits['group_rainrate'], 'mm_per_hour') + self.assertEqual(weewx.units.MetricWXUnits['group_speed'], 'meter_per_second') + self.assertEqual(weewx.units.MetricWXUnits['group_speed2'], 'meter_per_second2') + + +class ValueTupleTest(unittest.TestCase): + + def testVT(self): + a=ValueTuple(68.0, "degree_F", "group_temperature") + b=ValueTuple(18.0, "degree_F", "group_temperature") + c=ValueTuple(None, "degree_F", "group_temperature") + d=ValueTuple(1020.0, "mbar", "group_pressure") + + self.assertEqual(a + b, ValueTuple(86.0, "degree_F", "group_temperature")) + self.assertEqual(a - b, ValueTuple(50.0, "degree_F", "group_temperature")) + self.assertRaises(TypeError, operator.add, a, c) + self.assertRaises(TypeError, operator.add, a, d) + +class ConverterTest(unittest.TestCase): + + def testConvert(self): + #Test the US converter: + c = weewx.units.Converter() + value_t_m = (20.01, "degree_C", "group_temperature") + value_t_us = (68.018, "degree_F", "group_temperature") + self.assertEqual(c.convert(value_t_m), value_t_us) + + # Test converting mbar_per_hour to inHg_per_hour + value_t = (1, "mbar_per_hour", "group_pressurerate") + converted = c.convert(value_t) + # Do a formatted comparison to compensate for small rounding errors: + self.assertEqual(("%.5f" % converted[0],)+converted[1:3], ("0.02953", "inHg_per_hour", "group_pressurerate")) + + # Test converting a sequence: + value_t_m_seq = ([10.0, 20.0, 30.0], "degree_C", "group_temperature") + value_t_us_seq= ([50.0, 68.0, 86.0], "degree_F", "group_temperature") + self.assertEqual(c.convert(value_t_m_seq), value_t_us_seq) + + # Now the metric converter: + cm = weewx.units.Converter(weewx.units.MetricUnits) + self.assertEqual(cm.convert(value_t_us), value_t_m) + self.assertEqual(cm.convert(value_t_us_seq), value_t_m_seq) + # Test a no-op conversion (US to US): + self.assertEqual(c.convert(value_t_us), value_t_us) + + # Test converting inHg_per_hour to mbar_per_hour + value_t = (0.6, "inHg_per_hour", "group_pressurerate") + converted = cm.convert(value_t) + # Do a formatted comparison to compensate for small rounding errors: + self.assertEqual(("%.4f" % converted[0],)+converted[1:3], ("20.3183", "mbar_per_hour", "group_pressurerate")) + + # Test impossible conversions: + self.assertRaises(KeyError, c.convert, (20.01, "foo", "group_temperature")) + self.assertRaises(KeyError, c.convert, (None, "foo", "group_temperature")) + self.assertRaises(KeyError, c.convert, (20.01, "degree_C", "group_foo")) + self.assertRaises(KeyError, c.convert, (20.01, None, "group_temperature")) + self.assertRaises(KeyError, c.convert, (20.01, "degree_C", None)) + self.assertEqual(c.convert((20.01, None, None)), (20.01, None, None)) + + # Test mmHg + value_t = (760.0, "mmHg", "group_pressure") + converted = cm.convert(value_t) + # Do a formatted comparison to compensate for small rounding errors: + self.assertEqual(("%.2f" % converted[0],)+converted[1:3], ("1013.25", "mbar", "group_pressure")) + + # Test mmHg_per_hour + value_t = (2.9, "mmHg_per_hour", "group_pressurerate") + converted = cm.convert(value_t) + # Do a formatted comparison to compensate for small rounding errors: + self.assertEqual(("%.4f" % converted[0],)+converted[1:3], ("3.8663", "mbar_per_hour", "group_pressurerate")) + + # Test second/minute/hour/day + value_t = (1440.0, "minute", "group_deltatime") + self.assertEqual(weewx.units.convert(value_t, "second"), (86400.0, 'second', 'group_deltatime')) + self.assertEqual(weewx.units.convert(value_t, "hour"), (24.0, 'hour', 'group_deltatime')) + self.assertEqual(weewx.units.convert(value_t, "day"), (1.0, 'day', 'group_deltatime')) + + def testConvertDict(self): + d_m = {'outTemp' : 20.01, + 'barometer' : 1002.3, + 'usUnits' : weewx.METRIC} + d_us = {'outTemp' : 68.018, + 'barometer' : 1002.3 * weewx.units.INHG_PER_MBAR, + 'usUnits' : weewx.US} + c = weewx.units.Converter() + d_test = c.convertDict(d_m) + self.assertEqual(d_test['outTemp'], d_us['outTemp']) + self.assertEqual(d_test['barometer'], d_us['barometer']) + self.assertFalse('usUnits' in d_test) + + # Go the other way: + cm = weewx.units.Converter(weewx.units.MetricUnits) + d_test = cm.convertDict(d_us) + self.assertEqual(d_test['outTemp'], d_m['outTemp']) + self.assertEqual(d_test['barometer'], d_m['barometer']) + self.assertFalse('usUnits' in d_test) + + # Test impossible conversions: + d_m['outTemp'] = (20.01, 'foo', 'group_temperature') + self.assertRaises(KeyError, c.convert, d_m) + d_m['outTemp'] = (20.01, 'degree_C', 'group_foo') + self.assertRaises(KeyError, c.convert, d_m) + + def testTargetUnits(self): + c = weewx.units.Converter() + self.assertEqual(c.getTargetUnit('outTemp'), ('degree_F', 'group_temperature')) + self.assertEqual(c.getTargetUnit('outTemp', 'max'), ('degree_F', 'group_temperature')) + self.assertEqual(c.getTargetUnit('outTemp', 'maxtime'), ('unix_epoch', 'group_time')) + self.assertEqual(c.getTargetUnit('outTemp', 'count'), ('count', 'group_count')) + self.assertEqual(c.getTargetUnit('outTemp', 'sum'), ('degree_F', 'group_temperature')) + self.assertEqual(c.getTargetUnit('wind', 'max'), ('mile_per_hour', 'group_speed')) + self.assertEqual(c.getTargetUnit('wind', 'vecdir'), ('degree_compass', 'group_direction')) + +class ValueHelperTest(unittest.TestCase): + + def testFormatting(self): + value_t = (68.01, "degree_F", "group_temperature") + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter) + self.assertEqual(vh.string(), "68.0°F") + self.assertEqual(vh.nolabel("T=%.3f"), "T=68.010") + self.assertEqual(vh.formatted, "68.0") + self.assertEqual(vh.raw, 68.01) + self.assertEqual(str(vh), "68.0°F") + self.assertEqual(str(vh.degree_F), "68.0°F") + self.assertEqual(str(vh.degree_C), "20.0°C") + + # Using .format() interface + self.assertEqual(vh.format(), "68.0°F") + self.assertEqual(vh.format("T=%.3f", add_label=False), "T=68.010") + + # Test None_string + value_t = (None, "degree_F", "group_temperature") + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter) + self.assertEqual(vh.format(None_string='foo'), 'foo') + self.assertTrue(isinstance(vh.format(None_string='foo'), str)) + # This one cannot be done with ASCII codec: + self.assertEqual(vh.format(None_string='unknown °F'), 'unknown °F') + self.assertTrue(isinstance(vh.format(None_string='unknown °F'), str)) + + def testFormattingWithConversion(self): + value_t = (68.01, "degree_F", "group_temperature") + c_m = weewx.units.Converter(weewx.units.MetricUnits) + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter, converter=c_m) + self.assertEqual(str(vh), "20.0°C") + self.assertEqual(str(vh.degree_F), "68.0°F") + self.assertEqual(str(vh.degree_C), "20.0°C") + # Try an impossible conversion: + self.assertRaises(AttributeError, getattr, vh, 'meter') + + def testExplicitConversion(self): + value_t = (10.0, "meter_per_second", "group_speed") + # Default converter converts to US Units + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter, converter=weewx.units.Converter()) + self.assertEqual(str(vh), "22 mph") + # Now explicitly convert to knots: + self.assertEqual(str(vh.knot), "19 knots") + + def testNoneValue(self): + value_t = (None, "degree_C", "group_temperature") + converter = weewx.units.Converter() + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter, converter=converter) + self.assertEqual(str(vh), " N/A") + self.assertEqual(str(vh.degree_C), " N/A") + + def testUnknownObsType(self): + value_t = weewx.units.UnknownObsType('foobar') + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter) + self.assertEqual(str(vh), "?'foobar'?") + self.assertFalse(vh.exists()) + self.assertFalse(vh.has_data()) + with self.assertRaises(TypeError): + vh.raw + + def testElapsedTime(self): + value_t = (2*86400 + 1*3600 + 5*60 + 12, "second", "group_deltatime") + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter, context='month') + self.assertEqual(vh.long_form(), "2 days, 1 hour, 5 minutes") + format_label = "%(day)d%(day_label)s, %(hour)d%(hour_label)s, " \ + "%(minute)d%(minute_label)s, %(second)d%(second_label)s" + self.assertEqual(vh.long_form(format_label), "2 days, 1 hour, 5 minutes, 12 seconds") + # Now try a 'None' value: + vh = weewx.units.ValueHelper((None, "second", "group_deltatime"), formatter=default_formatter) + self.assertEqual(vh.string(), " N/A") + self.assertEqual(vh.long_form(), " N/A") + self.assertEqual(vh.long_form(None_string="Nothing"), "Nothing") + + def test_JSON(self): + value_t = (68.1283, "degree_F", "group_temperature") + vh = weewx.units.ValueHelper(value_t, formatter=default_formatter) + self.assertEqual(vh.json(), "68.1283") + self.assertEqual(vh.round(2).json(), "68.13") + # Test sequence: + vh = weewx.units.ValueHelper(([68.1283, 65.201, None, 69.911], + "degree_F", "group_temperature"), + formatter=default_formatter) + self.assertEqual(vh.json(), "[68.1283, 65.201, null, 69.911]") + self.assertEqual(vh.round(2).json(), "[68.13, 65.2, null, 69.91]") + # Test sequence of complex + vh = weewx.units.ValueHelper(([complex(1.234, 2.3456), complex(9.1891, 2.764), None], + "degree_F", "group_temperature"), + formatter=default_formatter) + self.assertEqual(vh.json(), "[[1.234, 2.3456], [9.1891, 2.764], null]") + self.assertEqual(vh.round(2).json(), "[[1.23, 2.35], [9.19, 2.76], null]") + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_wxformulas.py b/dist/weewx-5.0.2/src/weewx/tests/test_wxformulas.py new file mode 100644 index 0000000..c4e11f2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_wxformulas.py @@ -0,0 +1,161 @@ +# +# Copyright (c) 2018-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test module weewx.wxformulas""" + +import os +import time +import unittest + +try: + import ephem +except ImportError: + pyephem_installed = False +else: + pyephem_installed = True + +try: + # Python 3 --- mock is included in unittest + from unittest import mock +except ImportError: + # Python 2 --- must have mock installed + import mock + +import weewx +import weewx.wxformulas +import weewx.units + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + + +class WXFormulasTest(unittest.TestCase): + + def test_dewpoint(self): + self.assertAlmostEqual(weewx.wxformulas.dewpointF(68, 50), 48.7, 1) + self.assertAlmostEqual(weewx.wxformulas.dewpointF(32, 50), 15.5, 1) + self.assertAlmostEqual(weewx.wxformulas.dewpointF(-10, 50), -23.5, 1) + self.assertIsNone(weewx.wxformulas.dewpointF(-10, None)) + self.assertIsNone(weewx.wxformulas.dewpointF(-10, 0)) + + def test_windchill(self): + self.assertAlmostEqual(weewx.wxformulas.windchillF(55, 20), 55.0, 0) + self.assertAlmostEqual(weewx.wxformulas.windchillF(45, 2), 45.0, 0) + self.assertAlmostEqual(weewx.wxformulas.windchillF(45, 20), 37.0, 0) + self.assertAlmostEqual(weewx.wxformulas.windchillF(-5, 20), -29.0, 0) + self.assertIsNone(weewx.wxformulas.windchillF(55, None)) + + self.assertAlmostEqual(weewx.wxformulas.windchillC(12, 30), 12, 0) + self.assertAlmostEqual(weewx.wxformulas.windchillC(5, 30), 0, 0) + self.assertAlmostEqual(weewx.wxformulas.windchillC(5, 3), 5, 0) + self.assertIsNone(weewx.wxformulas.windchillC(5, None)) + + def test_heatindex(self): + self.assertAlmostEqual(weewx.wxformulas.heatindexF(75.0, 50.0), 74.5, 1) + self.assertAlmostEqual(weewx.wxformulas.heatindexF(80.0, 50.0), 80.8, 1) + self.assertAlmostEqual(weewx.wxformulas.heatindexF(80.0, 95.0), 87.8, 1) + self.assertAlmostEqual(weewx.wxformulas.heatindexF(90.0, 50.0), 94.6, 1) + self.assertAlmostEqual(weewx.wxformulas.heatindexF(90.0, 95.0), 126.6, 1) + + # Look for a smooth transition as RH crosses 40% + self.assertAlmostEqual(weewx.wxformulas.heatindexF(95.0, 39.0), 98.5, 1) + self.assertAlmostEqual(weewx.wxformulas.heatindexF(95.0, 40.0), 99.0, 1) + self.assertAlmostEqual(weewx.wxformulas.heatindexF(95.0, 41.0), 99.5, 1) + + self.assertIsNone(weewx.wxformulas.heatindexF(90, None)) + + self.assertAlmostEqual(weewx.wxformulas.heatindexC(30, 80), 37.7, 1) + self.assertIsNone(weewx.wxformulas.heatindexC(30, None)) + + def test_altimeter_pressure(self): + self.assertAlmostEqual(weewx.wxformulas.altimeter_pressure_US(28.0, 0.0), 28.002, 3) + self.assertAlmostEqual(weewx.wxformulas.altimeter_pressure_US(28.0, 1000.0), 29.043, 3) + self.assertIsNone(weewx.wxformulas.altimeter_pressure_US(28.0, None)) + self.assertAlmostEqual(weewx.wxformulas.altimeter_pressure_Metric(948.08, 0.0), 948.2, 1) + self.assertAlmostEqual(weewx.wxformulas.altimeter_pressure_Metric(948.08, 304.8), 983.4, 1) + self.assertIsNone(weewx.wxformulas.altimeter_pressure_Metric(948.08, None)) + + def test_solar_rad(self): + if not pyephem_installed: + raise unittest.case.SkipTest("Skipping test_solar_rad: no pyephem") + + results = [weewx.wxformulas.solar_rad_Bras(42, -72, 0, t * 3600 + 1422936471) for t in range(24)] + expected = [0, 0, 0, 0, 0, 0, 0, 0, 1.86, 100.81, 248.71, + 374.68, 454.90, 478.76, 443.47, 353.23, 220.51, 73.71, 0, 0, 0, + 0, 0, 0] + for result, expect in zip(results, expected): + self.assertAlmostEqual(result, expect, 2) + + results = [weewx.wxformulas.solar_rad_RS(42, -72, 0, t * 3600 + 1422936471) for t in range(24)] + expected = [0, 0, 0, 0, 0, 0, 0, 0, 0.09, 79.31, 234.77, + 369.80, 455.66, 481.15, 443.44, 346.81, 204.64, 52.63, 0, 0, 0, + 0, 0, 0] + for result, expect in zip(results, expected): + self.assertAlmostEqual(result, expect, 2) + + def test_humidex(self): + self.assertAlmostEqual(weewx.wxformulas.humidexC(30.0, 80.0), 43.66, 2) + self.assertAlmostEqual(weewx.wxformulas.humidexC(30.0, 20.0), 30.00, 2) + self.assertAlmostEqual(weewx.wxformulas.humidexC(0.0, 80.0), 0, 2) + self.assertIsNone(weewx.wxformulas.humidexC(30.0, None)) + + def test_equation_of_time(self): + # 1 October + self.assertAlmostEqual(weewx.wxformulas.equation_of_time(274), 0.1889, 4) + + def test_hour_angle(self): + self.assertAlmostEqual(weewx.wxformulas.hour_angle(15.5, -16.25, 274), 0.6821, 4) + self.assertAlmostEqual(weewx.wxformulas.hour_angle(0, -16.25, 274), 2.9074, 4) + + def test_solar_declination(self): + # 1 October + self.assertAlmostEqual(weewx.wxformulas.solar_declination(274), -0.075274, 6) + + def test_sun_radiation(self): + self.assertAlmostEqual(weewx.wxformulas.sun_radiation(doy=274, + latitude_deg=16.217, longitude_deg=-16.25, + tod_utc=16.0, + interval=1.0), 3.543, 3) + + def test_longwave_radiation(self): + # In mm/day + self.assertAlmostEqual(weewx.wxformulas.longwave_radiation(Tmin_C=19.1, Tmax_C=25.1, + ea=2.1, Rs=14.5, Rso=18.8, rh=50), 3.5, 1) + self.assertAlmostEqual(weewx.wxformulas.longwave_radiation(Tmin_C=28, Tmax_C=28, + ea=3.402, Rs=0, Rso=0, rh=40), 2.4, 1) + + def test_ET(self): + sr_mean_wpm2 = 680.56 # == 2.45 MJ/m^2/hr + timestamp = 1475337600 # 1-Oct-2016 at 16:00UTC + self.assertAlmostEqual(weewx.wxformulas.evapotranspiration_Metric(Tmin_C=38, Tmax_C=38, + rh_min=52, rh_max=52, + sr_mean_wpm2=sr_mean_wpm2, + ws_mps=3.3, + wind_height_m=2, + latitude_deg=16.217, + longitude_deg=-16.25, + altitude_m=8, timestamp=timestamp), 0.63, 2) + + sr_mean_wpm2 = 0.0 # Night time + timestamp = 1475294400 # 1-Oct-2016 at 04:00UTC (0300 local) + self.assertAlmostEqual(weewx.wxformulas.evapotranspiration_Metric(Tmin_C=28, Tmax_C=28, + rh_min=90, rh_max=90, + sr_mean_wpm2=sr_mean_wpm2, + ws_mps=3.3, + wind_height_m=2, + latitude_deg=16.217, + longitude_deg=-16.25, + altitude_m=8, timestamp=timestamp), 0.03, 2) + sr_mean_wpm2 = 860 + timestamp = 1469829600 # 29-July-2016 22:00 UTC (15:00 local time) + self.assertAlmostEqual(weewx.wxformulas.evapotranspiration_US(Tmin_F=87.8, Tmax_F=89.1, + rh_min=34, rh_max=38, + sr_mean_wpm2=sr_mean_wpm2, ws_mph=9.58, + wind_height_ft=6, + latitude_deg=45.7, longitude_deg=-121.5, + altitude_ft=700, timestamp=timestamp), 0.028, 3) + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_wxxtypes.py b/dist/weewx-5.0.2/src/weewx/tests/test_wxxtypes.py new file mode 100644 index 0000000..e7b29ff --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_wxxtypes.py @@ -0,0 +1,464 @@ +# +# Copyright (c) 2019-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test weather-related XTypes extensions.""" + +import logging +import math +import unittest + +try: + # Python 3 --- mock is included in unittest + from unittest import mock +except ImportError: + # Python 2 --- must have mock installed + import mock + +import weewx.wxxtypes +import weeutil.logger +from weewx.units import ValueTuple +import schemas.wview_extended +import gen_fake_data + +weewx.debug = 1 + +log = logging.getLogger(__name__) +# Set up logging using the defaults. +weeutil.logger.setup('weetest_wxxtypes') + +altitude_vt = weewx.units.ValueTuple(700, "foot", "group_altitude") +latitude = 45 +longitude = -122 + +# Test values: +record_1 = { + 'dateTime': 1567515300, 'usUnits': 1, 'interval': 5, 'inTemp': 73.0, 'outTemp': 88.7, + 'inHumidity': 54.0, 'outHumidity': 90.0, 'windSpeed': 12.0, 'windDir': 250.0, 'windGust': 15.0, + 'windGustDir': 270.0, 'rain': 0.02, +} + +# These are the correct values +correct = { + 'dewpoint': 85.37502296294483, + 'inDewpoint': 55.3580124202402, + 'windchill': 88.7, + 'heatindex': 116.1964007023, + 'humidex': 121.31401772489724, + 'appTemp': 99.36405806590201, + 'beaufort': 3, + 'windrun': 1.0 +} + + +class TestSimpleFunctions(unittest.TestCase): + + def setUp(self): + # Make a copy. We may be modifying it. + self.record = dict(record_1) + self.wx_calc = weewx.wxxtypes.WXXTypes(altitude_vt, latitude, longitude) + + def test_appTemp(self): + self.calc('appTemp', 'outTemp', 'outHumidity', 'windSpeed') + + def test_beaufort(self): + self.calc('beaufort', 'windSpeed') + + def test_dewpoint(self): + self.calc('dewpoint', 'outTemp', 'outHumidity') + + def test_heatindex(self): + self.calc('heatindex', 'outTemp', 'outHumidity') + + def test_humidex(self): + self.calc('humidex', 'outTemp', 'outHumidity') + + def test_inDewpoint(self): + self.calc('inDewpoint', 'inTemp', 'inHumidity') + + def test_windchill(self): + self.calc('windchill', 'outTemp', 'windSpeed') + + def test_windrun(self): + self.calc('windrun', 'windSpeed') + + def test_windDir_default(self): + # With the default, windDir should be set to None if the windSpeed is zero. + self.record['windSpeed'] = 0.0 + result = self.wx_calc.get_scalar('windDir', self.record, None) + self.assertIsNone(result[0]) + + def test_windDir_no_ignore(self): + # Now let's not ignore zero wind. This should become a No-op, which is signaled by + # raising weewx.NoCalculate + wx_calc = weewx.wxxtypes.WXXTypes(altitude_vt, latitude, longitude, force_null=False) + with self.assertRaises(weewx.NoCalculate): + wx_calc.get_scalar('windDir', self.record, None) + + def calc(self, key, *crits): + """Calculate derived type 'key'. Parameters in "crits" are required to perform the + calculation. Their presence will be tested. + """ + result = self.wx_calc.get_scalar(key, self.record, None) + self.assertAlmostEqual(result[0], correct[key], 3) + # Now try it, but with a critical key missing + for crit in crits: + # Restore the record + self.setUp() + # Set the critical key to None + self.record[crit] = None + result = self.wx_calc.get_scalar(key, self.record, None) + # Result should be None + self.assertEqual(result[0], None) + # Try again, but delete the key completely. Should raise an exception. + with self.assertRaises(weewx.CannotCalculate): + del self.record[crit] + self.wx_calc.get_scalar(key, self.record, None) + + def test_unknownKey(self): + """Make sure get_scalar() raises weewx.UnknownType when presented with an unknown key""" + with self.assertRaises(weewx.UnknownType): + self.wx_calc.get_scalar('foo', self.record, None) + + +# Test values for the PressureCooker test: +record_2 = { + 'dateTime': 1567515300, 'usUnits': 1, 'interval': 5, 'inTemp': 73.0, 'outTemp': 55.7, + 'inHumidity': 54.0, 'outHumidity': 90.0, 'windSpeed': 0.0, 'windDir': None, 'windGust': 2.0, + 'windGustDir': 270.0, 'rain': 0.0, 'windchill': 55.7, 'heatindex': 55.7, + 'pressure': 29.259303850622302, 'barometer': 29.99, 'altimeter': 30.012983156964353, +} + +# These are the correct values +pressure = 29.259303850622302 +barometer = 30.01396476909608 +altimeter = 30.012983156964353 + + +class TestPressureCooker(unittest.TestCase): + """Test the class PressureCooker""" + + def setUp(self): + # Make a copy. We will be modifying it. + self.record = dict(record_2) + + def test_get_temperature_12h(self): + pc = weewx.wxxtypes.PressureCooker(altitude_vt) + + # Mock a database in US units + db_manager = mock.Mock() + with mock.patch.object(db_manager, 'getRecord', + return_value={'usUnits': weewx.US, 'outTemp': 80.3}) as mock_mgr: + t = pc._get_temperature_12h(self.record['dateTime'], db_manager) + # Make sure the mocked database manager got called with a time 12h ago + mock_mgr.assert_called_once_with(self.record['dateTime'] - 12 * 3600, max_delta=1800) + # The results should be in US units + self.assertEqual(t, (80.3, 'degree_F', 'group_temperature')) + + # Make sure the value has been cached: + with mock.patch.object(db_manager, 'getRecord', + return_value={'usUnits': weewx.US, 'outTemp': 80.3}) as mock_mgr: + t = pc._get_temperature_12h(self.record['dateTime'], db_manager) + # The cached value should have been used + mock_mgr.assert_not_called() + self.assertEqual(t, (80.3, 'degree_F', 'group_temperature')) + + def test_get_temperature_12h_metric(self): + pc = weewx.wxxtypes.PressureCooker(altitude_vt) + + # Mock a database in METRICWX units + db_manager = mock.Mock() + with mock.patch.object(db_manager, 'getRecord', + return_value={'usUnits': weewx.METRICWX, + 'outTemp': 30.0}) as mock_mgr: + t = pc._get_temperature_12h(self.record['dateTime'], db_manager) + # Make sure the mocked database manager got called with a time 12h ago + mock_mgr.assert_called_once_with(self.record['dateTime'] - 12 * 3600, max_delta=1800) + self.assertEqual(t, (30.0, 'degree_C', 'group_temperature')) + + def test_get_temperature_12h_missing(self): + pc = weewx.wxxtypes.PressureCooker(altitude_vt) + + db_manager = mock.Mock() + # Mock a database missing a record from 12h ago + with mock.patch.object(db_manager, 'getRecord', + return_value=None) as mock_mgr: + t = pc._get_temperature_12h(self.record['dateTime'], db_manager) + mock_mgr.assert_called_once_with(self.record['dateTime'] - 12 * 3600, max_delta=1800) + self.assertEqual(t, None) + + # Mock a database that has a record from 12h ago, but it's missing outTemp + with mock.patch.object(db_manager, 'getRecord', + return_value={'usUnits': weewx.METRICWX}) as mock_mgr: + t = pc._get_temperature_12h(self.record['dateTime'], db_manager) + mock_mgr.assert_called_once_with(self.record['dateTime'] - 12 * 3600, max_delta=1800) + self.assertEqual(t, None) + + def test_pressure(self): + """Test interface pressure()""" + + # Create a pressure cooker + pc = weewx.wxxtypes.PressureCooker(altitude_vt) + + # Mock up a database manager in US units + db_manager = mock.Mock() + with mock.patch.object(db_manager, 'getRecord', + return_value={'usUnits': weewx.US, 'outTemp': 80.3}): + p = pc.pressure(self.record, db_manager) + self.assertEqual(p, (pressure, 'inHg', 'group_pressure')) + + # Remove 'outHumidity' and try again. Should now raise exception. + del self.record['outHumidity'] + with self.assertRaises(weewx.CannotCalculate): + p = pc.pressure(self.record, db_manager) + + # Mock a database missing a record from 12h ago + with mock.patch.object(db_manager, 'getRecord', + return_value=None): + with self.assertRaises(weewx.CannotCalculate): + p = pc.pressure(self.record, db_manager) + + # Mock a database that has a record from 12h ago, but it's missing outTemp + with mock.patch.object(db_manager, 'getRecord', + return_value={'usUnits': weewx.METRICWX}) as mock_mgr: + with self.assertRaises(weewx.CannotCalculate): + p = pc.pressure(self.record, db_manager) + + def test_altimeter(self): + """Test interface altimeter()""" + + # First, try the example in wxformulas.py. This has elevation 1,000 feet + pc = weewx.wxxtypes.PressureCooker((1000.0, 'foot', 'group_altitude')) + a = pc.altimeter({'usUnits': 1, 'pressure': 28.0}) + self.assertAlmostEqual(a[0], 29.04, 2) + self.assertEqual(a[1:], ('inHg', 'group_pressure')) + + pc = weewx.wxxtypes.PressureCooker(altitude_vt) + a = pc.altimeter(self.record) + self.assertEqual(a, (altimeter, 'inHg', 'group_pressure')) + + # Remove 'pressure' from the record and check for exception + del self.record['pressure'] + with self.assertRaises(weewx.CannotCalculate): + a = pc.altimeter(self.record) + + def test_barometer(self): + """Test interface barometer()""" + + # Create a pressure cooker + pc = weewx.wxxtypes.PressureCooker(altitude_vt) + + b = pc.barometer(self.record) + self.assertEqual(b, (barometer, 'inHg', 'group_pressure')) + + # Remove 'outTemp' from the record and check for exception + del self.record['outTemp'] + with self.assertRaises(weewx.CannotCalculate): + b = pc.barometer(self.record) + + +class RainGenerator(object): + """Generator object that returns an increasing deluge of rain.""" + + def __init__(self, timestamp, time_increment=60, rain_increment=0.01): + """Initialize the rain generator.""" + self.timestamp = timestamp + self.time_increment = time_increment + self.rain_increment = rain_increment + self.rain = 0 + + def __iter__(self): + return self + + def __next__(self): + """Advance and return the next rain event""" + event = {'dateTime': self.timestamp, 'usUnits': weewx.US, 'interval': self.time_increment, + 'rain': self.rain} + self.timestamp += self.time_increment + self.rain += self.rain_increment + return event + + # For Python 2 compatibility: + next = __next__ + + +class TestRainRater(unittest.TestCase): + start = 1571083200 # 14-Oct-2019 1300 PDT + rain_period = 900 # 15 minute sliding window + retain_period = 915 + + def setUp(self): + """Set up and populate an in-memory database""" + self.db_manager = weewx.manager.Manager.open_with_create( + { + 'database_name': ':memory:', + 'driver': 'weedb.sqlite' + }, + schema=schemas.wview_extended.schema) + # Create a generator that will issue rain records on demand + self.rain_generator = RainGenerator(TestRainRater.start) + # Populate the database with 30 minutes worth of rain. + N = 30 + for record in self.rain_generator: + self.db_manager.addRecord(record) + N -= 1 + if not N: + break + + def tearDown(self): + self.db_manager.close() + self.db_manager = None + + def test_add_US(self): + """Test adding rain data in the US system""" + rain_rater = weewx.wxxtypes.RainRater(TestRainRater.rain_period, + TestRainRater.retain_period) + + # Get the next record out of the rain generator. + record = self.rain_generator.next() + # Make sure the event is what we think it is + self.assertEqual(record['dateTime'], TestRainRater.start + 30 * 60) + # Add it to the RainRater object + rain_rater.add_loop_packet(record) + # Get the rainRate out of it + rate = rain_rater.get_scalar('rainRate', record, self.db_manager) + # Check its values + self.assertAlmostEqual(rate[0], 13.80, 2) + self.assertEqual(rate[1:], ('inch_per_hour', 'group_rainrate')) + + def test_add_METRICWX(self): + """Test adding rain data in the METRICWX system""" + rain_rater = weewx.wxxtypes.RainRater(TestRainRater.rain_period, + TestRainRater.retain_period) + + # Get the next record out of the rain generator. + record = self.rain_generator.next() + # Make sure the event is what we think it is + self.assertEqual(record['dateTime'], TestRainRater.start + 30 * 60) + # Convert to metric: + record_metric = weewx.units.to_METRICWX(record) + # Add it to the RainRater object + rain_rater.add_loop_packet(record_metric) + # The results should be in metric. + # Get the rainRate out of it + rate = rain_rater.get_scalar('rainRate', record, self.db_manager) + # Check its values + self.assertAlmostEqual(rate[0], 350.52, 2) + self.assertEqual(rate[1:], ('mm_per_hour', 'group_rainrate')) + + def test_trim(self): + """"Test trimming old events""" + rain_rater = weewx.wxxtypes.RainRater(TestRainRater.rain_period, + TestRainRater.retain_period) + + # Add 20 minutes worth of rain + N = 20 + for record in self.rain_generator: + rain_rater.add_loop_packet(record) + N -= 1 + if not N: + break + + # The rain record object should have the last 15 minutes worth of rain in it. Let's peek + # inside to check. The first value should be 15 minutes old + self.assertEqual(rain_rater.rain_events[0][0], record['dateTime'] - 15 * 60) + # The last value should be the record we just put in it: + self.assertEqual(rain_rater.rain_events[-1][0], record['dateTime']) + + # Get the rainRate + rate = rain_rater.get_scalar('rainRate', record, self.db_manager) + # Check its values + self.assertAlmostEqual(rate[0], 25.20, 2) + self.assertEqual(rate[1:], ('inch_per_hour', 'group_rainrate')) + + +class TestDelta(unittest.TestCase): + """Test XTypes extension 'Delta'.""" + + def test_delta(self): + # Instantiate a Delta for calculating 'rain' from 'totalRain': + delta = weewx.wxxtypes.Delta({'rain': {'input': 'totalRain'}}) + + # Add a new total rain to it: + record = {'dateTime': 1567515300, 'usUnits': 1, 'interval': 5, 'totalRain': 0.05} + val = delta.get_scalar('rain', record, None) + self.assertIsNone(val[0]) + + # Add the same record again. No change in totalRain, so rain should be zero + val = delta.get_scalar('rain', record, None) + self.assertEqual(val[0], 0.0) + + # Add a little rain. + record['totalRain'] += 0.01 + val = delta.get_scalar('rain', record, None) + self.assertAlmostEqual(val[0], 0.01, 6) + + # Adding None should reset counter + record['totalRain'] = None + val = delta.get_scalar('rain', record, None) + self.assertIsNone(val[0]) + + # Try an unknown type + with self.assertRaises(weewx.UnknownType): + delta.get_scalar('foo', record, None) + +class TestET(unittest.TestCase): + start = 1562007600 # 1-Jul-2019 1200 + rain_period = 900 # 15 minute sliding window + retain_period = 915 + + def setUp(self): + """Set up an in-memory database""" + self.db_manager = weewx.manager.Manager.open_with_create( + { + 'database_name': ':memory:', + 'driver': 'weedb.sqlite' + }, + schema=schemas.wview_extended.schema) + # Populate the database with 60 minutes worth of data at 5 minute intervals. Set the annual + # phase to half a year, so that the temperatures will be high + for record in gen_fake_data.genFakeRecords(TestET.start, TestET.start + 3600, interval=300, + annual_phase_offset=math.pi * ( + 24.0 * 3600 * 365)): + # Add some radiation and humidity: + record['radiation'] = 860 + record['outHumidity'] = 50 + self.db_manager.addRecord(record) + + def test_ET(self): + wx_xtypes = weewx.wxxtypes.ETXType(altitude_vt, + latitude_f=latitude, + longitude_f=longitude) + ts = self.db_manager.lastGoodStamp() + record = self.db_manager.getRecord(ts) + et_vt = wx_xtypes.get_scalar('ET', record, self.db_manager) + + self.assertAlmostEqual(et_vt[0], 0.00193, 5) + self.assertEqual((et_vt[1], et_vt[2]), ("inch", "group_rain")) + + +class TestWindRun(unittest.TestCase): + """Windrun calculations always seem to give us trouble...""" + + def setUp(self): + self.wx_calc = weewx.wxxtypes.WXXTypes(altitude_vt, latitude, longitude) + + def test_US(self): + record = {'usUnits': weewx.US, 'interval': 5, 'windSpeed': 3.8} + result = self.wx_calc.get_scalar('windrun', record, None) + self.assertAlmostEqual(result[0], 0.3167, 4) + + def test_METRIC(self): + record = {'usUnits': weewx.METRIC, 'interval': 5, 'windSpeed': 3.8} + result = self.wx_calc.get_scalar('windrun', record, None) + self.assertAlmostEqual(result[0], 0.3167, 4) + + def test_METRICWX(self): + record = {'usUnits': weewx.METRICWX, 'interval': 5, 'windSpeed': 3.8} + result = self.wx_calc.get_scalar('windrun', record, None) + self.assertAlmostEqual(result[0], 1.14, 4) + + +if __name__ == '__main__': + unittest.main() diff --git a/dist/weewx-5.0.2/src/weewx/tests/test_xtypes.py b/dist/weewx-5.0.2/src/weewx/tests/test_xtypes.py new file mode 100644 index 0000000..f3b5c97 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/test_xtypes.py @@ -0,0 +1,155 @@ +# +# Copyright (c) 2019-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +import locale +import logging +import os.path +import sys +import time +import unittest + +import configobj + +import gen_fake_data +import weeutil.logger +import weeutil.weeutil +import weewx.units +import weewx.xtypes +from weewx.units import ValueTuple + +weewx.debug = 1 + +log = logging.getLogger(__name__) +# Set up logging using the defaults. +weeutil.logger.setup('weetest_xtypes') + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +# This will use the locale specified by the environment variable 'LANG' +# Other options are possible. See: +# http://docs.python.org/2/library/locale.html#locale.setlocale +locale.setlocale(locale.LC_ALL, '') + +# Find the configuration file. It's assumed to be in the same directory as me, so first figure +# out where that is. +my_dir = os.path.normpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) +# The full path to the configuration file: +config_path = os.path.join(my_dir, "testgen.conf") + +# Month of September 2010: +month_timespan = weeutil.weeutil.TimeSpan(1283324400, 1285916400) + + +class Common(object): + + def setUp(self): + global config_path + + try: + self.config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') + except IOError: + sys.stderr.write("Unable to open configuration file %s" % config_path) + # Reraise the exception (this will eventually cause the program to exit) + raise + except configobj.ConfigObjError: + sys.stderr.write("Error while parsing configuration file %s" % config_path) + raise + + # This will generate the test databases if necessary: + gen_fake_data.configDatabases(self.config_dict, database_type=self.database_type) + + def tearDown(self): + pass + + def test_daily_vecdir(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + vt = weewx.xtypes.DailySummaries.get_aggregate('wind', + month_timespan, + 'vecdir', + db_manager) + self.assertAlmostEqual(vt[0], 60.52375, 5) + self.assertEqual(vt[1], 'degree_compass', 'group_direction') + + def test_daily_vecavg(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + vt = weewx.xtypes.DailySummaries.get_aggregate('wind', + month_timespan, + 'vecavg', + db_manager) + self.assertAlmostEqual(vt[0], 8.13691, 5) + self.assertEqual(vt[1], 'mile_per_hour', 'group_speed') + + def test_archive_table_vecdir(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + vt = weewx.xtypes.ArchiveTable.get_aggregate('wind', + month_timespan, + 'vecdir', + db_manager) + self.assertAlmostEqual(vt[0], 60.52375, 5) + self.assertEqual(vt[1], 'degree_compass', 'group_direction') + + def test_archive_table_vecavg(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + vt = weewx.xtypes.ArchiveTable.get_aggregate('wind', + month_timespan, + 'vecavg', + db_manager) + self.assertAlmostEqual(vt[0], 8.13691, 5) + self.assertEqual(vt[1], 'mile_per_hour', 'group_speed') + + def test_archive_table_long_vecdir(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + vt = weewx.xtypes.ArchiveTable.get_wind_aggregate_long('wind', + month_timespan, + 'vecdir', + db_manager) + self.assertAlmostEqual(vt[0], 60.52375, 5) + self.assertEqual(vt[1], 'degree_compass', 'group_direction') + + def test_archive_table_long_vecavg(self): + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + vt = weewx.xtypes.ArchiveTable.get_wind_aggregate_long('wind', + month_timespan, + 'vecavg', + db_manager) + self.assertAlmostEqual(vt[0], 8.13691, 5) + self.assertEqual(vt[1], 'mile_per_hour', 'group_speed') + + +class TestSqlite(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "sqlite" + super().__init__(*args, **kwargs) + + +class TestMySQL(Common, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "mysql" + super().__init__(*args, **kwargs) + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + +def suite(): + tests = ['test_daily_vecdir', 'test_daily_vecavg', + 'test_archive_table_vecdir', 'test_archive_table_vecavg', + 'test_archive_table_long_vecdir', 'test_archive_table_long_vecavg'] + return unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=1).run(suite()) diff --git a/dist/weewx-5.0.2/src/weewx/tests/testgen.conf b/dist/weewx-5.0.2/src/weewx/tests/testgen.conf new file mode 100644 index 0000000..4812b12 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/testgen.conf @@ -0,0 +1,236 @@ +############################################################################### +# # +# # +# WEEWX TEST CONFIGURATION FILE # +# # +# # +############################################################################### +# # +# Copyright (c) 2009 - 2022 Tom Keffer # +# # +# See the file LICENSE.txt for your full rights. # +# # +############################################################################### + +# +# This section is for general configuration information +# + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 1 + +# Root directory of the weewx data file hierarchy for this station. +WEEWX_ROOT = /var/tmp/weewx_test + +# How long to wait before timing out a socket (FTP, HTTP) connection: +socket_timeout = 20 + +# Current version +version = test + +############################################################################################ + +[Station] + + # + # This section is for information about your station + # + + location = "Ĺōćāţĩőń with UTF8 characters" + + # Latitude, longitude in decimal degrees + latitude = 45.686 + longitude = -121.566 + + # Altitude of the station, with unit it is in: + altitude = 100, meter # Choose 'foot' or 'meter' for unit + + rain_year_start = 1 + + # Start of week (0=Monday, 6 = Sunday) + week_start = 6 + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + beaufort = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################################ + +[StdArchive] + + # + # This section is for configuring the archive databases. + # + + # If your station hardware supports data logging (such as the Davis Vantage + # series), then the archive interval will be downloaded off the station. + # Otherwise, you must specify it below (in seconds): + archive_interval = 300 + + # How long to wait (in seconds) before processing new archive data + archive_delay = 15 + + # Generally, if possible, new records are downloaded from the console hardware. + # If the console does not support this, then software record generation is done. + # Set the following to "software" to force software record generation: + record_generation = hardware + +############################################################################################ + +[DataBindings] + + # + # This section lists bindings. It's rather "non-standard", so as to be driven by + # the test suites. + # + + [[wx_binding]] + # The database to be used - it should match one of the sections in [Databases] + database = replace_me + # The name of the table within the database + table_name = archive + # The class to manage the database + manager = weewx.manager.DaySummaryManager + # For the schema, use the "small" schema. It is much faster. + schema = tst_schema.schema + + [[alt_binding]] + # The database to be used - it should match one of the sections in [Databases] + database = replace_me + # The name of the table within the database + table_name = archive + # The class to manage the database + manager = weewx.wxmanager.WXDaySummaryManager + # For the "alternate" database, use the "small" schema. + schema = tst_schema.schema + +[Databases] + + # + # This section lists possible databases. + # + + [[archive_sqlite]] + # Test with SQLITE_ROOT relative to WEEWX_ROOT + SQLITE_ROOT = archive + database_name = test.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[archive_mysql]] + host = localhost + user = weewx1 + password = weewx1 + database_name = test_weewx + driver = weedb.mysql + + [[alt_sqlite]] + # This also tests for an absolute path for SQLITE_ROOT. + SQLITE_ROOT = /var/tmp/weewx_test/archive/ + database_name = test_alt.sdb + driver = weedb.sqlite + + # MySQL databases require setting an appropriate 'user' and 'password' + [[alt_mysql]] + host = localhost + user = weewx1 + password = weewx1 + database_name = test_alt_weewx + driver = weedb.mysql + +############################################################################################ + +[StdReport] + + # + # This section specifies what reports, using which skins, are to be generated. + # + + # Where the skins reside, relative to WEEWX_ROOT: + # (this will get overridden by the test software): + SKIN_ROOT = test_skins + + # Where the generated reports should go, relative to WEEWX_ROOT: + HTML_ROOT = test_results + + data_binding = wx_binding + + # Run a "standard test" (using US units) + [[StandardTest]] + HTML_ROOT = test_results/StandardTest + + # What skin this report should be based on: + skin = StandardTest + lang = en + unit_system = US + enable = true + + # Test adding a file by using an override. Because the skin configuration + # file uses the deprecated 'FileGenerator', we must too. + [[[FileGenerator]]] + [[[[ByRecords]]]] + # + # Reports that include every record + # + [[[[[by_record]]]]] + encoding = strict_ascii + template = byrecord.txt.tmpl + + # Run it again, this time using metric units, and the German ("de") locale: + [[MetricTest]] + HTML_ROOT = test_results/StandardTest/metric + skin = StandardTest + lang = de + unit_system = METRICWX + enable = true + # Test overriding unit groups. + [[[Units]]] + [[[[Groups]]]] + group_speed = km_per_hour + group_speed2 = km_per_hour2 + + # Test adding a file by using an override. Because the skin configuration + # file uses the deprecated 'FileGenerator', we must too. + [[[FileGenerator]]] + [[[[ByRecords]]]] + # + # Reports that include every record + # + [[[[[by_record]]]]] + encoding = strict_ascii + template = byrecord.txt.tmpl + +############################################################################## + +[Engine] + [[Services]] + +[Accumulator] + [[stringData]] + accumulator = firstlast + extractor = last \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx/tests/tst_schema.py b/dist/weewx-5.0.2/src/weewx/tests/tst_schema.py new file mode 100644 index 0000000..0b6965f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/tests/tst_schema.py @@ -0,0 +1,39 @@ +# +# Copyright (c) 2019-2022 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +table = [('dateTime', 'INTEGER NOT NULL UNIQUE PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('altimeter', 'REAL'), + ('barometer', 'REAL'), + ('dewpoint', 'REAL'), + ('ET', 'REAL'), + ('heatindex', 'REAL'), + ('inHumidity', 'REAL'), + ('inTemp', 'REAL'), + ('outHumidity', 'REAL'), + ('outTemp', 'REAL'), + ('pressure', 'REAL'), + ('radiation', 'REAL'), + ('rain', 'REAL'), + ('rainRate', 'REAL'), + ('rxCheckPercent', 'REAL'), + ('sunshineDur', 'REAL'), + ('UV', 'REAL'), + ('windchill', 'REAL'), + ('windDir', 'REAL'), + ('windGust', 'REAL'), + ('windGustDir', 'REAL'), + ('windSpeed', 'REAL'), + ('stringData', 'VARCHAR(30)') + ] + +day_types = [e[0] for e in table if e[0] not in {'dateTime', 'usUnits', 'interval', 'stringData'}] +day_summaries = [(day_type, 'scalar') for day_type in day_types] + [('wind', 'VECTOR')] + +schema = { + 'table': table, + 'day_summaries' : day_summaries +} diff --git a/dist/weewx-5.0.2/src/weewx/units.py b/dist/weewx-5.0.2/src/weewx/units.py new file mode 100644 index 0000000..6fc70b2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/units.py @@ -0,0 +1,1685 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Data structures and functions for dealing with units.""" + +# +# The doctest examples work under Python 3 only!! +# + +import json +import locale +import logging +import math +import time + +import weeutil.weeutil +import weewx +from weeutil.weeutil import ListOfDicts, Polar, is_iterable + +log = logging.getLogger(__name__) + +# Handy conversion constants and functions: +INHG_PER_MBAR = 0.0295299875 +MM_PER_INCH = 25.4 +CM_PER_INCH = MM_PER_INCH / 10.0 +METER_PER_MILE = 1609.34 +METER_PER_FOOT = METER_PER_MILE / 5280.0 +MILE_PER_KM = 1000.0 / METER_PER_MILE +SECS_PER_DAY = 86400 + +def CtoK(x): + return x + 273.15 + +def KtoC(x): + return x - 273.15 + +def KtoF(x): + return CtoF(KtoC(x)) + +def FtoK(x): + return CtoK(FtoC(x)) + +def CtoF(x): + return x * 1.8 + 32.0 + +def FtoC(x): + return (x - 32.0) / 1.8 + +# Conversions to and from Felsius. +# For the definition of Felsius, see https://xkcd.com/1923/ +def FtoE(x): + return (7.0 * x - 80.0) / 9.0 + +def EtoF(x): + return (9.0 * x + 80.0) / 7.0 + +def CtoE(x): + return (7.0 / 5.0) * x + 16.0 + +def EtoC(x): + return (x - 16.0) * 5.0 / 7.0 + +def mps_to_mph(x): + return x * 3600.0 / METER_PER_MILE + +def kph_to_mph(x): + return x * 1000.0 / METER_PER_MILE + +def mph_to_knot(x): + return x * 0.868976242 + +def kph_to_knot(x): + return x * 0.539956803 + +def mps_to_knot(x): + return x * 1.94384449 + +unit_constants = { + 'US' : weewx.US, + 'METRIC' : weewx.METRIC, + 'METRICWX' : weewx.METRICWX +} + +unit_nicknames = { + weewx.US : 'US', + weewx.METRIC : 'METRIC', + weewx.METRICWX : 'METRICWX' +} + +# This data structure maps observation types to a "unit group" +# We start with a standard object group dictionary, but users are +# free to extend it: +obs_group_dict = ListOfDicts({ + "altimeter" : "group_pressure", + "altimeterRate" : "group_pressurerate", + "altitude" : "group_altitude", + "appTemp" : "group_temperature", + "appTemp1" : "group_temperature", + "barometer" : "group_pressure", + "barometerRate" : "group_pressurerate", + "beaufort" : "group_count", # DEPRECATED + "cloudbase" : "group_altitude", + "cloudcover" : "group_percent", + "co" : "group_fraction", + "co2" : "group_fraction", + "consBatteryVoltage" : "group_volt", + "cooldeg" : "group_degree_day", + "dateTime" : "group_time", + "dayRain" : "group_rain", + "daySunshineDur" : "group_deltatime", + "dewpoint" : "group_temperature", + "dewpoint1" : "group_temperature", + "ET" : "group_rain", + "extraHumid1" : "group_percent", + "extraHumid2" : "group_percent", + "extraHumid3" : "group_percent", + "extraHumid4" : "group_percent", + "extraHumid5" : "group_percent", + "extraHumid6" : "group_percent", + "extraHumid7" : "group_percent", + "extraHumid8" : "group_percent", + "extraTemp1" : "group_temperature", + "extraTemp2" : "group_temperature", + "extraTemp3" : "group_temperature", + "extraTemp4" : "group_temperature", + "extraTemp5" : "group_temperature", + "extraTemp6" : "group_temperature", + "extraTemp7" : "group_temperature", + "extraTemp8" : "group_temperature", + "growdeg" : "group_degree_day", + "gustdir" : "group_direction", + "hail" : "group_rain", + "hailRate" : "group_rainrate", + "heatdeg" : "group_degree_day", + "heatindex" : "group_temperature", + "heatindex1" : "group_temperature", + "heatingTemp" : "group_temperature", + "heatingVoltage" : "group_volt", + "highOutTemp" : "group_temperature", + "hourRain" : "group_rain", + "humidex" : "group_temperature", + "humidex1" : "group_temperature", + "illuminance" : "group_illuminance", + "inDewpoint" : "group_temperature", + "inHumidity" : "group_percent", + "inTemp" : "group_temperature", + "interval" : "group_interval", + "leafTemp1" : "group_temperature", + "leafTemp2" : "group_temperature", + "leafTemp3" : "group_temperature", + "leafTemp4" : "group_temperature", + "leafWet1" : "group_count", + "leafWet2" : "group_count", + "lightning_distance" : "group_distance", + "lightning_disturber_count" : "group_count", + "lightning_noise_count" : "group_count", + "lightning_strike_count" : "group_count", + "lowOutTemp" : "group_temperature", + "maxSolarRad" : "group_radiation", + "monthRain" : "group_rain", + "nh3" : "group_fraction", + "no2" : "group_concentration", + "noise" : "group_db", + "o3" : "group_fraction", + "outHumidity" : "group_percent", + "outTemp" : "group_temperature", + "outWetbulb" : "group_temperature", + "pb" : "group_fraction", + "pm1_0" : "group_concentration", + "pm2_5" : "group_concentration", + "pm10_0" : "group_concentration", + "pop" : "group_percent", + "pressure" : "group_pressure", + "pressureRate" : "group_pressurerate", + "radiation" : "group_radiation", + "rain" : "group_rain", + "rain24" : "group_rain", + "rainDur" : "group_deltatime", + "rainRate" : "group_rainrate", + "referenceVoltage" : "group_volt", + "rms" : "group_speed2", + "rxCheckPercent" : "group_percent", + "snow" : "group_rain", + "snowDepth" : "group_rain", + "snowMoisture" : "group_percent", + "snowRate" : "group_rainrate", + "so2" : "group_fraction", + "soilMoist1" : "group_moisture", + "soilMoist2" : "group_moisture", + "soilMoist3" : "group_moisture", + "soilMoist4" : "group_moisture", + "soilTemp1" : "group_temperature", + "soilTemp2" : "group_temperature", + "soilTemp3" : "group_temperature", + "soilTemp4" : "group_temperature", + "stormRain" : "group_rain", + "stormStart" : "group_time", + "sunshineDur" : "group_deltatime", + "supplyVoltage" : "group_volt", + "THSW" : "group_temperature", + "totalRain" : "group_rain", + "UV" : "group_uv", + "vecavg" : "group_speed2", + "vecdir" : "group_direction", + "wind" : "group_speed", + "windchill" : "group_temperature", + "windDir" : "group_direction", + "windDir10" : "group_direction", + "windGust" : "group_speed", + "windGustDir" : "group_direction", + "windgustvec" : "group_speed", + "windrun" : "group_distance", + "windSpeed" : "group_speed", + "windSpeed10" : "group_speed", + "windvec" : "group_speed", + "yearRain" : "group_rain", +}) + +# Some aggregations when applied to a type result in a different unit +# group. This data structure maps aggregation type to the group: +agg_group = { + "firsttime" : "group_time", + "lasttime" : "group_time", + "maxsumtime" : "group_time", + "minsumtime" : "group_time", + 'count' : "group_count", + 'gustdir' : "group_direction", + 'max_ge' : "group_count", + 'max_le' : "group_count", + 'maxmintime' : "group_time", + 'maxtime' : "group_time", + 'min_ge' : "group_count", + 'min_le' : "group_count", + 'minmaxtime' : "group_time", + 'mintime' : "group_time", + 'not_null' : "group_boolean", + 'sum_ge' : "group_count", + 'sum_le' : "group_count", + 'vecdir' : "group_direction", + 'avg_ge' : "group_count", + 'avg_le' : "group_count", +} + +# This dictionary maps unit groups to a standard unit type in the +# US customary unit system: +USUnits = ListOfDicts({ + "group_altitude" : "foot", + "group_amp" : "amp", + "group_angle" : "degree_angle", + "group_boolean" : "boolean", + "group_concentration": "microgram_per_meter_cubed", + "group_count" : "count", + "group_data" : "byte", + "group_db" : "dB", + "group_degree_day" : "degree_F_day", + "group_deltatime" : "second", + "group_direction" : "degree_compass", + "group_distance" : "mile", + "group_elapsed" : "second", + "group_energy" : "watt_hour", + "group_energy2" : "watt_second", + "group_fraction" : "ppm", + "group_frequency" : "hertz", + "group_illuminance" : "lux", + "group_interval" : "minute", + "group_length" : "inch", + "group_moisture" : "centibar", + "group_percent" : "percent", + "group_power" : "watt", + "group_pressure" : "inHg", + "group_pressurerate": "inHg_per_hour", + "group_radiation" : "watt_per_meter_squared", + "group_rain" : "inch", + "group_rainrate" : "inch_per_hour", + "group_speed" : "mile_per_hour", + "group_speed2" : "mile_per_hour2", + "group_temperature" : "degree_F", + "group_time" : "unix_epoch", + "group_uv" : "uv_index", + "group_volt" : "volt", + "group_volume" : "gallon" +}) + +# This dictionary maps unit groups to a standard unit type in the +# metric unit system: +MetricUnits = ListOfDicts({ + "group_altitude" : "meter", + "group_amp" : "amp", + "group_angle" : "degree_angle", + "group_boolean" : "boolean", + "group_concentration": "microgram_per_meter_cubed", + "group_count" : "count", + "group_data" : "byte", + "group_db" : "dB", + "group_degree_day" : "degree_C_day", + "group_deltatime" : "second", + "group_direction" : "degree_compass", + "group_distance" : "km", + "group_elapsed" : "second", + "group_energy" : "watt_hour", + "group_energy2" : "watt_second", + "group_fraction" : "ppm", + "group_frequency" : "hertz", + "group_illuminance" : "lux", + "group_interval" : "minute", + "group_length" : "cm", + "group_moisture" : "centibar", + "group_percent" : "percent", + "group_power" : "watt", + "group_pressure" : "mbar", + "group_pressurerate": "mbar_per_hour", + "group_radiation" : "watt_per_meter_squared", + "group_rain" : "cm", + "group_rainrate" : "cm_per_hour", + "group_speed" : "km_per_hour", + "group_speed2" : "km_per_hour2", + "group_temperature" : "degree_C", + "group_time" : "unix_epoch", + "group_uv" : "uv_index", + "group_volt" : "volt", + "group_volume" : "liter" +}) + +# This dictionary maps unit groups to a standard unit type in the +# "Metric WX" unit system. It's the same as the "Metric" system, +# except for rain and speed: +MetricWXUnits = ListOfDicts(*MetricUnits.maps) +MetricWXUnits.prepend({ + 'group_rain': 'mm', + 'group_rainrate' : 'mm_per_hour', + 'group_speed': 'meter_per_second', + 'group_speed2': 'meter_per_second2', +}) + +std_groups = { + weewx.US: USUnits, + weewx.METRIC: MetricUnits, + weewx.METRICWX: MetricWXUnits +} + +# Conversion functions to go from one unit type to another. +conversionDict = { + 'bit' : {'byte' : lambda x : x / 8}, + 'byte' : {'bit' : lambda x : x * 8}, + 'cm' : {'inch' : lambda x : x / CM_PER_INCH, + 'mm' : lambda x : x * 10.0}, + 'cm_per_hour' : {'inch_per_hour' : lambda x : x * 0.393700787, + 'mm_per_hour' : lambda x : x * 10.0}, + 'cubic_foot' : {'gallon' : lambda x : x * 7.48052, + 'litre' : lambda x : x * 28.3168, + 'liter' : lambda x : x * 28.3168}, + 'day' : {'second' : lambda x : x * SECS_PER_DAY, + 'minute' : lambda x : x*1440.0, + 'hour' : lambda x : x*24.0}, + 'degree_angle' : {'radian' : math.radians}, + 'degree_C' : {'degree_F' : CtoF, + 'degree_E' : CtoE, + 'degree_K' : CtoK}, + 'degree_C_day' : {'degree_F_day' : lambda x : x * (9.0/5.0)}, + 'degree_E' : {'degree_C' : EtoC, + 'degree_F' : EtoF}, + 'degree_F' : {'degree_C' : FtoC, + 'degree_E' : FtoE, + 'degree_K' : FtoK}, + 'degree_F_day' : {'degree_C_day' : lambda x : x * (5.0/9.0)}, + 'degree_K' : {'degree_C' : KtoC, + 'degreeF' : KtoF}, + 'dublin_jd' : {'unix_epoch' : lambda x : (x-25567.5) * SECS_PER_DAY, + 'unix_epoch_ms' : lambda x : (x-25567.5) * SECS_PER_DAY * 1000, + 'unix_epoch_ns' : lambda x : (x-25567.5) * SECS_PER_DAY * 1e06}, + 'foot' : {'meter' : lambda x : x * METER_PER_FOOT}, + 'gallon' : {'liter' : lambda x : x * 3.78541, + 'litre' : lambda x : x * 3.78541, + 'cubic_foot' : lambda x : x * 0.133681}, + 'hour' : {'second' : lambda x : x*3600.0, + 'minute' : lambda x : x*60.0, + 'day' : lambda x : x/24.0}, + 'hPa' : {'inHg' : lambda x : x * INHG_PER_MBAR, + 'mmHg' : lambda x : x * 0.75006168, + 'mbar' : lambda x : x, + 'kPa' : lambda x : x / 10.0}, + 'hPa_per_hour' : {'inHg_per_hour' : lambda x : x * INHG_PER_MBAR, + 'mmHg_per_hour' : lambda x : x * 0.75006168, + 'mbar_per_hour' : lambda x : x, + 'kPa_per_hour' : lambda x : x / 10.0}, + 'inch' : {'cm' : lambda x : x * CM_PER_INCH, + 'mm' : lambda x : x * MM_PER_INCH}, + 'inch_per_hour' : {'cm_per_hour' : lambda x : x * 2.54, + 'mm_per_hour' : lambda x : x * 25.4}, + 'inHg' : {'mbar' : lambda x : x / INHG_PER_MBAR, + 'hPa' : lambda x : x / INHG_PER_MBAR, + 'kPa' : lambda x : x / INHG_PER_MBAR / 10.0, + 'mmHg' : lambda x : x * 25.4}, + 'inHg_per_hour' : {'mbar_per_hour' : lambda x : x / INHG_PER_MBAR, + 'hPa_per_hour' : lambda x : x / INHG_PER_MBAR, + 'kPa_per_hour' : lambda x : x / INHG_PER_MBAR / 10.0, + 'mmHg_per_hour' : lambda x : x * 25.4}, + 'kilowatt' : {'watt' : lambda x : x * 1000.0}, + 'kilowatt_hour' : {'mega_joule' : lambda x : x * 3.6, + 'watt_second' : lambda x : x * 3.6e6, + 'watt_hour' : lambda x : x * 1000.0}, + 'km' : {'meter' : lambda x : x * 1000.0, + 'mile' : lambda x : x * 0.621371192}, + 'km_per_hour' : {'mile_per_hour' : kph_to_mph, + 'knot' : kph_to_knot, + 'meter_per_second' : lambda x : x * 0.277777778}, + 'knot' : {'mile_per_hour' : lambda x : x * 1.15077945, + 'km_per_hour' : lambda x : x * 1.85200, + 'meter_per_second' : lambda x : x * 0.514444444}, + 'knot2' : {'mile_per_hour2' : lambda x : x * 1.15077945, + 'km_per_hour2' : lambda x : x * 1.85200, + 'meter_per_second2': lambda x : x * 0.514444444}, + 'kPa' : {'inHg' : lambda x: x * INHG_PER_MBAR * 10.0, + 'mmHg' : lambda x: x * 7.5006168, + 'mbar' : lambda x: x * 10.0, + 'hPa' : lambda x: x * 10.0}, + 'kPa_per_hour' : {'inHg_per_hour' : lambda x: x * INHG_PER_MBAR * 10.0, + 'mmHg_per_hour' : lambda x: x * 7.5006168, + 'mbar_per_hour' : lambda x: x * 10.0, + 'hPa_per_hour' : lambda x: x * 10.0}, + 'liter' : {'gallon' : lambda x : x * 0.264172, + 'cubic_foot' : lambda x : x * 0.0353147}, + 'mbar' : {'inHg' : lambda x : x * INHG_PER_MBAR, + 'mmHg' : lambda x : x * 0.75006168, + 'hPa' : lambda x : x, + 'kPa' : lambda x : x / 10.0}, + 'mbar_per_hour' : {'inHg_per_hour' : lambda x : x * INHG_PER_MBAR, + 'mmHg_per_hour' : lambda x : x * 0.75006168, + 'hPa_per_hour' : lambda x : x, + 'kPa_per_hour' : lambda x : x / 10.0}, + 'mega_joule' : {'kilowatt_hour' : lambda x : x / 3.6, + 'watt_hour' : lambda x : x * 1000000 / 3600, + 'watt_second' : lambda x : x * 1000000}, + 'meter' : {'foot' : lambda x : x / METER_PER_FOOT, + 'km' : lambda x : x / 1000.0}, + 'meter_per_second' : {'mile_per_hour' : mps_to_mph, + 'knot' : mps_to_knot, + 'km_per_hour' : lambda x : x * 3.6}, + 'meter_per_second2': {'mile_per_hour2' : lambda x : x * 2.23693629, + 'knot2' : lambda x : x * 1.94384449, + 'km_per_hour2' : lambda x : x * 3.6}, + 'mile' : {'km' : lambda x : x * 1.609344}, + 'mile_per_hour' : {'km_per_hour' : lambda x : x * 1.609344, + 'knot' : mph_to_knot, + 'meter_per_second' : lambda x : x * 0.44704}, + 'mile_per_hour2' : {'km_per_hour2' : lambda x : x * 1.609344, + 'knot2' : lambda x : x * 0.868976242, + 'meter_per_second2': lambda x : x * 0.44704}, + 'minute' : {'second' : lambda x : x * 60.0, + 'hour' : lambda x : x / 60.0, + 'day' : lambda x : x / 1440.0}, + 'mm' : {'inch' : lambda x : x / MM_PER_INCH, + 'cm' : lambda x : x * 0.10}, + 'mm_per_hour' : {'inch_per_hour' : lambda x : x * .0393700787, + 'cm_per_hour' : lambda x : x * 0.10}, + 'mmHg' : {'inHg' : lambda x : x / MM_PER_INCH, + 'mbar' : lambda x : x / 0.75006168, + 'hPa' : lambda x : x / 0.75006168, + 'kPa' : lambda x : x / 7.5006168}, + 'mmHg_per_hour' : {'inHg_per_hour' : lambda x : x / MM_PER_INCH, + 'mbar_per_hour' : lambda x : x / 0.75006168, + 'hPa_per_hour' : lambda x : x / 0.75006168, + 'kPa_per_hour' : lambda x : x / 7.5006168}, + 'radian' : {'degree_angle' : math.degrees}, + 'second' : {'hour' : lambda x : x/3600.0, + 'minute' : lambda x : x/60.0, + 'day' : lambda x : x / SECS_PER_DAY}, + 'unix_epoch' : {'dublin_jd' : lambda x: x / SECS_PER_DAY + 25567.5, + 'unix_epoch_ms' : lambda x : x * 1000, + 'unix_epoch_ns' : lambda x : x * 1000000}, + 'unix_epoch_ms' : {'dublin_jd' : lambda x: x / (SECS_PER_DAY * 1000) + 25567.5, + 'unix_epoch' : lambda x : x / 1000, + 'unix_epoch_ns' : lambda x : x * 1000}, + 'unix_epoch_ns' : {'dublin_jd' : lambda x: x / (SECS_PER_DAY * 1e06) + 25567.5, + 'unix_epoch' : lambda x : x / 1e06, + 'unix_epoch_ms' : lambda x : x / 1000}, + 'watt' : {'kilowatt' : lambda x : x / 1000.0}, + 'watt_hour' : {'kilowatt_hour' : lambda x : x / 1000.0, + 'mega_joule' : lambda x : x * 0.0036, + 'watt_second' : lambda x : x * 3600.0}, + 'watt_second' : {'kilowatt_hour' : lambda x : x / 3.6e6, + 'mega_joule' : lambda x : x / 1000000, + 'watt_hour' : lambda x : x / 3600.0}, +} + + +# These used to hold default values for formats and labels, but that has since been moved +# to units.defaults. However, they are still used by modules that extend the unit system +# programmatically. +default_unit_format_dict = {} +default_unit_label_dict = {} + +DEFAULT_DELTATIME_FORMAT = "%(day)d%(day_label)s, " \ + "%(hour)d%(hour_label)s, " \ + "%(minute)d%(minute_label)s" + +# Default mapping from compass degrees to ordinals +DEFAULT_ORDINATE_NAMES = [ + 'N', 'NNE','NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW','SW', 'WSW', 'W', 'WNW', 'NW', 'NNW', + 'N/A' +] + +complex_conversions = { + 'x': lambda c: c.real if c is not None else None, + 'y': lambda c: c.imag if c is not None else None, + 'magnitude': lambda c: abs(c) if c is not None else None, + 'direction': weeutil.weeutil.dirN, + 'polar': lambda c: weeutil.weeutil.Polar.from_complex(c) if c is not None else None, +} + +class ValueTuple(tuple): + """ + A value, along with the unit it is in, can be represented by a 3-way tuple called a value + tuple. All weewx routines can accept a simple unadorned 3-way tuple as a value tuple, but they + return the type ValueTuple. It is useful because its contents can be accessed using named + attributes. + + Item attribute Meaning + 0 value The data value(s). Can be a series (eg, [20.2, 23.2, ...]) + or a scalar (eg, 20.2). + 1 unit The unit it is in ("degree_C") + 2 group The unit group ("group_temperature") + + It is valid to have a datum value of None. + + It is also valid to have a unit type of None (meaning there is no information about the unit + the value is in). In this case, you won't be able to convert it to another unit. + """ + def __new__(cls, *args): + return tuple.__new__(cls, args) + + @property + def value(self): + return self[0] + + @property + def unit(self): + return self[1] + + @property + def group(self): + return self[2] + + # ValueTuples have some modest math abilities: subtraction and addition. + def __sub__(self, other): + if self[1] != other[1] or self[2] != other[2]: + raise TypeError("Unsupported operand error for subtraction: %s and %s" + % (self[1], other[1])) + return ValueTuple(self[0] - other[0], self[1], self[2]) + + def __add__(self, other): + if self[1] != other[1] or self[2] != other[2]: + raise TypeError("Unsupported operand error for addition: %s and %s" + % (self[1], other[1])) + return ValueTuple(self[0] + other[0], self[1], self[2]) + + +class UnknownObsType: + """A type of ValueTuple that indicates the observation type is unknown.""" + # def __new__(cls, obs_type): + # vt = ValueTuple.__new__(cls, None, None, None) + # vt.obs_type = obs_type + # return vt + def __init__(self, obs_type): + self.obs_type = obs_type + + def __str__(self): + return u"?'%s'?" % self.obs_type + +# Backwards compatible reference: +UnknownType = UnknownObsType + + +#============================================================================== +# class Formatter +#============================================================================== + +class Formatter(object): + """Holds formatting information for the various unit types. """ + + def __init__(self, unit_format_dict = None, + unit_label_dict = None, + time_format_dict = None, + ordinate_names = None, + deltatime_format_dict = None): + """ + + Args: + unit_format_dict (dict): Key is unit type (e.g., 'inHg'), value is a + string format (e.g., "%.1f") + unit_label_dict (dict): Key is unit type (e.g., 'inHg'), value is a + label (e.g., " inHg") + time_format_dict (dict): Key is a context (e.g., 'week'), value is a + strftime format (e.g., "%d-%b-%Y %H:%M"). + ordinate_names(list): A list containing ordinal compass names (e.g., ['N', 'NNE', etc.] + deltatime_format_dict (dict): Key is a context (e.g., 'week'), value is a deltatime + format string (e.g., "%(minute)d%(minute_label)s, %(second)d%(second_label)s") + """ + + self.unit_format_dict = unit_format_dict or {} + self.unit_label_dict = unit_label_dict or {} + self.time_format_dict = time_format_dict or {} + self.ordinate_names = ordinate_names or DEFAULT_ORDINATE_NAMES + self.deltatime_format_dict = deltatime_format_dict or {} + + @staticmethod + def fromSkinDict(skin_dict): + """Factory static method to initialize from a skin dictionary.""" + try: + unit_format_dict = skin_dict['Units']['StringFormats'] + except KeyError: + unit_format_dict = {} + + try: + unit_label_dict = skin_dict['Units']['Labels'] + except KeyError: + unit_label_dict = {} + + try: + time_format_dict = skin_dict['Units']['TimeFormats'] + except KeyError: + time_format_dict = {} + + try: + ordinate_names = weeutil.weeutil.option_as_list( + skin_dict['Units']['Ordinates']['directions']) + except KeyError: + ordinate_names = {} + + try: + deltatime_format_dict = skin_dict['Units']['DeltaTimeFormats'] + except KeyError: + deltatime_format_dict = {} + + return Formatter(unit_format_dict, + unit_label_dict, + time_format_dict, + ordinate_names, + deltatime_format_dict) + + def get_format_string(self, unit): + """Return a suitable format string.""" + + # First, try the (misnamed) custom unit format dictionary + if unit in default_unit_format_dict: + return default_unit_format_dict[unit] + # If that didn't work, try my internal format dictionary + elif unit in self.unit_format_dict: + return self.unit_format_dict[unit] + else: + # Can't find one. Return a generic formatter: + return '%f' + + def get_label_string(self, unit, plural=True): + """Return a suitable label. + + This function looks up a suitable label in the unit_label_dict. If the + associated value is a string, it returns it. If it is a tuple or a list, + then it is assumed the first value is a singular version of the label + (e.g., "foot"), the second a plural version ("feet"). If the parameter + plural=False, then the singular version is returned. Otherwise, the + plural version. + """ + + # First, try the (misnamed) custom dictionary + if unit in default_unit_label_dict: + label = default_unit_label_dict[unit] + # Then try my internal label dictionary: + elif unit in self.unit_label_dict: + label = self.unit_label_dict[unit] + else: + # Can't find a label. Just return an empty string: + return u'' + + # Is the label a tuple or list? + if isinstance(label, (tuple, list)): + # Yes. Return the singular or plural version as requested + return label[1] if plural and len(label) > 1 else label[0] + else: + # No singular/plural version. It's just a string. Return it. + return label + + def toString(self, val_t, context='current', addLabel=True, + useThisFormat=None, None_string=None, + localize=True): + """Format the value as a unicode string. + + Args: + val_t (ValueTuple): A ValueTuple holding the value to be formatted. The value can be an iterable. + context (str): A time context (eg, 'day'). + [Optional. If not given, context 'current' will be used.] + addLabel (bool): True to add a unit label (eg, 'mbar'), False to not. + [Optional. If not given, a label will be added.] + useThisFormat (str): An optional string or strftime format to be used. + [Optional. If not given, the format given in the initializer will be used.] + None_string (str): A string to be used if the value val is None. + [Optional. If not given, the string given by unit_format_dict['NONE'] + will be used.] + localize (bool): True to localize the results. False otherwise. + + Returns: + str. The localized, formatted, and labeled value. + """ + + # Check to see if the ValueTuple holds an iterable: + if type(val_t) is not UnknownObsType and is_iterable(val_t[0]): + # Yes. Format each element individually, then stick them all together. + s_list = [self._to_string((v, val_t[1], val_t[2]), + context, addLabel, useThisFormat, None_string, localize) + for v in val_t[0]] + s = ", ".join(s_list) + else: + # The value is a simple scalar. + s = self._to_string(val_t, context, addLabel, useThisFormat, None_string, localize) + + return s + + def _to_string(self, val_t, context='current', addLabel=True, + useThisFormat=None, None_string=None, + localize=True): + """Similar to the function toString(), except that the value in val_t must be a + simple scalar.""" + + if type(val_t) is UnknownObsType: + return str(val_t) + elif val_t is None or val_t[0] is None: + if None_string is None: + val_str = self.unit_format_dict.get('NONE', u'N/A') + else: + # Make sure the "None_string" is, in fact, a string + if isinstance(None_string, str): + val_str = None_string + else: + # Coerce to a string. + val_str = str(None_string) + addLabel = False + elif type(val_t[0]) is complex: + # The type is complex. Break it up into real and imaginary, then format + # them separately. No label --- it will get added later + r = ValueTuple(val_t[0].real, val_t[1], val_t[2]) + i = ValueTuple(val_t[0].imag, val_t[1], val_t[2]) + val_str = "(%s, %s)" % (self._to_string(r, context, False, + useThisFormat, None_string, localize), + self._to_string(i, context, False, + useThisFormat, None_string, localize)) + elif type(val_t[0]) is Polar: + # The type is a Polar number. Break it up into magnitude and direction, then format + # them separately. + mag = ValueTuple(val_t[0].mag, val_t[1], val_t[2]) + dir = ValueTuple(val_t[0].dir, "degree_compass", "group_direction") + val_str = "(%s, %s)" % (self._to_string(mag, context, addLabel, + useThisFormat, None_string, localize), + self._to_string(dir, context, addLabel, + None, None_string, localize)) + addLabel = False + elif val_t[1] in {"unix_epoch", "unix_epoch_ms", "unix_epoch_ns"}: + # Different formatting routines are used if the value is a time. + t = val_t[0] + if val_t[1] == "unix_epoch_ms": + t /= 1000.0 + elif val_t[1] == "unix_epoch_ns": + t /= 1000000.0 + if useThisFormat is None: + val_str = time.strftime(self.time_format_dict.get(context, "%d-%b-%Y %H:%M"), + time.localtime(t)) + else: + val_str = time.strftime(useThisFormat, time.localtime(t)) + addLabel = False + else: + # It's not a time. It's a regular value. Get a suitable format string: + if useThisFormat is None: + # No user-specified format string. Go get one: + format_string = self.get_format_string(val_t[1]) + else: + # User has specified a string. Use it. + format_string = useThisFormat + if localize: + # Localization requested. Use locale with the supplied format: + val_str = locale.format_string(format_string, val_t[0]) + else: + # No localization. Just format the string. + val_str = format_string % val_t[0] + + # Add a label, if requested: + if addLabel: + # Tack the label on to the end: + val_str += self.get_label_string(val_t[1], plural=(not val_t[0]==1)) + + return val_str + + def to_ordinal_compass(self, val_t): + if val_t[0] is None: + return self.ordinate_names[-1] + _sector_size = 360.0 / (len(self.ordinate_names)-1) + _degree = (val_t[0] + _sector_size/2.0) % 360.0 + _sector = int(_degree / _sector_size) + return self.ordinate_names[_sector] + + def long_form(self, val_t, context, format_string=None, None_string=None): + """Format a delta time using the long-form. + + Args: + val_t (ValueTuple): a ValueTuple holding the delta time. + context (str): The time context. Something like 'day', 'current', etc. + format_string (str|None): An optional custom format string. Otherwise, an appropriate + string will be looked up in deltatime_format_dict. + Returns + str: The results formatted in a "long-form" time. This is something like + "2 hours, 14 minutes, 21 seconds". + """ + # Get a delta-time format string. Use a default if the user did not supply one: + if not format_string: + format_string = self.deltatime_format_dict.get(context, DEFAULT_DELTATIME_FORMAT) + # Now format the delta time, using the function delta_time_to_string: + val_str = self.delta_time_to_string(val_t, format_string, None_string) + return val_str + + def delta_time_to_string(self, val_t, label_format, None_string=None): + """Format elapsed time as a string + + Args: + val_t (ValueTuple): A ValueTuple containing the elapsed time. + label_format (str): The formatting string. + + Returns: + str: The formatted time as a string. + """ + if val_t is None or val_t[0] is None: + if None_string is None: + val_str = self.unit_format_dict.get('NONE', 'N/A') + else: + # Make sure the "None_string" is, in fact, a string + if isinstance(None_string, str): + val_str = None_string + else: + # Coerce to a string. + val_str = str(None_string) + return val_str + secs = convert(val_t, 'second')[0] + etime_dict = {} + secs = abs(secs) + for (label, interval) in (('day', 86400), ('hour', 3600), ('minute', 60), ('second', 1)): + amt = int(secs // interval) + etime_dict[label] = amt + etime_dict[label + '_label'] = self.get_label_string(label, not amt == 1) + secs %= interval + if 'day' not in label_format: + # If 'day' does not appear in the formatting string, add its time to hours + etime_dict['hour'] += 24 * etime_dict['day'] + ans = locale.format_string(label_format, etime_dict) + return ans + +#============================================================================== +# class Converter +#============================================================================== + +class Converter(object): + """Holds everything necessary to do conversions to a target unit system.""" + + def __init__(self, group_unit_dict=USUnits): + """Initialize an instance of Converter + + group_unit_dict: A dictionary holding the conversion information. + Key is a unit_group (eg, 'group_pressure'), value is the target + unit type ('mbar')""" + + self.group_unit_dict = group_unit_dict + + @staticmethod + def fromSkinDict(skin_dict): + """Factory static method to initialize from a skin dictionary.""" + try: + group_unit_dict = skin_dict['Units']['Groups'] + except KeyError: + group_unit_dict = USUnits + return Converter(group_unit_dict) + + def convert(self, val_t): + """Convert a value from a given unit type to the target type. + + val_t: A value tuple with the datum, a unit type, and a unit group + + returns: A value tuple in the new, target unit type. If the input + value tuple contains an unknown unit type an exception of type KeyError + will be thrown. If the input value tuple has either a unit + type of None, or a group type of None (but not both), then an + exception of type KeyError will be thrown. If both the + unit and group are None, then the original val_t will be + returned (i.e., no conversion is done). + + Examples: + >>> p_m = (1016.5, 'mbar', 'group_pressure') + >>> c = Converter() + >>> print("%.3f %s %s" % c.convert(p_m)) + 30.017 inHg group_pressure + + Try an unspecified unit type: + >>> p2 = (1016.5, None, None) + >>> print(c.convert(p2)) + (1016.5, None, None) + + Try a bad unit type: + >>> p3 = (1016.5, 'foo', 'group_pressure') + >>> try: + ... print(c.convert(p3)) + ... except KeyError: + ... print("Exception thrown") + Exception thrown + + Try a bad group type: + >>> p4 = (1016.5, 'mbar', 'group_foo') + >>> try: + ... print(c.convert(p4)) + ... except KeyError: + ... print("Exception thrown") + Exception thrown + """ + if val_t[1] is None and val_t[2] is None: + return val_t + # Determine which units (eg, "mbar") this group should be in. + # If the user has not specified anything, then fall back to US Units. + new_unit_type = self.group_unit_dict.get(val_t[2], USUnits[val_t[2]]) + # Now convert to this new unit type: + new_val_t = convert(val_t, new_unit_type) + return new_val_t + + def convertDict(self, obs_dict): + """Convert an observation dictionary into the target unit system. + + The source dictionary must include the key 'usUnits' in order for the + converter to figure out what unit system it is in. + + The output dictionary will contain no information about the unit + system (that is, it will not contain a 'usUnits' entry). This is + because the conversion is general: it may not result in a standard + unit system. + + Example: convert a dictionary which is in the metric unit system + into US units + + >>> # Construct a default converter, which will be to US units + >>> c = Converter() + >>> # Source dictionary is in metric units + >>> source_dict = {'dateTime': 194758100, 'outTemp': 20.0,\ + 'usUnits': weewx.METRIC, 'barometer':1015.9166, 'interval':15} + >>> target_dict = c.convertDict(source_dict) + >>> print("dateTime: %d, interval: %d, barometer: %.3f, outTemp: %.3f" %\ + (target_dict['dateTime'], target_dict['interval'], \ + target_dict['barometer'], target_dict['outTemp'])) + dateTime: 194758100, interval: 15, barometer: 30.000, outTemp: 68.000 + """ + target_dict = {} + for obs_type in obs_dict: + if obs_type == 'usUnits': continue + # Do the conversion, but keep only the first value in + # the ValueTuple: + target_dict[obs_type] = self.convert(as_value_tuple(obs_dict, obs_type))[0] + return target_dict + + + def getTargetUnit(self, obs_type, agg_type=None): + """Given an observation type and an aggregation type, return the + target unit type and group, or (None, None) if they cannot be determined. + + obs_type: An observation type ('outTemp', 'rain', etc.) + + agg_type: Type of aggregation ('mintime', 'count', etc.) + [Optional. default is no aggregation] + + returns: A 2-way tuple holding the unit type and the unit group + or (None, None) if they cannot be determined. + """ + unit_group = _getUnitGroup(obs_type, agg_type) + if unit_group in self.group_unit_dict: + unit_type = self.group_unit_dict[unit_group] + else: + unit_type = USUnits.get(unit_group) + return (unit_type, unit_group) + +#============================================================================== +# Standard Converters +#============================================================================== + +# This dictionary holds converters for the standard unit conversion systems. +StdUnitConverters = {weewx.US : Converter(USUnits), + weewx.METRIC : Converter(MetricUnits), + weewx.METRICWX : Converter(MetricWXUnits)} + + +#============================================================================== +# class ValueHelper +#============================================================================== + +class ValueHelper(object): + """A helper class that binds a value tuple together with everything needed to do a + context-sensitive formatting """ + def __init__(self, value_t, context='current', formatter=Formatter(), converter=None): + """Initialize a ValueHelper + + Args: + value_t (ValueTuple|UnknownObsType|tuple): This parameter can be either a ValueTuple, + or an instance of UnknownObsType. If a ValueTuple, the "value" part can be either a + scalar, or a series. If a converter is given, it will be used to convert the + ValueTuple before storing. If the parameter is 'UnknownObsType', it is an error to + perform any operation on the resultant ValueHelper, except ask it to be formatted + as a string. In this case, the name of the unknown type will be included in the + resultant string. + context (str): The time context. Something like 'current', 'day', 'week'. + [Optional. If not given, context 'current' will be used.] + formatter (Formatter): An instance of class Formatter. + [Optional. If not given, then the default Formatter() will be used] + converter (Converter): An instance of class Converter. + [Optional.] + """ + # If there's a converter, then perform the conversion: + if converter and not isinstance(value_t, UnknownObsType): + self.value_t = converter.convert(value_t) + else: + self.value_t = value_t + self.context = context + self.formatter = formatter + + def toString(self, + addLabel=True, + useThisFormat=None, + None_string=None, + localize=True, + NONE_string=None): + """Convert my internally held ValueTuple to a unicode string, using the supplied + converter and formatter. + + Args: + addLabel (bool): If True, add a unit label + useThisFormat (str): String with a format to be used when formatting the value. + If None, then a format will be supplied. Default is None. + None_string (str): A string to be used if the value is None. If None, then a default + string from skin.conf will be used. Default is None. + localize (bool): If True, localize the results. Default is True + NONE_string (str): Supplied for backwards compatibility. Identical semantics to + None_string. + + Returns: + str. The formatted and labeled string + """ + # Check NONE_string for backwards compatibility: + if None_string is None and NONE_string is not None: + None_string = NONE_string + # Then do the format conversion: + s = self.formatter.toString(self.value_t, self.context, addLabel=addLabel, + useThisFormat=useThisFormat, None_string=None_string, + localize=localize) + return s + + def __str__(self): + """Coerce to string""" + return self.toString() + + def format(self, format_string=None, None_string=None, add_label=True, localize=True): + """Returns a formatted version of the datum, using user-supplied customizations.""" + return self.toString(useThisFormat=format_string, None_string=None_string, + addLabel=add_label, localize=localize) + + def ordinal_compass(self): + """Returns an ordinal compass direction (eg, 'NNW')""" + # Get the raw value tuple, then ask the formatter to look up an + # appropriate ordinate: + return self.formatter.to_ordinal_compass(self.value_t) + + def long_form(self, format_string=None, None_string=None): + """Format a delta time""" + return self.formatter.long_form(self.value_t, + context=self.context, + format_string=format_string, + None_string=None_string) + + def json(self, **kwargs): + return json.dumps(self.raw, cls=ComplexEncoder, **kwargs) + + def round(self, ndigits=None): + """Round the data part to ndigits decimal digits.""" + # Create a new ValueTuple with the rounded data + vt = ValueTuple(weeutil.weeutil.rounder(self.value_t[0], ndigits), + self.value_t[1], + self.value_t[2]) + # Use it to create a new ValueHelper + return ValueHelper(vt, self.context, self.formatter) + + @property + def raw(self): + """Returns just the data part, without any formatting.""" + return self.value_t[0] + + def convert(self, target_unit): + """Return a ValueHelper in a new target unit. + + Args: + target_unit (str): The unit (eg, 'degree_C') to which the data will be converted + + Returns: + ValueHelper. + """ + value_t = convert(self.value_t, target_unit) + return ValueHelper(value_t, self.context, self.formatter) + + def __getattr__(self, target_unit): + """Convert to a new unit type. + + Args: + target_unit (str): The new target unit + + Returns: + ValueHelper. The data in the new ValueHelper will be in the desired units. + """ + + # This is to get around bugs in the Python version of Cheetah's namemapper: + if target_unit in ['__call__', 'has_key']: + raise AttributeError + + # Convert any illegal conversions to an AttributeError: + try: + converted = self.convert(target_unit) + except KeyError: + raise AttributeError("Illegal conversion from '%s' to '%s'" + % (self.value_t[1], target_unit)) + return converted + + def __iter__(self): + """Return an iterator that can iterate over the elements of self.value_t.""" + for row in self.value_t[0]: + # Form a ValueTuple using the value, plus the unit and unit group + vt = ValueTuple(row, self.value_t[1], self.value_t[2]) + # Form a ValueHelper out of that + vh = ValueHelper(vt, self.context, self.formatter) + yield vh + + def exists(self): + return not isinstance(self.value_t, UnknownObsType) + + def has_data(self): + return self.exists() and self.value_t[0] is not None + + # Backwards compatibility + def string(self, None_string=None): + """Return as string with an optional user specified string to be used if None. + DEPRECATED.""" + return self.toString(None_string=None_string) + + # Backwards compatibility + def nolabel(self, format_string, None_string=None): + """Returns a formatted version of the datum, using a user-supplied format. No label. + DEPRECATED.""" + return self.toString(addLabel=False, useThisFormat=format_string, None_string=None_string) + + # Backwards compatibility + @property + def formatted(self): + """Return a formatted version of the datum. No label. + DEPRECATED.""" + return self.toString(addLabel=False) + + +#============================================================================== +# SeriesHelper +#============================================================================== + +class SeriesHelper(object): + """Convenience class that binds the series data, along with start and stop times.""" + + def __init__(self, start, stop, data): + """Initializer + + Args: + start (ValueHelper): A ValueHelper holding the start times of the data. None if + there is no start series. + stop (ValueHelper): A ValueHelper holding the stop times of the data. None if + there is no stop series + data (ValueHelper): A ValueHelper holding the data. + """ + self.start = start + self.stop = stop + self.data = data + + def json(self, order_by='row', **kwargs): + """Return the data in this series as JSON. + + Args: + order_by (str): A string that determines whether the generated string is ordered by + row or column. Either 'row' or 'column'. + **kwargs (Any): Any extra arguments are passed on to json.loads() + + Returns: + str. A string with the encoded JSON. + """ + + if order_by == 'row': + if self.start and self.stop: + json_data = list(zip(self.start.raw, self.stop.raw, self.data.raw)) + elif self.start and not self.stop: + json_data = list(zip(self.start.raw, self.data.raw)) + else: + json_data = list(zip(self.stop.raw, self.data.raw)) + elif order_by == 'column': + if self.start and self.stop: + json_data = [self.start.raw, self.stop.raw, self.data.raw] + elif self.start and not self.stop: + json_data = [self.start.raw, self.data.raw] + else: + json_data = [self.stop.raw, self.data.raw] + else: + raise ValueError("Unknown option '%s' for parameter 'order_by'" % order_by) + + return json.dumps(json_data, cls=ComplexEncoder, **kwargs) + + def round(self, ndigits=None): + """ + Round the data part to ndigits number of decimal digits. + + Args: + ndigits (int): The number of decimal digits to include in the data. Default is None, + which means keep all digits. + + Returns: + SeriesHelper: A new SeriesHelper, with the data part rounded to the requested number of + decimal digits. + """ + return SeriesHelper(self.start, self.stop, self.data.round(ndigits)) + + def __str__(self): + """Coerce to string.""" + return self.format() + + def __len__(self): + return len(self.start) + + def format(self, format_string=None, None_string=None, add_label=True, + localize=True, order_by='row'): + """Format a series as a string. + + Args: + format_string (str): String with a format to be used when formatting the values. + If None, then a format will be supplied. Default is None. + None_string (str): A string to be used if a value is None. If None, + then a default string from skin.conf will be used. Default is None. + add_label (bool): If True, add a unit label to each value. + localize (bool): If True, localize the results. Default is True + order_by (str): A string that determines whether the generated string is ordered by + row or column. Either 'row' or 'column'. + + Returns: + str. The formatted and labeled string + """ + + if order_by == 'row': + rows = [] + if self.start and self.stop: + for start_, stop_, data_ in self: + rows += ["%s, %s, %s" + % (str(start_), + str(stop_), + data_.format(format_string, None_string, add_label, localize)) + ] + elif self.start and not self.stop: + for start_, data_ in zip(self.start, self.data): + rows += ["%s, %s" + % (str(start_), + data_.format(format_string, None_string, add_label, localize)) + ] + else: + for stop_, data_ in zip(self.stop, self.data): + rows += ["%s, %s" + % (str(stop_), + data_.format(format_string, None_string, add_label, localize)) + ] + return "\n".join(rows) + + elif order_by == 'column': + if self.start and self.stop: + return "%s\n%s\n%s" \ + % (str(self.start), + str(self.stop), + self.data.format(format_string, None_string, add_label, localize)) + elif self.start and not self.stop: + return "%s\n%s" \ + % (str(self.start), + self.data.format(format_string, None_string, add_label, localize)) + else: + return "%s\n%s" \ + % (str(self.stop), + self.data.format(format_string, None_string, add_label, localize)) + else: + raise ValueError("Unknown option '%s' for parameter 'order_by'" % order_by) + + def __getattr__(self, target_unit): + """Return a new SeriesHelper, with the data part converted to a new unit + + Args: + target_unit (str): The data part of the returned SeriesHelper will be in this unit. + + Returns: + SeriesHelper. The data in the new SeriesHelper will be in the target unit. + """ + + # This is to get around bugs in the Python version of Cheetah's namemapper: + if target_unit in ['__call__', 'has_key']: + raise AttributeError + + # This will be a ValueHelper. + converted_data = self.data.convert(target_unit) + + return SeriesHelper(self.start, self.stop, converted_data) + + def __iter__(self): + """Iterate over myself by row.""" + for start, stop, data in zip(self.start, self.stop, self.data): + yield start, stop, data + +#============================================================================== +# class UnitInfoHelper and friends +#============================================================================== + +class UnitHelper(object): + def __init__(self, converter): + self.converter = converter + def __getattr__(self, obs_type): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if obs_type in ['__call__', 'has_key']: + raise AttributeError + return self.converter.getTargetUnit(obs_type)[0] + +class FormatHelper(object): + def __init__(self, formatter, converter): + self.formatter = formatter + self.converter = converter + def __getattr__(self, obs_type): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if obs_type in ['__call__', 'has_key']: + raise AttributeError + return get_format_string(self.formatter, self.converter, obs_type) + +class LabelHelper(object): + def __init__(self, formatter, converter): + self.formatter = formatter + self.converter = converter + def __getattr__(self, obs_type): + # This is to get around bugs in the Python version of Cheetah's namemapper: + if obs_type in ['__call__', 'has_key']: + raise AttributeError + return get_label_string(self.formatter, self.converter, obs_type) + +class UnitInfoHelper(object): + """Helper class used for the $unit template tag.""" + def __init__(self, formatter, converter): + """ + formatter: an instance of Formatter + converter: an instance of Converter + """ + self.unit_type = UnitHelper(converter) + self.format = FormatHelper(formatter, converter) + self.label = LabelHelper(formatter, converter) + self.group_unit_dict = converter.group_unit_dict + + # This is here for backwards compatibility: + @property + def unit_type_dict(self): + return self.group_unit_dict + + +class ObsInfoHelper(object): + """Helper class to implement the $obs template tag.""" + def __init__(self, skin_dict): + try: + d = skin_dict['Labels']['Generic'] + except KeyError: + d = {} + self.label = weeutil.weeutil.KeyDict(d) + + +#============================================================================== +# Helper functions +#============================================================================== +def getUnitGroup(obs_type, agg_type=None): + """Given an observation type and an aggregation type, what unit group + does it belong to? + + Examples: + +-------------+-----------+---------------------+ + | obs_type | agg_type | Returns | + +=============+===========+=====================+ + | 'outTemp' | None | 'group_temperature' | + +-------------+-----------+---------------------+ + | 'outTemp' | 'min' | 'group_temperature' | + +-------------+-----------+---------------------+ + | 'outTemp' | 'mintime' | 'group_time' | + +-------------+-----------+---------------------+ + | 'wind' | 'avg' | 'group_speed' | + +-------------+-----------+---------------------+ + | 'wind' | 'vecdir' | 'group_direction' | + +-------------+-----------+---------------------+ + + Args: + obs_type (str): An observation type (eg, 'barometer') + agg_type (str): An aggregation (eg, 'mintime', or 'avg'.) + + Returns: + str or None. The unit group or None if it cannot be determined. + """ + if agg_type and agg_type in agg_group: + return agg_group[agg_type] + else: + return obs_group_dict.get(obs_type) + + +# For backwards compatibility: +_getUnitGroup = getUnitGroup + + +def convert(val_t, target_unit): + """Convert a ValueTuple to a new unit + + Args: + val_t (ValueTuple): A ValueTuple containing the value to be converted. The first element + can be either a scalar or an iterable. + target_unit (str): The unit type (e.g., "meter", or "mbar") to which the value is to be + converted. If the ValueTuple holds a complex number, target_unit can be a complex + conversion nickname, such as 'polar'. + + Returns: + ValueTuple. An instance of ValueTuple, where the desired conversion has been performed. + """ + + # Is the "target_unit" really a conversion for complex numbers? + if target_unit in complex_conversions: + # Yes. Get the conversion function. Also, note that these operations do not change the + # unit the ValueTuple is in. + conversion_func = complex_conversions[target_unit] + target_unit = val_t[1] + else: + # We are converting between units. If the value is already in the target unit type, then + # just return it: + if val_t[1] == target_unit: + return val_t + + # Retrieve the conversion function. An exception of type KeyError + # will occur if the target or source units are invalid + try: + conversion_func = conversionDict[val_t[1]][target_unit] + except KeyError: + log.debug("Unable to convert from %s to %s", val_t[1], target_unit) + raise + # Are we converting a list, or a simple scalar? + if isinstance(val_t[0], (list, tuple)): + # A list + new_val = [conversion_func(x) if x is not None else None for x in val_t[0]] + else: + # A scalar + new_val = conversion_func(val_t[0]) if val_t[0] is not None else None + + # Add on the unit type and the group type and return the results: + return ValueTuple(new_val, target_unit, val_t[2]) + + +def convertStd(val_t, target_std_unit_system): + """Convert a value tuple to an appropriate unit in a target standardized + unit system + + Example: + >>> value_t = (30.02, 'inHg', 'group_pressure') + >>> print("(%.2f, %s, %s)" % convertStd(value_t, weewx.METRIC)) + (1016.59, mbar, group_pressure) + >>> value_t = (1.2, 'inch', 'group_rain') + >>> print("(%.2f, %s, %s)" % convertStd(value_t, weewx.METRICWX)) + (30.48, mm, group_rain) + + Args: + val_t (ValueTuple): The ValueTuple to be converted. + target_std_unit_system (int): A standardized WeeWX unit system (weewx.US, weewx.METRIC, + or weewx.METRICWX) + + Returns: + ValueTuple. A value tuple in the given standardized unit system. + """ + return StdUnitConverters[target_std_unit_system].convert(val_t) + +def convertStdName(val_t, target_nickname): + """Convert to a target standard unit system, using the unit system's nickname""" + return convertStd(val_t, unit_constants[target_nickname.upper()]) + +def getStandardUnitType(target_std_unit_system, obs_type, agg_type=None): + """Given a standard unit system (weewx.US, weewx.METRIC, weewx.METRICWX), + an observation type, and an aggregation type, what units would it be in? + + Examples: + >>> print(getStandardUnitType(weewx.US, 'barometer')) + ('inHg', 'group_pressure') + >>> print(getStandardUnitType(weewx.METRIC, 'barometer')) + ('mbar', 'group_pressure') + >>> print(getStandardUnitType(weewx.US, 'barometer', 'mintime')) + ('unix_epoch', 'group_time') + >>> print(getStandardUnitType(weewx.METRIC, 'barometer', 'avg')) + ('mbar', 'group_pressure') + >>> print(getStandardUnitType(weewx.METRIC, 'wind', 'rms')) + ('km_per_hour', 'group_speed') + >>> print(getStandardUnitType(None, 'barometer', 'avg')) + (None, None) + + Args: + target_std_unit_system (int): A standardized unit system. If None, then + the output units are indeterminate, so (None, None) is returned. + + obs_type (str): An observation type, e.g., 'outTemp' + + agg_type (str): An aggregation type, e.g., 'mintime', or 'avg'. + + Returns: + tuple. A 2-way tuple containing the target units, and the target group. + """ + + if target_std_unit_system is not None: + return StdUnitConverters[target_std_unit_system].getTargetUnit(obs_type, agg_type) + else: + return None, None + + +def get_format_string(formatter, converter, obs_type): + # First convert to the target unit type: + u = converter.getTargetUnit(obs_type)[0] + # Then look up the format string for that unit type: + return formatter.get_format_string(u) + + +def get_label_string(formatter, converter, obs_type, plural=True): + # First convert to the target unit type: + u = converter.getTargetUnit(obs_type)[0] + # Then look up the label for that unit type: + return formatter.get_label_string(u, plural) + + +class GenWithConvert(object): + """Generator wrapper. Converts the output of the wrapped generator to a + target unit system. + + Example: + >>> def genfunc(): + ... for i in range(3): + ... _rec = {'dateTime' : 194758100 + i*300, + ... 'outTemp' : 68.0 + i * 9.0/5.0, + ... 'usUnits' : weewx.US} + ... yield _rec + >>> # First, try the raw generator function. Output should be in US + >>> for _out in genfunc(): + ... print("Timestamp: %d; Temperature: %.2f; Unit system: %d" + ... % (_out['dateTime'], _out['outTemp'], _out['usUnits'])) + Timestamp: 194758100; Temperature: 68.00; Unit system: 1 + Timestamp: 194758400; Temperature: 69.80; Unit system: 1 + Timestamp: 194758700; Temperature: 71.60; Unit system: 1 + >>> # Now do it again, but with the generator function wrapped by GenWithConvert: + >>> for _out in GenWithConvert(genfunc(), weewx.METRIC): + ... print("Timestamp: %d; Temperature: %.2f; Unit system: %d" + ... % (_out['dateTime'], _out['outTemp'], _out['usUnits'])) + Timestamp: 194758100; Temperature: 20.00; Unit system: 16 + Timestamp: 194758400; Temperature: 21.00; Unit system: 16 + Timestamp: 194758700; Temperature: 22.00; Unit system: 16 + """ + + def __init__(self, input_generator, target_unit_system=weewx.METRIC): + """Initialize an instance of GenWithConvert + + input_generator: An iterator which will return dictionary records. + + target_unit_system: The unit system the output of the generator should + use, or 'None' if it should leave the output unchanged.""" + self.input_generator = input_generator + self.target_unit_system = target_unit_system + + def __iter__(self): + return self + + def __next__(self): + _record = next(self.input_generator) + if self.target_unit_system is None: + return _record + else: + return to_std_system(_record, self.target_unit_system) + + # For Python 2: + next = __next__ + + +def to_US(datadict): + """Convert the units used in a dictionary to US Customary.""" + return to_std_system(datadict, weewx.US) + +def to_METRIC(datadict): + """Convert the units used in a dictionary to Metric.""" + return to_std_system(datadict, weewx.METRIC) + +def to_METRICWX(datadict): + """Convert the units used in a dictionary to MetricWX.""" + return to_std_system(datadict, weewx.METRICWX) + +def to_std_system(datadict, unit_system): + """Convert the units used in a dictionary to a target unit system.""" + if datadict['usUnits'] == unit_system: + # It's already in the unit system. + return datadict + else: + # It's in something else. Perform the conversion + _datadict_target = StdUnitConverters[unit_system].convertDict(datadict) + # Add the new unit system + _datadict_target['usUnits'] = unit_system + return _datadict_target + + +def as_value_tuple(record_dict, obs_type): + """Look up an observation type in a record, returning the result as a ValueTuple. + + Args: + record_dict (dict|None): A record. May be None. If it is not None, then it must contain an + entry for `usUnits`. + obs_type (str): The observation type to be returned + + Returns: + ValueTuple: The observation type as a ValueTuple. + + Raises: + KeyIndex: If the observation type cannot be found in the record, a KeyIndex error is + raised. + """ + + # Is the record None? + if record_dict is None: + # Yes. Signal a value of None and, arbitrarily, pick the US unit system: + val = None + std_unit_system = weewx.US + else: + # There is a record. Get the value, and the unit system. + val = record_dict[obs_type] + std_unit_system = record_dict['usUnits'] + + # Given this standard unit system, what is the unit type of this + # particular observation type? If the observation type is not recognized, + # a unit_type of None will be returned + (unit_type, unit_group) = StdUnitConverters[std_unit_system].getTargetUnit(obs_type) + + # Form the value-tuple and return it: + return ValueTuple(val, unit_type, unit_group) + + +class ComplexEncoder(json.JSONEncoder): + """Custom encoder that knows how to encode complex and polar objects""" + def default(self, obj): + if isinstance(obj, complex): + # Return as tuple + return obj.real, obj.imag + elif isinstance(obj, Polar): + # Return as tuple: + return obj.mag, obj.dir + # Otherwise, let the base class handle it + return json.JSONEncoder.default(self, obj) + + +def get_default_formatter(): + """Get a default formatter. Useful for the test suites.""" + import weewx.defaults + weewx.defaults.defaults.interpolation = False + formatter = Formatter( + unit_format_dict=weewx.defaults.defaults['Units']['StringFormats'], + unit_label_dict=weewx.defaults.defaults['Units']['Labels'], + time_format_dict=weewx.defaults.defaults['Units']['TimeFormats'], + ordinate_names=weewx.defaults.defaults['Units']['Ordinates']['directions'], + deltatime_format_dict=weewx.defaults.defaults['Units']['DeltaTimeFormats'] + ) + return formatter + + +if __name__ == "__main__": + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weewx/uwxutils.py b/dist/weewx-5.0.2/src/weewx/uwxutils.py new file mode 100644 index 0000000..e873650 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/uwxutils.py @@ -0,0 +1,528 @@ +# Adapted for use with weewx +# +# This source code may be freely used, including for commercial purposes +# Steve Hatchett info@softwx.com +# http:#www.softwx.org/weather + +""" +Functions for performing various weather related calculations. + +Notes about pressure + Sensor Pressure raw pressure indicated by the barometer instrument + Station Pressure Sensor Pressure adjusted for any difference between + sensor elevation and official station elevation + Field Pressure (QFE) Usually the same as Station Pressure + Altimeter Setting (QNH) Station Pressure adjusted for elevation (assumes + standard atmosphere) + Sea Level Pressure (QFF) Station Pressure adjusted for elevation, + temperature and humidity + +Notes about input parameters: + currentTemp - current instantaneous station temperature + meanTemp - average of current temp and the temperature 12 hours in + the past. If the 12-hour temp is not known, simply pass + the same value as currentTemp for the mean temp. + humidity - Value should be 0 to 100. For the pressure conversion + functions, pass a value of zero if you do not want to + the algorithm to include the humidity correction factor + in the calculation. If you provide a humidity value + > 0, then humidity effect will be included in the + calculation. + elevation - This should be the geometric altitude of the station + (this is the elevation provided by surveys and normally + used by people when they speak of elevation). Some + algorithms will convert the elevation internally into + a geopotential altitude. + sensorElevation - This should be the geometric altitude of the actual + barometric sensor (which could be different from the + official station elevation). + +Notes about Sensor Pressure vs. Station Pressure: + SensorToStationPressure and StationToSensorPressure functions are based + on an ASOS algorithm. It corrects for a difference in elevation between + the official station location and the location of the barometetric sensor. + It turns out that if the elevation difference is under 30 ft, then the + algorithm will give the same result (a 0 to .01 inHg adjustment) regardless + of temperature. In that case, the difference can be covered using a simple + fixed offset. If the difference is 30 ft or greater, there is some effect + from temperature, though it is small. For example, at a 100ft difference, + the adjustment will be .13 inHg at -30F and .10 at 100F. The bottom line + is that while ASOS stations may do this calculation, it is likely unneeded + for home weather stations, and the station pressure and the sensor pressure + can be treated as equivalent.""" + +import math + +def FToC(value): + return (value - 32.0) * (5.0 / 9.0) + +def CToF(value): + return (9.0/5.0)*value + 32.0 + +def CToK(value): + return value + 273.15 + +def KToC(value): + return value - 273.15 + +def FToR(value): + return value + 459.67 + +def RToF(value): + return value - 459.67 + +def InToHPa(value): + return value / 0.02953 + +def HPaToIn(value): + return value * 0.02953 + +def FtToM(value): + return value * 0.3048 + +def MToFt(value): + return value / 0.3048 + +def InToMm(value): + return value * 25.4 + +def MmToIn(value): + return value / 25.4 + +def MToKm(value): # NB: This is *miles* to Km. + return value * 1.609344 + +def KmToM(value): # NB: This is Km to *miles* + return value / 1.609344 + +def msToKmh(value): + return value * 3.6 + +def Power10(y): + return pow(10.0, y) + +# This maps various Pascal functions to Python functions. +Power = pow +Exp = math.exp +Round = round + +class TWxUtils(object): + + gravity = 9.80665 # g at sea level at lat 45.5 degrees in m/sec^2 + uGC = 8.31432 # universal gas constant in J/mole-K + moleAir = 0.0289644 # mean molecular mass of air in kg/mole + moleWater = 0.01801528 # molecular weight of water in kg/mole + gasConstantAir = uGC/moleAir # (287.053) gas constant for air in J/kgK + standardSLP = 1013.25 # standard sea level pressure in hPa + standardSlpInHg = 29.921 # standard sea level pressure in inHg + standardTempK = 288.15 # standard sea level temperature in Kelvin + earthRadius45 = 6356.766 # radius of the earth at lat 45.5 degrees in km + + # standard lapse rate (6.5C/1000m i.e. 6.5K/1000m) + standardLapseRate = 0.0065 + # (0.0019812) standard lapse rate per foot (1.98C/1000ft) + standardLapseRateFt = standardLapseRate * 0.3048 + vpLapseRateUS = 0.00275 # lapse rate used by VantagePro (2.75F/1000ft) + manBarLapseRate = 0.0117 # lapse rate from Manual of Barometry (11.7F/1000m, which = 6.5C/1000m) + + @staticmethod + def StationToSensorPressure(pressureHPa, sensorElevationM, stationElevationM, currentTempC): + # from ASOS formula specified in US units + Result = InToHPa(HPaToIn(pressureHPa) / Power10(0.00813 * MToFt(sensorElevationM - stationElevationM) / FToR(CToF(currentTempC)))) + return Result + + @staticmethod + def StationToAltimeter(pressureHPa, elevationM, algorithm='aaMADIS'): + if algorithm == 'aaASOS': + # see ASOS training at http://www.nwstc.noaa.gov + # see also http://wahiduddin.net/calc/density_altitude.htm + Result = InToHPa(Power(Power(HPaToIn(pressureHPa), 0.1903) + (1.313E-5 * MToFt(elevationM)), 5.255)) + + elif algorithm == 'aaASOS2': + geopEl = TWxUtils.GeopotentialAltitude(elevationM) + k1 = TWxUtils.standardLapseRate * TWxUtils.gasConstantAir / TWxUtils.gravity # approx. 0.190263 + k2 = 8.41728638E-5 # (stdLapseRate / stdTempK) * (Power(stdSLP, k1) + Result = Power(Power(pressureHPa, k1) + (k2 * geopEl), 1/k1) + + elif algorithm == 'aaMADIS': + # from MADIS API by NOAA Forecast Systems Lab + # http://madis.noaa.gov/madis_api.html + k1 = 0.190284 # discrepency with calculated k1 probably + # because Smithsonian used less precise gas + # constant and gravity values + k2 = 8.4184960528E-5 # (stdLapseRate / stdTempK) * (Power(stdSLP, k1) + Result = Power(Power(pressureHPa - 0.3, k1) + (k2 * elevationM), 1/k1) + + elif algorithm == 'aaNOAA': + # http://www.srh.noaa.gov/elp/wxclc/formulas/altimeterSetting.html + k1 = 0.190284 # discrepency with k1 probably because + # Smithsonian used less precise gas constant + # and gravity values + k2 = 8.42288069E-5 # (stdLapseRate / 288) * (Power(stdSLP, k1SMT) + Result = (pressureHPa - 0.3) * Power(1 + (k2 * (elevationM / Power(pressureHPa - 0.3, k1))), 1/k1) + + elif algorithm == 'aaWOB': + # see http://www.wxqa.com/archive/obsman.pdf + k1 = TWxUtils.standardLapseRate * TWxUtils.gasConstantAir / TWxUtils.gravity # approx. 0.190263 + k2 = 1.312603E-5 # (stdLapseRateFt / stdTempK) * Power(stdSlpInHg, k1) + Result = InToHPa(Power(Power(HPaToIn(pressureHPa), k1) + (k2 * MToFt(elevationM)), 1/k1)) + + elif algorithm == 'aaSMT': + # WMO Instruments and Observing Methods Report No.19 + # http://www.wmo.int/pages/prog/www/IMOP/publications/IOM-19-Synoptic-AWS.pdf + k1 = 0.190284 # discrepency with calculated value probably + # because Smithsonian used less precise gas + # constant and gravity values + k2 = 4.30899E-5 # (stdLapseRate / 288) * (Power(stdSlpInHg, k1SMT)) + geopEl = TWxUtils.GeopotentialAltitude(elevationM) + Result = InToHPa((HPaToIn(pressureHPa) - 0.01) * Power(1 + (k2 * (geopEl / Power(HPaToIn(pressureHPa) - 0.01, k1))), 1/k1)) + + else: + raise ValueError("Unknown StationToAltimeter algorithm '%s'" % + algorithm) + return Result + + @staticmethod + def StationToSeaLevelPressure(pressureHPa, elevationM, + currentTempC, meanTempC, humidity, + algorithm = 'paManBar'): + Result = pressureHPa * TWxUtils.PressureReductionRatio(pressureHPa, + elevationM, + currentTempC, + meanTempC, + humidity, + algorithm) + return Result + + @staticmethod + def SensorToStationPressure(pressureHPa, sensorElevationM, + stationElevationM, currentTempC): + # see ASOS training at http://www.nwstc.noaa.gov + # from US units ASOS formula + Result = InToHPa(HPaToIn(pressureHPa) * Power10(0.00813 * MToFt(sensorElevationM - stationElevationM) / FToR(CToF(currentTempC)))) + return Result + + # FIXME: still to do + #class function TWxUtils.AltimeterToStationPressure(pressureHPa: TWxReal; + # elevationM: TWxReal; + # algorithm: TAltimeterAlgorithm = DefaultAltimeterAlgorithm): TWxReal; + #begin + #end; + #} + + @staticmethod + def SeaLevelToStationPressure(pressureHPa, elevationM, + currentTempC, meanTempC, humidity, + algorithm = 'paManBar'): + Result = pressureHPa / TWxUtils.PressureReductionRatio(pressureHPa, + elevationM, + currentTempC, + meanTempC, + humidity, + algorithm) + return Result + + @staticmethod + def PressureReductionRatio(pressureHPa, elevationM, + currentTempC, meanTempC, humidity, + algorithm = 'paManBar'): + if algorithm == 'paUnivie': + # http://www.univie.ac.at/IMG-Wien/daquamap/Parametergencom.html + geopElevationM = TWxUtils.GeopotentialAltitude(elevationM) + Result = Exp(((TWxUtils.gravity/TWxUtils.gasConstantAir) * geopElevationM) / (TWxUtils.VirtualTempK(pressureHPa, meanTempC, humidity) + (geopElevationM * TWxUtils.standardLapseRate/2))) + + elif algorithm == 'paDavisVp': + # http://www.exploratorium.edu/weather/barometer.html + if (humidity > 0): + hCorr = (9.0/5.0) * TWxUtils.HumidityCorrection(currentTempC, elevationM, humidity, 'vaDavisVp') + else: + hCorr = 0 + # In the case of DavisVp, take the constant values literally. + Result = Power(10, (MToFt(elevationM) / (122.8943111 * (CToF(meanTempC) + 460 + (MToFt(elevationM) * TWxUtils.vpLapseRateUS/2) + hCorr)))) + + elif algorithm == 'paManBar': + # see WMO Instruments and Observing Methods Report No.19 + # http://www.wmo.int/pages/prog/www/IMOP/publications/IOM-19-Synoptic-AWS.pdf + # http://www.wmo.ch/web/www/IMOP/publications/IOM-19-Synoptic-AWS.pdf + if (humidity > 0): + hCorr = (9.0/5.0) * TWxUtils.HumidityCorrection(currentTempC, elevationM, humidity, 'vaBuck') + else: + hCorr = 0 + geopElevationM = TWxUtils.GeopotentialAltitude(elevationM) + Result = Exp(geopElevationM * 6.1454E-2 / (CToF(meanTempC) + 459.7 + (geopElevationM * TWxUtils.manBarLapseRate / 2) + hCorr)) + + else: + raise ValueError("Unknown PressureReductionRatio algorithm '%s'" % + algorithm) + return Result + + @staticmethod + def ActualVaporPressure(tempC, humidity, algorithm='vaBolton'): + result = (humidity * TWxUtils.SaturationVaporPressure(tempC, algorithm)) / 100.0 + return result + + @staticmethod + def SaturationVaporPressure(tempC, algorithm='vaBolton'): + # comparison of vapor pressure algorithms + # http://cires.colorado.edu/~voemel/vp.html + # (for DavisVP) http://www.exploratorium.edu/weather/dewpoint.html + if algorithm == 'vaDavisVp': + # Davis Calculations Doc + Result = 6.112 * Exp((17.62 * tempC)/(243.12 + tempC)) + elif algorithm == 'vaBuck': + # Buck(1996) + Result = 6.1121 * Exp((18.678 - (tempC/234.5)) * tempC / (257.14 + tempC)) + elif algorithm == 'vaBuck81': + # Buck(1981) + Result = 6.1121 * Exp((17.502 * tempC)/(240.97 + tempC)) + elif algorithm == 'vaBolton': + # Bolton(1980) + Result = 6.112 * Exp(17.67 * tempC / (tempC + 243.5)) + elif algorithm == 'vaTetenNWS': + # Magnus Teten + # www.srh.weather.gov/elp/wxcalc/formulas/vaporPressure.html + Result = 6.112 * Power(10,(7.5 * tempC / (tempC + 237.7))) + elif algorithm == 'vaTetenMurray': + # Magnus Teten (Murray 1967) + Result = Power(10, (7.5 * tempC / (237.5 + tempC)) + 0.7858) + elif algorithm == 'vaTeten': + # Magnus Teten + # www.vivoscuola.it/US/RSIGPP3202/umidita/attivita/relhumONA.htm + Result = 6.1078 * Power(10, (7.5 * tempC / (tempC + 237.3))) + else: + raise ValueError("Unknown SaturationVaporPressure algorithm '%s'" % + algorithm) + return Result + + @staticmethod + def MixingRatio(pressureHPa, tempC, humidity): + k1 = TWxUtils.moleWater / TWxUtils.moleAir # 0.62198 + # http://www.wxqa.com/archive/obsman.pdf + # http://www.vivoscuola.it/US/RSIGPP3202/umidita/attiviat/relhumONA.htm + vapPres = TWxUtils.ActualVaporPressure(tempC, humidity, 'vaBuck') + Result = 1000 * ((k1 * vapPres) / (pressureHPa - vapPres)) + return Result + + @staticmethod + def VirtualTempK(pressureHPa, tempC, humidity): + epsilon = 1 - (TWxUtils.moleWater / TWxUtils.moleAir) # 0.37802 + # http://www.univie.ac.at/IMG-Wien/daquamap/Parametergencom.html + # http://www.vivoscuola.it/US/RSIGPP3202/umidita/attiviat/relhumONA.htm + # http://wahiduddin.net/calc/density_altitude.htm + vapPres = TWxUtils.ActualVaporPressure(tempC, humidity, 'vaBuck') + Result = (CToK(tempC)) / (1-(epsilon * (vapPres/pressureHPa))) + return Result + + @staticmethod + def HumidityCorrection(tempC, elevationM, humidity, algorithm='vaBolton'): + vapPress = TWxUtils.ActualVaporPressure(tempC, humidity, algorithm) + Result = (vapPress * ((2.8322E-9 * (elevationM**2)) + (2.225E-5 * elevationM) + 0.10743)) + return Result + + @staticmethod + def GeopotentialAltitude(geometricAltitudeM): + Result = (TWxUtils.earthRadius45 * 1000 * geometricAltitudeM) / ((TWxUtils.earthRadius45 * 1000) + geometricAltitudeM) + return Result + + +#============================================================================== +# class TWxUtilsUS +#============================================================================== + +class TWxUtilsUS(object): + + """This class provides US unit versions of the functions in uWxUtils. + Refer to uWxUtils for documentation. All input and output paramters are + in the following US units: + pressure in inches of mercury + temperature in Fahrenheit + wind in MPH + elevation in feet""" + + @staticmethod + def StationToSensorPressure(pressureIn, sensorElevationFt, + stationElevationFt, currentTempF): + Result = pressureIn / Power10(0.00813 * (sensorElevationFt - stationElevationFt) / FToR(currentTempF)) + return Result + + @staticmethod + def StationToAltimeter(pressureIn, elevationFt, + algorithm='aaMADIS'): + """Example: + >>> p = TWxUtilsUS.StationToAltimeter(24.692, 5431, 'aaASOS') + >>> print("Station pressure to altimeter = %.3f" % p) + Station pressure to altimeter = 30.153 + """ + Result = HPaToIn(TWxUtils.StationToAltimeter(InToHPa(pressureIn), + FtToM(elevationFt), + algorithm)) + return Result + + @staticmethod + def StationToSeaLevelPressure(pressureIn, elevationFt, + currentTempF, meanTempF, humidity, + algorithm='paManBar'): + """Example: + >>> p = TWxUtilsUS.StationToSeaLevelPressure(24.692, 5431, 59.0, 50.5, 40.5) + >>> print("Station to SLP = %.3f" % p) + Station to SLP = 30.006 + """ + Result = pressureIn * TWxUtilsUS.PressureReductionRatio(pressureIn, + elevationFt, + currentTempF, + meanTempF, + humidity, + algorithm) + return Result + + @staticmethod + def SensorToStationPressure(pressureIn, + sensorElevationFt, stationElevationFt, + currentTempF): + Result = pressureIn * Power10(0.00813 * (sensorElevationFt - stationElevationFt) / FToR(currentTempF)) + return Result + + @staticmethod + def AltimeterToStationPressure(pressureIn, elevationFt, + algorithm='aaMADIS'): + Result = TWxUtils.AltimeterToStationPressure(InToHPa(pressureIn), + FtToM(elevationFt), + algorithm) + return Result + + @staticmethod + def SeaLevelToStationPressure(pressureIn, elevationFt, + currentTempF, meanTempF, humidity, + algorithm='paManBar'): + """Example: + >>> p = TWxUtilsUS.SeaLevelToStationPressure(30.153, 5431, 59.0, 50.5, 40.5) + >>> print("Station to SLP = %.3f" % p) + Station to SLP = 24.813 + """ + Result = pressureIn / TWxUtilsUS.PressureReductionRatio(pressureIn, + elevationFt, + currentTempF, + meanTempF, + humidity, + algorithm) + return Result + + @staticmethod + def PressureReductionRatio(pressureIn, elevationFt, + currentTempF, meanTempF, humidity, + algorithm='paManBar'): + Result = TWxUtils.PressureReductionRatio(InToHPa(pressureIn), + FtToM(elevationFt), + FToC(currentTempF), + FToC(meanTempF), + humidity, algorithm) + return Result + + @staticmethod + def ActualVaporPressure(tempF, humidity, algorithm='vaBolton'): + Result = (humidity * TWxUtilsUS.SaturationVaporPressure(tempF, algorithm)) / 100 + return Result + + @staticmethod + def SaturationVaporPressure(tempF, algorithm='vaBolton'): + Result = HPaToIn(TWxUtils.SaturationVaporPressure(FToC(tempF), + algorithm)) + return Result + + @staticmethod + def MixingRatio(pressureIn, tempF, humidity): + Result = HPaToIn(TWxUtils.MixingRatio(InToHPa(pressureIn), + FToC(tempF), humidity)) + return Result + + @staticmethod + def HumidityCorrection(tempF, elevationFt, humidity, algorithm='vaBolton'): + Result = TWxUtils.HumidityCorrection(FToC(tempF), + FtToM(elevationFt), + humidity, + algorithm) + return Result + + @staticmethod + def GeopotentialAltitude(geometricAltitudeFt): + Result = MToFt(TWxUtils.GeopotentialAltitude(FtToM(geometricAltitudeFt))) + return Result + +#============================================================================== +# class TWxUtilsVP +#============================================================================== + +class uWxUtilsVP(object): + """ This class contains functions for calculating the raw sensor pressure + of a Vantage Pro weather station from the sea level reduced pressure it + provides. + + The sensor pressure can then be used to calcuate altimeter setting using + other functions in the uWxUtils and uWxUtilsUS units. + + notes about input parameters: + currentTemp - current instantaneous station temperature + temp12HrsAgoF - temperature from 12 hours ago. If the 12-hour temp is + not known, simply pass the same value as currentTemp + for the 12-hour temp. For the vantage pro sea level + to sensor pressure conversion, the 12-hour temp + should be the hourly temp that is 11 hours to 11:59 + in the past. For example, if the current time is + 3:59pm, use the 4:00am temp, and if it is currently + 4:00pm, use the 5:00am temp. Also, the vantage pro + seems to use only whole degree temp values in the sea + level calculation, so the function performs rounding + on the temperature. + meanTemp - average of current temp and the temperature 12 hours in + the past. If the 12-hour temp is not known, simply pass + the same value as currentTemp for the mean temp. For the + Vantage Pro, the mean temperature should come from the + BARDATA.VirtualTemp. The value in BARDATA is an integer + (whole degrees). The vantage pro calculates the mean by + Round(((Round(currentTempF - 0.01) + + Round(temp12HrsAgoF - 0.01)) / 2) - 0.01); + humidity - Value should be 0 to 100. For the pressure conversion + functions, pass a value of zero if you do not want to + the algorithm to include the humidity correction factor + in the calculation. If you provide a humidity value + > 0, then humidity effect will be included in the + calculation. + elevation - This should be the geometric altitude of the station + (this is the elevation provided by surveys and normally + used by people when they speak of elevation). Some + algorithms will convert the elevation internally into + a geopotential altitude.""" + + # this function is used if you have access to BARDATA (Davis Serial docs) + # meanTempF is from BARDATA.VirtualTemp + # humidityCorr is from BARDATA.C (remember to first divide C by 10) + @staticmethod + def SeaLevelToSensorPressure_meanT(pressureIn, elevationFt, meanTempF, + humidityCorr): + Result = TWxUtilsUS.SeaLevelToStationPressure( + pressureIn, elevationFt, meanTempF, + meanTempF + humidityCorr, 0, 'paDavisVp') + return Result + + # this function is used if you do not have access to BARDATA. The function + # will internally calculate the mean temp and the humidity correction. + # which would normally come from the BARDATA. + # currentTempF is the value of the current sensor temp + # temp12HrsAgoF is the temperature from 12 hours ago (see comments on + # temp12Hr from earlier in this document for more on this). + @staticmethod + def SeaLevelToSensorPressure_12(pressureIn, elevationFt, currentTempF, + temp12HrsAgoF, humidity): + Result = TWxUtilsUS.SeaLevelToStationPressure( + pressureIn, elevationFt, currentTempF, + Round(((Round(currentTempF - 0.01) + Round(temp12HrsAgoF - 0.01)) / 2) - 0.01), + humidity, 'paDavisVp') + return Result + + +if __name__ == "__main__": + + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weewx/wxengine.py b/dist/weewx-5.0.2/src/weewx/wxengine.py new file mode 100644 index 0000000..0f00692 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/wxengine.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +import weewx.engine + +# For backwards compatibility: +StdService = weewx.engine.StdService \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx/wxformulas.py b/dist/weewx-5.0.2/src/weewx/wxformulas.py new file mode 100644 index 0000000..dc67513 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/wxformulas.py @@ -0,0 +1,973 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""Various weather related formulas and utilities.""" + +import cmath +import logging +import math +import time + +import weewx.units +import weewx.uwxutils +from weewx.units import CtoK, CtoF, FtoC, mph_to_knot, kph_to_knot, mps_to_knot +from weewx.units import INHG_PER_MBAR, METER_PER_FOOT, METER_PER_MILE, MM_PER_INCH + +log = logging.getLogger(__name__) + + +def dewpointF(T, R): + """Calculate dew point in Fahrenheit + + Args: + T (float|None): Temperature in Fahrenheit + R (float|None): Relative humidity in percent. + + Returns: + float|None: Dewpoint in Fahrenheit or None if it cannot be calculated + + Examples: + + >>> print("%.1f" % dewpointF(68, 50)) + 48.7 + >>> print("%.1f" % dewpointF(32, 50)) + 15.5 + >>> print("%.1f" % dewpointF(-10, 50)) + -23.5 + """ + + if T is None or R is None: + return None + + TdC = dewpointC(FtoC(T), R) + + return CtoF(TdC) if TdC is not None else None + + +def dewpointC(T, R): + """Calculate dew point in Celsius + http://en.wikipedia.org/wiki/Dew_point + + Args: + T (float|None): Temperature in Celsius + R (float|None): Relative humidity in percent. + + Returns: + float|None: Dewpoint in Celsius, or None if it cannot be calculated. + """ + + if T is None or R is None: + return None + R = R / 100.0 + try: + _gamma = 17.27 * T / (237.7 + T) + math.log(R) + TdC = 237.7 * _gamma / (17.27 - _gamma) + except (ValueError, OverflowError): + TdC = None + return TdC + + +def windchillF(T_F, V_mph): + """Calculate wind chill in Fahrenhei + http://www.nws.noaa.gov/om/cold/wind_chill.shtml + + Args: + T_F (float|None): Temperature in Fahrenheit + V_mph (float|None): Wind speed in mph + + Returns: + float|None: Wind Chill in Fahrenheit or None if it cannot be calculated. + """ + + if T_F is None or V_mph is None: + return None + + # only valid for temperatures below 50F and wind speeds over 3.0 mph + if T_F >= 50.0 or V_mph <= 3.0: + return T_F + + WcF = 35.74 + 0.6215 * T_F + (-35.75 + 0.4275 * T_F) * math.pow(V_mph, 0.16) + return WcF + + +def windchillMetric(T_C, V_kph): + """Wind chill, metric version, with wind in kph. + + Args: + T (float|None): Temperature in Celsius + V (float|None): Wind speed in kph + + Returns + float|None: wind chill in Celsius, or None if it cannot be calculated + """ + + if T_C is None or V_kph is None: + return None + + T_F = CtoF(T_C) + V_mph = 0.621371192 * V_kph + + WcF = windchillF(T_F, V_mph) + + return FtoC(WcF) if WcF is not None else None + + +# For backwards compatibility +windchillC = windchillMetric + + +def windchillMetricWX(T_C, V_mps): + """Wind chill, metric version, with wind in mps. + + Args: + T_C (float|None): Temperature in Celsius + V_mps (float|None): Wind speed in mps + + Returns: + float|None: wind chill in Celsius or None if it cannot be calculated. + """ + + if T_C is None or V_mps is None: + return None + + T_F = CtoF(T_C) + V_mph = 2.237 * V_mps + + WcF = windchillF(T_F, V_mph) + + return FtoC(WcF) if WcF is not None else None + + +def heatindexF(T, R, algorithm='new'): + """Calculate heat index in Fahrenheit. + + The 'new' algorithm uses: https://www.wpc.ncep.noaa.gov/html/heatindex_equation.shtml + + Args: + T (float|None): Temperature in Fahrenheit + R (float|None): Relative humidity in percent + + Returns: + float|None: heat index in Fahrenheit, or None if it cannot be calculated. + + Examples: + (Expected values obtained from https://www.wpc.ncep.noaa.gov/html/heatindex.shtml): + + >>> print("%0.0f" % heatindexF(75.0, 50.0)) + 75 + >>> print("%0.0f" % heatindexF(80.0, 50.0)) + 81 + >>> print("%0.0f" % heatindexF(80.0, 95.0)) + 88 + >>> print("%0.0f" % heatindexF(90.0, 50.0)) + 95 + >>> print("%0.0f" % heatindexF(90.0, 95.0)) + 127 + + """ + if T is None or R is None: + return None + + if algorithm == 'new': + # Formula only valid for temperatures over 40F: + if T <= 40.0: + return T + + # Use simplified formula + hi_F = 0.5 * (T + 61.0 + ((T - 68.0) * 1.2) + (R * 0.094)) + + # Apply full formula if the above, averaged with temperature, is greater than 80F: + if (hi_F + T) / 2.0 >= 80.0: + hi_F = -42.379 \ + + 2.04901523 * T \ + + 10.14333127 * R \ + - 0.22475541 * T * R \ + - 6.83783e-3 * T ** 2 \ + - 5.481717e-2 * R ** 2 \ + + 1.22874e-3 * T ** 2 * R \ + + 8.5282e-4 * T * R ** 2 \ + - 1.99e-6 * T ** 2 * R ** 2 + # Apply an adjustment for low humidities + if R < 13 and 80 < T < 112: + adjustment = ((13 - R) / 4.0) * math.sqrt((17 - abs(T - 95.)) / 17.0) + hi_F -= adjustment + # Apply an adjustment for high humidities + elif R > 85 and 80 <= T < 87: + adjustment = ((R - 85) / 10.0) * ((87 - T) / 5.0) + hi_F += adjustment + else: + # Formula only valid for temperatures 80F or more, and RH 40% or more: + if T < 80.0 or R < 40.0: + return T + + hi_F = -42.379 \ + + 2.04901523 * T \ + + 10.14333127 * R \ + - 0.22475541 * T * R \ + - 6.83783e-3 * T ** 2 \ + - 5.481717e-2 * R ** 2 \ + + 1.22874e-3 * T ** 2 * R \ + + 8.5282e-4 * T * R ** 2 \ + - 1.99e-6 * T ** 2 * R ** 2 + if hi_F < T: + hi_F = T + + return hi_F + + +def heatindexC(T_C, R, algorithm='new'): + if T_C is None or R is None: + return None + T_F = CtoF(T_C) + hi_F = heatindexF(T_F, R, algorithm) + return FtoC(hi_F) + + +def heating_degrees(t, base): + return max(base - t, 0) if t is not None else None + + +def cooling_degrees(t, base): + return max(t - base, 0) if t is not None else None + + +def altimeter_pressure_US(SP_inHg, Z_foot, algorithm='aaASOS'): + """Calculate the altimeter pressure, given the raw, station pressure in inHg and the altitude + in feet. + + Examples: + >>> print("%.2f" % altimeter_pressure_US(28.0, 0.0)) + 28.00 + >>> print("%.2f" % altimeter_pressure_US(28.0, 1000.0)) + 29.04 + """ + if SP_inHg is None or Z_foot is None: + return None + if SP_inHg <= 0.008859: + return None + return weewx.uwxutils.TWxUtilsUS.StationToAltimeter(SP_inHg, Z_foot, + algorithm=algorithm) + + +def altimeter_pressure_Metric(SP_mbar, Z_meter, algorithm='aaASOS'): + """Convert from (uncorrected) station pressure to altitude-corrected + pressure. + + Examples: + >>> print("%.1f" % altimeter_pressure_Metric(948.08, 0.0)) + 948.2 + >>> print("%.1f" % altimeter_pressure_Metric(948.08, 304.8)) + 983.4 + """ + if SP_mbar is None or Z_meter is None: + return None + if SP_mbar <= 0.3: + return None + return weewx.uwxutils.TWxUtils.StationToAltimeter(SP_mbar, Z_meter, + algorithm=algorithm) + + +def _etterm(elev_meter, t_C): + """Calculate elevation/temperature term for sea level calculation.""" + t_K = CtoK(t_C) + return math.exp(-elev_meter / (t_K * 29.263)) + + +def sealevel_pressure_Metric(sp_mbar, elev_meter, t_C): + """Convert station pressure to sea level pressure. This implementation was copied from wview. + + sp_mbar - station pressure in millibars + + elev_meter - station elevation in meters + + t_C - temperature in degrees Celsius + + bp - sea level pressure (barometer) in millibars + """ + if sp_mbar is None or elev_meter is None or t_C is None: + return None + pt = _etterm(elev_meter, t_C) + bp_mbar = sp_mbar / pt if pt != 0 else 0 + return bp_mbar + + +def sealevel_pressure_US(sp_inHg, elev_foot, t_F): + if sp_inHg is None or elev_foot is None or t_F is None: + return None + sp_mbar = sp_inHg / INHG_PER_MBAR + elev_meter = elev_foot * METER_PER_FOOT + t_C = FtoC(t_F) + slp_mbar = sealevel_pressure_Metric(sp_mbar, elev_meter, t_C) + slp_inHg = slp_mbar * INHG_PER_MBAR + return slp_inHg + + +def calculate_delta(newtotal, oldtotal, delta_key='rain'): + """Calculate the differential given two cumulative measurements.""" + if newtotal is not None and oldtotal is not None: + if newtotal >= oldtotal: + delta = newtotal - oldtotal + else: + log.info("'%s' counter reset detected: new=%s old=%s", delta_key, + newtotal, oldtotal) + delta = None + else: + delta = None + return delta + +# For backwards compatibility: +calculate_rain = calculate_delta + +def solar_rad_Bras(lat, lon, altitude_m, ts=None, nfac=2): + """Calculate maximum solar radiation using Bras method + http://www.ecy.wa.gov/programs/eap/models.html + + lat, lon - latitude and longitude in decimal degrees + + altitude_m - altitude in meters + + ts - timestamp as unix epoch + + nfac - atmospheric turbidity (2=clear, 4-5=smoggy) + + Example: + + >>> for t in range(0,24): + ... print("%.2f" % solar_rad_Bras(42, -72, 0, t*3600+1422936471)) + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 1.86 + 100.81 + 248.71 + 374.68 + 454.90 + 478.76 + 443.47 + 353.23 + 220.51 + 73.71 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + """ + from weewx.almanac import Almanac + if ts is None: + ts = time.time() + sr = 0.0 + try: + alm = Almanac(ts, lat, lon, altitude_m) + el = alm.sun.alt # solar elevation degrees from horizon + R = alm.sun.earth_distance + # NREL solar constant W/m^2 + nrel = 1367.0 + # radiation on horizontal surface at top of atmosphere (bras eqn 2.9) + sinel = math.sin(math.radians(el)) + io = sinel * nrel / (R * R) + if sinel >= 0: + # optical air mass (bras eqn 2.22) + m = 1.0 / (sinel + 0.15 * math.pow(el + 3.885, -1.253)) + # molecular scattering coefficient (bras eqn 2.26) + a1 = 0.128 - 0.054 * math.log(m) / math.log(10.0) + # clear-sky radiation at earth surface W / m^2 (bras eqn 2.25) + sr = io * math.exp(-nfac * a1 * m) + except (AttributeError, ValueError, OverflowError): + sr = None + return sr + + +def solar_rad_RS(lat, lon, altitude_m, ts=None, atc=0.8): + """Calculate maximum solar radiation + Ryan-Stolzenbach, MIT 1972 + http://www.ecy.wa.gov/programs/eap/models.html + + lat, lon - latitude and longitude in decimal degrees + + altitude_m - altitude in meters + + ts - time as unix epoch + + atc - atmospheric transmission coefficient (0.7-0.91) + + Example: + + >>> for t in range(0,24): + ... print("%.2f" % solar_rad_RS(42, -72, 0, t*3600+1422936471)) + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.09 + 79.31 + 234.77 + 369.80 + 455.66 + 481.15 + 443.44 + 346.81 + 204.64 + 52.63 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + """ + from weewx.almanac import Almanac + if atc < 0.7 or atc > 0.91: + atc = 0.8 + if ts is None: + ts = time.time() + sr = 0.0 + try: + alm = Almanac(ts, lat, lon, altitude_m) + el = alm.sun.alt # solar elevation degrees from horizon + R = alm.sun.earth_distance + z = altitude_m + nrel = 1367.0 # NREL solar constant, W/m^2 + sinal = math.sin(math.radians(el)) + if sinal >= 0: # sun must be above horizon + rm = math.pow((288.0 - 0.0065 * z) / 288.0, 5.256) \ + / (sinal + 0.15 * math.pow(el + 3.885, -1.253)) + toa = nrel * sinal / (R * R) + sr = toa * math.pow(atc, rm) + except (AttributeError, ValueError, OverflowError): + sr = None + return sr + + +def cloudbase_Metric(t_C, rh, altitude_m): + """Calculate the cloud base in meters + + t_C - temperature in degrees Celsius + + rh - relative humidity [0-100] + + altitude_m - altitude in meters + """ + dp_C = dewpointC(t_C, rh) + if dp_C is None: + return None + cb = (t_C - dp_C) * 1000 / 2.5 + return altitude_m + cb * METER_PER_FOOT if cb is not None else None + + +def cloudbase_US(t_F, rh, altitude_ft): + """Calculate the cloud base in feet + + t_F - temperature in degrees Fahrenheit + + rh - relative humidity [0-100] + + altitude_ft - altitude in feet + """ + dp_F = dewpointF(t_F, rh) + if dp_F is None: + return None + cb = altitude_ft + (t_F - dp_F) * 1000.0 / 4.4 + return cb + + +def humidexC(t_C, rh): + """Calculate the humidex. + Reference (look under heading "Humidex"): https://tinyurl.com/mr3ch6cc + + Args: + t_C (float): Temperature in degree Celsius + rh (float): Relative humidity [0-100] + + Returns: + float|None: Value of humidex in Celsius, or None if it cannot be calculated. + + Examples: + >>> print("%.2f" % humidexC(30.0, 80.0)) + 43.66 + >>> print("%.2f" % humidexC(30.0, 20.0)) + 30.00 + >>> print("%.2f" % humidexC(0, 80.0)) + 0.00 + >>> print(humidexC(30.0, None)) + None + """ + try: + dp_C = dewpointC(t_C, rh) + dp_K = CtoK(dp_C) + e = 6.11 * math.exp(5417.7530 * (1 / 273.15 - 1 / dp_K)) + h = 0.5555 * (e - 10.0) + except (ValueError, OverflowError, TypeError): + return None + + return t_C + h if h > 0 else t_C + + +def humidexF(t_F, rh): + """Calculate the humidex in degree Fahrenheit + + t_F - temperature in degree Fahrenheit + + rh - relative humidity [0-100] + """ + if t_F is None: + return None + h_C = humidexC(FtoC(t_F), rh) + return CtoF(h_C) if h_C is not None else None + + +def apptempC(t_C, rh, ws_mps): + """Calculate the apparent temperature in degree Celsius + + t_C - temperature in degree Celsius + + rh - relative humidity [0-100] + + ws_mps - wind speed in meters per second + + http://www.bom.gov.au/info/thermal_stress/#atapproximation + AT = Ta + 0.33*e - 0.70*ws - 4.00 + where + AT and Ta (air temperature) are deg-C + e is water vapor pressure + ws is wind speed (m/s) at elevation of 10 meters + e = rh / 100 * 6.105 * exp(17.27 * Ta / (237.7 + Ta)) + rh is relative humidity + + http://www.ncdc.noaa.gov/societal-impacts/apparent-temp/ + AT = -2.7 + 1.04*T + 2.0*e -0.65*v + where + AT and T (air temperature) are deg-C + e is vapor pressure in kPa + v is 10m wind speed in m/sec + """ + if t_C is None: + return None + if rh is None or rh < 0 or rh > 100: + return None + if ws_mps is None or ws_mps < 0: + return None + try: + e = (rh / 100.0) * 6.105 * math.exp(17.27 * t_C / (237.7 + t_C)) + at_C = t_C + 0.33 * e - 0.7 * ws_mps - 4.0 + except (ValueError, OverflowError): + at_C = None + return at_C + + +def apptempF(t_F, rh, ws_mph): + """Calculate apparent temperature in degree Fahrenheit + + t_F - temperature in degree Fahrenheit + + rh - relative humidity [0-100] + + ws_mph - wind speed in miles per hour + """ + if t_F is None: + return None + if rh is None or rh < 0 or rh > 100: + return None + if ws_mph is None or ws_mph < 0: + return None + t_C = FtoC(t_F) + ws_mps = ws_mph * METER_PER_MILE / 3600.0 + at_C = apptempC(t_C, rh, ws_mps) + return CtoF(at_C) if at_C is not None else None + + +def beaufort(ws_kts): + """Return the beaufort number given a wind speed in knots""" + if ws_kts is None: + return None + mag_knts = abs(ws_kts) + if mag_knts is None: + beaufort_mag = None + elif mag_knts < 1: + beaufort_mag = 0 + elif mag_knts < 4: + beaufort_mag = 1 + elif mag_knts < 7: + beaufort_mag = 2 + elif mag_knts < 11: + beaufort_mag = 3 + elif mag_knts < 17: + beaufort_mag = 4 + elif mag_knts < 22: + beaufort_mag = 5 + elif mag_knts < 28: + beaufort_mag = 6 + elif mag_knts < 34: + beaufort_mag = 7 + elif mag_knts < 41: + beaufort_mag = 8 + elif mag_knts < 48: + beaufort_mag = 9 + elif mag_knts < 56: + beaufort_mag = 10 + elif mag_knts < 64: + beaufort_mag = 11 + else: + beaufort_mag = 12 + + if isinstance(ws_kts, complex): + return cmath.rect(beaufort_mag, cmath.phase(ws_kts)) + else: + return beaufort_mag + + +weewx.units.conversionDict['mile_per_hour']['beaufort'] = lambda x : beaufort(mph_to_knot(x)) +weewx.units.conversionDict['knot']['beaufort'] = beaufort +weewx.units.conversionDict['km_per_hour']['beaufort'] = lambda x: beaufort(kph_to_knot(x)) +weewx.units.conversionDict['meter_per_second']['beaufort'] = lambda x : beaufort(mps_to_knot(x)) +weewx.units.default_unit_format_dict['beaufort'] = "%d" + +def equation_of_time(doy): + """Equation of time in minutes. Plus means sun leads local time. + + Example (1 October): + >>> print("%.4f" % equation_of_time(274)) + 0.1889 + """ + b = 2 * math.pi * (doy - 81) / 364.0 + return 0.1645 * math.sin(2 * b) - 0.1255 * math.cos(b) - 0.025 * math.sin(b) + + +def hour_angle(t_utc, longitude, doy): + """Solar hour angle at a given time in radians. + + t_utc: The time in UTC. + longitude: the longitude in degrees + doy: The day of year + + Returns hour angle in radians. 0 <= omega < 2*pi + + Example: + >>> print("%.4f radians" % hour_angle(15.5, -16.25, 274)) + 0.6821 radians + >>> print("%.4f radians" % hour_angle(0, -16.25, 274)) + 2.9074 radians + """ + Sc = equation_of_time(doy) + omega = (math.pi / 12.0) * (t_utc + longitude / 15.0 + Sc - 12) + if omega < 0: + omega += 2.0 * math.pi + return omega + + +def solar_declination(doy): + """Solar declination for the day of the year in radians + + Example (1 October is the 274th day of the year): + >>> print("%.6f" % solar_declination(274)) + -0.075274 + """ + return 0.409 * math.sin(2.0 * math.pi * doy / 365 - 1.39) + + +def sun_radiation(doy, latitude_deg, longitude_deg, tod_utc, interval): + """Extraterrestrial radiation. Radiation at the top of the atmosphere + + doy: Day-of-year + + latitude_deg, longitude_deg: Lat and lon in degrees + + tod_utc: Time-of-day (UTC) at the end of the interval in hours (0-24) + + interval: The time interval over which the radiation is to be calculated in hours + + Returns the (average?) solar radiation over the time interval in MJ/m^2/hr + + Example: + >>> print("%.3f" % sun_radiation(doy=274, latitude_deg=16.217, + ... longitude_deg=-16.25, tod_utc=16.0, interval=1.0)) + 3.543 + """ + + # Solar constant in MJ/m^2/hr + Gsc = 4.92 + + delta = solar_declination(doy) + + earth_distance = 1.0 + 0.033 * math.cos(2.0 * math.pi * doy / 365.0) # dr + + start_utc = tod_utc - interval + stop_utc = tod_utc + start_omega = hour_angle(start_utc, longitude_deg, doy) + stop_omega = hour_angle(stop_utc, longitude_deg, doy) + + latitude_radians = math.radians(latitude_deg) + + part1 = (stop_omega - start_omega) * math.sin(latitude_radians) * math.sin(delta) + part2 = math.cos(latitude_radians) * math.cos(delta) * (math.sin(stop_omega) + - math.sin(start_omega)) + + # http://www.fao.org/docrep/x0490e/x0490e00.htm Eqn 28 + Ra = (12.0 / math.pi) * Gsc * earth_distance * (part1 + part2) + + if Ra < 0: + Ra = 0 + + return Ra + + +def longwave_radiation(Tmin_C, Tmax_C, ea, Rs, Rso, rh): + """Calculate the net long-wave radiation. + Ref: http://www.fao.org/docrep/x0490e/x0490e00.htm Eqn 39 + + Tmin_C: Minimum temperature during the calculation period + Tmax_C: Maximum temperature during the calculation period + ea: Actual vapor pressure in kPa + Rs: Measured radiation. See below for units. + Rso: Calculated clear-wky radiation. See below for units. + rh: Relative humidity in percent + + Because the formula uses the ratio of Rs to Rso, their actual units do not matter, + so long as they use the same units. + + Returns back radiation in MJ/m^2/day + + Example: + >>> print("%.1f mm/day" % longwave_radiation(Tmin_C=19.1, Tmax_C=25.1, ea=2.1, + ... Rs=14.5, Rso=18.8, rh=50)) + 3.5 mm/day + + Night time example. Set rh = 40% to reproduce the Rs/Rso ratio of 0.8 used in the paper. + >>> print("%.1f mm/day" % longwave_radiation(Tmin_C=28, Tmax_C=28, ea=3.402, + ... Rs=0, Rso=0, rh=40)) + 2.4 mm/day + """ + # Calculate temperatures in Kelvin: + Tmin_K = Tmin_C + 273.16 + Tmax_K = Tmax_C + 273.16 + + # Stefan-Boltzman constant in MJ/K^4/m^2/day + sigma = 4.903e-09 + + # Use the ratio of measured to expected radiation as a measure of cloudiness, but + # only if it's daylight + if Rso: + cloud_factor = Rs / Rso + else: + # If it's nighttime (no expected radiation), then use this totally made up formula + if rh > 80: + # Humid. Lots of clouds + cloud_factor = 0.3 + elif rh > 40: + # Somewhat humid. Modest cloud cover + cloud_factor = 0.5 + else: + # Low humidity. No clouds. + cloud_factor = 0.8 + + # Calculate the longwave (back) radiation (Eqn 39). Result will be in MJ/m^2/day. + Rnl_part1 = sigma * (Tmin_K ** 4 + Tmax_K ** 4) / 2.0 + Rnl_part2 = (0.34 - 0.14 * math.sqrt(ea)) + Rnl_part3 = (1.35 * cloud_factor - 0.35) + Rnl = Rnl_part1 * Rnl_part2 * Rnl_part3 + + return Rnl + + +def evapotranspiration_Metric(Tmin_C, Tmax_C, rh_min, rh_max, sr_mean_wpm2, + ws_mps, wind_height_m, latitude_deg, longitude_deg, altitude_m, + timestamp, albedo=0.23, cn=37, cd=0.34): + """Calculate the rate of evapotranspiration during a one-hour time period. + Ref: http://www.fao.org/docrep/x0490e/x0490e00.htm. + + The document "Step by Step Calculation of the Penman-Monteith Evapotranspiration" + https://edis.ifas.ufl.edu/pdf/AE/AE45900.pdf is also helpful. See it for values + of cn and cd. + + Args: + + Tmin_C (float): Minimum temperature during the hour in degrees Celsius. + Tmax_C (float): Maximum temperature during the hour in degrees Celsius. + rh_min (float): Minimum relative humidity during the hour in percent. + rh_max (float): Maximum relative humidity during the hour in percent. + sr_mean_wpm2 (float): Mean solar radiation during the hour in watts per sq meter. + ws_mps (float): Average wind speed during the hour in meters per second. + wind_height_m (float): Height in meters at which windspeed is measured. + latitude_deg (float): Latitude of the station in degrees. + longitude_deg (float): Longitude of the station in degrees. + altitude_m (float): Altitude of the station in meters. + timestamp (float): The time, as unix epoch time, at the end of the hour. + albedo (float): Albedo. Default is 0.23 (grass reference crop). + cn (float): The numerator constant for the reference crop type and time step. + Default is 37 (short reference crop). + cd (float): The denominator constant for the reference crop type and time step. + Default is 0.34 (daytime short reference crop). + + Returns: + float: Evapotranspiration in mm/hr + + Example (Example 19 in the reference document): + >>> sr_mean_wpm2 = 680.56 # == 2.45 MJ/m^2/hr + >>> timestamp = 1475337600 # 1-Oct-2016 at 16:00UTC + >>> print("ET0 = %.2f mm/hr" % evapotranspiration_Metric(Tmin_C=38, Tmax_C=38, + ... rh_min=52, rh_max=52, + ... sr_mean_wpm2=sr_mean_wpm2, ws_mps=3.3, wind_height_m=2, + ... latitude_deg=16.217, longitude_deg=-16.25, altitude_m=8, + ... timestamp=timestamp)) + ET0 = 0.63 mm/hr + + Another example, this time for night + >>> sr_mean_wpm2 = 0.0 # night time + >>> timestamp = 1475294400 # 1-Oct-2016 at 04:00UTC (0300 local) + >>> print("ET0 = %.2f mm/hr" % evapotranspiration_Metric(Tmin_C=28, Tmax_C=28, + ... rh_min=90, rh_max=90, + ... sr_mean_wpm2=sr_mean_wpm2, ws_mps=3.3, wind_height_m=2, + ... latitude_deg=16.217, longitude_deg=-16.25, altitude_m=8, + ... timestamp=timestamp)) + ET0 = 0.03 mm/hr + """ + if None in (Tmin_C, Tmax_C, rh_min, rh_max, sr_mean_wpm2, ws_mps, + latitude_deg, longitude_deg, timestamp): + return None + + if wind_height_m is None: + wind_height_m = 2.0 + if altitude_m is None: + altitude_m = 0.0 + + # figure out the day of year [1-366] from the timestamp + doy = time.localtime(timestamp)[7] - 1 + # Calculate the UTC time-of-day in hours + time_tt_utc = time.gmtime(timestamp) + tod_utc = time_tt_utc.tm_hour + time_tt_utc.tm_min / 60.0 + time_tt_utc.tm_sec / 3600.0 + + # Calculate mean temperature + tavg_C = (Tmax_C + Tmin_C) / 2.0 + + # Mean humidity + rh_avg = (rh_min + rh_max) / 2.0 + + # Adjust windspeed for height + u2 = 4.87 * ws_mps / math.log(67.8 * wind_height_m - 5.42) + + # Calculate the atmospheric pressure in kPa + p = 101.3 * math.pow((293.0 - 0.0065 * altitude_m) / 293.0, 5.26) + # Calculate the psychrometric constant in kPa/C (Eqn 8) + gamma = 0.665e-03 * p + + # Calculate mean saturation vapor pressure, converting from hPa to kPa (Eqn 12) + etmin = weewx.uwxutils.TWxUtils.SaturationVaporPressure(Tmin_C, 'vaTeten') / 10.0 + etmax = weewx.uwxutils.TWxUtils.SaturationVaporPressure(Tmax_C, 'vaTeten') / 10.0 + e0T = (etmin + etmax) / 2.0 + + # Calculate the slope of the saturation vapor pressure curve in kPa/C (Eqn 13) + delta = 4098.0 * (0.6108 * math.exp(17.27 * tavg_C / (tavg_C + 237.3))) / \ + ((tavg_C + 237.3) * (tavg_C + 237.3)) + + # Calculate actual vapor pressure from relative humidity (Eqn 17) + ea = (etmin * rh_max + etmax * rh_min) / 200.0 + + # Convert solar radiation from W/m^2 to MJ/m^2/hr + Rs = sr_mean_wpm2 * 3.6e-3 + + # Net shortwave (measured) radiation in MJ/m^2/hr (eqn 38) + Rns = (1.0 - albedo) * Rs + + # Extraterrestrial radiation in MJ/m^2/hr + Ra = sun_radiation(doy, latitude_deg, longitude_deg, tod_utc, interval=1.0) + # Clear sky solar radiation in MJ/m^2/hr (eqn 37) + Rso = (0.75 + 2e-5 * altitude_m) * Ra + + # Longwave (back) radiation. Convert from MJ/m^2/day to MJ/m^2/hr (Eqn 39): + Rnl = longwave_radiation(Tmin_C, Tmax_C, ea, Rs, Rso, rh_avg) / 24.0 + + # Calculate net radiation at the surface in MJ/m^2/hr (Eqn. 40) + Rn = Rns - Rnl + + # Calculate the soil heat flux. (see section "For hourly or shorter + # periods" in http://www.fao.org/docrep/x0490e/x0490e07.htm#radiation + G = 0.1 * Rn if Rs else 0.5 * Rn + + # Put it all together. Result is in mm/hr (Eqn 53) + ET0 = (0.408 * delta * (Rn - G) + gamma * (cn / (tavg_C + 273)) * u2 * (e0T - ea)) \ + / (delta + gamma * (1 + cd * u2)) + + # We don't allow negative ET's + if ET0 < 0: + ET0 = 0 + + return ET0 + + +def evapotranspiration_US(Tmin_F, Tmax_F, rh_min, rh_max, + sr_mean_wpm2, ws_mph, wind_height_ft, + latitude_deg, longitude_deg, altitude_ft, timestamp, + albedo=0.23, cn=37, cd=0.34): + """Calculate the rate of evapotranspiration during a one-hour time period, + returning result in inches/hr. + + See function evapotranspiration_Metric() for references. + + Args: + + Tmin_F (float): Minimum temperature during the hour in degrees Fahrenheit. + Tmax_F (float): Maximum temperature during the hour in degrees Fahrenheit. + rh_min (float): Minimum relative humidity during the hour in percent. + rh_max (float): Maximum relative humidity during the hour in percent. + sr_mean_wpm2 (float): Mean solar radiation during the hour in watts per sq meter. + ws_mph (float): Average wind speed during the hour in miles per hour. + wind_height_ft (float): Height in feet at which windspeed is measured. + latitude_deg (float): Latitude of the station in degrees. + longitude_deg (float): Longitude of the station in degrees. + altitude_ft (float): Altitude of the station in feet. + timestamp (float): The time, as unix epoch time, at the end of the hour. + albedo (float): Albedo. Default is 0.23 (grass reference crop). + cn (float): The numerator constant for the reference crop type and time step. + Default is 37 (short reference crop). + cd (float): The denominator constant for the reference crop type and time step. + Default is 0.34 (daytime short reference crop). + + Returns: + float: Evapotranspiration in inches/hr + + Example (using data from HR station): + >>> sr_mean_wpm2 = 860 + >>> timestamp = 1469829600 # 29-July-2016 22:00 UTC (15:00 local time) + >>> print("ET0 = %.3f in/hr" % evapotranspiration_US(Tmin_F=87.8, Tmax_F=89.1, + ... rh_min=34, rh_max=38, + ... sr_mean_wpm2=sr_mean_wpm2, ws_mph=9.58, wind_height_ft=6, + ... latitude_deg=45.7, longitude_deg=-121.5, altitude_ft=700, + ... timestamp=timestamp)) + ET0 = 0.028 in/hr + """ + try: + Tmin_C = FtoC(Tmin_F) + Tmax_C = FtoC(Tmax_F) + ws_mps = ws_mph * METER_PER_MILE / 3600.0 + wind_height_m = wind_height_ft * METER_PER_FOOT + altitude_m = altitude_ft * METER_PER_FOOT + except TypeError: + return None + evt = evapotranspiration_Metric(Tmin_C=Tmin_C, Tmax_C=Tmax_C, + rh_min=rh_min, rh_max=rh_max, sr_mean_wpm2=sr_mean_wpm2, + ws_mps=ws_mps, wind_height_m=wind_height_m, + latitude_deg=latitude_deg, longitude_deg=longitude_deg, + altitude_m=altitude_m, timestamp=timestamp, + albedo=albedo, cn=cn, cd=cd) + return evt / MM_PER_INCH if evt is not None else None + + +if __name__ == "__main__": + + import doctest + + if not doctest.testmod().failed: + print("PASSED") diff --git a/dist/weewx-5.0.2/src/weewx/wxmanager.py b/dist/weewx-5.0.2/src/weewx/wxmanager.py new file mode 100644 index 0000000..b1e4f11 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/wxmanager.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2009-2019 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Weather-specific database manager.""" + +import weewx.manager + + +class WXDaySummaryManager(weewx.manager.DaySummaryManager): + """Daily summaries, suitable for WX applications. + + OBSOLETE. Provided for backwards compatibility. + + """ diff --git a/dist/weewx-5.0.2/src/weewx/wxservices.py b/dist/weewx-5.0.2/src/weewx/wxservices.py new file mode 100644 index 0000000..3e38e5f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/wxservices.py @@ -0,0 +1,154 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Calculate derived variables, depending on software/hardware preferences. + +While this is named 'StdWXCalculate' for historical reasons, it can actually calculate +non-weather related derived types as well. +""" +import logging + +import weeutil.weeutil +import weewx.engine +import weewx.units + +log = logging.getLogger(__name__) + + +class StdWXCalculate(weewx.engine.StdService): + + def __init__(self, engine, config_dict): + """Initialize an instance of StdWXCalculate and determine the calculations to be done. + + Directives look like: + + obs_type = [prefer_hardware|hardware|software], [loop|archive] + + where: + + obs_type is an observation type to be calculated, such as 'heatindex' + + The choice [prefer_hardware|hardware|software] determines how the value is to be + calculated. Option "prefer_hardware" means that if the hardware supplies a value, it will + be used, otherwise the value will be calculated in software. + + The choice [loop|archive] indicates whether the calculation is to be done for only LOOP + packets, or only archive records. If left out, it will be done for both. + + Examples: + + cloudbase = software,loop + The derived type 'cloudbase' will always be calculated in software, but only for LOOP + packets + + cloudbase = software, record + The derived type 'cloudbase' will always be calculated in software, but only for archive + records. + + cloudbase = software + The derived type 'cloudbase' will always be calculated in software, for both LOOP packets + and archive records""" + + super().__init__(engine, config_dict) + + self.loop_calc_dict = dict() # map {obs->directive} for LOOP packets + self.archive_calc_dict = dict() # map {obs->directive} for archive records + + for obs_type, rule in config_dict.get('StdWXCalculate', {}).get('Calculations', {}).items(): + # Ensure we have a list: + words = weeutil.weeutil.option_as_list(rule) + # Split the list up into a directive, and (optionally) which bindings it applies to + # (loop or archive). + directive = words[0].lower() + bindings = [w.lower() for w in words[1:]] + if not bindings or 'loop' in bindings: + # no bindings mentioned, or 'loop' plus maybe others + self.loop_calc_dict[obs_type] = directive + if not bindings or 'archive' in bindings: + # no bindings mentioned, or 'archive' plus maybe others + self.archive_calc_dict[obs_type] = directive + + # Backwards compatibility for configuration files v4.1 or earlier: + self.loop_calc_dict.setdefault('windDir', 'software') + self.archive_calc_dict.setdefault('windDir', 'software') + self.loop_calc_dict.setdefault('windGustDir', 'software') + self.archive_calc_dict.setdefault('windGustDir', 'software') + # For backwards compatibility: + self.calc_dict = self.archive_calc_dict + + if weewx.debug > 1: + log.debug("Calculations for LOOP packets: %s", self.loop_calc_dict) + log.debug("Calculations for archive records: %s", self.archive_calc_dict) + + # Get the data binding. Default to 'wx_binding'. + data_binding = config_dict.get('StdWXCalculate', + {'data_binding': 'wx_binding'}).get('data_binding', + 'wx_binding') + # Log the data binding we are to use + log.info("StdWXCalculate will use data binding %s" % data_binding) + # If StdArchive and StdWXCalculate use different data bindings it could + # be a problem. Get the data binding to be used by StdArchive. + std_arch_data_binding = config_dict.get('StdArchive', {}).get('data_binding', + 'wx_binding') + # Is the data binding the same as will be used by StdArchive? + if data_binding != std_arch_data_binding: + # The data bindings are different, don't second guess the user but + # log the difference as this could be an oversight + log.warning("The StdWXCalculate data binding (%s) does not " + "match the StdArchive data binding (%s).", + data_binding, std_arch_data_binding) + # Now obtain a database manager using the data binding + self.db_manager = engine.db_binder.get_manager(data_binding=data_binding, + initialize=True) + + # We will process both loop and archive events + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def new_loop_packet(self, event): + self.do_calculations(event.packet, self.loop_calc_dict) + + def new_archive_record(self, event): + self.do_calculations(event.record, self.archive_calc_dict) + + def do_calculations(self, data_dict, calc_dict=None): + """Augment the data dictionary with derived types as necessary. + + data_dict: The incoming LOOP packet or archive record. + calc_dict: the directives to apply + """ + + if calc_dict is None: + calc_dict = self.archive_calc_dict + + # Go through the list of potential calculations and see which ones need to be done + for obs in calc_dict: + # Keys in calc_dict are in unicode. Keys in packets and records are in native strings. + # Just to keep things consistent, convert. + obs_type = str(obs) + if calc_dict[obs] == 'software' \ + or (calc_dict[obs] == 'prefer_hardware' and data_dict.get(obs_type) is None): + # We need to do a calculation for type 'obs_type'. This may raise an exception, + # so be prepared to catch it. + try: + val = weewx.xtypes.get_scalar(obs_type, data_dict, self.db_manager) + except weewx.CannotCalculate: + # XTypes is aware of the type, but can't calculate it, probably because of + # missing data. Set the type to None. + data_dict[obs_type] = None + except weewx.NoCalculate: + # XTypes is aware of the type, but does not need to calculate it. + pass + except weewx.UnknownType as e: + log.debug("Unknown extensible type '%s'" % e) + except weewx.UnknownAggregation as e: + log.debug("Unknown aggregation '%s'" % e) + else: + # If there was no exception, then all is good. Convert to the same unit + # as the record... + new_value = weewx.units.convertStd(val, data_dict['usUnits']) + # ... then add the results to the dictionary + data_dict[obs_type] = new_value[0] + diff --git a/dist/weewx-5.0.2/src/weewx/wxxtypes.py b/dist/weewx-5.0.2/src/weewx/wxxtypes.py new file mode 100644 index 0000000..7703369 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/wxxtypes.py @@ -0,0 +1,822 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""A set of XTypes extensions for calculating weather-related derived observation types.""" +import logging +import threading + +import weedb +import weeutil.config +import weeutil.logger +import weewx.engine +import weewx.units +import weewx.uwxutils +import weewx.wxformulas +import weewx.xtypes +from weeutil.weeutil import to_int, to_float, to_bool +from weewx.units import ValueTuple, mps_to_mph, kph_to_mph, METER_PER_FOOT, CtoF + +log = logging.getLogger(__name__) + +DEFAULTS_INI = """ +[StdWXCalculate] + [[WXXTypes]] + [[[windDir]]] + force_null = True + [[[maxSolarRad]]] + algorithm = rs + atc = 0.8 + nfac = 2 + [[[ET]]] + et_period = 3600 + wind_height = 2.0 + albedo = 0.23 + cn = 37 + cd = 0.34 + [[[heatindex]]] + algorithm = new + [[PressureCooker]] + max_delta_12h = 1800 + [[[altimeter]]] + algorithm = aaASOS # Case-sensitive! + [[RainRater]] + rain_period = 900 + retain_period = 930 + [[Delta]] + [[[rain]]] + input = totalRain +""" +defaults_dict = weeutil.config.config_from_str(DEFAULTS_INI) + +first_time = True + + +class WXXTypes(weewx.xtypes.XType): + """Weather extensions to the WeeWX xtype system that are relatively simple. These types + are generally stateless, such as dewpoint, heatindex, etc. """ + + def __init__(self, altitude_vt, latitude_f, longitude_f, + atc=0.8, + nfac=2, + force_null=True, + maxSolarRad_algo='rs', + heatindex_algo='new' + ): + # Fail hard if out of range: + if not 0.7 <= atc <= 0.91: + raise weewx.ViolatedPrecondition("Atmospheric transmission coefficient (%f) " + "out of range [.7-.91]" % atc) + self.altitude_vt = altitude_vt + self.latitude_f = latitude_f + self.longitude_f = longitude_f + self.atc = atc + self.nfac = nfac + self.force_null = force_null + self.maxSolarRad_algo = maxSolarRad_algo.lower() + self.heatindex_algo = heatindex_algo.lower() + + def get_scalar(self, obs_type, record, db_manager, **option_dict): + """Invoke the proper method for the desired observation type.""" + try: + # Form the method name, then call it with arguments + return getattr(self, 'calc_%s' % obs_type)(obs_type, record, db_manager) + except AttributeError: + raise weewx.UnknownType(obs_type) + + def calc_windDir(self, key, data, db_manager): + """ Set windDir to None if windSpeed is zero. Otherwise, raise weewx.NoCalculate. """ + if 'windSpeed' not in data \ + or not self.force_null \ + or data['windSpeed']: + raise weewx.NoCalculate + return ValueTuple(None, 'degree_compass', 'group_direction') + + def calc_windGustDir(self, key, data, db_manager): + """ Set windGustDir to None if windGust is zero. Otherwise, raise weewx.NoCalculate.If""" + if 'windGust' not in data \ + or not self.force_null \ + or data['windGust']: + raise weewx.NoCalculate + return ValueTuple(None, 'degree_compass', 'group_direction') + + def calc_maxSolarRad(self, key, data, db_manager): + altitude_m = weewx.units.convert(self.altitude_vt, 'meter')[0] + if self.maxSolarRad_algo == 'bras': + val = weewx.wxformulas.solar_rad_Bras(self.latitude_f, self.longitude_f, altitude_m, + data['dateTime'], self.nfac) + elif self.maxSolarRad_algo == 'rs': + val = weewx.wxformulas.solar_rad_RS(self.latitude_f, self.longitude_f, altitude_m, + data['dateTime'], self.atc) + else: + raise weewx.ViolatedPrecondition("Unknown solar algorithm '%s'" + % self.maxSolarRad_algo) + return ValueTuple(val, 'watt_per_meter_squared', 'group_radiation') + + def calc_cloudbase(self, key, data, db_manager): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + # Convert altitude to the same unit system as the incoming record + altitude = weewx.units.convertStd(self.altitude_vt, data['usUnits']) + # Use the appropriate formula + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.cloudbase_US(data['outTemp'], + data['outHumidity'], altitude[0]) + u = 'foot' + else: + val = weewx.wxformulas.cloudbase_Metric(data['outTemp'], + data['outHumidity'], altitude[0]) + u = 'meter' + return ValueTuple(val, u, 'group_altitude') + + @staticmethod + def calc_dewpoint(key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.dewpointF(data['outTemp'], data['outHumidity']) + u = 'degree_F' + else: + val = weewx.wxformulas.dewpointC(data['outTemp'], data['outHumidity']) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_inDewpoint(key, data, db_manager=None): + if 'inTemp' not in data or 'inHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.dewpointF(data['inTemp'], data['inHumidity']) + u = 'degree_F' + else: + val = weewx.wxformulas.dewpointC(data['inTemp'], data['inHumidity']) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_windchill(key, data, db_manager=None): + if 'outTemp' not in data or 'windSpeed' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.windchillF(data['outTemp'], data['windSpeed']) + u = 'degree_F' + elif data['usUnits'] == weewx.METRIC: + val = weewx.wxformulas.windchillMetric(data['outTemp'], data['windSpeed']) + u = 'degree_C' + elif data['usUnits'] == weewx.METRICWX: + val = weewx.wxformulas.windchillMetricWX(data['outTemp'], data['windSpeed']) + u = 'degree_C' + else: + raise weewx.ViolatedPrecondition("Unknown unit system %s" % data['usUnits']) + return ValueTuple(val, u, 'group_temperature') + + def calc_heatindex(self, key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.heatindexF(data['outTemp'], data['outHumidity'], + algorithm=self.heatindex_algo) + u = 'degree_F' + else: + val = weewx.wxformulas.heatindexC(data['outTemp'], data['outHumidity'], + algorithm=self.heatindex_algo) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_humidex(key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.humidexF(data['outTemp'], data['outHumidity']) + u = 'degree_F' + else: + val = weewx.wxformulas.humidexC(data['outTemp'], data['outHumidity']) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_appTemp(key, data, db_manager=None): + if 'outTemp' not in data or 'outHumidity' not in data or 'windSpeed' not in data: + raise weewx.CannotCalculate(key) + if data['usUnits'] == weewx.US: + val = weewx.wxformulas.apptempF(data['outTemp'], data['outHumidity'], + data['windSpeed']) + u = 'degree_F' + else: + # The metric equivalent needs wind speed in mps. Convert. + windspeed_vt = weewx.units.as_value_tuple(data, 'windSpeed') + windspeed_mps = weewx.units.convert(windspeed_vt, 'meter_per_second')[0] + val = weewx.wxformulas.apptempC(data['outTemp'], data['outHumidity'], windspeed_mps) + u = 'degree_C' + return ValueTuple(val, u, 'group_temperature') + + @staticmethod + def calc_beaufort(key, data, db_manager=None): + global first_time + if first_time: + print("Type beaufort has been deprecated. Use unit beaufort instead.") + log.info("Type beaufort has been deprecated. Use unit beaufort instead.") + first_time = False + if 'windSpeed' not in data: + raise weewx.CannotCalculate + windspeed_vt = weewx.units.as_value_tuple(data, 'windSpeed') + windspeed_kn = weewx.units.convert(windspeed_vt, 'knot')[0] + return ValueTuple(weewx.wxformulas.beaufort(windspeed_kn), None, None) + + @staticmethod + def calc_windrun(key, data, db_manager=None): + """Calculate wind run. Requires key 'interval'""" + if 'windSpeed' not in data or 'interval' not in data: + raise weewx.CannotCalculate(key) + + if data['windSpeed'] is not None: + if data['usUnits'] == weewx.US: + val = data['windSpeed'] * data['interval'] / 60.0 + u = 'mile' + elif data['usUnits'] == weewx.METRIC: + val = data['windSpeed'] * data['interval'] / 60.0 + u = 'km' + elif data['usUnits'] == weewx.METRICWX: + val = data['windSpeed'] * data['interval'] * 60.0 / 1000.0 + u = 'km' + else: + raise weewx.ViolatedPrecondition("Unknown unit system %s" % data['usUnits']) + else: + val = None + u = 'mile' + return ValueTuple(val, u, 'group_distance') + + +# +# ########################### Class ETXType ################################## +# + +class ETXType(weewx.xtypes.XType): + """XType extension for calculating ET""" + + def __init__(self, altitude_vt, + latitude_f, longitude_f, + et_period=3600, + wind_height=2.0, + albedo=0.23, + cn=37, + cd=.34): + """ + + Args: + altitude_vt (ValueTuple): Altitude as a ValueTuple + latitude_f (float): Latitude in decimal degrees. + longitude_f (float): Longitude in decimal degrees. + et_period (float): Window of time for ET calculation in seconds. + wind_height (float):Height above ground at which the wind is measured in meters. + albedo (float): The albedo to use. + cn (float): The numerator constant for the reference crop type and time step. + cd (float): The denominator constant for the reference crop type and time step. + """ + self.altitude_vt = altitude_vt + self.latitude_f = latitude_f + self.longitude_f = longitude_f + self.et_period = et_period + self.wind_height = wind_height + self.albedo = albedo + self.cn = cn + self.cd = cd + + def get_scalar(self, obs_type, data, db_manager, **option_dict): + """Calculate ET as a scalar""" + if obs_type != 'ET': + raise weewx.UnknownType(obs_type) + + if 'interval' not in data: + # This will cause LOOP data not to be processed. + raise weewx.CannotCalculate(obs_type) + + interval = data['interval'] + end_ts = data['dateTime'] + start_ts = end_ts - self.et_period + try: + r = db_manager.getSql("SELECT MAX(outTemp), MIN(outTemp), " + "AVG(radiation), AVG(windSpeed), " + "MAX(outHumidity), MIN(outHumidity), " + "MAX(usUnits), MIN(usUnits) FROM %s " + "WHERE dateTime>? AND dateTime <=?" + % db_manager.table_name, (start_ts, end_ts)) + except weedb.DatabaseError: + return ValueTuple(None, None, None) + + # Make sure everything is there: + if r is None or None in r: + return ValueTuple(None, None, None) + + # Unpack the results + T_max, T_min, rad_avg, wind_avg, rh_max, rh_min, std_unit_min, std_unit_max = r + + # Check for mixed units + if std_unit_min != std_unit_max: + log.info("Mixed unit system not allowed in ET calculation. Skipped.") + return ValueTuple(None, None, None) + std_unit = std_unit_min + if std_unit == weewx.METRIC or std_unit == weewx.METRICWX: + T_max = CtoF(T_max) + T_min = CtoF(T_min) + if std_unit == weewx.METRICWX: + wind_avg = mps_to_mph(wind_avg) + else: + wind_avg = kph_to_mph(wind_avg) + # Wind height is in meters, so convert it: + height_ft = self.wind_height / METER_PER_FOOT + # Get altitude in feet + altitude_ft = weewx.units.convert(self.altitude_vt, 'foot')[0] + + try: + ET_rate = weewx.wxformulas.evapotranspiration_US( + T_min, T_max, rh_min, rh_max, rad_avg, wind_avg, height_ft, + self.latitude_f, self.longitude_f, altitude_ft, end_ts, + self.albedo, self.cn, self.cd) + except ValueError as e: + log.error("Calculation of evapotranspiration failed: %s", e) + weeutil.logger.log_traceback(log.error) + ET_inch = None + else: + # The formula returns inches/hour. We need the total ET over the interval, so multiply + # by the length of the interval in hours. Remember that 'interval' is actually in + # minutes. + ET_inch = ET_rate * interval / 60.0 if ET_rate is not None else None + + return ValueTuple(ET_inch, 'inch', 'group_rain') + + +# +# ######################## Class PressureCooker ############################## +# + +class PressureCooker(weewx.xtypes.XType): + """Pressure related extensions to the WeeWX type system. """ + + def __init__(self, altitude_vt, + max_delta_12h=1800, + altimeter_algorithm='aaASOS'): + + # Algorithms can be abbreviated without the prefix 'aa': + if not altimeter_algorithm.startswith('aa'): + altimeter_algorithm = 'aa%s' % altimeter_algorithm + + self.altitude_vt = altitude_vt + self.max_delta_12h = max_delta_12h + self.altimeter_algorithm = altimeter_algorithm + + # Timestamp (roughly) 12 hours ago + self.ts_12h = None + # Temperature 12 hours ago as a ValueTuple + self.temp_12h_vt = None + + def _get_temperature_12h(self, ts, dbmanager): + """Get the temperature as a ValueTuple from 12 hours ago. The ValueTuple will use the same + unit system as the database. The value will be None if no temperature is available. + """ + + ts_12h = ts - 12 * 3600 + + # Look up the temperature 12h ago if this is the first time through, + # or we don't have a usable temperature, or the old temperature is too stale. + if self.ts_12h is None \ + or self.temp_12h_vt is None \ + or abs(self.ts_12h - ts_12h) > self.max_delta_12h: + # Hit the database to get a newer temperature. + record = dbmanager.getRecord(ts_12h, max_delta=self.max_delta_12h) + if record and 'outTemp' in record: + # Figure out what unit the record is in ... + unit = weewx.units.getStandardUnitType(record['usUnits'], 'outTemp') + # ... then form a ValueTuple. + self.temp_12h_vt = weewx.units.ValueTuple(record['outTemp'], *unit) + else: + # Invalidate the temperature ValueTuple from 12h ago + self.temp_12h_vt = None + # Save the timestamp + self.ts_12h = ts_12h + + return self.temp_12h_vt + + def get_scalar(self, key, record, dbmanager, **option_dict): + if key == 'pressure': + return self.pressure(record, dbmanager) + elif key == 'altimeter': + return self.altimeter(record) + elif key == 'barometer': + return self.barometer(record) + else: + raise weewx.UnknownType(key) + + def pressure(self, record, dbmanager): + """Calculate the observation type 'pressure'.""" + + # All the following keys are required: + if any(key not in record for key in ['usUnits', 'outTemp', 'barometer', 'outHumidity']): + raise weewx.CannotCalculate('pressure') + + # Get the temperature in Fahrenheit from 12 hours ago + temp_12h_vt = self._get_temperature_12h(record['dateTime'], dbmanager) + if temp_12h_vt is None \ + or temp_12h_vt[0] is None \ + or record['outTemp'] is None \ + or record['barometer'] is None \ + or record['outHumidity'] is None: + pressure = None + else: + # The following requires everything to be in US Customary units. + # Rather than convert the whole record, just convert what we need: + record_US = weewx.units.to_US({'usUnits': record['usUnits'], + 'outTemp': record['outTemp'], + 'barometer': record['barometer'], + 'outHumidity': record['outHumidity']}) + # Get the altitude in feet + altitude_ft = weewx.units.convert(self.altitude_vt, "foot") + # The outside temperature in F. + temp_12h_F = weewx.units.convert(temp_12h_vt, "degree_F") + pressure = weewx.uwxutils.uWxUtilsVP.SeaLevelToSensorPressure_12( + record_US['barometer'], + altitude_ft[0], + record_US['outTemp'], + temp_12h_F[0], + record_US['outHumidity'] + ) + + return ValueTuple(pressure, 'inHg', 'group_pressure') + + def altimeter(self, record): + """Calculate the observation type 'altimeter'.""" + if 'pressure' not in record: + raise weewx.CannotCalculate('altimeter') + + # Convert altitude to same unit system of the incoming record + altitude = weewx.units.convertStd(self.altitude_vt, record['usUnits']) + + # Figure out which altimeter formula to use, and what unit the results will be in: + if record['usUnits'] == weewx.US: + formula = weewx.wxformulas.altimeter_pressure_US + u = 'inHg' + else: + formula = weewx.wxformulas.altimeter_pressure_Metric + u = 'mbar' + # Apply the formula + altimeter = formula(record['pressure'], altitude[0], self.altimeter_algorithm) + + return ValueTuple(altimeter, u, 'group_pressure') + + def barometer(self, record): + """Calculate the observation type 'barometer'""" + + if 'pressure' not in record or 'outTemp' not in record: + raise weewx.CannotCalculate('barometer') + + # Convert altitude to same unit system of the incoming record + altitude = weewx.units.convertStd(self.altitude_vt, record['usUnits']) + + # Figure out what barometer formula to use: + if record['usUnits'] == weewx.US: + formula = weewx.wxformulas.sealevel_pressure_US + u = 'inHg' + else: + formula = weewx.wxformulas.sealevel_pressure_Metric + u = 'mbar' + # Apply the formula + barometer = formula(record['pressure'], altitude[0], record['outTemp']) + + return ValueTuple(barometer, u, 'group_pressure') + + +# +# ######################## Class RainRater ############################## +# + +class RainRater(weewx.xtypes.XType): + + def __init__(self, rain_period=900, retain_period=930): + + self.rain_period = rain_period + self.retain_period = retain_period + # This will be a list of two-way tuples (timestamp, rain) + self.rain_events = [] + self.unit_system = None + self.augmented = False + self.run_lock = threading.Lock() + + def add_loop_packet(self, packet): + """Process LOOP packets, adding them to the list of recent rain events.""" + with self.run_lock: + self._add_loop_packet(packet) + + def _add_loop_packet(self, packet): + # Was there any rain? If so, convert the rain to the unit system we are using, + # then intern it + if 'rain' in packet and packet['rain']: + if self.unit_system is None: + # Adopt the unit system of the first record. + self.unit_system = packet['usUnits'] + # Get the unit system and group of the incoming rain. In theory, this should be + # the same as self.unit_system, but ... + u, g = weewx.units.getStandardUnitType(packet['usUnits'], 'rain') + # Convert to the unit system that we are using + rain = weewx.units.convertStd((packet['rain'], u, g), self.unit_system)[0] + # Add it to the list of rain events + self.rain_events.append((packet['dateTime'], rain)) + + # Trim any old packets: + self.rain_events = [x for x in self.rain_events + if x[0] >= packet['dateTime'] - self.rain_period] + + def get_scalar(self, key, record, db_manager, **option_dict): + """Calculate the rainRate""" + if key != 'rainRate': + raise weewx.UnknownType(key) + + with self.run_lock: + # First time through, augment the event queue from the database + if not self.augmented: + self._setup(record['dateTime'], db_manager) + self.augmented = True + + # Sum the rain events within the time window... + rainsum = sum(x[1] for x in self.rain_events + if x[0] > record['dateTime'] - self.rain_period) + # ...then divide by the period and scale to an hour + val = 3600 * rainsum / self.rain_period + # Get the unit and unit group for rainRate + u, g = weewx.units.getStandardUnitType(self.unit_system, 'rainRate') + return ValueTuple(val, u, g) + + def _setup(self, stop_ts, db_manager): + """Initialize the rain event list""" + + # Beginning of the window + start_ts = stop_ts - self.retain_period + + # Query the database for only the events before what we already have + if self.rain_events: + first_event = min(x[0] for x in self.rain_events) + stop_ts = min(first_event, stop_ts) + + # Get all rain events since the window start from the database. Put it in + # a 'try' block because the database may not have a 'rain' field. + try: + for row in db_manager.genSql("SELECT dateTime, usUnits, rain FROM %s " + "WHERE dateTime>? AND dateTime<=?;" + % db_manager.table_name, (start_ts, stop_ts)): + # Unpack the row: + time_ts, unit_system, rain = row + # Skip the row if we already have it in rain_events + if not any(x[0] == time_ts for x in self.rain_events): + self._add_loop_packet({'dateTime': time_ts, + 'usUnits': unit_system, + 'rain': rain}) + except weedb.DatabaseError as e: + log.debug("Database error while initializing rainRate: '%s'" % e) + + # It's not strictly necessary to sort the rain event list for things to work, but it + # makes things easier to debug + self.rain_events.sort(key=lambda x: x[0]) + + +# +# ######################## Class Delta ############################## +# + +class Delta(weewx.xtypes.XType): + """Derived types that are the difference between two adjacent measurements. + + For example, this is useful for calculating observation type 'rain' from a daily total, + such as 'dayRain'. In this case, the configuration would look like: + + [StdWXCalculate] + [[Calculations]] + ... + rain = prefer_hardware + ... + [[Delta]] + [[[rain]]] + input = totalRain + """ + + def __init__(self, delta_config={}): + # The dictionary 'totals' will hold two-way lists. The first element of the list is the key + # to be used for the cumulative value. The second element holds the previous total (None + # to start). The result will be something like + # {'rain' : ['totalRain', None]} + self.totals = {k: [delta_config[k]['input'], None] for k in delta_config} + + def get_scalar(self, key, record, db_manager, **option_dict): + # See if we know how to handle this type + if key not in self.totals: + raise weewx.UnknownType(key) + + # Get the key of the type to be used for the cumulative total. This is + # something like 'totalRain': + total_key = self.totals[key][0] + if total_key not in record: + raise weewx.CannotCalculate(key) + # Calculate the delta + delta = weewx.wxformulas.calculate_delta(record[total_key], + self.totals[key][1], + total_key) + # Save the new total + self.totals[key][1] = record[total_key] + + # Get the unit and group of the key. This will be the same as for the result + unit_and_group = weewx.units.getStandardUnitType(record['usUnits'], key) + # ... then form and return the ValueTuple. + return ValueTuple(delta, *unit_and_group) + + +# +# ########## Services that instantiate the above XTypes extensions ########## +# + +class StdWXXTypes(weewx.engine.StdService): + """Instantiate and register the xtype extension WXXTypes.""" + + def __init__(self, engine, config_dict): + """Initialize an instance of StdWXXTypes""" + super().__init__(engine, config_dict) + + altitude_vt = engine.stn_info.altitude_vt + latitude_f = engine.stn_info.latitude_f + longitude_f = engine.stn_info.longitude_f + + # These options were never documented. They have moved. Fail hard if they are present. + if 'StdWXCalculate' in config_dict \ + and any(key in config_dict['StdWXCalculate'] + for key in ['rain_period', 'et_period', 'wind_height', + 'atc', 'nfac', 'max_delta_12h']): + raise ValueError("Undocumented options for [StdWXCalculate] have moved. " + "See User's Guide.") + + # Get any user-defined overrides + try: + override_dict = config_dict['StdWXCalculate']['WXXTypes'] + except KeyError: + override_dict = {} + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['WXXTypes']) + option_dict.merge(override_dict) + + # Get force_null from the option dictionary + force_null = to_bool(option_dict['windDir'].get('force_null', True)) + + # Option ignore_zero_wind has also moved, but we will support it in a backwards-compatible + # way, provided that it doesn't conflict with any setting of force_null. + try: + # Is there a value for ignore_zero_wind as well? + ignore_zero_wind = to_bool(config_dict['StdWXCalculate']['ignore_zero_wind']) + except KeyError: + # No. We're done + pass + else: + # No exception, so there must be a value for ignore_zero_wind. + # Is there an explicit value for 'force_null'? That is, a default was not used? + if 'force_null' in override_dict: + # Yes. Make sure they match + if ignore_zero_wind != to_bool(override_dict['force_null']): + raise ValueError("Conflicting values for " + "ignore_zero_wind (%s) and force_null (%s)" + % (ignore_zero_wind, force_null)) + else: + # No explicit value for 'force_null'. Use 'ignore_zero_wind' in its place + force_null = ignore_zero_wind + + # maxSolarRad-related options + maxSolarRad_algo = option_dict['maxSolarRad'].get('algorithm', 'rs').lower() + # atmospheric transmission coefficient [0.7-0.91] + atc = to_float(option_dict['maxSolarRad'].get('atc', 0.8)) + # atmospheric turbidity (2=clear, 4-5=smoggy) + nfac = to_float(option_dict['maxSolarRad'].get('nfac', 2)) + # heatindex-related options + heatindex_algo = option_dict['heatindex'].get('algorithm', 'new').lower() + + # Instantiate an instance of WXXTypes and register it with the XTypes system + self.wxxtypes = WXXTypes(altitude_vt, latitude_f, longitude_f, + atc=atc, + nfac=nfac, + force_null=force_null, + maxSolarRad_algo=maxSolarRad_algo, + heatindex_algo=heatindex_algo) + weewx.xtypes.xtypes.append(self.wxxtypes) + + # ET-related options + # height above ground at which wind is measured, in meters + wind_height = to_float(weeutil.config.search_up(option_dict['ET'], 'wind_height', 2.0)) + # window of time for evapotranspiration calculation, in seconds + et_period = to_int(option_dict['ET'].get('et_period', 3600)) + # The albedo to use + albedo = to_float(option_dict['ET'].get('albedo', 0.23)) + # The numerator constant for the reference crop type and time step. + cn = to_float(option_dict['ET'].get('cn', 37)) + # The denominator constant for the reference crop type and time step. + cd = to_float(option_dict['ET'].get('cd', 0.34)) + + # Instantiate an instance of ETXType and register it with the XTypes system + self.etxtype = ETXType(altitude_vt, + latitude_f, longitude_f, + et_period=et_period, + wind_height=wind_height, + albedo=albedo, + cn=cn, + cd=cd) + weewx.xtypes.xtypes.append(self.etxtype) + + + def shutDown(self): + """Engine shutting down. """ + # Remove from the XTypes system: + weewx.xtypes.xtypes.remove(self.etxtype) + weewx.xtypes.xtypes.remove(self.wxxtypes) + + +class StdPressureCooker(weewx.engine.StdService): + """Instantiate and register the XTypes extension PressureCooker""" + + def __init__(self, engine, config_dict): + """Initialize the PressureCooker. """ + super().__init__(engine, config_dict) + + try: + override_dict = config_dict['StdWXCalculate']['PressureCooker'] + except KeyError: + override_dict = {} + + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['PressureCooker']) + option_dict.merge(override_dict) + + max_delta_12h = to_float(option_dict.get('max_delta_12h', 1800)) + altimeter_algorithm = option_dict['altimeter'].get('algorithm', 'aaASOS') + + self.pressure_cooker = PressureCooker(engine.stn_info.altitude_vt, + max_delta_12h, + altimeter_algorithm) + + # Add pressure_cooker to the XTypes system + weewx.xtypes.xtypes.append(self.pressure_cooker) + + def shutDown(self): + """Engine shutting down. """ + weewx.xtypes.xtypes.remove(self.pressure_cooker) + + +class StdRainRater(weewx.engine.StdService): + """"Instantiate and register the XTypes extension RainRater.""" + + def __init__(self, engine, config_dict): + """Initialize the RainRater.""" + super().__init__(engine, config_dict) + + # Get any user-defined overrides + try: + override_dict = config_dict['StdWXCalculate']['RainRater'] + except KeyError: + override_dict = {} + + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['RainRater']) + option_dict.merge(override_dict) + + rain_period = to_int(option_dict.get('rain_period', 900)) + retain_period = to_int(option_dict.get('retain_period', 930)) + + self.rain_rater = RainRater(rain_period, retain_period) + # Add to the XTypes system + weewx.xtypes.xtypes.append(self.rain_rater) + + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + + def shutDown(self): + """Engine shutting down. """ + # Remove from the XTypes system: + weewx.xtypes.xtypes.remove(self.rain_rater) + + def new_loop_packet(self, event): + self.rain_rater.add_loop_packet(event.packet) + + +class StdDelta(weewx.engine.StdService): + """Instantiate and register the XTypes extension Delta.""" + + def __init__(self, engine, config_dict): + super().__init__(engine, config_dict) + + # Get any user-defined overrides + try: + override_dict = config_dict['StdWXCalculate']['Delta'] + except KeyError: + override_dict = {} + + # Get the default values, then merge the user overrides into it + option_dict = weeutil.config.deep_copy(defaults_dict['StdWXCalculate']['Delta']) + option_dict.merge(override_dict) + + self.delta = Delta(option_dict) + weewx.xtypes.xtypes.append(self.delta) + + def shutDown(self): + weewx.xtypes.xtypes.remove(self.delta) diff --git a/dist/weewx-5.0.2/src/weewx/xtypes.py b/dist/weewx-5.0.2/src/weewx/xtypes.py new file mode 100644 index 0000000..03577b1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx/xtypes.py @@ -0,0 +1,1237 @@ +# +# Copyright (c) 2019-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""User-defined extensions to the WeeWX type system""" + +import datetime +import time +import math + +import weedb +import weeutil.weeutil +import weewx +import weewx.units +import weewx.wxformulas +from weeutil.weeutil import isStartOfDay, to_float +from weewx.units import ValueTuple + +# A list holding the type extensions. Each entry should be a subclass of XType, defined below. +xtypes = [] + + +class XType(object): + """Base class for extensions to the WeeWX type system.""" + + def get_scalar(self, obs_type, record, db_manager=None, **option_dict): + """Calculate a scalar. + + Args: + obs_type (str): The name of the XType + record (dict): The current record. + db_manager(weewx.manager.Manager|None): An open database manager + option_dict(dict): A dictionary containing optional values + + Returns: + ValueTuple: The value of the xtype as a ValueTuple + + Raises: + weewx.UnknownType: If the type `obs_type` is unknown to the function. + weewx.CannotCalculate: If the type is known to the function, but all the information + necessary to calculate the type is not there. + """ + raise weewx.UnknownType + + def get_series(self, obs_type, timespan, db_manager, aggregate_type=None, + aggregate_interval=None, **option_dict): + """Calculate a series, possibly with aggregation. Specializing versions should raise... + + - an exception of type `weewx.UnknownType`, if the type `obs_type` is unknown to the + function. + - an exception of type `weewx.CannotCalculate` if the type is known to the function, but + all the information necessary to calculate the series is not there. + """ + raise weewx.UnknownType + + def get_aggregate(self, obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Calculate an aggregation. Specializing versions should raise... + + - an exception of type `weewx.UnknownType`, if the type `obs_type` is unknown to the + function. + - an exception of type `weewx.UnknownAggregation` if the aggregation type `aggregate_type` + is unknown to the function. + - an exception of type `weewx.CannotCalculate` if the type is known to the function, but + all the information necessary to calculate the type is not there. + """ + raise weewx.UnknownAggregation + + def shut_down(self): + """Opportunity to do any clean up.""" + pass + + +# ##################### Retrieval functions ########################### + +def get_scalar(obs_type, record, db_manager=None, **option_dict): + """Return a scalar value""" + + # Search the list, looking for a get_scalar() method that does not raise an UnknownType + # exception + for xtype in xtypes: + try: + # Try this function. Be prepared to catch the TypeError exception if it is a legacy + # style XType that does not accept kwargs. + try: + return xtype.get_scalar(obs_type, record, db_manager, **option_dict) + except TypeError: + # We likely have a legacy style XType, so try calling it again, but this time + # without the kwargs. + return xtype.get_scalar(obs_type, record, db_manager) + except weewx.UnknownType: + # This function does not know about the type. Move on to the next one. + pass + # None of the functions worked. + raise weewx.UnknownType(obs_type) + + +def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Return a series (aka vector) of, possibly aggregated, values.""" + + # Search the list, looking for a get_series() method that does not raise an UnknownType or + # UnknownAggregation exception + for xtype in xtypes: + try: + # Try this function. Be prepared to catch the TypeError exception if it is a legacy + # style XType that does not accept kwargs. + try: + return xtype.get_series(obs_type, timespan, db_manager, aggregate_type, + aggregate_interval, **option_dict) + except TypeError: + # We likely have a legacy style XType, so try calling it again, but this time + # without the kwargs. + return xtype.get_series(obs_type, timespan, db_manager, aggregate_type, + aggregate_interval) + except (weewx.UnknownType, weewx.UnknownAggregation): + # This function does not know about the type and/or aggregation. + # Move on to the next one. + pass + # None of the functions worked. Raise an exception with a hopefully helpful error message. + if aggregate_type: + msg = "'%s' or '%s'" % (obs_type, aggregate_type) + else: + msg = obs_type + raise weewx.UnknownType(msg) + + +def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Calculate an aggregation over a timespan""" + # Search the list, looking for a get_aggregate() method that does not raise an + # UnknownAggregation exception + for xtype in xtypes: + try: + # Try this function. It will raise an exception if it doesn't know about the type of + # aggregation. + return xtype.get_aggregate(obs_type, timespan, aggregate_type, db_manager, + **option_dict) + except (weewx.UnknownType, weewx.UnknownAggregation): + pass + raise weewx.UnknownAggregation("%s('%s')" % (aggregate_type, obs_type)) + + +def has_data(obs_type, timespan, db_manager): + """Search the list, looking for a version that has data. + Args: + obs_type(str): The name of a potential xtype + timespan(tuple[float, float]): A two-way tuple (start time, stop time) + db_manager(weewx.manager.Manager|None): An open database manager + Returns: + bool: True if there is non-null xtype data in the timespan. False otherwise. + """ + for xtype in xtypes: + try: + # Try this function. It will raise an exception if it doesn't know about the type of + # aggregation. + vt = xtype.get_aggregate(obs_type, timespan, 'not_null', db_manager) + # Check to see if we found a non-null value. Otherwise, keep going. + if vt[0]: + return True + except (weewx.UnknownType, weewx.UnknownAggregation): + pass + # Tried all the get_aggregates() and didn't find a non-null value. Either it doesn't exist, + # or doesn't have any data + return False + + # try: + # vt = get_aggregate(obs_type, timespan, 'not_null', db_manager) + # return bool(vt[0]) + # except (weewx.UnknownAggregation, weewx.UnknownType): + # return False + + +# +# ######################## Class ArchiveTable ############################## +# + +class ArchiveTable(XType): + """Calculate types and aggregates directly from the archive table""" + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Get a series, possibly with aggregation, from the main archive database. + + The general strategy is that if aggregation is asked for, chop the series up into separate + chunks, calculating the aggregate for each chunk. Then assemble the results. + + If no aggregation is called for, just return the data directly out of the database. + """ + + startstamp, stopstamp = timespan + start_vec = list() + stop_vec = list() + data_vec = list() + + if aggregate_type: + # Return a series with aggregation + unit, unit_group = None, None + + if aggregate_type == 'cumulative': + do_aggregate = 'sum' + total = 0 + else: + do_aggregate = aggregate_type + + for stamp in weeutil.weeutil.intervalgen(startstamp, stopstamp, aggregate_interval): + if db_manager.first_timestamp is None or stamp.stop <= db_manager.first_timestamp: + continue + if db_manager.last_timestamp is None or stamp.start >= db_manager.last_timestamp: + break + try: + # Get the aggregate as a ValueTuple + agg_vt = get_aggregate(obs_type, stamp, do_aggregate, db_manager, + **option_dict) + except weewx.CannotCalculate: + agg_vt = ValueTuple(None, unit, unit_group) + if unit: + # Make sure units are consistent so far. + if agg_vt[1] is not None and (unit != agg_vt[1] or unit_group != agg_vt[2]): + raise weewx.UnsupportedFeature("Cannot change units within a series.") + else: + unit, unit_group = agg_vt[1], agg_vt[2] + start_vec.append(stamp.start) + stop_vec.append(stamp.stop) + if aggregate_type == 'cumulative': + if agg_vt[0] is not None: + total += agg_vt[0] + data_vec.append(total) + else: + data_vec.append(agg_vt[0]) + + else: + + # No aggregation + sql_str = "SELECT dateTime, %s, usUnits, `interval` FROM %s " \ + "WHERE dateTime > ? AND dateTime <= ?" % (obs_type, db_manager.table_name) + + std_unit_system = None + + # Hit the database. It's possible the type is not in the database, so be prepared + # to catch a NoColumnError: + try: + for record in db_manager.genSql(sql_str, (startstamp, stopstamp)): + + # Unpack the record + timestamp, value, unit_system, interval = record + + if std_unit_system: + if std_unit_system != unit_system: + raise weewx.UnsupportedFeature("Unit type cannot change " + "within an aggregation interval.") + else: + std_unit_system = unit_system + start_vec.append(timestamp - interval * 60) + stop_vec.append(timestamp) + data_vec.append(value) + except weedb.NoColumnError: + # The sql type doesn't exist. Convert to an UnknownType error + raise weewx.UnknownType(obs_type) + + unit, unit_group = weewx.units.getStandardUnitType(std_unit_system, obs_type, + aggregate_type) + + return (ValueTuple(start_vec, 'unix_epoch', 'group_time'), + ValueTuple(stop_vec, 'unix_epoch', 'group_time'), + ValueTuple(data_vec, unit, unit_group)) + + # Set of SQL statements to be used for calculating aggregates from the main archive table. + agg_sql_dict = { + 'diff': "SELECT (b.%(sql_type)s - a.%(sql_type)s) FROM archive a, archive b " + "WHERE b.dateTime = (SELECT MAX(dateTime) FROM archive " + "WHERE dateTime <= %(stop)s) " + "AND a.dateTime = (SELECT MIN(dateTime) FROM archive " + "WHERE dateTime >= %(start)s);", + 'first': "SELECT %(sql_type)s FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY dateTime ASC LIMIT 1", + 'firsttime': "SELECT MIN(dateTime) FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL", + 'last': "SELECT %(sql_type)s FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY dateTime DESC LIMIT 1", + 'lasttime': "SELECT MAX(dateTime) FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL", + 'maxtime': "SELECT dateTime FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY %(sql_type)s DESC LIMIT 1", + 'mintime': "SELECT dateTime FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL ORDER BY %(sql_type)s ASC LIMIT 1", + 'not_null': "SELECT 1 FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(sql_type)s IS NOT NULL LIMIT 1", + 'tderiv': "SELECT (b.%(sql_type)s - a.%(sql_type)s) / (b.dateTime-a.dateTime) " + "FROM archive a, archive b " + "WHERE b.dateTime = (SELECT MAX(dateTime) FROM archive " + "WHERE dateTime <= %(stop)s) " + "AND a.dateTime = (SELECT MIN(dateTime) FROM archive " + "WHERE dateTime >= %(start)s);", + 'gustdir': "SELECT windGustDir FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "ORDER BY windGust DESC limit 1", + # Aggregations 'vecdir' and 'vecavg' require built-in math functions, + # which were introduced in sqlite v3.35.0, 12-Mar-2021. If they don't exist, then + # weewx will raise an exception of type "weedb.OperationalError". + 'vecdir': "SELECT SUM(`interval` * windSpeed * COS(RADIANS(90 - windDir))), " + " SUM(`interval` * windSpeed * SIN(RADIANS(90 - windDir))) " + "FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s ", + 'vecavg': "SELECT SUM(`interval` * windSpeed * COS(RADIANS(90 - windDir))), " + " SUM(`interval` * windSpeed * SIN(RADIANS(90 - windDir))), " + " SUM(`interval`) " + "FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND windSpeed is not null" + } + + valid_aggregate_types = set(['sum', 'count', 'avg', 'max', 'min']).union(agg_sql_dict.keys()) + + simple_agg_sql = "SELECT %(aggregate_type)s(%(sql_type)s) FROM %(table_name)s " \ + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " \ + "AND %(sql_type)s IS NOT NULL" + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns an aggregation of an observation type over a given time period, using the + main archive table. + + Args: + obs_type (str): The type over which aggregation is to be done (e.g., 'barometer', + 'outTemp', 'rain', ...) + timespan (weeutil.weeutil.TimeSpan): An instance of weeutil.Timespan with the time + period over which aggregation is to be done. + aggregate_type (str): The type of aggregation to be done. + db_manager (weewx.manager.Manager): An instance of weewx.manager.Manager or subclass. + option_dict (dict): Not used in this version. + + Returns: + ValueTuple: A ValueTuple containing the result. + """ + + if aggregate_type not in ArchiveTable.valid_aggregate_types: + raise weewx.UnknownAggregation(aggregate_type) + + # For older versions of sqlite, we need to do these calculations the hard way: + if obs_type == 'wind' \ + and aggregate_type in ('vecdir', 'vecavg') \ + and not db_manager.connection.has_math: + return ArchiveTable.get_wind_aggregate_long(obs_type, + timespan, + aggregate_type, + db_manager) + + if obs_type == 'wind': + sql_type = 'windGust' if aggregate_type in ('max', 'maxtime') else 'windSpeed' + else: + sql_type = obs_type + + interpolate_dict = { + 'aggregate_type': aggregate_type, + 'sql_type': sql_type, + 'table_name': db_manager.table_name, + 'start': timespan.start, + 'stop': timespan.stop + } + + select_stmt = ArchiveTable.agg_sql_dict.get(aggregate_type, + ArchiveTable.simple_agg_sql) % interpolate_dict + + try: + row = db_manager.getSql(select_stmt) + except weedb.NoColumnError: + raise weewx.UnknownType(aggregate_type) + + if aggregate_type == 'not_null': + value = row is not None + elif aggregate_type == 'vecdir': + if None in row or row == (0.0, 0.0): + value = None + else: + deg = 90.0 - math.degrees(math.atan2(row[1], row[0])) + value = deg if deg >= 0 else deg + 360.0 + elif aggregate_type == 'vecavg': + value = math.sqrt((row[0] ** 2 + row[1] ** 2) / row[2] ** 2) if row[2] else None + else: + value = row[0] if row else None + + # Look up the unit type and group of this combination of observation type and aggregation: + u, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + + # Time derivatives have special rules. For example, the time derivative of watt-hours is + # watts, scaled by the number of seconds in an hour. The unit group also changes to + # group_power. + if aggregate_type == 'tderiv': + if u == 'watt_second': + u = 'watt' + elif u == 'watt_hour': + u = 'watt' + value *= 3600 + elif u == 'kilowatt_hour': + u = 'kilowatt' + value *= 3600 + g = 'group_power' + + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, u, g) + + @staticmethod + def get_wind_aggregate_long(obs_type, timespan, aggregate_type, db_manager): + """Calculate the math algorithm for vecdir and vecavg in Python. Suitable for + versions of sqlite that do not have math functions.""" + + # This should never happen: + if aggregate_type not in ['vecdir', 'vecavg']: + raise weewx.UnknownAggregation(aggregate_type) + + # Nor this: + if obs_type != 'wind': + raise weewx.UnknownType(obs_type) + + sql_stmt = "SELECT `interval`, windSpeed, windDir " \ + "FROM %(table_name)s " \ + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s;" \ + % { + 'table_name': db_manager.table_name, + 'start': timespan.start, + 'stop': timespan.stop + } + xsum = 0.0 + ysum = 0.0 + sumtime = 0.0 + for row in db_manager.genSql(sql_stmt): + if row[1] is not None: + sumtime += row[0] + if row[2] is not None: + xsum += row[0] * row[1] * math.cos(math.radians(90.0 - row[2])) + ysum += row[0] * row[1] * math.sin(math.radians(90.0 - row[2])) + + if not sumtime or (xsum == 0.0 and ysum == 0.0): + value = None + elif aggregate_type == 'vecdir': + deg = 90.0 - math.degrees((math.atan2(ysum, xsum))) + value = deg if deg >= 0 else deg + 360.0 + else: + assert aggregate_type == 'vecavg' + value = math.sqrt((xsum ** 2 + ysum ** 2) / sumtime ** 2) + + # Look up the unit type and group of this combination of observation type and aggregation: + u, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, u, g) + + +# +# ######################## Class DailySummaries ############################## +# + +class DailySummaries(XType): + """Calculate from the daily summaries.""" + + # Set of SQL statements to be used for calculating simple aggregates from the daily summaries. + agg_sql_dict = { + 'avg': "SELECT SUM(wsum),SUM(sumtime) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'avg_ge': "SELECT SUM((wsum/sumtime) >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s and sumtime <> 0", + 'avg_le': "SELECT SUM((wsum/sumtime) <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s and sumtime <> 0", + 'count': "SELECT SUM(count) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'gustdir': "SELECT max_dir FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "ORDER BY max DESC, maxtime ASC LIMIT 1", + 'max': "SELECT MAX(max) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'max_ge': "SELECT SUM(max >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'max_le': "SELECT SUM(max <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'maxmin': "SELECT MAX(min) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'maxmintime': "SELECT mintime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND mintime IS NOT NULL " + "ORDER BY min DESC, mintime ASC LIMIT 1", + 'maxsum': "SELECT MAX(sum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'maxsumtime': "SELECT dateTime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "ORDER BY sum DESC, dateTime ASC LIMIT 1", + 'maxtime': "SELECT maxtime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND maxtime IS NOT NULL " + "ORDER BY max DESC, maxtime ASC LIMIT 1", + 'meanmax': "SELECT AVG(max) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'meanmin': "SELECT AVG(min) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'min': "SELECT MIN(min) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'min_ge': "SELECT SUM(min >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'min_le': "SELECT SUM(min <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'minmax': "SELECT MIN(max) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'minmaxtime': "SELECT maxtime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND maxtime IS NOT NULL " + "ORDER BY max ASC, maxtime ASC ", + 'minsum': "SELECT MIN(sum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'minsumtime': "SELECT dateTime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "ORDER BY sum ASC, dateTime ASC LIMIT 1", + 'mintime': "SELECT mintime FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s " + "AND mintime IS NOT NULL " + "ORDER BY min ASC, mintime ASC LIMIT 1", + 'not_null': "SELECT count>0 as c FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s ORDER BY c DESC LIMIT 1", + 'rms': "SELECT SUM(wsquaresum),SUM(sumtime) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'sum': "SELECT SUM(sum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'sum_ge': "SELECT SUM(sum >= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'sum_le': "SELECT SUM(sum <= %(val)s) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'vecavg': "SELECT SUM(xsum),SUM(ysum),SUM(sumtime) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + 'vecdir': "SELECT SUM(xsum),SUM(ysum) FROM %(table_name)s_day_%(obs_key)s " + "WHERE dateTime >= %(start)s AND dateTime < %(stop)s", + } + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns an aggregation of a statistical type for a given time period, + by using the daily summaries. + + obs_type: The type over which aggregation is to be done (e.g., 'barometer', + 'outTemp', 'rain', ...) + + timespan: An instance of weeutil.Timespan with the time period over which + aggregation is to be done. + + aggregate_type: The type of aggregation to be done. + + db_manager: An instance of weewx.manager.Manager or subclass. + + option_dict: Not used in this version. + + returns: A ValueTuple containing the result.""" + + # We cannot use the daily summaries if there is no aggregation + if not aggregate_type: + raise weewx.UnknownAggregation(aggregate_type) + + aggregate_type = aggregate_type.lower() + + # Raise exception if we don't know about this type of aggregation + if aggregate_type not in DailySummaries.agg_sql_dict: + raise weewx.UnknownAggregation(aggregate_type) + + # Check to see whether we can use the daily summaries: + DailySummaries.check_eligibility(obs_type, timespan, db_manager, aggregate_type) + + val = option_dict.get('val') + if val is None: + target_val = None + else: + # The following is for backwards compatibility when ValueTuples had + # just two members. This hack avoids breaking old skins. + if len(val) == 2: + if val[1] in ['degree_F', 'degree_C']: + val += ("group_temperature",) + elif val[1] in ['inch', 'mm', 'cm']: + val += ("group_rain",) + # Make sure the first element is a float (and not a string). + val = ValueTuple(to_float(val[0]), val[1], val[2]) + target_val = weewx.units.convertStd(val, db_manager.std_unit_system)[0] + + # Form the interpolation dictionary + inter_dict = { + 'start': weeutil.weeutil.startOfDay(timespan.start), + 'stop': timespan.stop, + 'obs_key': obs_type, + 'aggregate_type': aggregate_type, + 'val': target_val, + 'table_name': db_manager.table_name + } + + # Run the query against the database: + row = db_manager.getSql(DailySummaries.agg_sql_dict[aggregate_type] % inter_dict) + + # Each aggregation type requires a slightly different calculation. + if not row or None in row: + # If no row was returned, or if it contains any nulls (meaning that not + # all required data was available to calculate the requested aggregate), + # then set the resulting value to None. + value = None + + elif aggregate_type in {'min', 'maxmin', 'max', 'minmax', 'meanmin', 'meanmax', + 'maxsum', 'minsum', 'sum', 'gustdir'}: + # These aggregates are passed through 'as is'. + value = row[0] + + elif aggregate_type in {'mintime', 'maxmintime', 'maxtime', 'minmaxtime', 'maxsumtime', + 'minsumtime', 'count', 'max_ge', 'max_le', 'min_ge', 'min_le', + 'not_null', 'sum_ge', 'sum_le', 'avg_ge', 'avg_le'}: + # These aggregates are always integers: + value = int(row[0]) + + elif aggregate_type == 'avg': + value = row[0] / row[1] if row[1] else None + + elif aggregate_type == 'rms': + value = math.sqrt(row[0] / row[1]) if row[1] else None + + elif aggregate_type == 'vecavg': + value = math.sqrt((row[0] ** 2 + row[1] ** 2) / row[2] ** 2) if row[2] else None + + elif aggregate_type == 'vecdir': + if row == (0.0, 0.0): + value = None + else: + deg = 90.0 - math.degrees(math.atan2(row[1], row[0])) + value = deg if deg >= 0 else deg + 360.0 + else: + # Unknown aggregation. Should not have gotten this far... + raise ValueError("Unexpected error. Aggregate type '%s'" % aggregate_type) + + # Look up the unit type and group of this combination of observation type and aggregation: + t, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, t, g) + + # These are SQL statements used for calculating series from the daily summaries. + # They include "group_def", which will be replaced with a database-specific GROUP BY clause + common = { + 'min': "SELECT MIN(dateTime), MAX(dateTime), MIN(min) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'max': "SELECT MIN(dateTime), MAX(dateTime), MAX(max) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'avg': "SELECT MIN(dateTime), MAX(dateTime), SUM(wsum), SUM(sumtime) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'sum': "SELECT MIN(dateTime), MAX(dateTime), SUM(sum) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + 'count': "SELECT MIN(dateTime), MAX(dateTime), SUM(count) " + "FROM %(day_table)s " + "WHERE dateTime>=%(start)s AND dateTime<%(stop)s %(group_def)s", + } + # Database- and interval-specific "GROUP BY" clauses. + group_defs = { + 'sqlite': { + 'day': "GROUP BY CAST(" + " (julianday(dateTime,'unixepoch','localtime') - 0.5 " + " - CAST(julianday(%(sod)s, 'unixepoch','localtime') AS int)) " + " / %(agg_days)s " + "AS int)", + 'month': "GROUP BY strftime('%%Y-%%m',dateTime,'unixepoch','localtime') ", + 'year': "GROUP BY strftime('%%Y',dateTime,'unixepoch','localtime') ", + }, + 'mysql': { + 'day': "GROUP BY TRUNCATE((TO_DAYS(FROM_UNIXTIME(dateTime)) " + "- TO_DAYS(FROM_UNIXTIME(%(sod)s)))/ %(agg_days)s, 0) ", + 'month': "GROUP BY DATE_FORMAT(FROM_UNIXTIME(dateTime), '%%%%Y-%%%%m') ", + 'year': "GROUP BY DATE_FORMAT(FROM_UNIXTIME(dateTime), '%%%%Y') ", + }, + } + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + + # We cannot use the daily summaries if there is no aggregation + if not aggregate_type: + raise weewx.UnknownAggregation(aggregate_type) + + aggregate_type = aggregate_type.lower() + + # Raise exception if we don't know about this type of aggregation + if aggregate_type not in DailySummaries.common: + raise weewx.UnknownAggregation(aggregate_type) + + # Check to see whether we can use the daily summaries: + DailySummaries.check_eligibility(obs_type, timespan, db_manager, aggregate_type) + + # We also have to make sure the aggregation interval is either the length of a nominal + # month or year, or some multiple of a calendar day. + aggregate_interval = weeutil.weeutil.nominal_spans(aggregate_interval) + if aggregate_interval != weeutil.weeutil.nominal_intervals['year'] \ + and aggregate_interval != weeutil.weeutil.nominal_intervals['month'] \ + and aggregate_interval % 86400: + raise weewx.UnknownAggregation(aggregate_interval) + + # We're good. Proceed. + dbtype = db_manager.connection.dbtype + interp_dict = { + 'agg_days': aggregate_interval / 86400, + 'day_table': "%s_day_%s" % (db_manager.table_name, obs_type), + 'obs_type': obs_type, + 'sod': weeutil.weeutil.startOfDay(timespan.start), + 'start': timespan.start, + 'stop': timespan.stop, + } + if aggregate_interval == weeutil.weeutil.nominal_intervals['year']: + group_by_group = 'year' + elif aggregate_interval == weeutil.weeutil.nominal_intervals['month']: + group_by_group = 'month' + else: + group_by_group = 'day' + # Add the database-specific GROUP_BY clause to the interpolation dictionary + interp_dict['group_def'] = DailySummaries.group_defs[dbtype][group_by_group] % interp_dict + # This is the final SELECT statement. + sql_stmt = DailySummaries.common[aggregate_type] % interp_dict + + start_list = list() + stop_list = list() + data_list = list() + + for row in db_manager.genSql(sql_stmt): + # Find the start of this aggregation interval. That's easy: it's the minimum value. + start_time = row[0] + # The stop is a little trickier. It's the maximum dateTime in the interval, plus one + # day. The extra day is needed because the timestamp marks the beginning of a day in a + # daily summary. + stop_date = datetime.date.fromtimestamp(row[1]) + datetime.timedelta(days=1) + stop_time = int(time.mktime(stop_date.timetuple())) + + if aggregate_type in {'min', 'max', 'sum', 'count'}: + data = row[2] + elif aggregate_type == 'avg': + data = row[2] / row[3] if row[3] else None + else: + # Shouldn't really have made it here. Fail hard + raise ValueError("Unknown aggregation type %s" % aggregate_type) + + start_list.append(start_time) + stop_list.append(stop_time) + data_list.append(data) + + # Look up the unit type and group of this combination of observation type and aggregation: + unit, unit_group = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + return (ValueTuple(start_list, 'unix_epoch', 'group_time'), + ValueTuple(stop_list, 'unix_epoch', 'group_time'), + ValueTuple(data_list, unit, unit_group)) + + @staticmethod + def check_eligibility(obs_type, timespan, db_manager, aggregate_type): + + # It has to be a type we know about + if not hasattr(db_manager, 'daykeys') or obs_type not in db_manager.daykeys: + raise weewx.UnknownType(obs_type) + + # We cannot use the day summaries if the starting and ending times of the aggregation + # interval are not on midnight boundaries, and are not the first or last records in the + # database. + if db_manager.first_timestamp is None or db_manager.last_timestamp is None: + raise weewx.UnknownAggregation(aggregate_type) + if not (isStartOfDay(timespan.start) or timespan.start == db_manager.first_timestamp) \ + or not (isStartOfDay(timespan.stop) or timespan.stop == db_manager.last_timestamp): + raise weewx.UnknownAggregation(aggregate_type) + + +# +# ######################## Class AggregateHeatCool ############################## +# + +class AggregateHeatCool(XType): + """Calculate heating and cooling degree-days.""" + + # Default base temperature and unit type for heating and cooling degree days, + # as a value tuple + default_heatbase = (65.0, "degree_F", "group_temperature") + default_coolbase = (65.0, "degree_F", "group_temperature") + default_growbase = (50.0, "degree_F", "group_temperature") + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns heating and cooling degree days over a time period. + + obs_type: The type over which aggregation is to be done. Must be one of 'heatdeg', + 'cooldeg', or 'growdeg'. + + timespan: An instance of weeutil.Timespan with the time period over which + aggregation is to be done. + + aggregate_type: The type of aggregation to be done. Must be 'avg' or 'sum'. + + db_manager: An instance of weewx.manager.Manager or subclass. + + option_dict: Not used in this version. + + returns: A ValueTuple containing the result. + """ + + # Check to see whether heating or cooling degree days are being asked for: + if obs_type not in ['heatdeg', 'cooldeg', 'growdeg']: + raise weewx.UnknownType(obs_type) + + # Only summation (total) or average heating or cooling degree days is supported: + if aggregate_type not in {'sum', 'avg', 'not_null'}: + raise weewx.UnknownAggregation(aggregate_type) + + # Get the base for heating and cooling degree-days + units_dict = option_dict.get('skin_dict', {}).get('Units', {}) + dd_dict = units_dict.get('DegreeDays', {}) + heatbase = dd_dict.get('heating_base', AggregateHeatCool.default_heatbase) + coolbase = dd_dict.get('cooling_base', AggregateHeatCool.default_coolbase) + growbase = dd_dict.get('growing_base', AggregateHeatCool.default_growbase) + # Convert to a ValueTuple in the same unit system as the database + heatbase_t = weewx.units.convertStd((float(heatbase[0]), heatbase[1], "group_temperature"), + db_manager.std_unit_system) + coolbase_t = weewx.units.convertStd((float(coolbase[0]), coolbase[1], "group_temperature"), + db_manager.std_unit_system) + growbase_t = weewx.units.convertStd((float(growbase[0]), growbase[1], "group_temperature"), + db_manager.std_unit_system) + + total = 0.0 + count = 0 + for daySpan in weeutil.weeutil.genDaySpans(timespan.start, timespan.stop): + # Get the average temperature for the day as a value tuple: + Tavg_t = DailySummaries.get_aggregate('outTemp', daySpan, 'avg', db_manager) + # Make sure it's valid before including it in the aggregation: + if Tavg_t is not None and Tavg_t[0] is not None: + if aggregate_type == 'not_null': + return ValueTuple(True, 'boolean', 'group_boolean') + if obs_type == 'heatdeg': + total += weewx.wxformulas.heating_degrees(Tavg_t[0], heatbase_t[0]) + elif obs_type == 'cooldeg': + total += weewx.wxformulas.cooling_degrees(Tavg_t[0], coolbase_t[0]) + else: + total += weewx.wxformulas.cooling_degrees(Tavg_t[0], growbase_t[0]) + + count += 1 + + if aggregate_type == 'not_null': + value = False + elif aggregate_type == 'sum': + value = total + else: + value = total / count if count else None + + # Look up the unit type and group of the result: + t, g = weewx.units.getStandardUnitType(db_manager.std_unit_system, obs_type, + aggregate_type) + # Return as a value tuple + return weewx.units.ValueTuple(value, t, g) + + +class XTypeTable(XType): + """Calculate a series for an xtype. An xtype may not necessarily be in the database, so + this version calculates it on the fly. Note: this version only works if no aggregation has + been requested.""" + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Get a series of an xtype, by using the main archive table. Works only for no + aggregation. """ + + start_vec = list() + stop_vec = list() + data_vec = list() + + if aggregate_type: + # This version does not know how to do aggregations, although this could be + # added in the future. + raise weewx.UnknownAggregation(aggregate_type) + + else: + # No aggregation + + std_unit_system = None + + # Hit the database. + for record in db_manager.genBatchRecords(*timespan): + + if std_unit_system: + if std_unit_system != record['usUnits']: + raise weewx.UnsupportedFeature("Unit system cannot change " + "within a series.") + else: + std_unit_system = record['usUnits'] + + # Given a record, use the xtypes system to calculate a value: + try: + value = get_scalar(obs_type, record, db_manager) + data_vec.append(value[0]) + except weewx.CannotCalculate: + data_vec.append(None) + start_vec.append(record['dateTime'] - record['interval'] * 60) + stop_vec.append(record['dateTime']) + + unit, unit_group = weewx.units.getStandardUnitType(std_unit_system, obs_type) + + return (ValueTuple(start_vec, 'unix_epoch', 'group_time'), + ValueTuple(stop_vec, 'unix_epoch', 'group_time'), + ValueTuple(data_vec, unit, unit_group)) + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Calculate an aggregate value for an xtype. Addresses issue #864. """ + + # This version offers a limited set of aggregation types + if aggregate_type not in {'sum', 'count', 'avg', 'max', 'min', + 'mintime', 'maxtime', 'not_null'}: + raise weewx.UnknownAggregation(aggregate_type) + + std_unit_system = None + total = 0.0 + count = 0 + minimum = None + maximum = None + mintime = None + maxtime = None + + # Hit the database. + for record in db_manager.genBatchRecords(*timespan): + if std_unit_system: + if std_unit_system != record['usUnits']: + raise weewx.UnsupportedFeature("Unit system cannot change within the database") + else: + std_unit_system = record['usUnits'] + + # Given a record, use the xtypes system to calculate a value. A ValueTuple will be + # returned, so use only the first element. NB: If the xtype cannot be calculated, + # the call to get_scalar() will raise a CannotCalculate exception. We let it + # bubble up. + value = get_scalar(obs_type, record, db_manager)[0] + if value is not None: + if aggregate_type == 'not_null': + return ValueTuple(True, 'boolean', 'group_boolean') + total += value + count += 1 + if minimum is None or value < minimum: + minimum = value + mintime = record['dateTime'] + if maximum is None or value > maximum: + maximum = value + maxtime = record['dateTime'] + + if aggregate_type == 'sum': + result = total + elif aggregate_type == 'count': + result = count + elif aggregate_type == 'avg': + result = total / count if count else None + elif aggregate_type == 'mintime': + result = mintime + elif aggregate_type == 'maxtime': + result = maxtime + elif aggregate_type == 'min': + result = minimum + elif aggregate_type == 'not_null': + result = False + else: + assert aggregate_type == 'max' + result = maximum + + u, g = weewx.units.getStandardUnitType(std_unit_system, obs_type, aggregate_type) + + return weewx.units.ValueTuple(result, u, g) + + +# ############################# WindVec extensions ######################################### + +class WindVec(XType): + """Extensions for calculating special observation types 'windvec' and 'windgustvec' from the + main archive table. It provides functions for calculating series, and for calculating + aggregates. + """ + + windvec_types = { + 'windvec': ('windSpeed', 'windDir'), + 'windgustvec': ('windGust', 'windGustDir') + } + + agg_sql_dict = { + 'count': "SELECT COUNT(dateTime), usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL", + 'first': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY dateTime ASC LIMIT 1", + 'last': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY dateTime DESC LIMIT 1", + 'min': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY %(mag)s ASC LIMIT 1;", + 'max': "SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s AND %(mag)s IS NOT NULL " + "ORDER BY %(mag)s DESC LIMIT 1;", + 'not_null': "SELECT 1, usUnits FROM %(table_name)s " + "WHERE dateTime > %(start)s AND dateTime <= %(stop)s " + "AND %(mag)s IS NOT NULL LIMIT 1;" + } + # for types 'avg', 'sum' + complex_sql_wind = 'SELECT %(mag)s, %(dir)s, usUnits FROM %(table_name)s WHERE dateTime > ? ' \ + 'AND dateTime <= ?' + + @staticmethod + def get_series(obs_type, timespan, db_manager, aggregate_type=None, aggregate_interval=None, + **option_dict): + """Get a series, possibly with aggregation, for special 'wind vector' types. These are + typically used for the wind vector plots. + """ + + # Check to see if the requested type is not 'windvec' or 'windgustvec' + if obs_type not in WindVec.windvec_types: + # The type is not one of the extended wind types. We can't handle it. + raise weewx.UnknownType(obs_type) + + # It is an extended wind type. Prepare the lists that will hold the + # final results. + start_vec = list() + stop_vec = list() + data_vec = list() + + # Is aggregation requested? + if aggregate_type: + # Yes. Just use the regular series function. When it comes time to do the aggregation, + # the specialized function WindVec.get_aggregate() (defined below), will be used. + return ArchiveTable.get_series(obs_type, timespan, db_manager, aggregate_type, + aggregate_interval, **option_dict) + + else: + # No aggregation desired. However, we have will have to assemble the wind vector from + # its flattened types. This SQL select string will select the proper wind types + sql_str = 'SELECT dateTime, %s, %s, usUnits, `interval` FROM %s ' \ + 'WHERE dateTime >= ? AND dateTime <= ?' \ + % (WindVec.windvec_types[obs_type][0], WindVec.windvec_types[obs_type][1], + db_manager.table_name) + std_unit_system = None + + for record in db_manager.genSql(sql_str, timespan): + ts, magnitude, direction, unit_system, interval = record + if std_unit_system: + if std_unit_system != unit_system: + raise weewx.UnsupportedFeature( + "Unit type cannot change within a time interval.") + else: + std_unit_system = unit_system + + value = weeutil.weeutil.to_complex(magnitude, direction) + + start_vec.append(ts - interval * 60) + stop_vec.append(ts) + data_vec.append(value) + + unit, unit_group = weewx.units.getStandardUnitType(std_unit_system, obs_type, + aggregate_type) + + return (ValueTuple(start_vec, 'unix_epoch', 'group_time'), + ValueTuple(stop_vec, 'unix_epoch', 'group_time'), + ValueTuple(data_vec, unit, unit_group)) + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Returns an aggregation of a wind vector type over a timespan by using the main archive + table. + + Args: + obs_type (str): The type over which aggregation is to be done. For this function, it + must be 'windvec' or 'windgustvec'. Anything else will cause an exception of + type weewx.UnknownType to be raised. + timespan (weeutil.weeutil.TimeSpan): An instance of Timespan with the time period over + which aggregation is to be done. + aggregate_type (str): The type of aggregation to be done. For this function, must be + 'avg', 'sum', 'count', 'first', 'last', 'min', or 'max'. Anything else will cause + weewx.UnknownAggregation to be raised. + db_manager (weewx.manager.Manager): An instance of Manager or subclass. + option_dict (dict): Not used in this version. + + Returns: + ValueTuple: A ValueTuple containing the result. Note that the value contained + in the ValueTuple will be a complex number. + """ + if obs_type not in WindVec.windvec_types: + raise weewx.UnknownType(obs_type) + + aggregate_type = aggregate_type.lower() + + # Raise exception if we don't know about this type of aggregation + if aggregate_type not in ['avg', 'sum'] + list(WindVec.agg_sql_dict.keys()): + raise weewx.UnknownAggregation(aggregate_type) + + # Form the interpolation dictionary + interpolation_dict = { + 'dir': WindVec.windvec_types[obs_type][1], + 'mag': WindVec.windvec_types[obs_type][0], + 'start': timespan.start, + 'stop': timespan.stop, + 'table_name': db_manager.table_name + } + + if aggregate_type in WindVec.agg_sql_dict: + # For these types (e.g., first, last, etc.), we can do the aggregation in a SELECT + # statement. + select_stmt = WindVec.agg_sql_dict[aggregate_type] % interpolation_dict + try: + row = db_manager.getSql(select_stmt) + except weedb.NoColumnError as e: + raise weewx.UnknownType(e) + + if aggregate_type == 'not_null': + value = row is not None + std_unit_system = db_manager.std_unit_system + else: + if row: + if aggregate_type == 'count': + value, std_unit_system = row + else: + magnitude, direction, std_unit_system = row + value = weeutil.weeutil.to_complex(magnitude, direction) + else: + std_unit_system = db_manager.std_unit_system + value = None + else: + # The requested aggregation must be either 'sum' or 'avg', which will require some + # arithmetic in Python, so it cannot be done by a simple query. + std_unit_system = None + xsum = ysum = 0.0 + count = 0 + select_stmt = WindVec.complex_sql_wind % interpolation_dict + + for rec in db_manager.genSql(select_stmt, timespan): + + # Unpack the record + mag, direction, unit_system = rec + + # Ignore rows where magnitude is NULL + if mag is None: + continue + + # A good direction is necessary unless the mag is zero: + if mag == 0.0 or direction is not None: + if std_unit_system: + if std_unit_system != unit_system: + raise weewx.UnsupportedFeature( + "Unit type cannot change within a time interval.") + else: + std_unit_system = unit_system + + # An undefined direction is OK (and expected) if the magnitude + # is zero. But, in that case, it doesn't contribute to the sums either. + if direction is None: + # Sanity check + if weewx.debug: + assert (mag == 0.0) + else: + xsum += mag * math.cos(math.radians(90.0 - direction)) + ysum += mag * math.sin(math.radians(90.0 - direction)) + count += 1 + + # We've gone through the whole interval. Were there any good data? + if count: + # Form the requested aggregation: + if aggregate_type == 'sum': + value = complex(xsum, ysum) + else: + # Must be 'avg' + value = complex(xsum, ysum) / count + else: + value = None + + # Look up the unit type and group of this combination of observation type and aggregation: + t, g = weewx.units.getStandardUnitType(std_unit_system, obs_type, aggregate_type) + # Form the ValueTuple and return it: + return weewx.units.ValueTuple(value, t, g) + + +class WindVecDaily(XType): + """Extension for calculating the average windvec, using the daily summaries.""" + + @staticmethod + def get_aggregate(obs_type, timespan, aggregate_type, db_manager, **option_dict): + """Optimization for calculating 'avg' aggregations for type 'windvec'. The + timespan must be on a daily boundary.""" + + # We can only do observation type 'windvec' + if obs_type != 'windvec': + # We can't handle it. + raise weewx.UnknownType(obs_type) + + # We can only do 'avg' or 'not_null + if aggregate_type not in ['avg', 'not_null']: + raise weewx.UnknownAggregation(aggregate_type) + + # Check to see whether we can use the daily summaries: + DailySummaries.check_eligibility('wind', timespan, db_manager, aggregate_type) + + if aggregate_type == 'not_null': + # Aggregate type 'not_null' is actually run against 'wind'. + return DailySummaries.get_aggregate('wind', timespan, 'not_null', db_manager, + **option_dict) + + sql = 'SELECT SUM(xsum), SUM(ysum), SUM(dirsumtime) ' \ + 'FROM %s_day_wind WHERE dateTime>=? AND dateTime +# +# See the file LICENSE.txt for your full rights. +# +""" +Package of user extensions to weewx. + +This package is for your use. Generally, extensions to weewx go here. +Any modules you add to it will not be touched by the upgrade process. +""" diff --git a/dist/weewx-5.0.2/src/weewx_data/bin/user/extensions.py b/dist/weewx-5.0.2/src/weewx_data/bin/user/extensions.py new file mode 100644 index 0000000..df848ce --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/bin/user/extensions.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# + +"""User extensions module + +This module is imported from the main executable, so anything put here will be +executed before anything else happens. This makes it a good place to put user +extensions. +""" + +import locale +# This will use the locale specified by the environment variable 'LANG' +# Other options are possible. See: +# http://docs.python.org/2/library/locale.html#locale.setlocale +locale.setlocale(locale.LC_ALL, '') diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/alarm.py b/dist/weewx-5.0.2/src/weewx_data/examples/alarm.py new file mode 100644 index 0000000..0c25053 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/alarm.py @@ -0,0 +1,280 @@ +# Copyright (c) 2009-2024 Tom Keffer +# See the file LICENSE.txt for your rights. + +"""Example of how to implement an alarm in WeeWX. + +******************************************************************************* + +To use this alarm, add the following somewhere in your configuration file +weewx.conf: + +[Alarm] + expression = "outTemp < 40.0" + time_wait = 3600 + smtp_host = smtp.example.com + smtp_user = myusername + smtp_password = mypassword + from = sally@example.com + mailto = jane@example.com, bob@example.com + subject = "Alarm message from weewx!" + +In this example, if the outside temperature falls below 40, it will send an +email to the users specified in the comma separated list specified in option +"mailto", in this case: + +jane@example.com, bob@example.com + +The example assumes an SMTP email server at smtp.example.com that requires +login. If the SMTP server does not require login, leave out the lines for +smtp_user and smtp_password. + +Setting an email "from" is optional. If not supplied, one will be filled in, +but your SMTP server may or may not accept it. + +Setting an email "subject" is optional. If not supplied, one will be filled in. + +To avoid a flood of emails, one will only be sent every 3600 seconds (one +hour). + +******************************************************************************* + +To enable this service: + +1) Copy this file to the user directory. For pip install, this directory is +at ~/weewx-data/bin/user. For package installs, it's /usr/share/weewx/user. + +2) Modify the weewx configuration file by adding this service to the option +"report_services", located in section [Engine][[Services]]. + +[Engine] + [[Services]] + ... + report_services = weewx.engine.StdPrint, weewx.engine.StdReport, user.alarm.MyAlarm + +******************************************************************************* + +If you wish to use both this example and the lowBattery.py example, simply +merge the two configuration options together under [Alarm] and add both +services to report_services. + +******************************************************************************* +""" + +import logging +import smtplib +import socket +import threading +import time +from email.mime.text import MIMEText + +import weewx +from weeutil.weeutil import timestamp_to_string, option_as_list +from weewx.engine import StdService + +log = logging.getLogger(__name__) + + +# Inherit from the base class StdService: +class MyAlarm(StdService): + """Service that sends email if an arbitrary expression evaluates true""" + + def __init__(self, engine, config_dict): + # Pass the initialization information on to my superclass: + super().__init__(engine, config_dict) + + # This will hold the time when the last alarm message went out: + self.last_msg_ts = 0 + + try: + # Dig the needed options out of the configuration dictionary. + # If a critical option is missing, an exception will be raised and + # the alarm will not be set. + self.expression = config_dict['Alarm']['expression'] + self.time_wait = int(config_dict['Alarm'].get('time_wait', 3600)) + self.timeout = int(config_dict['Alarm'].get('timeout', 10)) + self.smtp_host = config_dict['Alarm']['smtp_host'] + self.smtp_user = config_dict['Alarm'].get('smtp_user') + self.smtp_password = config_dict['Alarm'].get('smtp_password') + self.SUBJECT = config_dict['Alarm'].get('subject', + "Alarm message from weewx") + self.FROM = config_dict['Alarm'].get('from', + 'alarm@example.com') + self.TO = option_as_list(config_dict['Alarm']['mailto']) + except KeyError as e: + log.info("No alarm set. Missing parameter: %s", e) + else: + # If we got this far, it's ok to start intercepting events: + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) # 1 + log.info("Alarm set for expression: '%s'", self.expression) + + def new_archive_record(self, event): + """Gets called on a new archive record event.""" + + # To avoid a flood of nearly identical emails, this will do + # the check only if we have never sent an email, or if we haven't + # sent one in the last self.time_wait seconds: + if (not self.last_msg_ts + or abs(time.time() - self.last_msg_ts) >= self.time_wait): + # Get the new archive record: + record = event.record + + # Be prepared to catch an exception in the case that the expression + # contains a variable that is not in the record: + try: # 2 + # Evaluate the expression in the context of the event archive + # record. Sound the alarm if it evaluates true: + if eval(self.expression, None, record): # 3 + # Sound the alarm! Launch in a separate thread, + # so it doesn't block the main LOOP thread: + t = threading.Thread(target=MyAlarm.sound_the_alarm, + args=(self, record)) + t.start() + # Record when the message went out: + self.last_msg_ts = time.time() + except NameError as e: + # The record was missing a named variable. Log it. + log.info("%s", e) + + def sound_the_alarm(self, record): + """Sound the alarm in a 'try' block""" + + # Wrap the attempt in a 'try' block, so we can log a failure. + try: + self.do_alarm(record) + except socket.gaierror: + # A gaierror exception is usually caused by an unknown host + log.critical("Unknown host %s", self.smtp_host) + # Reraise the exception. This will cause the thread to exit. + raise + except Exception as e: + log.critical("Unable to sound alarm. Reason: %s", e) + # Reraise the exception. This will cause the thread to exit. + raise + + def do_alarm(self, record): + """Send an email out""" + + # Get the time and convert to a string: + t_str = timestamp_to_string(record['dateTime']) + + # Log the alarm + log.info('Alarm expression "%s" evaluated True at %s' + % (self.expression, t_str)) + + # Form the message text: + msg_text = 'Alarm expression "%s" evaluated True at %s\nRecord:\n%s' \ + % (self.expression, t_str, str(record)) + # Convert to MIME: + msg = MIMEText(msg_text) + + # Fill in MIME headers: + msg['Subject'] = self.SUBJECT + msg['From'] = self.FROM + msg['To'] = ','.join(self.TO) + + try: + # First try end-to-end encryption + s = smtplib.SMTP_SSL(self.smtp_host, timeout=self.timeout) + log.debug("Using SMTP_SSL") + except (AttributeError, socket.timeout, socket.error) as e: + log.debug("Unable to use SMTP_SSL connection. Reason: %s", e) + # If that doesn't work, try creating an insecure host, + # then upgrading + s = smtplib.SMTP(self.smtp_host, timeout=self.timeout) + try: + # Be prepared to catch an exception if the server + # does not support encrypted transport. + s.ehlo() + s.starttls() + s.ehlo() + log.debug("Using SMTP encrypted transport") + except smtplib.SMTPException as e: + log.debug("Using SMTP unencrypted transport. Reason: %s", e) + + try: + # If a username has been given, assume that login is required + # for this host: + if self.smtp_user: + s.login(self.smtp_user, self.smtp_password) + log.debug("Logged in with user name %s", self.smtp_user) + + # Send the email: + s.sendmail(msg['From'], self.TO, msg.as_string()) + # Log out of the server: + s.quit() + except Exception as e: + log.error("SMTP mailer refused message with error %s", e) + raise + + # Log sending the email: + log.info("Email sent to: %s", self.TO) + + +if __name__ == '__main__': + """This section is used to test alarm.py. It uses a record and alarm + expression that are guaranteed to trigger an alert. + + You will need a valid weewx.conf configuration file with an [Alarm] + section that has been set up as illustrated at the top of this file.""" + + from optparse import OptionParser + import weecfg + import weeutil.logger + + usage = """Usage: python alarm.py --help + python alarm.py [CONFIG_FILE|--config=CONFIG_FILE] + +Arguments: + + CONFIG_PATH: Path to weewx.conf """ + + epilog = """You must be sure the WeeWX modules are in your PYTHONPATH. + For example: + + PYTHONPATH=/home/weewx/bin python alarm.py --help""" + + # Force debug: + weewx.debug = 1 + + # Create a command line parser: + parser = OptionParser(usage=usage, epilog=epilog) + parser.add_option("--config", dest="config_path", metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + # Parse the arguments and options + (options, args) = parser.parse_args() + + try: + config_path, config_dict = weecfg.read_config(options.config_path, args) + except IOError as e: + exit("Unable to open configuration file: %s" % e) + + print("Using configuration file %s" % config_path) + + # Set logging configuration: + weeutil.logger.setup('wee_alarm', config_dict) + + if 'Alarm' not in config_dict: + exit("No [Alarm] section in the configuration file %s" % config_path) + + # This is a fake record that we'll use + rec = {'extraTemp1': 1.0, + 'outTemp': 38.2, + 'dateTime': int(time.time())} + + # Use an expression that will evaluate to True by our fake record. + config_dict['Alarm']['expression'] = "outTemp<40.0" + + # We need the main WeeWX engine in order to bind to the event, + # but we don't need for it to completely start up. So get rid of all + # services: + config_dict['Engine']['Services'] = {} + # Now we can instantiate our slim engine, using the DummyEngine class... + engine = weewx.engine.DummyEngine(config_dict) + # ... and set the alarm using it. + alarm = MyAlarm(engine, config_dict) + + # Create a NEW_ARCHIVE_RECORD event + event = weewx.Event(weewx.NEW_ARCHIVE_RECORD, record=rec) + + # Use it to trigger the alarm: + alarm.new_archive_record(event) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/changelog b/dist/weewx-5.0.2/src/weewx_data/examples/basic/changelog new file mode 100644 index 0000000..328da6b --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/changelog @@ -0,0 +1,17 @@ +0.5 20aug2023 +* Correct errors using gettext(). +* Use StringIO to initialize configuration. +* Change skin name from 'basic' to 'Basic'. +* Use .long_form for elapsed times. + +0.4 22jan2023 +* Ported to WeeWX V5. -tk + +0.3 10may2021 +* Internationalized. -tk + +0.2 02may2021 +* Fixed some bugs. -tk + +0.1 02feb2014 +* initial release as packaged weewx extension diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/install.py b/dist/weewx-5.0.2/src/weewx_data/examples/basic/install.py new file mode 100644 index 0000000..4738ae1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/install.py @@ -0,0 +1,63 @@ +# installer for the 'basic' skin +# Copyright 2014-2024 Matthew Wall + +import os.path +from io import StringIO + +import configobj + +from weecfg.extension import ExtensionInstaller + + +def loader(): + return BasicInstaller() + + +# By creating the configuration dictionary from a StringIO, we can preserve any comments +BASIC_CONFIG = """ +[StdReport] + + [[BasicReport]] + skin = Basic + enable = True + # Language to use: + lang = en + # Unit system to use: + unit_system = US + # Where to put the results: + HTML_ROOT = basic +""" + +basic_dict = configobj.ConfigObj(StringIO(BASIC_CONFIG)) + + +class BasicInstaller(ExtensionInstaller): + def __init__(self): + super(BasicInstaller, self).__init__( + version="0.5", + name='basic', + description='Very basic skin for WeeWX.', + author="Matthew Wall", + author_email="mwall@users.sourceforge.net", + config=basic_dict, + files=[ + ('skins/Basic', + ['skins/Basic/basic.css', + 'skins/Basic/current.inc', + 'skins/Basic/favicon.ico', + 'skins/Basic/hilo.inc', + 'skins/Basic/index.html.tmpl', + 'skins/Basic/skin.conf', + 'skins/Basic/lang/en.conf', + 'skins/Basic/lang/fr.conf', + ]), + ] + ) + + def configure(self, engine): + """Customized configuration that sets a language code""" + # TODO: Set a units code as well + my_skin_path = os.path.join(os.path.dirname(__file__), 'skins/Basic') + code = engine.get_lang_code(my_skin_path, 'en') + self['config']['StdReport']['BasicReport']['lang'] = code + return True diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/readme.md b/dist/weewx-5.0.2/src/weewx_data/examples/basic/readme.md new file mode 100644 index 0000000..8546f72 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/readme.md @@ -0,0 +1,55 @@ +basic - a very basic WeeWX skin +============= + +Copyright 2014-2024 Matthew Wall + +This example illustrates how to implement a skin and package it so that it can be installed by the +extension installer. It also illustrates how to internationalize a skin. + + +Installation instructions using the installer (recommended) +------------------------- + +1) Install the extension. + + For pip installs: + + weectl extension install ~/weewx-data/examples/basic + + For package installs + + sudo weectl extension install /usr/share/doc/weewx/examples/basic + +2) Restart WeeWX + + sudo systemctl restart weewx + + +Manual installation instructions +------------------------- + +1) Copy files to the WeeWX skins directory. + + If you used the pip install method: + + cd ~/weewx-data + cp -rp skins/Basic skins + + If you used a package installer: + + cd /usr/share/doc/weewx/examples/basic + sudo cp -rp skins/Basic/ /etc/weewx/skins/ + +2) In the WeeWX configuration file, add a report + + [StdReport] + ... + [[basic]] + skin = Basic + HTML_ROOT = basic + lang = en + unit_system = us + +3) Restart WeeWX + + sudo systemctl restart weewx diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/basic.css b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/basic.css new file mode 100644 index 0000000..4c52f94 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/basic.css @@ -0,0 +1,126 @@ +/* + * + * Copyright (c) 2019-2021 Tom Keffer + * + * See the file LICENSE.txt for your full rights. + * + */ + +/* css for the basic skin */ +/* Copyright 2014 Matthew Wall */ + +body { + font-family: Verdana, Arial, Helvetica, sans-serif; + font-size: 10pt; + background-color: #ffffff; +} +h1 { + font-size: 110%; +} +h2 { + font-size: 100%; +} +a:link { + text-decoration: none; + color: #207070; +} +a:hover { + text-decoration: none; + color: #30a0a0; +} +a:visited { + text-decoration: none; + color: #207070; +} + +#header { + clear: both; + margin: 0; + padding: 0; +} + +#content { + clear: both; +} + +#station_info { + float: left; + line-height: 95%; +} +.station_title { + font-size: 120%; + font-weight: bold; +} +.station_location { + font-size: 75%; +} +.station_time { + font-size: 75%; +} + +#observation_title { + clear: left; + font-weight: bold; + padding-top: 2px; +} + +#navigation_controls { + float: right; + font-size: 85%; +} +#navigation_controls a { + padding-left: 10px; + padding-right: 10px; +} + +#data_graphs { +} +#data_table { + float: right; +} + +#footer { + clear: both; +} +#footer p { + font-size: 8pt; + font-style: italic; + color: #aaaaaa; +} + +.metrics { + font-size: 80%; +} +.metrics a { + text-decoration: none; +} +.metric_title { + text-align: left; + font-weight: bold; +} +.metric_name { + text-align: right; +} +.metric_large { + text-align: left; + font-weight: bold; + font-size: 230%; +} +.metric_value { + text-align: left; + font-weight: bold; +} +.metric_units { + text-align: left; +} +.hilo_time { + text-align: left; + color: #aaaaaa; + font-size: 85%; +} +.heatindex { + color: #aa4444; +} +.windchill { + color: #4444aa; +} diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/current.inc b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/current.inc new file mode 100644 index 0000000..d5672c5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/current.inc @@ -0,0 +1,172 @@ +## basic for weewx - Copyright 2013 Matthew Wall + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +#if $day.UV.has_data + + + + + + +#end if + + + + + + + + + + + + + + +
$obs.label.outTemp$current.outTemp.format(add_label=False)$current.heatindex.format(add_label=False)
$current.windchill.format(add_label=False)
+ $day.outTemp.max.format(add_label=False)
+ $day.outTemp.min.format(add_label=False)
$unit.label.outTemp
$obs.label.outHumidity + $current.outHumidity.format(add_label=False) + + $day.outHumidity.max.format(add_label=False)
+ $day.outHumidity.min.format(add_label=False)
$unit.label.outHumidity
$obs.label.dewpoint$current.dewpoint.format(add_label=False) + $day.dewpoint.max.format(add_label=False)
+ $day.dewpoint.min.format(add_label=False)
$unit.label.dewpoint
$obs.label.barometer$current.barometer.format(add_label=False) + $day.barometer.max.format(add_label=False)
+ $day.barometer.min.format(add_label=False)
$unit.label.barometer
+#if $varExists('trend') + #set $mbar_trend = $trend.barometer.mbar.raw + #if $mbar_trend is not None: + ## Note: these thresholds are for millibar, not inHg + #if $mbar_trend > 6 + ⇧⇧⇧ + #elif $mbar_trend > 3 + ⇧⇧ + #elif $mbar_trend > 0.5 + ⇧ + #elif $mbar_trend < -6 + ⇩⇩⇩ + #elif $mbar_trend < -3 + ⇩⇩ + #elif $mbar_trend < 0.5 + ⇩ + #end if + #end if +#end if +
$obs.label.wind + + + + +
$current.windSpeed.format(add_label=False) + + $current.windDir.ordinal_compass
$current.windDir
+
+ +#if $current.windDir.raw is None: + - +#else + #if $current.windDir.raw < 22.5 + ↑ + #elif $current.windDir.raw < 67.5 + ↗ + #elif $current.windDir.raw < 112.5 + → + #elif $current.windDir.raw < 157.5 + ↘ + #elif $current.windDir.raw < 202.5 + ↓ + #elif $current.windDir.raw < 247.5 + ↙ + #elif $current.windDir.raw < 292.5 + ← + #elif $current.windDir.raw < 337.5 + ↖ + #else + ↑ + #end if +#end if + +
+
+ $day.wind.max.format(add_label=False)
+ $day.wind.avg.format(add_label=False) avg +
$unit.label.windSpeed
+#if $varExists('trend') + #if $trend.windSpeed.raw is not None: + #if $trend.windSpeed.raw > 0 + ⇧ + #elif $trend.windSpeed.raw < 0 + ⇩ + #end if + #end if +#end if +
$gettext("Precipitation") + $current.rain.format(add_label=False) + + $day.rainRate.max.format(add_label=False)
+ $day.rain.sum.format(add_label=False) +
+ $unit.label.rainRate
+ $unit.label.rain +
$obs.label.UV + $current.UV.format(add_label=False) + + $day.UV.max.format(add_label=False)
+ $day.UV.min.format(add_label=False) +
$obs.label.outTemp
$gettext("Inside")
+ $current.inTemp.format(add_label=False) + + $day.inTemp.max.format(add_label=False)
+ $day.inTemp.min.format(add_label=False) +
$unit.label.outTemp
$obs.label.outHumidity
$gettext("Inside")
+ $current.inHumidity.format(add_label=False) + + $day.inHumidity.max.format(add_label=False)
+ $day.inHumidity.min.format(add_label=False) +
$unit.label.outHumidity
diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/favicon.ico b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/favicon.ico differ diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/hilo.inc b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/hilo.inc new file mode 100644 index 0000000..98b6252 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/hilo.inc @@ -0,0 +1,197 @@ +## basic for weewx - Copyright 2013 Matthew Wall + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$gettext("Today")$gettext("Month")$gettext("Year")
$gettext("Maximum Temperature"):$day.outTemp.max.format(add_label=False)
+ $day.outTemp.maxtime
$month.outTemp.max.format(add_label=False)
+ $month.outTemp.maxtime
$year.outTemp.max.format(add_label=False)
+ $year.outTemp.maxtime
$unit.label.outTemp
$gettext("Minimum Temperature"):$day.outTemp.min.format(add_label=False)
+ $day.outTemp.mintime
$month.outTemp.min.format(add_label=False)
+ $month.outTemp.mintime
$year.outTemp.min.format(add_label=False)
+ $year.outTemp.mintime
$unit.label.outTemp
$gettext("Maximum Humidity"):$day.outHumidity.max.format(add_label=False)
+ $day.outHumidity.maxtime
$month.outHumidity.max.format(add_label=False)
+ $month.outHumidity.maxtime
$year.outHumidity.max.format(add_label=False)
+ $year.outHumidity.maxtime
$unit.label.outHumidity
$gettext("Minimum Humidity"):$day.outHumidity.min.format(add_label=False)
+ $day.outHumidity.mintime
$month.outHumidity.min.format(add_label=False)
+ $month.outHumidity.mintime
$year.outHumidity.min.format(add_label=False)
+ $year.outHumidity.mintime
$unit.label.outHumidity
$gettext("Maximum Dewpoint"):$day.dewpoint.max.format(add_label=False)
+ $day.dewpoint.maxtime
$month.dewpoint.max.format(add_label=False)
+ $month.dewpoint.maxtime
$year.dewpoint.max.format(add_label=False)
+ $year.dewpoint.maxtime
$unit.label.dewpoint
$gettext("Minimum Dewpoint"):$day.dewpoint.min.format(add_label=False)
+ $day.dewpoint.mintime
$month.dewpoint.min.format(add_label=False)
+ $month.dewpoint.mintime
$year.dewpoint.min.format(add_label=False)
+ $year.dewpoint.mintime
$unit.label.dewpoint
$gettext("Maximum Barometer"):$day.barometer.max.format(add_label=False)
+ $day.barometer.maxtime
$month.barometer.max.format(add_label=False)
+ $month.barometer.maxtime
$year.barometer.max.format(add_label=False)
+ $year.barometer.maxtime
$unit.label.barometer
$gettext("Minimum Barometer"):$day.barometer.min.format(add_label=False)
+ $day.barometer.mintime
$month.barometer.min.format(add_label=False)
+ $month.barometer.mintime
$year.barometer.min.format(add_label=False)
+ $year.barometer.mintime
$unit.label.barometer
$gettext("Maximum Heat Index"):$day.heatindex.max.format(add_label=False)
+ $day.heatindex.maxtime
$month.heatindex.max.format(add_label=False)
+ $month.heatindex.maxtime
$year.heatindex.max.format(add_label=False)
+ $year.heatindex.maxtime
$unit.label.heatindex
$gettext("Minimum Wind Chill"):$day.windchill.min.format(add_label=False)
+ $day.windchill.mintime
$month.windchill.min.format(add_label=False)
+ $month.windchill.mintime
$year.windchill.min.format(add_label=False)
+ $year.windchill.mintime
$unit.label.windchill
$gettext("Maximum Wind Speed"):$day.wind.max.format(add_label=False)
+ $day.wind.maxtime
$month.wind.max.format(add_label=False)
+ $month.wind.maxtime
$year.wind.max.format(add_label=False)
+ $year.wind.maxtime
$unit.label.wind
$gettext("Average Wind Speed"):$day.wind.avg.format(add_label=False)$month.wind.avg.format(add_label=False)$year.wind.avg.format(add_label=False)$unit.label.wind
$gettext("Maximum Rain Rate"):$day.rainRate.max.format(add_label=False)
+ $day.rainRate.maxtime
$month.rainRate.max.format(add_label=False)
+ $month.rainRate.maxtime
$year.rainRate.max.format(add_label=False)
+ $year.rainRate.maxtime
$unit.label.rainRate
$gettext("Rain Total"):$day.rain.sum.format(add_label=False)
$month.rain.sum.format(add_label=False)$year.rain.sum.format(add_label=False)$unit.label.rain
 
$gettext("Inside")
$gettext("Maximum Temperature"):$day.inTemp.max.format(add_label=False)
+ $day.inTemp.maxtime
$month.inTemp.max.format(add_label=False)
+ $month.inTemp.maxtime
$year.inTemp.max.format(add_label=False)
+ $year.inTemp.maxtime
$unit.label.inTemp
$gettext("Minimum Temperature"):$day.inTemp.min.format(add_label=False)
+ $day.inTemp.mintime
$month.inTemp.min.format(add_label=False)
+ $month.inTemp.mintime
$year.inTemp.min.format(add_label=False)
+ $year.inTemp.mintime
$unit.label.inTemp
$gettext("Maximum Humidity"):$day.inHumidity.max.format(add_label=False)
+ $day.inHumidity.maxtime
$month.inHumidity.max.format(add_label=False)
+ $month.inHumidity.maxtime
$year.inHumidity.max.format(add_label=False)
+ $year.inHumidity.maxtime
$unit.label.inHumidity
$gettext("Minimum Humidity"):$day.inHumidity.min.format(add_label=False)
+ $day.inHumidity.mintime
$month.inHumidity.min.format(add_label=False)
+ $month.inHumidity.mintime
$year.inHumidity.min.format(add_label=False)
+ $year.inHumidity.mintime
$unit.label.inHumidity
diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/index.html.tmpl new file mode 100644 index 0000000..dba23ad --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/index.html.tmpl @@ -0,0 +1,53 @@ +## basic skin for weewx - Copyright 2014 Matthew Wall +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Current Weather Conditions") + + + + + + +
+
+ #include "current.inc" +

 

+ #include "hilo.inc" +
+
+ temperatures + humidity + barometer + heatchill + wind + wind direction + wind vectors + rain + #if $day.radiation.has_data + radiation + #end if + #if $day.UV.has_data + uv + #end if +
+
+ + + diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/lang/en.conf b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/lang/en.conf new file mode 100644 index 0000000..231e822 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/lang/en.conf @@ -0,0 +1,58 @@ +############################################################################### +# Localization File # +# English # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + barometer = Barometer + dewpoint = Dew Point + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + UV = UV Index + wind = Wind + windchill = Wind Chill + windDir = Wind Direction + windGust = Gust Speed + windgustvec = Gust Vector + windSpeed = Wind Speed + windvec = Wind Vector + +[Texts] + "Language" = English + + "Average Wind Speed" = Average Wind Speed + "Current Weather Conditions" = "Current Weather Conditions" + "Inside" = Inside + "Maximum Barometer" = Maximum Barometer + "Maximum Dewpoint" = Maximum Dewpoint + "Maximum Heat Index" = Maximum Heat Index + "Maximum Humidity" = Maximum Humidity + "maximum rain rate this day" = maximum rain rate this day + "Maximum Rain Rate" = Maximum Rain Rate + "Maximum Temperature" = Maximum Temperature + "maximum UV this day" = maximum UV this day + "Maximum Wind Speed" = Maximum Wind Speed + "Minimum Barometer" = Minimum Barometer + "Minimum Dewpoint" = Minimum Dewpoint + "Minimum Humidity" = Minimum Humidity + "Minimum Temperature" = Minimum Temperature + "minimum UV this day" = minimum UV this day + "Minimum Wind Chill" = Minimum Wind Chill + "Month" = Month + "Precipitation" = "Precipitation" + "Rain Total" = Rain Total + "rainfall within the past few minutes" = rainfall within the past few minutes + "Today" = Today + "total rainfall this day" = total rainfall this day + "UV Index" = UV Index + "Year" = Year + + [[Images]] + "Rain (hourly total)" = Rain (hourly total) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/lang/fr.conf b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/lang/fr.conf new file mode 100644 index 0000000..5189e68 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/lang/fr.conf @@ -0,0 +1,58 @@ +############################################################################### +# Localization File # +# English # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + barometer = Baromètre + dewpoint = Point de Rosée + heatindex = Indice de Chaleur + inHumidity = Humidité Intérieure + inTemp = Température Intérieure + outHumidity = Humidité + outTemp = Température Extérieure + radiation = Radiation Solaire + UV = L'indice UV + wind = Vent + windchill = Refroidissement Éolien + windDir = Direction du Vent + windGust = Vitesse Maximale du Vent + windgustvec = Vecteur de Rafale + windSpeed = Vitesse du Vent + windvec = Vecteur de Vent + +[Texts] + "Language" = Français + + "Average Wind Speed" = Vitesse Moyenne du Vent + "Current Weather Conditions" = Conditions Météorologiques Actuelles + "Inside" = à l'Intérieur + "Maximum Barometer" = Baromètre Maximum + "Maximum Dewpoint" = Point de Rosée Maximum + "Maximum Heat Index" = Indice de Chaleur Maximum + "Maximum Humidity" = Humidité Maximale + "maximum rain rate this day" = taux de pluie maximum ce jour-là + "Maximum Rain Rate" = Taux de Pluie Maximum + "Maximum Temperature" = Température Maximale + "maximum UV this day" = UV maximum ce jour + "Maximum Wind Speed" = Vitesse Maximale du Vent + "Minimum Barometer" = Baromètre Minimum + "Minimum Dewpoint" = Point de Rosée Minimum + "Minimum Humidity" = Humidité Minimale + "Minimum Temperature" = Température Minimale + "minimum UV this day" = UV minimum ce jour + "Minimum Wind Chill" = Refroidissement Éolien Minimum + "Month" = Mois + "Precipitation" = Précipitation + "Rain Total" = Pluie Totale + "rainfall within the past few minutes" = pluies au cours des dernières minutes + "Today" = Aujourd'hui + "total rainfall this day" = précipitations totales ce jour + "UV Index" = L'indice UV + "Year" = An + + [[Images]] + "Rain (hourly total)" = Pluie (total horaire) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/skin.conf b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/skin.conf new file mode 100644 index 0000000..20fad47 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/basic/skins/Basic/skin.conf @@ -0,0 +1,124 @@ +# configuration file for the basic skin +# The basic skin was created by Matthew Wall. +# +# This skin can be copied, modified, and distributed as long as this notice +# is included in any derivative work. +# +# This skin uses the dejavu font: +# apt-get install ttf-dejavu-core +# apt-get install ttf-dejavu-extra + +SKIN_NAME = Basic +SKIN_VERSION = 0.5 + +[CheetahGenerator] + encoding = html_entities + [[ToDate]] + [[[index]]] + template = index.html.tmpl + +[CopyGenerator] + copy_once = favicon.ico, basic.css + +[ImageGenerator] + image_width = 700 + image_height = 150 + image_background_color = "#ffffff" + + chart_background_color = "#ffffff" + chart_gridline_color = "#eaeaea" + + top_label_font_path = DejaVuSansCondensed-Bold.ttf + top_label_font_size = 10 + + unit_label_font_path = DejaVuSansCondensed.ttf + unit_label_font_size = 10 + unit_label_font_color = "#aaaaaa" + + bottom_label_font_path = DejaVuSansCondensed.ttf + bottom_label_font_size = 10 + bottom_label_font_color = "#aaaaaa" + + axis_label_font_path = DejaVuSansCondensed.ttf + axis_label_font_size = 10 + axis_label_font_color = "#aaaaaa" + + rose_label = N + rose_label_font_path = DejaVuSansCondensed.ttf + rose_label_font_size = 8 + rose_label_font_color = "#888888" + rose_color = "#aaaaaa" + + chart_line_colors = "#30a0a0", "#80d0d0", "#010a0a" + chart_fill_colors = "#90d0d0", "#d0dfdf", "#515a5a" + + daynight_day_color = "#ffffff" + daynight_night_color = "#f8f6f6" + daynight_edge_color = "#efefaf" + + line_type = 'solid' + + marker_size = 2 + marker_type ='none' + + plot_type = line + aggregate_type = none + width = 1 + time_length = 97200 # 27 hours + + [[day_images]] + x_label_format = %H:%M + show_daynight = true + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[dayrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 3600 + label = Rain (hourly total) + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[daywinddir]]] + line_type = None + marker_type = 'box' + marker_size = 2 + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[[daywindvec]]] + [[[[windvec]]]] + plot_type = vector + [[[[windgustvec]]]] + plot_type = vector + aggregate_type = max + aggregate_interval = 3600 + + [[[dayinouthum]]] + yscale = 0, 100, 10 + [[[[outHumidity]]]] + [[[[inHumidity]]]] + + [[[daytempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[dayinouttempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + [[[[inTemp]]]] + + [[[dayradiation]]] + [[[[radiation]]]] + + [[[dayuv]]] + [[[[UV]]]] + +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_1.py b/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_1.py new file mode 100644 index 0000000..8fd7bd2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_1.py @@ -0,0 +1,74 @@ +# Copyright (c) 2022 Tom Keffer +# See the file LICENSE.txt for your rights. + +"""Pick a color on the basis of a temperature. Simple, hardwired version. + +******************************************************************************* + +This search list extension offers an extra tag: + + 'colorize': Returns a color depending on a temperature measured in Celsius. + +******************************************************************************* + +To use this search list extension: + +1) Copy this file to the user directory. + + For example, for pip installs: + + cp colorize_1.py ~/weewx-data/bin/user + + For package installers: + + sudo cp colorize_1.py /usr/share/weewx/user + +2) Modify the option search_list_extensions in the skin.conf configuration file, adding +the name of this extension. When you're done, it will look something like this: + + [CheetahGenerator] + search_list_extensions = user.colorize_1.Colorize + +You can then colorize backgrounds. For example, to colorize an HTML table cell: + + + + + + +
Outside temperature$current.outTemp
+ +******************************************************************************* +""" + +from weewx.cheetahgenerator import SearchList + + +class Colorize(SearchList): # 1 + + def colorize(self, t_c): # 2 + """Choose a color on the basis of temperature + + Args: + t_c (float): The temperature in degrees Celsius + + Returns: + str: A color string + """ + + if t_c is None: # 3 + return "#00000000" + elif t_c < -10: + return "magenta" + elif t_c < 0: + return "violet" + elif t_c < 10: + return "lavender" + elif t_c < 20: + return "moccasin" + elif t_c < 30: + return "yellow" + elif t_c < 40: + return "coral" + else: + return "tomato" diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_2.py b/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_2.py new file mode 100644 index 0000000..078b744 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_2.py @@ -0,0 +1,85 @@ +# Copyright (c) 2022 Tom Keffer +# See the file LICENSE.txt for your rights. + +"""Pick a color on the basis of a temperature. This version works for any unit system. + +******************************************************************************* + +This search list extension offers an extra tag: + + 'colorize': Returns a color depending on temperature + +******************************************************************************* + +To use this search list extension: + +1) Copy this file to the user directory. + + For example, for pip installs: + + cp colorize_2.py ~/weewx-data/bin/user + + For package installers: + + sudo cp colorize_2.py /usr/share/weewx/user + +2) Modify the option search_list_extensions in the skin.conf configuration file, adding +the name of this extension. When you're done, it will look something like this: + + [CheetahGenerator] + search_list_extensions = user.colorize_2.Colorize + +You can then colorize backgrounds. For example, to colorize an HTML table cell: + + + + + + +
Outside temperature$current.outTemp
+ +******************************************************************************* +""" +import weewx.units +from weewx.cheetahgenerator import SearchList + + +class Colorize(SearchList): # 1 + + def colorize(self, value_vh): # 2 + """ + Choose a color on the basis of a temperature value in any unit. + + Args: + value_vh (ValueHelper): The temperature, represented as a ValueHelper + + Returns: + str: A color string + """ + + # Extract the ValueTuple part out of the ValueHelper + value_vt = value_vh.value_t # 3 + + # Convert to Celsius: + t_celsius = weewx.units.convert(value_vt, 'degree_C') # 4 + + # The variable "t_celsius" is a ValueTuple. Get just the value: + t_c = t_celsius.value # 5 + + # Pick a color based on the temperature + if t_c is None: # 6 + return "#00000000" + elif t_c < -10: + return "magenta" + elif t_c < 0: + return "violet" + elif t_c < 10: + return "lavender" + elif t_c < 20: + return "moccasin" + elif t_c < 30: + return "yellow" + elif t_c < 40: + return "coral" + else: + return "tomato" diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_3.py b/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_3.py new file mode 100644 index 0000000..5b23488 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/colorize/colorize_3.py @@ -0,0 +1,114 @@ +# Copyright (c) 2022 Tom Keffer +# See the file LICENSE.txt for your rights. + +"""Pick a color on the basis of a value. This version uses information from +the skin configuration file. + +******************************************************************************* + +This search list extension offers an extra tag: + + 'colorize': Returns a color depending on a value + +******************************************************************************* + +To use this search list extension: + +1) Copy this file to the user directory. + + For example, for pip installs: + + cp colorize_3.py ~/weewx-data/bin/user + + For package installers: + + sudo cp colorize_3.py /usr/share/weewx/user + +2) Modify the option search_list_extensions in the skin.conf configuration file, adding +the name of this extension. When you're done, it will look something like this: + + [CheetahGenerator] + search_list_extensions = user.colorize_3.Colorize + +3) Add a section [Colorize] to skin.conf. For example, this version would +allow you to colorize both temperature and UV values: + + [Colorize] + [[group_temperature]] + unit_system = metricwx + default = tomato + None = lightgray + [[[upper_bounds]]] + -10 = magenta + 0 = violet + 10 = lavender + 20 = moccasin + 30 = yellow + 40 = coral + [[group_uv]] + unit_system = metricwx + default = darkviolet + [[[upper_bounds]]] + 2.4 = limegreen + 5.4 = yellow + 7.4 = orange + 10.4 = red + +You can then colorize backgrounds. For example, to colorize an HTML table cell: + + + + + + +
Outside temperature$current.outTemp
+ +******************************************************************************* +""" + +import weewx.units +from weewx.cheetahgenerator import SearchList + +class Colorize(SearchList): # 1 + + def __init__(self, generator): # 2 + SearchList.__init__(self, generator) + self.color_tables = self.generator.skin_dict.get('Colorize', {}) + + def colorize(self, value_vh): + """ + Pick a color on the basis of a value. The color table will be obtained + from the configuration file. + + Args: + value_vh (ValueHelper): The value, represented as ValueHelper. + + Returns: + str: A color string. + """ + + # Get the ValueTuple and unit group from the incoming ValueHelper + value_vt = value_vh.value_t # 3 + unit_group = value_vt.group # 4 + + # Make sure unit_group is in the color table, and that the table + # specifies a unit system. + if unit_group not in self.color_tables \ + or 'unit_system' not in self.color_tables[unit_group]: # 5 + return "#00000000" + + # Convert the value to the same unit used by the color table: + unit_system = self.color_tables[unit_group]['unit_system'] # 6 + converted_vt = weewx.units.convertStdName(value_vt, unit_system) # 7 + + # Check for a value of None + if converted_vt.value is None: # 8 + return self.color_tables[unit_group].get('none') \ + or self.color_tables[unit_group].get('None', "#00000000") + + # Search for the value in the color table: + for upper_bound in self.color_tables[unit_group]['upper_bounds']: # 9 + if converted_vt.value <= float(upper_bound): # 10 + return self.color_tables[unit_group]['upper_bounds'][upper_bound] + + return self.color_tables[unit_group].get('default', "#00000000") # 11 diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/bin/user/fileparse.py b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/bin/user/fileparse.py new file mode 100644 index 0000000..f6f37b9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/bin/user/fileparse.py @@ -0,0 +1,131 @@ +# Copyright 2014 Matthew Wall +# +# weewx driver that reads data from a file +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. +# +# See http://www.gnu.org/licenses/ + + +# This driver will read data from a file. Each line of the file is a +# name=value pair, for example: +# +# temperature=50 +# humidity=54 +# in_temperature=75 +# +# The units must be in the weewx.US unit system: +# degree_F, inHg, inch, inch_per_hour, mile_per_hour +# +# To use this driver, put this file in the weewx user directory, then make +# the following changes to weewx.conf: +# +# [Station] +# station_type = FileParse +# [FileParse] +# poll_interval = 2 # number of seconds +# path = /var/tmp/wxdata # location of data file +# driver = user.fileparse +# +# If the variables in the file have names different from those in the database +# schema, then create a mapping section called label_map. This will map the +# variables in the file to variables in the database columns. For example: +# +# [FileParse] +# ... +# [[label_map]] +# temp = outTemp +# humi = outHumidity +# in_temp = inTemp +# in_humid = inHumidity + +import logging +import time + +import weewx.drivers + +DRIVER_NAME = 'FileParse' +DRIVER_VERSION = "0.9" + +log = logging.getLogger(__name__) + + +def _get_as_float(data, key): + v = None + if key in data: + try: + v = float(data[key]) + except ValueError as e: + log.error("cannot read value for '%s': %s" % (data[key], e)) + return v + + +def loader(config_dict, engine): + return FileParseDriver(**config_dict[DRIVER_NAME]) + + +class FileParseDriver(weewx.drivers.AbstractDevice): + """weewx driver that reads data from a file""" + + def __init__(self, **stn_dict): + # where to find the data file + self.path = stn_dict.get('path', '/var/tmp/wxdata') + # how often to poll the weather data file, seconds + self.poll_interval = float(stn_dict.get('poll_interval', 2.5)) + # mapping from variable names to weewx names + self.label_map = stn_dict.get('label_map', {}) + + log.info("Data file is %s" % self.path) + log.info("Polling interval is %s" % self.poll_interval) + log.info('Label map is %s' % self.label_map) + + def genLoopPackets(self): + while True: + # read whatever values we can get from the file + data = {} + try: + with open(self.path) as f: + for line in f: + eq_index = line.find('=') + # Ignore all lines that do not have an equal sign + if eq_index == -1: + continue + name = line[:eq_index].strip() + value = line[eq_index + 1:].strip() + data[name] = value + except Exception as e: + log.error("read failed: %s" % e) + + # map the data into a weewx loop packet + _packet = {'dateTime': int(time.time() + 0.5), + 'usUnits': weewx.US} + for vname in data: + _packet[self.label_map.get(vname, vname)] = _get_as_float(data, vname) + + yield _packet + time.sleep(self.poll_interval) + + @property + def hardware_name(self): + return "FileParse" + + +# To test this driver, run it directly as follows: +# PYTHONPATH=/home/weewx/bin python /home/weewx/bin/user/fileparse.py +if __name__ == "__main__": + import weeutil.weeutil + import weeutil.logger + import weewx + + weewx.debug = 1 + weeutil.logger.setup('fileparse') + + driver = FileParseDriver() + for packet in driver.genLoopPackets(): + print(weeutil.weeutil.timestamp_to_string(packet['dateTime']), packet) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/changelog b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/changelog new file mode 100644 index 0000000..7acb8ae --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/changelog @@ -0,0 +1,18 @@ +0.9 20aug2023 +* Fix bug in error message + +0.8 22jan2023 +* Port to WeeWX V5 - tk + +0.7 03jul2019 +* Port to Python 3 + +0.6 17apr2016 +* keep pylint happy +* provide more configuration instructions in comments + +0.5 17nov2014 +* configure for use in weewx v3 + +0.1 13nov2014 +* initial release as packaged weewx extension diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/install.py b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/install.py new file mode 100644 index 0000000..55d78ae --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/install.py @@ -0,0 +1,27 @@ +# installer for the fileparse driver +# Copyright 2014 Matthew Wall + +from weecfg.extension import ExtensionInstaller + + +def loader(): + return FileParseInstaller() + + +class FileParseInstaller(ExtensionInstaller): + def __init__(self): + super(FileParseInstaller, self).__init__( + version="0.9", + name='fileparse', + description='File parsing driver for weewx.', + author="Matthew Wall", + author_email="mwall@users.sourceforge.net", + config={ + 'Station': { + 'station_type': 'FileParse'}, + 'FileParse': { + 'poll_interval': '10', + 'path': '/var/tmp/datafile', + 'driver': 'user.fileparse'}}, + files=[('bin/user', ['bin/user/fileparse.py'])] + ) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/readme.md b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/readme.md new file mode 100644 index 0000000..ab772a5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/fileparse/readme.md @@ -0,0 +1,84 @@ +fileparse - simple driver that reads data from a file +========== + +Copyright 2014-2024 Matthew Wall + +This example illustrates how to implement a driver and package it so that it +can be installed by the extension installer. The fileparse driver reads data +from a file of name=value pairs. + + +Installation instructions using the installer (recommended) +----------------------------------------------------------- + +1) Install the extension. + + For pip installs: + + weectl extension install ~/weewx-data/examples/fileparse + + For package installs + + sudo weectl extension install /usr/share/doc/weewx/examples/fileparse + +2) Select the driver. + + For pip installs: + + weectl station reconfigure + + For package installs: + + sudo weectl station reconfigure + +3) Restart WeeWX + + sudo systemctl restart weewx + + +Manual installation instructions +-------------------------------- + +1) Copy the fileparse driver to the WeeWX user directory. + + For pip installs: + + cd ~/weewx-data/examples/fileparse + cp bin/user/fileparse.py ~/etc/weewx-data/bin/user + + For package installs: + + cd /usr/share/doc/weewx/examples/fileparse + sudo cp bin/user/fileparse.py /usr/share/weewx/user + +2) Add a new `[FileParse]` stanza to the WeeWX configuration file + + [FileParse] + poll_interval = 10 + path = /var/tmp/datafile + driver = user.fileparse + +3) If the variables in the file have names different from those in the database +schema, then add a mapping section called `label_map`. This will map the +variables in the file to variables in the database columns. For example: + + [FileParse] + + ... (as before) + + [[label_map]] + temp = outTemp + humi = outHumidity + in_temp = inTemp + in_humid = inHumidity + +4) In the WeeWX configuration file, modify the `station_type` setting to use the +fileparse driver + + [Station] + ... + station_type = FileParse + +5) Restart WeeWX + + sudo systemctl restart weewx diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/lowBattery.py b/dist/weewx-5.0.2/src/weewx_data/examples/lowBattery.py new file mode 100644 index 0000000..3253289 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/lowBattery.py @@ -0,0 +1,278 @@ +# Copyright (c) 2009-2024 Tom Keffer +# See the file LICENSE.txt for your rights. + +"""Example of how to implement a low battery alarm in WeeWX. + +******************************************************************************* + +To use this alarm, add the following somewhere in your configuration file +weewx.conf: + +[Alarm] + time_wait = 3600 + count_threshold = 10 + smtp_host = smtp.example.com + smtp_user = myusername + smtp_password = mypassword + from = sally@example.com + mailto = jane@example.com, bob@example.com + subject = "Time to change the battery!" + +An email will be sent to each address in the comma separated list of recipients + +The example assumes an SMTP email server at smtp.example.com that requires +login. If the SMTP server does not require login, leave out the lines for +smtp_user and smtp_password. + +Setting an email "from" is optional. If not supplied, one will be filled in, +but your SMTP server may or may not accept it. + +Setting an email "subject" is optional. If not supplied, one will be filled in. + +To avoid a flood of emails, one will only be sent every 3600 seconds (one +hour). + +It will also not send an email unless the low battery indicator has been on +greater than or equal to count_threshold times in an archive period. This +avoids sending out an alarm if the battery is only occasionally being signaled +as bad. + +******************************************************************************* + +To enable this service: + +1) Copy this file to your user directory. For pip install, this directory is +at ~/weewx-data/bin/user. For package installs, it's /usr/share/weewx/user. + +2) Modify the weewx configuration file by adding this service to the option +"report_services", located in section [Engine][[Services]]. + +[Engine] + [[Services]] + ... + report_services = weewx.engine.StdPrint, weewx.engine.StdReport, user.lowBattery.BatteryAlarm + +******************************************************************************* + +If you wish to use both this example and the alarm.py example, simply merge the +two configuration options together under [Alarm] and add both services to +report_services. + +******************************************************************************* +""" + +import logging +import time +import smtplib +from email.mime.text import MIMEText +import threading + +import weewx +from weewx.engine import StdService +from weeutil.weeutil import timestamp_to_string, option_as_list + +log = logging.getLogger(__name__) + + +# Inherit from the base class StdService: +class BatteryAlarm(StdService): + """Service that sends email if one of the batteries is low""" + + battery_flags = ['txBatteryStatus', 'windBatteryStatus', + 'rainBatteryStatus', 'inTempBatteryStatus', + 'outTempBatteryStatus'] + + def __init__(self, engine, config_dict): + # Pass the initialization information on to my superclass: + super().__init__(engine, config_dict) + + # This will hold the time when the last alarm message went out: + self.last_msg_ts = 0 + # This will hold the count of the number of times the VP2 has signaled + # a low battery alarm this archive period + self.alarm_count = 0 + + try: + # Dig the needed options out of the configuration dictionary. + # If a critical option is missing, an exception will be thrown and + # the alarm will not be set. + self.time_wait = int(config_dict['Alarm'].get('time_wait', 3600)) + self.count_threshold = int(config_dict['Alarm'].get('count_threshold', 10)) + self.smtp_host = config_dict['Alarm']['smtp_host'] + self.smtp_user = config_dict['Alarm'].get('smtp_user') + self.smtp_password = config_dict['Alarm'].get('smtp_password') + self.SUBJECT = config_dict['Alarm'].get('subject', "Low battery alarm message from weewx") + self.FROM = config_dict['Alarm'].get('from', 'alarm@example.com') + self.TO = option_as_list(config_dict['Alarm']['mailto']) + except KeyError as e: + log.info("No alarm set. Missing parameter: %s", e) + else: + # If we got this far, it's ok to start intercepting events: + self.bind(weewx.NEW_LOOP_PACKET, self.new_loop_packet) + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + log.info("LowBattery alarm enabled. Count threshold is %d", self.count_threshold) + + def new_loop_packet(self, event): + """This function is called on each new LOOP packet.""" + + packet = event.packet + + # If any battery status flag is non-zero, a battery is low. Use dictionary comprehension + # to build a new dictionary that just holds the non-zero values. + low_batteries = {k : packet[k] for k in BatteryAlarm.battery_flags + if k in packet and packet[k]} + + # If there are any low batteries, see if we need to send an alarm + if low_batteries: + self.alarm_count += 1 + + # Don't panic on the first occurrence. We must see the alarm at + # least count_threshold times before sounding the alarm. + if self.alarm_count >= self.count_threshold: + # We've hit the threshold. However, to avoid a flood of nearly + # identical emails, send a new one only if it's been a long + # time since we sent the last one: + if abs(time.time() - self.last_msg_ts) >= self.time_wait : + # Sound the alarm! + timestamp = event.packet['dateTime'] + # Launch in a separate thread, so it does not block the + # main LOOP thread: + t = threading.Thread(target=BatteryAlarm.sound_the_alarm, + args=(self, timestamp, + low_batteries, + self.alarm_count)) + t.start() + # Record when the message went out: + self.last_msg_ts = time.time() + + def new_archive_record(self, event): + """This function is called on each new archive record.""" + + # Reset the alarm counter + self.alarm_count = 0 + + def sound_the_alarm(self, timestamp, battery_flags, alarm_count): + """This function is called when the alarm has been triggered.""" + + # Get the time and convert to a string: + t_str = timestamp_to_string(timestamp) + + # Log it in the system log: + log.info("Low battery status sounded at %s: %s" % (t_str, battery_flags)) + + # Form the message text: + indicator_strings = [] + for bat in battery_flags: + indicator_strings.append("%s: %04x" % (bat, int(battery_flags[bat]))) + msg_text = """ +The low battery indicator has been seen %d times since the last archive period. + +Alarm sounded at %s + +Low battery indicators: +%s + +""" % (alarm_count, t_str, '\n'.join(indicator_strings)) + # Convert to MIME: + msg = MIMEText(msg_text) + + # Fill in MIME headers: + msg['Subject'] = self.SUBJECT + msg['From'] = self.FROM + msg['To'] = ','.join(self.TO) + + try: + # First try end-to-end encryption + s=smtplib.SMTP_SSL(self.smtp_host) + log.debug("Using SMTP_SSL") + except AttributeError: + # If that doesn't work, try creating an insecure host, then upgrading + s = smtplib.SMTP(self.smtp_host) + try: + # Be prepared to catch an exception if the server + # does not support encrypted transport. + s.ehlo() + s.starttls() + s.ehlo() + log.debug("Using SMTP encrypted transport") + except smtplib.SMTPException: + log.debug("Using SMTP unencrypted transport") + + try: + # If a username has been given, assume that login is required + # for this host: + if self.smtp_user: + s.login(self.smtp_user, self.smtp_password) + log.debug("Logged in as %s", self.smtp_user) + + # Send the email: + s.sendmail(msg['From'], self.TO, msg.as_string()) + # Log out of the server: + s.quit() + except Exception as e: + log.error("Send email failed: %s", e) + raise + + # Log sending the email: + log.info("Email sent to: %s", self.TO) + + +if __name__ == '__main__': + """This section is used to test lowBattery.py. It uses a record that is guaranteed to + sound a battery alert. + + You will need a valid weewx.conf configuration file with an [Alarm] + section that has been set up as illustrated at the top of this file.""" + + from optparse import OptionParser + import weecfg + import weeutil.logger + + usage = """Usage: python lowBattery.py --help + python lowBattery.py [CONFIG_FILE|--config=CONFIG_FILE] + +Arguments: + + CONFIG_PATH: Path to weewx.conf """ + + # Force debug: + weewx.debug = 1 + + # Create a command line parser: + parser = OptionParser(usage=usage) + parser.add_option("--config", dest="config_path", metavar="CONFIG_FILE", + help="Use configuration file CONFIG_FILE.") + # Parse the arguments and options + (options, args) = parser.parse_args() + + try: + config_path, config_dict = weecfg.read_config(options.config_path, args) + except IOError as e: + exit("Unable to open configuration file: %s" % e) + + print("Using configuration file %s" % config_path) + + # Set logging configuration: + weeutil.logger.setup('wee_lowBattery', config_dict) + + if 'Alarm' not in config_dict: + exit("No [Alarm] section in the configuration file %s" % config_path) + + # This is the fake packet that we'll use + pack = {'txBatteryStatus': 1.0, + 'dateTime': int(time.time())} + + # We need the main WeeWX engine in order to bind to the event, but we don't need + # for it to completely start up. So get rid of all services: + config_dict['Engine']['Services'] = {} + # Now we can instantiate our slim engine, using the DummyEngine class... + engine = weewx.engine.DummyEngine(config_dict) + # ... and set the alarm using it. + alarm = BatteryAlarm(engine, config_dict) + + # Create a NEW_LOOP_PACKET event + event = weewx.Event(weewx.NEW_LOOP_PACKET, packet=pack) + + # Trigger the alarm enough that we reach the threshold + for count in range(alarm.count_threshold): + alarm.new_loop_packet(event) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/mem.py b/dist/weewx-5.0.2/src/weewx_data/examples/mem.py new file mode 100644 index 0000000..984f8fa --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/mem.py @@ -0,0 +1,37 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +import os +import resource + +import weewx +from weewx.wxengine import StdService + +class Memory(StdService): + + def __init__(self, engine, config_dict): + # Pass the initialization information on to my superclass: + super(Memory, self).__init__(engine, config_dict) + + self.page_size = resource.getpagesize() + self.bind(weewx.NEW_ARCHIVE_RECORD, self.newArchiveRecord) + + def newArchiveRecord(self, event): + + pid = os.getpid() + procfile = "/proc/%s/statm" % pid + try: + mem_tuple = open(procfile).read().split() + except (IOError, ): + return + + # Unpack the tuple: + (size, resident, share, text, lib, data, dt) = mem_tuple + + mb = 1024 * 1024 + event.record['soilMoist1'] = float(size) * self.page_size / mb + event.record['soilMoist2'] = float(resident) * self.page_size / mb + event.record['soilMoist3'] = float(share) * self.page_size / mb + \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/pmon/bin/user/pmon.py b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/bin/user/pmon.py new file mode 100644 index 0000000..0a845db --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/bin/user/pmon.py @@ -0,0 +1,201 @@ +# Copyright 2013-2013 Matthew Wall +"""weewx module that records process information. + +Installation + +Put this file in the bin/user directory. + + +Configuration + +Add the following to weewx.conf: + +[ProcessMonitor] + data_binding = pmon_binding + +[DataBindings] + [[pmon_binding]] + database = pmon_sqlite + manager = weewx.manager.Manager + table_name = archive + schema = user.pmon.schema + +[Databases] + [[pmon_sqlite]] + database_name = archive/pmon.sdb + database_type = SQLite + +[Engine] + [[Services]] + archive_services = ..., user.pmon.ProcessMonitor +""" + +import logging +import os +import re +import time +from subprocess import Popen, PIPE + +import weedb +import weewx.manager +from weeutil.weeutil import to_int +from weewx.engine import StdService + +VERSION = "0.7" + +log = logging.getLogger(__name__) + +schema = [ + ('dateTime', 'INTEGER NOT NULL PRIMARY KEY'), + ('usUnits', 'INTEGER NOT NULL'), + ('interval', 'INTEGER NOT NULL'), + ('mem_vsz', 'INTEGER'), + ('mem_rss', 'INTEGER'), +] + + +class ProcessMonitor(StdService): + + def __init__(self, engine, config_dict): + super(ProcessMonitor, self).__init__(engine, config_dict) + + # To make what follows simpler, isolate the "pmon" part of the configuration file + pmon_dict = config_dict.get('ProcessMonitor', {}) + self.process = pmon_dict.get('process', 'weewxd') + self.max_age = to_int(pmon_dict.get('max_age', 2592000)) + + # get the database parameters we need to function + binding = pmon_dict.get('data_binding', 'pmon_binding') + self.dbm = self.engine.db_binder.get_manager(data_binding=binding, + initialize=True) + + # be sure database matches the schema we have + dbcol = self.dbm.connection.columnsOf(self.dbm.table_name) + dbm_dict = weewx.manager.get_manager_dict_from_config(config_dict, binding) + memcol = [x[0] for x in dbm_dict['schema']] + if dbcol != memcol: + raise Exception('pmon schema mismatch: %s != %s' % (dbcol, memcol)) + + self.last_ts = None + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def shutDown(self): + try: + self.dbm.close() + except weedb.DatabaseError: + pass + + def new_archive_record(self, event): + """save data to database then prune old records as needed""" + now = int(time.time() + 0.5) + delta = now - event.record['dateTime'] + if delta > event.record['interval'] * 60: + log.debug("Skipping record: time difference %s too big" % delta) + return + if self.last_ts is not None: + self.save_data(self.get_data(now, self.last_ts)) + self.last_ts = now + if self.max_age is not None: + self.prune_data(now - self.max_age) + + def save_data(self, record): + """save data to database""" + self.dbm.addRecord(record) + + def prune_data(self, ts): + """delete records with dateTime older than ts""" + sql = "delete from %s where dateTime < %d" % (self.dbm.table_name, ts) + self.dbm.getSql(sql) + try: + # sqlite databases need some help to stay small + self.dbm.getSql('vacuum') + except weedb.DatabaseError: + pass + + COLUMNS = re.compile(r'\S+\s+\d+\s+[\d.]+\s+[\d.]+\s+(\d+)\s+(\d+)') + + def get_data(self, now_ts, last_ts): + record = { + 'dateTime' : now_ts, + 'usUnits' : weewx.METRIC, + 'interval' : int((now_ts - last_ts) / 60.0) + } + try: + cmd = 'ps aux' + p = Popen(cmd, shell=True, stdout=PIPE) + o = p.communicate()[0].decode('ascii') + for line in o.split('\n'): + if line.find(self.process) >= 0: + m = self.COLUMNS.search(line) + if m: + record['mem_vsz'] = int(m.group(1)) + record['mem_rss'] = int(m.group(2)) + except (ValueError, IOError, KeyError) as e: + log.error('apcups_info failed: %s' % e) + return record + + +# what follows is a basic unit test of this module. to run the test: +# +# cd ~/weewx-data +# PYTHONPATH=bin python bin/user/pmon.py +# +if __name__ == "__main__": + from weewx.engine import StdEngine + import weeutil.logger + import weewx + + weewx.debug = 1 + weeutil.logger.setup('pmon') + + config = { + 'Station': { + 'station_type': 'Simulator', + 'altitude': [0, 'foot'], + 'latitude': 0, + 'longitude': 0}, + 'Simulator': { + 'driver': 'weewx.drivers.simulator', + 'mode': 'simulator'}, + 'ProcessMonitor': { + 'data_binding': 'pmon_binding', + 'process': 'pmon'}, + 'DataBindings': { + 'pmon_binding': { + 'database': 'pmon_sqlite', + 'manager': 'weewx.manager.DaySummaryManager', + 'table_name': 'archive', + 'schema': 'user.pmon.schema'}}, + 'Databases': { + 'pmon_sqlite': { + 'database_name': 'pmon.sdb', + 'database_type': 'SQLite'}}, + 'DatabaseTypes': { + 'SQLite': { + 'driver': 'weedb.sqlite', + 'SQLITE_ROOT': '/var/tmp'}}, + 'Engine': { + 'Services': { + 'process_services': 'user.pmon.ProcessMonitor'}} + } + eng = StdEngine(config) + svc = ProcessMonitor(eng, config) + + nowts = lastts = int(time.time()) + + loop = 0 + try: + while True: + rec = svc.get_data(nowts, lastts) + print(rec) + loop += 1 + if loop >= 3: + break + time.sleep(5) + lastts = nowts + nowts = int(time.time()+0.5) + finally: + try: + os.remove('/var/tmp/pmon.sdb') + except FileNotFoundError: + pass diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/pmon/changelog b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/changelog new file mode 100644 index 0000000..fc934d9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/changelog @@ -0,0 +1,20 @@ +0.7 22jan2023 +* Ported to WeeWX V5.0 + +0.6 17nov2019 +* ported to python 3 +* ported to weewx v4 + +0.4 24apr2016 +* fixed database declarations for direct invocation +* fixed timestamp typo now_ts +* fixed incorrect temporary directory + +0.3 17apr2016 +* keep pylint happy + +0.2 24nov2014 +* update for weewx v3 + +0.1 24mar2014 +* initial public release diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/pmon/install.py b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/install.py new file mode 100644 index 0000000..390614e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/install.py @@ -0,0 +1,41 @@ +# installer for pmon +# Copyright 2014-2024 Matthew Wall + +from weecfg.extension import ExtensionInstaller + + +def loader(): + return ProcessMonitorInstaller() + + +class ProcessMonitorInstaller(ExtensionInstaller): + def __init__(self): + super(ProcessMonitorInstaller, self).__init__( + version="0.7", + name='pmon', + description='Collect and display process memory usage.', + author="Matthew Wall", + author_email="mwall@users.sourceforge.net", + process_services='user.pmon.ProcessMonitor', + config={ + 'ProcessMonitor': { + 'data_binding': 'pmon_binding', + 'process': 'weewxd'}, + 'DataBindings': { + 'pmon_binding': { + 'database': 'pmon_sqlite', + 'table_name': 'archive', + 'manager': 'weewx.manager.Manager', + 'schema': 'user.pmon.schema'}}, + 'Databases': { + 'pmon_sqlite': { + 'database_name': 'pmon.sdb', + 'driver': 'weedb.sqlite'}}, + 'StdReport': { + 'pmon': { + 'skin': 'pmon', + 'HTML_ROOT': 'pmon'}}}, + files=[('bin/user', ['bin/user/pmon.py']), + ('skins/pmon', ['skins/pmon/skin.conf', + 'skins/pmon/index.html.tmpl'])] + ) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/pmon/readme.md b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/readme.md new file mode 100644 index 0000000..7d85706 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/readme.md @@ -0,0 +1,105 @@ +pmon - Process Monitor +====================== + +Copyright 2014-2024 Matthew Wall + +This example illustrates how to implement a service and package it so that it +can be installed by the extension installer. The pmon service collects memory +usage information about a single process then saves it in its own database. +Data are then displayed using standard WeeWX reporting and plotting utilities. + + +Installation instructions using the installer (recommended) +----------------------------------------------------------- + +1) Install the extension. + + For pip installs: + + weectl extension install ~/weewx-data/examples/pmon + + For package installs + + sudo weectl extension install /usr/share/doc/weewx/examples/pmon + + +2) Restart WeeWX + + sudo systemctl restart weewx + + +This will result in a skin called `pmon` with a single web page that illustrates +how to use the monitoring data. See comments in pmon.py for customization +options. + + +Manual installation instructions +-------------------------------- + +1) Copy the pmon service file to the WeeWX user directory. + + For pip installs: + + cd ~/weewx-data/examples/pmon + cp bin/user/pmon.py ~/etc/weewx-data/bin/user + + For package installs: + + cd /usr/share/doc/weewx/examples/pmon + sudo cp bin/user/pmon.py /usr/share/weewx/user + + +2) Copy the pmon skin to the WeeWX skins directory. + + For pip installs: + + cd ~/weewx-data/examples/pmon + cp skins/pmon ~/weewx-data/skins/ + + For package installs: + + cd /usr/share/doc/weewx/examples/pmon + sudo cp skins/pmon/ /etc/weewx/skins/ + + +3) In the WeeWX configuration file, add a new `[ProcessMonitor]` stanza + + [ProcessMonitor] + data_binding = pmon_binding + process = weewxd + +4) In the WeeWX configuration file, add a data binding + + [DataBindings] + ... + [[pmon_binding]] + database = pmon_sqlite + table_name = archive + manager = weewx.manager.Manager + schema = user.pmon.schema + +5) In the WeeWX configuration file, add a database + + [Databases] + ... + [[pmon_sqlite]] + database_name = pmon.sdb + driver = weedb.sqlite + +6) In the WeeWX configuration file, add a report + + [StdReport] + ... + [[pmon]] + skin = pmon + HTML_ROOT = pmon + +7) In the WeeWX configuration file, add the pmon service + + [Engine] + [[Services]] + process_services = ..., user.pmon.ProcessMonitor + +8) Restart WeeWX + + sudo systemctl restart weewx \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/pmon/skins/pmon/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/skins/pmon/index.html.tmpl new file mode 100644 index 0000000..031ab62 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/skins/pmon/index.html.tmpl @@ -0,0 +1,25 @@ +## pmon for weewx - Copyright 2013-2014 Matthew Wall +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + pmon + + + + + + + + + diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/pmon/skins/pmon/skin.conf b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/skins/pmon/skin.conf new file mode 100644 index 0000000..49452de --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/pmon/skins/pmon/skin.conf @@ -0,0 +1,47 @@ +# configuration file for the pmon skin +# Copyright 2014 Matthew Wall + +[Extras] + version = X + +[CheetahGenerator] + [[ToDate]] + [[[pmon]]] + template = index.html.tmpl + +[ImageGenerator] + data_binding = pmon_binding + image_width = 700 + image_height = 200 + image_background_color = "#ffffff" + chart_background_color = "#ffffff" + chart_gridline_color = "#eaeaea" + unit_label_font_color = "#aaaaaa" + bottom_label_font_color = "#aaaaaa" + axis_label_font_color = "#aaaaaa" + chart_line_colors = "#30a030", "#80d090", "#111a11", "#a03030", "#d09080", "#1a1111", "#3030a0" + marker_type = 'none' + + [[day_images]] + time_length = 86400 + x_label_format = %H:%M + [[[dayprocmem]]] + [[[[mem_vsz]]]] + [[[[mem_rss]]]] + + [[week_images]] + time_length = 604800 + x_label_format = %d + [[[weekprocmem]]] + [[[[mem_vsz]]]] + [[[[mem_rss]]]] + + [[month_images]] + time_length = 2592000 + x_label_format = %d + [[[monthprocmem]]] + [[[[mem_vsz]]]] + [[[[mem_rss]]]] + +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/tests/__init__.py b/dist/weewx-5.0.2/src/weewx_data/examples/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/tests/test_vaporpressure.py b/dist/weewx-5.0.2/src/weewx_data/examples/tests/test_vaporpressure.py new file mode 100644 index 0000000..16fad89 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/tests/test_vaporpressure.py @@ -0,0 +1,132 @@ +# +# Copyright (c) 2020-2024 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Test the xtypes example vaporpressure""" +import logging +import os +import sys +import time +import unittest + +import configobj + +import weewx + +# We need to add two things to the python path: the test directory used by the weewx package (so we +# can find 'gen_fake_data'), and the examples directory (so we can find 'vaporpressure'). +test_path = os.path.join(os.path.dirname(weewx.__file__), './tests') +sys.path.append(test_path) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +# Now we can import gen_fake_data and vaporpressure +import gen_fake_data +import vaporpressure + +import weeutil.logger +import weewx.xtypes +from weeutil.weeutil import TimeSpan + +log = logging.getLogger(__name__) +weeutil.logger.setup('weetest_vaporpressure') + +weewx.debug = 1 + +# Register vapor pressure with the xtypes system +weewx.xtypes.xtypes.append(vaporpressure.VaporPressure()) + +# Find the configuration file used by the weewx package. +config_path = os.path.join(test_path, 'testgen.conf') + +cwd = None + +os.environ['TZ'] = 'America/Los_Angeles' +time.tzset() + +# Test for 1-Mar-2010 in the synthetic database +day_start_tt = (2010, 3, 1, 0, 0, 0, 0, 0, -1) +day_stop_tt = (2010, 3, 2, 0, 0, 0, 0, 0, -1) +day_start_ts = time.mktime(day_start_tt) +day_stop_ts = time.mktime(day_stop_tt) + + +class CommonTests(object): + """Test that inserting records get the weighted sums right. Regression test for issue #623. """ + + expected_vapor_p = [1.5263, 1.434, 1.3643, 1.3157, 1.2873, 1.2784, 1.2887, 1.3186, 1.3686, + 1.4401, 1.5343, 1.6532, 1.7987, 1.9728, 2.1773, 2.4137, 2.6826, 2.9833, + 3.314, 3.6706, 4.0472, 4.4354, 4.825, 5.2035, 5.5573, 5.8724, 6.1351, + 6.3333, 6.4576, 6.5017, 6.4635, 6.3449, 6.152, 5.8941, 5.5831, 5.2327, + 4.8568, 4.4692, 4.0822, 3.7063, 3.3498, 3.0189, 2.7176, 2.4481, 2.2109, + 2.0056, 1.8308, 1.6847] + + + def setUp(self): + global config_path + global cwd + + # Save and set the current working directory in case some service changes it. + if cwd: + os.chdir(cwd) + else: + cwd = os.getcwd() + + try: + self.config_dict = configobj.ConfigObj(config_path, file_error=True, encoding='utf-8') + except IOError: + sys.stderr.write("Unable to open configuration file %s" % config_path) + # Reraise the exception (this will eventually cause the program to exit) + raise + except configobj.ConfigObjError: + sys.stderr.write("Error while parsing configuration file %s" % config_path) + raise + + # This will generate the test databases if necessary: + gen_fake_data.configDatabases(self.config_dict, database_type=self.database_type) + + def tearDown(self): + pass + + def test_series(self): + """Check for calculating a series of vapor pressures.""" + with weewx.manager.open_manager_with_config(self.config_dict, 'wx_binding') as db_manager: + start_vec, stop_vec, data_vec = weewx.xtypes.get_series( + 'vapor_p', + TimeSpan(day_start_ts, day_stop_ts), + db_manager) + self.assertEqual([round(x, 4) for x in data_vec[0]], CommonTests.expected_vapor_p, 4) + + +class TestSqlite(CommonTests, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "sqlite" + super().__init__(*args, **kwargs) + + +class TestMySQL(CommonTests, unittest.TestCase): + + def __init__(self, *args, **kwargs): + self.database_type = "mysql" + super().__init__(*args, **kwargs) + + def setUp(self): + try: + import MySQLdb + except ImportError: + try: + import pymysql as MySQLdb + except ImportError as e: + raise unittest.case.SkipTest(e) + super().setUp() + + +def suite(): + tests = ['test_series', ] + + # Test both sqlite and MySQL: + return unittest.TestSuite(list(map(TestSqlite, tests)) + list(map(TestMySQL, tests))) + + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/vaporpressure.py b/dist/weewx-5.0.2/src/weewx_data/examples/vaporpressure.py new file mode 100644 index 0000000..ee0f979 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/vaporpressure.py @@ -0,0 +1,115 @@ +# +# Copyright (c) 2020 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""This example shows how to extend the XTypes system with a new type, vapor_p, the vapor +pressure of water. + +REQUIRES WeeWX V4.2 OR LATER! + +To use: + 1. Stop weewxd + 2. Put this file in your user subdirectory. + 3. In weewx.conf, subsection [Engine][[Services]], add VaporPressureService to the list + "xtype_services". For example, this means changing this + + [Engine] + [[Services]] + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater + + to this: + + [Engine] + [[Services]] + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, user.vaporpressure.VaporPressureService + + 4. Optionally, add the following section to weewx.conf: + [VaporPressure] + algorithm = simple # Or tetens + + 5. Restart weewxd + +""" +import math + +import weewx +import weewx.units +import weewx.xtypes +from weewx.engine import StdService +from weewx.units import ValueTuple + + +class VaporPressure(weewx.xtypes.XType): + + def __init__(self, algorithm='simple'): + # Save the algorithm to be used. + self.algorithm = algorithm.lower() + + def get_scalar(self, obs_type, record, db_manager): + # We only know how to calculate 'vapor_p'. For everything else, raise an exception UnknownType + if obs_type != 'vapor_p': + raise weewx.UnknownType(obs_type) + + # We need outTemp in order to do the calculation. + if 'outTemp' not in record or record['outTemp'] is None: + raise weewx.CannotCalculate(obs_type) + + # We have everything we need. Start by forming a ValueTuple for the outside temperature. + # To do this, figure out what unit and group the record is in ... + unit_and_group = weewx.units.getStandardUnitType(record['usUnits'], 'outTemp') + # ... then form the ValueTuple. + outTemp_vt = ValueTuple(record['outTemp'], *unit_and_group) + + # Both algorithms need temperature in Celsius, so let's make sure our incoming temperature + # is in that unit. Use function convert(). The results will be in the form of a ValueTuple + outTemp_C_vt = weewx.units.convert(outTemp_vt, 'degree_C') + # Get the first element of the ValueTuple. This will be in Celsius: + outTemp_C = outTemp_C_vt[0] + + if self.algorithm == 'simple': + # Use the "Simple" algorithm. + # We need temperature in Kelvin. + outTemp_K = weewx.units.CtoK(outTemp_C) + # Now we can use the formula. Results will be in mmHg. Create a ValueTuple out of it: + p_vt = ValueTuple(math.exp(20.386 - 5132.0 / outTemp_K), 'mmHg', 'group_pressure') + elif self.algorithm == 'tetens': + # Use Teten's algorithm. + # Use the formula. Results will be in kPa: + p_kPa = 0.61078 * math.exp(17.27 * outTemp_C_vt[0] / (outTemp_C_vt[0] + 237.3)) + # Form a ValueTuple + p_vt = ValueTuple(p_kPa, 'kPa', 'group_pressure') + else: + # Don't recognize the exception. Fail hard: + raise ValueError(self.algorithm) + + # If we got this far, we were able to calculate a value. Return it. + return p_vt + + +class VaporPressureService(StdService): + """ WeeWX service whose job is to register the XTypes extension VaporPressure with the + XType system. + """ + + def __init__(self, engine, config_dict): + super(VaporPressureService, self).__init__(engine, config_dict) + + # Get the desired algorithm. Default to "simple". + try: + algorithm = config_dict['VaporPressure']['algorithm'] + except KeyError: + algorithm = 'simple' + + # Instantiate an instance of VaporPressure: + self.vp = VaporPressure(algorithm) + # Register it: + weewx.xtypes.xtypes.append(self.vp) + + def shutDown(self): + # Remove the registered instance: + weewx.xtypes.xtypes.remove(self.vp) + + +# Tell the unit system what group our new observation type, 'vapor_p', belongs to: +weewx.units.obs_group_dict['vapor_p'] = "group_pressure" diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/xstats/bin/user/xstats.py b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/bin/user/xstats.py new file mode 100644 index 0000000..72383f0 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/bin/user/xstats.py @@ -0,0 +1,122 @@ +# +# Copyright (c) 2009-2015 Tom Keffer +# +# See the file LICENSE.txt for your full rights. +# +"""Extended stats based on the xsearch example + +This search list extension offers extra tags: + + 'alltime': All time statistics. + + 'seven_day': Statistics for the last seven days. + + 'thirty_day': Statistics for the last thirty days. + + 'last_month': Statistics for last calendar month. + + 'last_year': Statistics for last calendar year. + + 'last_year_todate': Statistics of last calendar year until this + time last year. This especially useful for + comprisons of rain fall up to this time last year. + +You can then use tags such as $alltime.outTemp.max for the all-time max +temperature, or $seven_day.rain.sum for the total rainfall in the last seven +days, or $thirty_day.wind.max for maximum wind speed in the past thirty days. +""" +import datetime +import time + +from weewx.cheetahgenerator import SearchList +from weewx.tags import TimespanBinder +from weeutil.weeutil import TimeSpan + + +class ExtendedStatistics(SearchList): + + def __init__(self, generator): + SearchList.__init__(self, generator) + + def get_extension_list(self, timespan, db_lookup): + """Returns a search list extension with additions. + + timespan: An instance of weeutil.weeutil.TimeSpan. This holds + the start and stop times of the domain of valid times. + + db_lookup: Function that returns a database manager given a + data binding. + """ + + # First, create a TimespanBinder object for all time. This one is easy + # because the object timespan already holds all valid times to be + # used in the report. + all_stats = TimespanBinder(timespan, + db_lookup, + context='alltime', + formatter=self.generator.formatter, + converter=self.generator.converter) + + # Now create a TimespanBinder for the last seven days. This one we + # will have to calculate. First, calculate the time at midnight, seven + # days ago. The variable week_dt will be an instance of datetime.date. + week_dt = datetime.date.fromtimestamp(timespan.stop) - datetime.timedelta(weeks=1) + # Now convert it to unix epoch time: + week_ts = time.mktime(week_dt.timetuple()) + # Now form a TimeSpanStats object, using the time span just calculated: + seven_day_stats = TimespanBinder(TimeSpan(week_ts, timespan.stop), + db_lookup, + context='seven_day', + formatter=self.generator.formatter, + converter=self.generator.converter) + + # Now use a similar process to get statistics for the last 30 days. + days_dt = datetime.date.fromtimestamp(timespan.stop) - datetime.timedelta(days=30) + days_ts = time.mktime(days_dt.timetuple()) + thirty_day_stats = TimespanBinder(TimeSpan(days_ts, timespan.stop), + db_lookup, + context='thirty_day', + formatter=self.generator.formatter, + converter=self.generator.converter) + + # Now use a similar process to get statistics for last year. + year = datetime.date.today().year + start_ts = time.mktime((year - 1, 1, 1, 0, 0, 0, 0, 0, 0)) + stop_ts = time.mktime((year, 1, 1, 0, 0, 0, 0, 0, 0)) + last_year_stats = TimespanBinder(TimeSpan(start_ts, stop_ts), + db_lookup, + context='last_year', + formatter=self.generator.formatter, + converter=self.generator.converter) + + # Now use a similar process to get statistics for last year to date. + year = datetime.date.today().year + month = datetime.date.today().month + day = datetime.date.today().day + start_ts = time.mktime((year - 1, 1, 1, 0, 0, 0, 0, 0, 0)) + stop_ts = time.mktime((year - 1, month, day, 0, 0, 0, 0, 0, 0)) + last_year_todate_stats = TimespanBinder(TimeSpan(start_ts, stop_ts), + db_lookup, + context='last_year_todate', + formatter=self.generator.formatter, + converter=self.generator.converter) + + # Now use a similar process to get statistics for last calendar month. + start_ts = time.mktime((year, month - 1, 1, 0, 0, 0, 0, 0, 0)) + stop_ts = time.mktime((year, month, 1, 0, 0, 0, 0, 0, 0)) - 1 + last_month_stats = TimespanBinder(TimeSpan(start_ts, stop_ts), + db_lookup, + context='last_year_todate', + formatter=self.generator.formatter, + converter=self.generator.converter) + + return [{'alltime': all_stats, + 'seven_day': seven_day_stats, + 'thirty_day': thirty_day_stats, + 'last_month': last_month_stats, + 'last_year': last_year_stats, + 'last_year_todate': last_year_todate_stats}] + + +# For backwards compatibility: +ExtStats = ExtendedStatistics diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/xstats/changelog b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/changelog new file mode 100644 index 0000000..ee7d68f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/changelog @@ -0,0 +1,8 @@ +0.3 23jan2023 +* Port to WeeWX V5.0 -tk + +0.2 13nov2014 +* update to work with weewx v3 + +0.1 02feb2014 +* initial release as packaged weewx extension diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/xstats/install.py b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/install.py new file mode 100644 index 0000000..0f83262 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/install.py @@ -0,0 +1,27 @@ +# installer for xstats +# Copyright 2014 Matthew Wall + +from weecfg.extension import ExtensionInstaller + + +def loader(): + return XStatsInstaller() + + +class XStatsInstaller(ExtensionInstaller): + def __init__(self): + super(XStatsInstaller, self).__init__( + version="0.3", + name='xstats', + description='Extended statistics for weewx reports', + author="Matthew Wall", + author_email="mwall@users.sourceforge.net", + config={ + 'StdReport': { + 'xstats': { + 'skin': 'xstats', + 'HTML_ROOT': 'xstats'}}}, + files=[('bin/user', ['bin/user/xstats.py']), + ('skins/xstats', ['skins/xstats/skin.conf', + 'skins/xstats/index.html.tmpl'])] + ) diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/xstats/readme.txt b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/readme.txt new file mode 100644 index 0000000..e3bd6df --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/readme.txt @@ -0,0 +1,99 @@ +xstats +====== + +WeeWX extension that provides extended statistics for reports + +Copyright 2014-2024 Matthew Wall + +This search list extension offers extra tags: + + 'alltime': All time statistics. + For example, "what is the all-time high temperature?" + $alltime.outTemp.max + + 'seven_day': Statistics for the last seven days, i.e., since midnight + seven days ago. For example, "what is the maximum wind + speed in the last seven days?" + $seven_day.wind.max + + 'thirty_day': Statistics for the last thirty days, i.e., since midnight + thirty days ago. For example, "what is the maximum wind + speed in the last thirty days?" + $thirty_day.wind.max + + 'last_month': Statistics for last calendar month, this is useful in + getting statistics such as the maximum/minimum records. + $last_month.outTemp.max at $last_month.outTemp.maxtime + $last_month.outTemp.min at $last_month.outTemp.mintime + + 'last_year': Statistics for last calendar year, this is useful for + things like total rainfall for last year. + $last_year.rain.sum + + 'last_year_todate': Statistics of last calendar year until this time + last year. This is useful for comprisons of rain + fall up to this time last year. + $last_year_todate.rain.sum + +Installation instructions using the installer (recommended) +----------------------------------------------------------- + +1) Install the extension. + + For pip installs: + + weectl extension install ~/weewx-data/examples/xstats + + For package installs + + sudo weectl extension install /usr/share/doc/weewx/examples/xstats + + +2) Restart WeeWX + + sudo systemctl restart weewx + + +This will result in a report called xstats that illustrates the use of the +extended statistics. + + +Manual installation instructions +-------------------------------- + +1) Copy the file `xstats.py` to the WeeWX `user` directory. + + For pip installs: + + cd ~/weewx-data/examples/xstats + cp bin/user/xstats.py ~/etc/weewx-data/bin/user + + For package installs: + + cd /usr/share/doc/weewx/examples/xstats + sudo cp bin/user/xstats.py /usr/share/weewx/user + +2) Copy the `xstats` demonstration skin to the `skins` directory. + + For pip installs: + + cd ~/weewx-data/examples/xstats + cp skins/xsstats ~/weewx-data/skins/ + + For package installs: + + cd /usr/share/doc/weewx/examples/xstats + sudo cp skins/xstats/ /etc/weewx/skins/ + + +3) In the WeeWX configuration file, arrange to use the demonstration skin `xstats`. + + [StdReport] + ... + [[XStats]] + skin = xstats + HTML_ROOT = xstats + +3) Restart WeeWX + + sudo systemctl restart weewx diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/xstats/skins/xstats/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/skins/xstats/index.html.tmpl new file mode 100644 index 0000000..1085c49 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/skins/xstats/index.html.tmpl @@ -0,0 +1,74 @@ +## xstats for weewx - Copyright 2014-2024 Matthew Wall +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + xstats + + + + + $current.dateTime
+ current temperature: $current.outTemp
+ day average: $day.outTemp.avg
+ week average: $week.outTemp.avg
+ month average: $month.outTemp.avg
+ +#if $varExists('seven_day') + seven day min: $seven_day.outTemp.min
+ seven day avg: $seven_day.outTemp.avg
+ seven day max: $seven_day.outTemp.max
+#else +
seven_day
is not functioning
+#end if + +#if $varExists('thirty_day') + thirty day min: $thirty_day.outTemp.min
+ thirty day avg: $thirty_day.outTemp.avg
+ thirty day max: $thirty_day.outTemp.max
+#else +
thirty_day
is not functioning
+#end if + +#if $varExists('alltime') + alltime min: $alltime.outTemp.min
+ alltime avg: $alltime.outTemp.avg
+ alltime max: $alltime.outTemp.max
+#else +
alltime
is not functioning
+#end if + +#if $varExists('last_month') + last_month rain total: $last_month.rain.sum
+ max temp last month: $last_month.outTemp.max at $last_month.outTemp.maxtime
+ min temp last month: $last_month.outTemp.min at $last_month.outTemp.mintime
+#else +
last_month
is not functioning
+#end if + +#if $varExists('last_year') + last_year rain total: $last_year.rain.sum
+ max temp last year: $last_year.outTemp.max at $last_year.outTemp.maxtime
+ min temp last year: $last_year.outTemp.min at $last_year.outTemp.mintime
+#else +
last_year
is not functioning
+#end if + +#if $varExists('last_year_todate') + last_year_todate rain total: $last_year_todate.rain.sum
+ rain total this year: $year.rain.sum
+#else +
last_year_todate
is not functioning
+#end if + + diff --git a/dist/weewx-5.0.2/src/weewx_data/examples/xstats/skins/xstats/skin.conf b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/skins/xstats/skin.conf new file mode 100644 index 0000000..db173aa --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/examples/xstats/skins/xstats/skin.conf @@ -0,0 +1,18 @@ +# configuration file for the xstats skin +# Copyright 2014-2024 Matthew Wall + +[Units] + [[Groups]] + # The sample template, index.html.tmpl, shows statistics for outTemp, + # the outside temperature. This option lets you change between + # Fahrenheit and Celsius: + group_temperature = degree_F # Options are 'degree_F' or 'degree_C' + +[CheetahGenerator] + search_list_extensions = user.xstats.ExtendedStatistics + [[ToDate]] + [[[xstats]]] + template = index.html.tmpl + +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator diff --git a/dist/weewx-5.0.2/src/weewx_data/scripts/setup-daemon.sh b/dist/weewx-5.0.2/src/weewx_data/scripts/setup-daemon.sh new file mode 100755 index 0000000..1837105 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/scripts/setup-daemon.sh @@ -0,0 +1,216 @@ +#!/bin/sh +# +# Install files that integrate WeeWX into an operating system. +# This script must be run using sudo, or as root. +# +set -e + +HOMEDIR=$HOME +if [ "$SUDO_USER" != "" ]; then + HOMEDIR=$(getent passwd $SUDO_USER | cut -d: -f6) +fi +UTIL_ROOT=$HOMEDIR/weewx-data/util + +if [ "$(id -u)" != "0" ]; then + echo "This script requires admin privileges. Use 'sudo' or run as root." + exit 1 +fi + +ts=`date +"%Y%m%d%H%M%S"` + +copy_file() { + src=$1 + dst=$2 + if [ -f "$dst" ]; then + mv ${dst} ${dst}.${ts} + fi + echo "Installing $dst" + cp $src $dst +} + +remove_file() { + dst=$1 + if [ -f "$dst" ]; then + echo "Removing $dst" + rm $dst + fi +} + +install_udev() { + if [ -d /etc/udev/rules.d ]; then + copy_file $UTIL_ROOT/udev/rules.d/weewx.rules /etc/udev/rules.d/60-weewx.rules + echo " If you are using a device that is connected to the computer by USB or" + echo " serial port, unplug the device then plug it back in again to ensure that" + echo " permissions are applied correctly." + fi +} + +uninstall_udev() { + remove_file /etc/udev/rules.d/60-weewx.rules +} + +install_systemd() { + copy_file $UTIL_ROOT/systemd/weewx.service /etc/systemd/system/weewx.service + copy_file $UTIL_ROOT/systemd/weewx@.service /etc/systemd/system/weewx@.service + + echo "Reloading systemd" + systemctl daemon-reload + echo "Enabling weewx to start when system boots" + systemctl enable weewx + + echo "You can start/stop weewx with the following commands:" + echo " sudo systemctl start weewx" + echo " sudo systemctl stop weewx" +} + +uninstall_systemd() { + echo "Stopping weewx" + systemctl stop weewx + echo "Disabling weewx" + systemctl disable weewx + remove_file /etc/systemd/system/weewx@.service + remove_file /etc/systemd/system/weewx.service +} + +install_sysv() { + if [ -d /etc/default ]; then + copy_file $UTIL_ROOT/default/weewx /etc/default/weewx + fi + copy_file $UTIL_ROOT/init.d/weewx-multi /etc/init.d/weewx + chmod 755 /etc/init.d/weewx + + echo "Enabling weewx to start when system boots" + update-rc.d weewx defaults + + echo "You can start/stop weewx with the following commands:" + echo " /etc/init.d/weewx start" + echo " /etc/init.d/weewx stop" +} + +uninstall_sysv() { + echo "Stopping weewx" + /etc/init.d/weewx stop + echo "Disabling weewx" + update-rc.d weewx remove + remove_file /etc/init.d/weewx + remove_file /etc/default/weewx +} + +install_bsd() { + if [ -d /etc/defaults ]; then + copy_file $UTIL_ROOT/default/weewx /etc/defaults/weewx.conf + fi + copy_file $UTIL_ROOT/init.d/weewx.bsd /usr/local/etc/rc.d/weewx + chmod 755 /usr/local/etc/rc.d/weewx + + echo "Enabling weewx to start when system boots" + sysrc weewx_enable="YES" + + echo "You can start/stop weewx with the following commands:" + echo " sudo service weewx start" + echo " sudo service weewx stop" +} + +uninstall_bsd() { + echo "Stopping weewx..." + service weewx stop + echo "Disabling weewx..." + sysrc weewx_enable="NO" + remove_file /usr/local/etc/rc.d/weewx + remove_file /etc/defaults/weewx.conf +} + +install_macos() { + copy_file $UTIL_ROOT/launchd/com.weewx.weewxd.plist /Library/LaunchDaemons + + echo "You can start/stop weewx with the following commands:" + echo " sudo launchctl load /Library/LaunchDaemons/com.weewx.weewxd.plist" + echo " sudo launchctl unload /Library/LaunchDaemons/com.weewx.weewxd.plist" +} + +uninstall_macos() { + echo "Stopping weewx" + launchctl unload /Library/LaunchDaemons/com.weewx.weewxd.plist + remove_file /Library/LaunchDaemons/com.weewx.weewxd.plist +} + +# check for systemd and/or sysV init files that might affect the init setup +# that we install. no need to check for the files that we install, since we +# move aside any direct conflicts. +check_init() { + init_system=$1 + files_to_check="/etc/init.d/weewx-multi" + if [ "$init_system" = "systemd" ]; then + files_to_check="$files_to_check /etc/init.d/weewx" + elif [ "$init_system" = "init" ]; then + files_to_check="$files_to_check /etc/systemd/system/weewx.service" + fi + files="" + for f in $files_to_check; do + if [ -f $f ]; then + files="$files $f" + fi + done + if [ "$files" != "" ]; then + echo "The following files might interfere with the init configuration:" + for f in $files; do + echo " $f" + done + fi +} + + +do_install() { + init_system=$1 + echo "Set up the files necessary to run WeeWX at system startup." + + if [ ! -d $UTIL_ROOT ]; then + echo "Cannot find utility files at location '$UTIL_ROOT'" + exit 1 + fi + + echo "Copying files from $UTIL_ROOT" + + if [ -d /usr/local/etc/rc.d ]; then + install_bsd + elif [ "$init_system" = "/sbin/launchd" ]; then + install_macos + elif [ "$init_system" = "systemd" ]; then + install_udev + install_systemd + check_init $init_system + elif [ "$init_system" = "init" ]; then + install_udev + install_sysv + check_init $init_system + else + echo "Unrecognized platform with init system $init_system" + fi +} + +do_uninstall() { + init_system=$1 + echo "Remove the files for running WeeWX at system startup." + + if [ -d /usr/local/etc/rc.d ]; then + uninstall_bsd + elif [ "$init_system" = "/sbin/launchd" ]; then + uninstall_macos + elif [ "$init_system" = "systemd" ]; then + uninstall_systemd + uninstall_udev + elif [ "$init_system" = "init" ]; then + uninstall_sysv + uninstall_udev + else + echo "Unrecognized platform with init system $init_system" + fi +} + +pid1=$(ps -p 1 -o comm=) +ACTION=$1 +if [ "$ACTION" = "" -o "$ACTION" = "install" ]; then + do_install $pid1 +elif [ "$ACTION" = "uninstall" ]; then + do_uninstall $pid1 +fi diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Ftp/skin.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Ftp/skin.conf new file mode 100644 index 0000000..6584b8f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Ftp/skin.conf @@ -0,0 +1,14 @@ +############################################################################### +# Copyright (c) 2010 Tom Keffer # +# # +# FTP CONFIGURATION FILE # +# This 'report' does not generate any files. Instead, we use the report # +# engine to invoke FTP, which copies files to another location. # +############################################################################### + +SKIN_NAME = Ftp +SKIN_VERSION = 5.0.2 + +[Generators] + generator_list = weewx.reportengine.FtpGenerator + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/favicon.ico b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/favicon.ico differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/index.html.tmpl new file mode 100644 index 0000000..9874dd3 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/index.html.tmpl @@ -0,0 +1,84 @@ +#encoding UTF-8 +## +## This is a phone-formatted summary page based on examples posted +## to the wview Google Group, and salted to taste for weewx. +## +## It takes a full screen on my Verizon Fascinate (Samsung Galaxy S) +## and its predecessor(s) were reportedly developed for a Apple iPhone. +## +## vince@skahan.net - 1/16/2010 +## +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$obs.label.outTemp / $obs.label.dewpoint: $current.outTemp / $current.dewpoint
$gettext("High Temp"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime
$gettext("Low Temp"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
$obs.label.heatindex / $obs.label.windchill: $current.heatindex / $current.windchill
$obs.label.outHumidity: $current.outHumidity
$obs.label.rain: $day.rain.sum
$gettext("High Rain Rate"): $day.rainRate.max $gettext("at") $day.rainRate.maxtime
$obs.label.windSpeed: $current.windSpeed
$gettext("High Wind"): $day.wind.max $gettext("at") $day.wind.maxtime
+
+ + +#if 'radar_img' in $Extras +
+ #if 'radar_url' in $Extras + + #end if + Radar + #if 'radar_url' in $Extras + + #end if +
+#end if + +

$current.dateTime

+ +
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/de.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/de.conf new file mode 100644 index 0000000..55d53ab --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/de.conf @@ -0,0 +1,25 @@ +############################################################################### +# Localization File # +# Deutsch # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + dewpoint = Taupunkt + heatindex= Hitzeindex + outHumidity = Außenluftfeuchte + outTemp = Außentemperatur + rain = Regen + windchill = Windchil + windGust = Windböen + windSpeed = Windstärke + +[Texts] + "at" = "um" # Time context. E.g., 15.1C "at" 12:22 + "High Rain Rate" = "max. Regenrate" + "High Wind" = "max. Wind" + "High Temp" = "max. Temp." + "Low Temp" = "min. Temp." + "Rain (hourly total)" = "Regen (stündlich gesamt)" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/en.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/en.conf new file mode 100644 index 0000000..80d2ab7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/en.conf @@ -0,0 +1,25 @@ +############################################################################### +# Localization File # +# English # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + dewpoint = Dew Point + heatindex= Heat Index + outHumidity = Outside Humidity + outTemp = Outside Temperature + rain = Rain + windchill = Wind Chil + windGust = Gust Speed + windSpeed = Wind Speed + +[Texts] + "at" = "at" # Time context. E.g., 15.1C "at" 12:22 + "High Rain Rate" = "High Rain Rate" + "High Wind" = "High Wind" + "High Temp" = "High Temp" + "Low Temp" = "Low Temp" + "Rain (hourly total)" = "Rain (hourly total)" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/nl.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/nl.conf new file mode 100644 index 0000000..c357f14 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/nl.conf @@ -0,0 +1,26 @@ +############################################################################### +# Localization File # +# Dutch # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# Translation by Eelco # +############################################################################### + +[Labels] + [[Generic]] + dewpoint = Dauwpunt + heatindex= Hitte Index + outHumidity = Luchtvochtigheid Buiten + outTemp = Temperatuur Buiten + rain = Regen + windchill = Wind Chill + windGust = Windvlaag Snelheid + windSpeed = Wind Snelheid + +[Texts] + "at" = "om" # Time context. E.g., 15.1C "at" 12:22 + "High Rain Rate" = "Max Regen Intensiteit" + "High Wind" = "Max Wind" + "High Temp" = "Max Temp" + "Low Temp" = "Min Temp" + "Rain (hourly total)" = "Regen (uur totaal)" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/no.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/no.conf new file mode 100644 index 0000000..14f3851 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/lang/no.conf @@ -0,0 +1,66 @@ +############################################################################### +# Localization File # +# Norwegian # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### +unit_system = metricwx + +[Units] + + # [[Groups]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_degree_day = degree_C_day # Options are 'degree_F_day' or 'degree_C_day' + # group_distance = km # Options are 'mile' or 'km' + # group_pressure = mBar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_speed = meter_per_second # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + # group_speed2 = meter_per_second2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + # group_temperature = degree_C # Options are 'degree_F' or 'degree_C' + + [[Labels]] + + # These are singular, plural + meter = " meter", " meter" + day = " dag", " dager" + hour = " time", " timer" + minute = " minutt", " minutter" + second = " sekund", " sekunder" + + cm_per_hour = " cm/t" + hPa_per_hour = " hPa/t" + inch_per_hour = " in/t" + inHg_per_hour = " inHg/t" + km_per_hour = " km/t" + km_per_hour2 = " km/t" + kPa_per_hour = " kPa/t", + mbar_per_hour = " mbar/t" + mm_per_hour = " mm/t" + mmHg_per_hour = " mmHg/t" + meter_per_second = " m/s" + meter_per_second2 = " m/s" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNØ, NØ, ØNØ, Ø, ØSØ, SØ, SSØ, S, SSV, SV, VSV, V, VNV, NV, NNV, N/A + +[Labels] + [[Generic]] + dewpoint =
Doggpunkt ute + heatindex= Varmeindeks + outHumidity = Fuktighet ute + outTemp = Temperatur ute + rain = Regn + windchill =
Føles som + windGust = Vindkast + windSpeed = Vindhastighet + +[Texts] + "at" = "" # Time context. E.g., 15.1C "at" 12:22 + "High Rain Rate" = "Høyeste regnmengde" + "High Wind" = "Høyeste vindhastighet" + "High Temp" = "Høyeste temperatur" + "Low Temp" = "Laveste temperatur" + "Rain (hourly total)" = "Regn (pr. time)" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/mobile.css b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/mobile.css new file mode 100644 index 0000000..5cb9461 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/mobile.css @@ -0,0 +1,48 @@ +/* weewx mobile CSS settings */ + +body { + background-color: #75a1d0; + font-family: helvetica, arial; + text-align: center; + width: 305px; +} + +a { + text-decoration: none; + color: black; +} + +table.readings { + font-size: 13px; + width: 100%; + border: 2px solid black; + border-collapse: collapse; +} + +tr.alt { + background-color: #9bc4e2; +} + +td { + text-align: right; + font-weight: 600; + border: 1px solid black; + width: 50%; + padding: 2px; +} + +td.data { + text-align: left; + color: #990000; +} + +img { + width: 301px; + border: 2px solid black; +} + +.lastupdate { + font-size: 12px; + color: black; + text-align: center; +} diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/skin.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/skin.conf new file mode 100644 index 0000000..cdc9716 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Mobile/skin.conf @@ -0,0 +1,170 @@ +# configuration file for Mobile skin + +SKIN_NAME = Mobile +SKIN_VERSION = 5.0.2 + +[Extras] + # Set this URL to display a radar image + #radar_img = http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif + # Set this URL for the radar image link + #radar_url = http://radar.weather.gov/ridge/radar.php?product=NCR&rid=RTX&loop=yes + +############################################################################### + +[Units] + [[Groups]] + # group_altitude = foot + # group_degree_day = degree_F_day + # group_pressure = inHg + # group_rain = inch + # group_rainrate = inch_per_hour + # group_speed = mile_per_hour + # group_temperature = degree_F + + [[Labels]] + # day = " day", " days" + # hour = " hour", " hours" + # minute = " minute", " minutes" + # second = " second", " seconds" + # NONE = "" + + [[TimeFormats]] + # day = %X + # week = %X (%A) + # month = %x %X + # year = %x %X + # rainyear = %x %X + # current = %x %X + # ephem_day = %X + # ephem_year = %x %X + + [[Ordinates]] + # directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +############################################################################### + +[Labels] + # Set to hemisphere abbreviations suitable for your location: + # hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole degrees, + # and minutes: + # latlon_formats = "%02d", "%03d", "%05.2f" + + [[Generic]] + # barometer = Barometer + # dewpoint = Dew Point + # heatindex = Heat Index + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # radiation = Radiation + # rain = Rain + # rainRate = Rain Rate + # UV = UV Index + # windDir = Wind Direction + # windGust = Gust Speed + # windGustDir = Gust Direction + # windSpeed = Wind Speed + # windchill = Wind Chill + # windgustvec = Gust Vector + # windvec = Wind Vector + +############################################################################### + +[CheetahGenerator] + encoding = html_entities + [[ToDate]] + [[[Mobile]]] + template = index.html.tmpl + +############################################################################### + +[CopyGenerator] + copy_once = mobile.css, favicon.ico + +############################################################################### + +[ImageGenerator] + image_width = 300 + image_height = 180 + image_background_color = "#f5f5f5" + + chart_background_color = "#d8d8d8" + chart_gridline_color = "#a0a0a0" + + top_label_font_path = DejaVuSansCondensed-Bold.ttf + top_label_font_size = 10 + + unit_label_font_path = DejaVuSansCondensed-Bold.ttf + unit_label_font_size = 10 + unit_label_font_color = "#000000" + + bottom_label_font_path = DejaVuSansCondensed-Bold.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#000000" + bottom_label_offset = 3 + + axis_label_font_path = DejaVuSansCondensed-Bold.ttf + axis_label_font_size = 10 + axis_label_font_color = "#000000" + + rose_label = N + rose_label_font_path = DejaVuSansCondensed-Bold.ttf + rose_label_font_size = 10 + rose_label_font_color = "#000000" + + line_type = 'solid' + marker_size = 8 + marker_type ='none' + + chart_line_colors = "#4282b4", "#b44242", "#42b442" + chart_fill_colors = "#72b2c4", "#c47272", "#72c472" + + yscale = None, None, None + + vector_rotate = 90 + + line_gap_fraction = 0.05 + + show_daynight = true + daynight_day_color = "#dfdfdf" + daynight_night_color = "#bbbbbb" + daynight_edge_color = "#d0d0d0" + + plot_type = line + aggregate_type = none + width = 1 + time_length = 86400 # == 24 hours + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %x %X + time_length = 27h + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[daytempfeel]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Rain (hourly total) + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + +############################################################################### + +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Rsync/skin.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Rsync/skin.conf new file mode 100644 index 0000000..130ea15 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Rsync/skin.conf @@ -0,0 +1,15 @@ +############################################################################### +# Copyright (c) 2012 Will Page # +# With credit to Tom Keffer # +# # +# RSYNC CONFIGURATION FILE # +# This 'report' does not generate any files. Instead, we use the report # +# engine to invoke rsync, which synchronizes files between two locations. # +############################################################################### + +SKIN_NAME = Rsync +SKIN_VERSION = 5.0.2 + +[Generators] + generator_list = weewx.reportengine.RsyncGenerator + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/NOAA/NOAA-%Y-%m.txt.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/NOAA/NOAA-%Y-%m.txt.tmpl new file mode 100644 index 0000000..adab2a6 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/NOAA/NOAA-%Y-%m.txt.tmpl @@ -0,0 +1,40 @@ +#errorCatcher Echo +#set $YM="%Y %m" +#set $D=" %d" +#set $M=" %b" +#set $Time=" %H:%M" +#set $NODAY=" N/A" +#set $Temp="%6.1f" +#set $Wind="%6.1f" +#set $Dir="%6.0f" +#set $Count="%6d" +#set $NONE=" N/A" +#if $unit.unit_type_dict.group_rain == "mm" +#set $Rain="%6.1f" +#else +#set $Rain="%6.2f" +#end if + MONTHLY CLIMATOLOGICAL SUMMARY for $month_name $year_name + + +NAME: $station.location +ELEV: $station.altitude LAT: $station.latitude[0]-$station.latitude[1] $station.latitude[2] LONG: $station.longitude[0]-$station.longitude[1] $station.longitude[2] + + + TEMPERATURE ($unit.label.outTemp.strip()), RAIN ($unit.label.rain.strip()), WIND SPEED ($unit.label.windSpeed.strip()) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- +#for $day in $month.days +#if $day.outTemp.has_data or $day.rain.has_data or $day.wind.has_data +$day.dateTime.format($D, add_label=False) $day.outTemp.avg.format($Temp,$NONE,add_label=False) $day.outTemp.max.format($Temp,$NONE,add_label=False) $day.outTemp.maxtime.format($Time,add_label=False) $day.outTemp.min.format($Temp,$NONE,add_label=False) $day.outTemp.mintime.format($Time,add_label=False) $day.heatdeg.sum.format($Temp,$NONE,add_label=False) $day.cooldeg.sum.format($Temp,$NONE,add_label=False) $day.rain.sum.format($Rain,$NONE,add_label=False) $day.wind.avg.format($Wind,$NONE,add_label=False) $day.wind.max.format($Wind,$NONE,add_label=False) $day.wind.maxtime.format($Time,add_label=False) $day.wind.vecdir.format($Dir,$NONE,add_label=False) +#else +$day.dateTime.format($D) +#end if +#end for +#if $month.outTemp.has_data or $month.rain.has_data or $month.wind.has_data +--------------------------------------------------------------------------------------- + $month.outTemp.avg.format($Temp,$NONE,add_label=False) $month.outTemp.max.format($Temp,$NONE,add_label=False) $month.outTemp.maxtime.format($D,add_label=False) $month.outTemp.min.format($Temp,$NONE,add_label=False) $month.outTemp.mintime.format($D,add_label=False) $month.heatdeg.sum.format($Temp,$NONE,add_label=False) $month.cooldeg.sum.format($Temp,$NONE,add_label=False) $month.rain.sum.format($Rain,$NONE,add_label=False) $month.wind.avg.format($Wind,$NONE,add_label=False) $month.wind.max.format($Wind,$NONE,add_label=False) $month.wind.maxtime.format($D,add_label=False) $month.wind.vecdir.format($Dir,add_label=False) +#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/NOAA/NOAA-%Y.txt.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/NOAA/NOAA-%Y.txt.tmpl new file mode 100644 index 0000000..1e3d5fe --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/NOAA/NOAA-%Y.txt.tmpl @@ -0,0 +1,102 @@ +#errorCatcher Echo +#set $YM="%Y %m" +#set $D=" %d" +#set $M=" %b" +#set $NODAY=" N/A" +#set $Temp="%6.1f" +#set $Wind="%6.1f" +#set $Dir="%6.0f" +#set $Count="%6d" +#set $NONE=" N/A" +#if $unit.unit_type_dict.group_temperature == "degree_F" +#set $Hot =(90.0,"degree_F") +#set $Cold =(32.0,"degree_F") +#set $VeryCold=(0.0, "degree_F") +#else +#set $Hot =(30.0,"degree_C") +#set $Cold =(0.0,"degree_C") +#set $VeryCold=(-20.0,"degree_C") +#end if +#if $unit.unit_type_dict.group_rain == "inch" +#set $Trace =(0.01,"inch") +#set $SomeRain =(0.1, "inch") +#set $Soak =(1.0, "inch") +#set $Rain="%6.2f" +#elif $unit.unit_type_dict.group_rain == "mm" +#set $Trace =(.3, "mm") +#set $SomeRain =(3, "mm") +#set $Soak =(30.0,"mm") +#set $Rain="%6.1f" +#else +#set $Trace =(.03,"cm") +#set $SomeRain =(.3, "cm") +#set $Soak =(3.0,"cm") +#set $Rain="%6.2f" +#end if +#def ShowInt($T) +$("%6d" % $T[0])#slurp +#end def +#def ShowFloat($R) +$("%6.2f" % $R[0])#slurp +#end def + CLIMATOLOGICAL SUMMARY for year $year_name + + +NAME: $station.location +ELEV: $station.altitude LAT: $station.latitude[0]-$station.latitude[1] $station.latitude[2] LONG: $station.longitude[0]-$station.longitude[1] $station.longitude[2] + + + TEMPERATURE ($unit.label.outTemp.strip()) + + HEAT COOL MAX MAX MIN MIN + MEAN MEAN DEG DEG >= <= <= <= + YR MO MAX MIN MEAN DAYS DAYS HI DAY LOW DAY $ShowInt($Hot) $ShowInt($Cold) $ShowInt($Cold) $ShowInt($VeryCold) +------------------------------------------------------------------------------------------------ +#for $month in $year.months +#if $month.outTemp.has_data +$month.dateTime.format($YM) $month.outTemp.meanmax.format($Temp,$NONE,add_label=False) $month.outTemp.meanmin.format($Temp,$NONE,add_label=False) $month.outTemp.avg.format($Temp,$NONE,add_label=False) $month.heatdeg.sum.format($Temp,$NONE,add_label=False) $month.cooldeg.sum.format($Temp,$NONE,add_label=False) $month.outTemp.max.format($Temp,$NONE,add_label=False) $month.outTemp.maxtime.format($D,$NODAY) $month.outTemp.min.format($Temp,$NONE,add_label=False) $month.outTemp.mintime.format($D,$NODAY) $month.outTemp.max_ge($Hot).format($Count,$NONE,add_label=False) $month.outTemp.max_le($Cold).format($Count,$NONE,add_label=False) $month.outTemp.min_le($Cold).format($Count,$NONE,add_label=False) $month.outTemp.min_le($VeryCold).format($Count,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +#if $year.outTemp.has_data +------------------------------------------------------------------------------------------------ + $year.outTemp.meanmax.format($Temp,$NONE,add_label=False) $year.outTemp.meanmin.format($Temp,$NONE,add_label=False) $year.outTemp.avg.format($Temp,$NONE,add_label=False) $year.heatdeg.sum.format($Temp,$NONE,add_label=False) $year.cooldeg.sum.format($Temp,$NONE,add_label=False) $year.outTemp.max.format($Temp,$NONE,add_label=False) $year.outTemp.maxtime.format($M,$NODAY) $year.outTemp.min.format($Temp,$NONE,add_label=False) $year.outTemp.mintime.format($M,$NODAY) $year.outTemp.max_ge($Hot).format($Count,$NONE,add_label=False) $year.outTemp.max_le($Cold).format($Count,$NONE,add_label=False) $year.outTemp.min_le($Cold).format($Count,$NONE,add_label=False) $year.outTemp.min_le($VeryCold).format($Count,$NONE,add_label=False) +#end if + + + PRECIPITATION ($unit.label.rain.strip()) + + MAX ---DAYS OF RAIN--- + OBS. OVER + YR MO TOTAL DAY DATE $ShowFloat(Trace) $ShowFloat($SomeRain) $ShowFloat($Soak) +------------------------------------------------ +#for $month in $year.months +#if $month.rain.has_data +$month.dateTime.format($YM) $month.rain.sum.format($Rain,$NONE,add_label=False) $month.rain.maxsum.format($Rain,$NONE,add_label=False) $month.rain.maxsumtime.format($D,$NODAY) $month.rain.sum_ge($Trace).format($Count,$NONE,add_label=False) $month.rain.sum_ge($SomeRain).format($Count,$NONE,add_label=False) $month.rain.sum_ge($Soak).format($Count,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +#if $year.rain.has_data +------------------------------------------------ + $year.rain.sum.format($Rain,$NONE,add_label=False) $year.rain.maxsum.format($Rain,$NONE,add_label=False) $year.rain.maxsumtime.format($M,$NODAY) $year.rain.sum_ge($Trace).format($Count,$NONE,add_label=False) $year.rain.sum_ge($SomeRain).format($Count,$NONE,add_label=False) $year.rain.sum_ge($Soak).format($Count,$NONE,add_label=False) +#end if + + + WIND SPEED ($unit.label.windSpeed.strip()) + + DOM + YR MO AVG HI DATE DIR +----------------------------------- +#for $month in $year.months +#if $month.wind.has_data +$month.dateTime.format($YM) $month.wind.avg.format($Wind,$NONE,add_label=False) $month.wind.max.format($Wind,$NONE,add_label=False) $month.wind.maxtime.format($D,$NODAY) $month.wind.vecdir.format($Dir,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +#if $year.wind.has_data +----------------------------------- + $year.wind.avg.format($Wind,$NONE,add_label=False) $year.wind.max.format($Wind,$NONE,add_label=False) $year.wind.maxtime.format($M,$NODAY) $year.wind.vecdir.format($Dir,$NONE,add_label=False) +#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/about.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/about.inc new file mode 100644 index 0000000..9629aff --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/about.inc @@ -0,0 +1,50 @@ +## about module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +
+
+ $gettext("About this station") + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$gettext("Hardware")$station.hardware
$gettext("Latitude")$station.latitude[0]° $station.latitude[1]' $station.latitude[2]
$gettext("Longitude")$station.longitude[0]° $station.longitude[1]' $station.longitude[2]
$pgettext("Geographical", "Altitude")$station.altitude
$gettext("Server uptime")$station.os_uptime.long_form
$gettext("WeeWX uptime")$station.uptime.long_form
$gettext("WeeWX version")$station.version
$gettext("Skin")$SKIN_NAME $SKIN_VERSION
+
+ +
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/analytics.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/analytics.inc new file mode 100644 index 0000000..e0ab693 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/analytics.inc @@ -0,0 +1,17 @@ +## analytics module weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +## If a Google Analytics GA4 code has been specified, include it. + +#if 'googleAnalyticsId' in $Extras + + +#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/celestial.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/celestial.html.tmpl new file mode 100644 index 0000000..c240525 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/celestial.html.tmpl @@ -0,0 +1,47 @@ +## Copyright 2017 Tom Keffer, Matthew Wall +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 + + + + + $station.location Celestial Details + + + #if $station.station_url + + #end if + + + + + + #include "titlebar.inc" + +
+

❰ $gettext("Current Conditions")

+ +
+ #include "celestial.inc" +
+ + #include "identifier.inc" +
+ + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/celestial.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/celestial.inc new file mode 100644 index 0000000..aa6b32e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/celestial.inc @@ -0,0 +1,164 @@ +## celestial module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +## If extended almanac information is available, do extra calculations. +#if $almanac.hasExtras + ## Pick a "None string" on the basis of whether the sun is above or below the horizon + #set $sun_altitude = $almanac.sun.altitude + #if $sun_altitude.raw < 0 + #set $sun_None='%s' % $gettext("Always down") + #else + #set $sun_None='%s' % $gettext("Always up") + #end if + + ## For the change in daylight, pick a string to indicate whether it is more or + ## less than yesterday: + #set $sun_visible_change = $almanac.sun.visible_change + #if $sun_visible_change.raw < 0 + #set $change_str = $gettext("less than yesterday") + #else + #set $change_str = $gettext("more than yesterday") + #end if +#end if + +
+
+ $gettext("Celestial") +
+
+ #if $almanac.hasExtras +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $almanac.next_equinox.raw < $almanac.next_solstice.raw + ## The equinox is before the solstice. Display them in order. + + + + + + + + + #else + ## The solstice is before the equinox. Display them in order. + + + + + + + + + #end if + + + + +
☀ $gettext("Sun")
$gettext("Start civil twilight")$almanac(horizon=-6).sun(use_center=1).rise
$gettext("Rise")$almanac.sun.rise.format(None_string=$sun_None)
$gettext("Transit")$almanac.sun.transit
$gettext("Set")$almanac.sun.set.format(None_string=$sun_None)
$gettext("End civil twilight")$almanac(horizon=-6).sun(use_center=1).set
$gettext("Azimuth")$almanac.sun.azimuth.format("%03.1f")
$pgettext("Astronomical", "Altitude")$sun_altitude.format("%.1f")
$gettext("Right ascension")$almanac.sun.topo_ra.format("%03.1f")
$gettext("Declination")$almanac.sun.topo_dec.format("%.1f")
$gettext("Equinox")$almanac.next_equinox
$gettext("Solstice")$almanac.next_solstice
$gettext("Solstice")$almanac.next_solstice
$gettext("Equinox")$almanac.next_equinox
$gettext("Total daylight")$almanac.sun.visible.long_form
$sun_visible_change.long_form $change_str
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $almanac.next_full_moon.raw < $almanac.next_new_moon.raw + + + + + + + + + #else + + + + + + + + + #end if + + + + +
☽ $gettext("Moon")
  
$gettext("Rise")$almanac.moon.rise
$gettext("Transit")$almanac.moon.transit
$gettext("Set")$almanac.moon.set
  
$gettext("Azimuth")$almanac.moon.azimuth.format("%.1f")
$pgettext("Astronomical", "Altitude")$almanac.moon.altitude.format("%.1f")
$gettext("Right ascension")$almanac.moon.topo_ra.format("%.1f")
$gettext("Declination")$almanac.moon.topo_dec.format("%.1f")
$gettext("Full moon")$almanac.next_full_moon
$gettext("New moon")$almanac.next_new_moon
$gettext("New moon")$almanac.next_new_moon
$gettext("Full moon")$almanac.next_full_moon
$gettext("Phase")$almanac.moon_phase
+ $almanac.moon_fullness% $gettext("full")
+
+
+ #else +

Install ephem for detailed celestial timings.

+ #end if +
+
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/current.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/current.inc new file mode 100644 index 0000000..bdd26ee --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/current.inc @@ -0,0 +1,50 @@ +## current module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +
+
+ $gettext("Current Conditions") + +
+ +
+ + + +#set $observations = $to_list($DisplayOptions.get('observations_current', ['outTemp', 'barometer'])) + +#for $x in $observations + #if $getVar('year.%s.has_data' % $x) + #if $x == 'barometer' + + + + + #elif $x == 'windSpeed' + + + + + #elif $x == 'rain' + + + + + #else + + + + + #end if + #end if +#end for + + +
$obs.label.barometer$current.barometer ($trend.barometer.formatted)
$obs.label.wind$current.windSpeed $current.windDir.ordinal_compass ($current.windDir)
$gettext("Rain Today")$day.rain.sum
$obs.label[$x]$getVar('current.' + $x)
+
+ +
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/favicon.ico b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/favicon.ico differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/Kanit-Bold.ttf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/Kanit-Bold.ttf new file mode 100644 index 0000000..39f7bfe Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/Kanit-Bold.ttf differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/Kanit-Regular.ttf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/Kanit-Regular.ttf new file mode 100644 index 0000000..3911320 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/Kanit-Regular.ttf differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OFL.txt b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OFL.txt new file mode 100644 index 0000000..1fd9f8e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Kanit Project Authors (https://github.com/cadsondemak/kanit) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans-Bold.ttf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans-Bold.ttf new file mode 100644 index 0000000..fd79d43 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans-Bold.ttf differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans-Regular.ttf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans-Regular.ttf new file mode 100644 index 0000000..db43334 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans-Regular.ttf differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans.woff b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans.woff new file mode 100644 index 0000000..e096d04 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans.woff differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans.woff2 b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans.woff2 new file mode 100644 index 0000000..402dfd7 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/OpenSans.woff2 differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/license.txt b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/license.txt new file mode 100644 index 0000000..b424dc4 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/font/license.txt @@ -0,0 +1,43 @@ +Copyright 2012 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the copyright statement(s). + +"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/hilo.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/hilo.inc new file mode 100644 index 0000000..cf7d37b --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/hilo.inc @@ -0,0 +1,126 @@ +## summary statistics module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +#set $timespans = ['day', 'week', 'month', 'year', 'rainyear'] + +## Get the list of observations from the configuration file, otherwise fallback +## to a very rudimentary set of observations. +#set $observations = $to_list($DisplayOptions.get('observations_stats', ['outTemp', 'windSpeed', 'rain'])) +#set $obs_type_sum = $to_list($DisplayOptions.get('obs_type_sum', ['rain'])) +#set $obs_type_max = $to_list($DisplayOptions.get('obs_type_max', ['rainRate'])) + +## use this span to determine whether there are any data to consider. +#set $recent=$span($day_delta=30, boundary='midnight') + +
+ + +
+ + + + + + + + + + + + +#for $x in $observations + #if getattr($recent, $x).has_data + #if $x == 'windSpeed' + + + #for $timespan in $timespans + + #end for + + + + + #for $timespan in $timespans + + #end for + + + + + #for $timespan in $timespans + + #end for + + + + + + #for $timespan in $timespans + + #end for + + + #else + + + #if $x in $obs_type_sum + #for $timespan in $timespans + + #end for + #elif $x in $obs_type_max + #for $timespan in $timespans + + #end for + #else + #for $timespan in $timespans + + #end for + #end if + + + #end if + #end if +#end for + + +
 
$gettext("Today")
 
$gettext("Week")
 
$gettext("Month")
+         
$gettext("Year")
+
+ $gettext("Rain
Year")
+
$gettext("Max Wind") + + $getVar($timespan).wind.max.format(add_label=False)
+ $getVar($timespan).wind.gustdir.format(add_label=False) +
+ $unit.label.wind
+ $unit.label.windDir +
$gettext("Average Wind") + $getVar($timespan).wind.avg.format(add_label=False)$unit.label.wind
$gettext("RMS Wind") + $getVar($timespan).wind.rms.format(add_label=False)$unit.label.wind
+ $gettext("Vector Average")
+ $gettext("Vector Direction") +
+ $getVar($timespan).wind.vecavg.format(add_label=False)
+ $getVar($timespan).wind.vecdir.format(add_label=False) +
+ $unit.label.wind
+ $unit.label.windDir +
$obs.label[$x] + $getVar('%s.%s' % ($timespan, $x)).sum.format(add_label=False) + + $getVar('%s.%s' % ($timespan, $x)).max.format(add_label=False) + + + $getVar('%s.%s' % ($timespan, $x)).max.format(add_label=False)
+ + $getVar('%s.%s' % ($timespan, $x)).min.format(add_label=False) +
$getattr($unit.label, $x, '')
+
+
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/identifier.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/identifier.inc new file mode 100644 index 0000000..8e1d5e5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/identifier.inc @@ -0,0 +1,31 @@ +## identifier for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +
+
+
+
+ + + + + + + + + + + + + + + + + +
$gettext("Latitude")$station.latitude[0]° $station.latitude[1]' $station.latitude[2]
$gettext("Longitude")$station.longitude[0]° $station.longitude[1]' $station.longitude[2]
$pgettext("Geographical", "Altitude")$station.altitude
WeeWX$station.version
+
+ +
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/index.html.tmpl new file mode 100644 index 0000000..d7dabf2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/index.html.tmpl @@ -0,0 +1,84 @@ +## Copyright 2009-2018 Tom Keffer, Matthew Wall +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo + +#set $periods = $to_list($DisplayOptions.get('periods', ['day', 'week', 'month', 'year'])) +#set $plot_groups = $to_list($DisplayOptions.get('plot_groups', ['tempdew', 'wind', 'rain'])) + +## use this span to determine whether there are any data to consider. +#set $recent=$span($day_delta=30, boundary='midnight') + + + + + + $station.location + + + #if $station.station_url + + #end if + + #include "analytics.inc" + + + + #include "titlebar.inc" + +
+
+ #include "current.inc" + #include "sunmoon.inc" + #include "hilo.inc" + #include "sensors.inc" + #include "about.inc" + #include "radar.inc" + #include "satellite.inc" + #include "map.inc" +
+ +
+
+ + +#for period in $periods + +#end for + +
+
+
+ +

+ $gettext("This station is controlled by WeeWX, an experimental weather software system written in Python.") +

+ + + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/cz.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/cz.conf new file mode 100644 index 0000000..ff6fd4e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/cz.conf @@ -0,0 +1,226 @@ +############################################################################### +# Localization File --- Seasons skin # +# Czech # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "František" # +############################################################################### + +# Generally want a metric system for the Czech language: +unit_system = metricwx + +[Units] + + [[Labels]] + + # Singular , Plural + meter = " metr", " metry/ů" + day = " den", " dny/í" + hour = " hodina", " hod." + minute = " minuta", " min." + second = " sekunda", " sek." + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = S, SSV, SV, VSV, V, VJV, JV, JJV, J, JJZ, JZ, ZJZ, Z, ZSZ, SZ, SSZ, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = S, J, V, Z + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Čas + interval = Interval + altimeter = Výškoměr # QNH + altimeterRate = Rychlost změny výškoměru + appTemp = Zdánlivá teplota + appTemp1 = Zdánlivá teplota1 + barometer = Barometr # QFF + barometerRate = Rychlost změny barometru + cloudbase = Základna mraků + dewpoint = Rosný bod + ET = Odpar + extraHumid1 = Vlhkost1 + extraHumid2 = Vlhkost2 + extraHumid3 = Vlhkost3 + extraHumid4 = Vlhkost4 + extraHumid5 = Vlhkost5 + extraHumid6 = Vlhkost6 + extraHumid7 = Vlhkost7 + extraHumid8 = Vlhkost8 + extraTemp1 = Teplota1 + extraTemp2 = Teplota2 + extraTemp3 = Teplota3 + extraTemp4 = Teplota4 + extraTemp5 = Teplota5 + extraTemp6 = Teplota6 + extraTemp7 = Teplota7 + extraTemp8 = Teplota8 + heatindex = Index horka + inHumidity = Vnitřní vlhkost + inTemp = Vnitřní teplota + inDewpoint = Vnitřní rosný bod + leafTemp1 = Listová teplota1 + leafTemp2 = Listová teplota2 + leafWet1 = Vlhkost listů1 + leafWet2 = Vlhkost listů2 + lightning_distance = Vzdálenost blesku + lightning_strike_count = Počet úderů blesku + luminosity = Zářivost + outHumidity = Venkovní vlhkost + outTemp = Venkovní teplota + pressure = Tlak # QFE + pressureRate = Rychlost změny tlaku + radiation = Sluneční záření + rain = Srážky + rainRate = Intenzita srážek + soilMoist1 = Půdní vlhkost1 + soilMoist2 = Půdní vlhkost2 + soilMoist3 = Půdní vlhkost3 + soilMoist4 = Půdní vlhkost4 + soilTemp1 = Teplota půdy1 + soilTemp2 = Teplota půdy2 + soilTemp3 = Teplota půdy3 + soilTemp4 = Teplota půdy4 + THSW = THSW Index + UV = UV Index + wind = Vítr + windDir = Směr větru + windGust = Nárazový vítr + windGustDir = Směr nárazu větru + windSpeed = Rychlost větru + windchill = Chlad větru + windgustvec = Vektor nárazu větru + windvec = Vektor větru + windrun = Proběh větru + + # used in Seasons skin, but not defined + feel = pocitově + + # Sensor status indicators + rxCheckPercent = Kvalita signálu + txBatteryStatus = Baterie vysílače + windBatteryStatus = Baterie anemometru + rainBatteryStatus = Baterie srážkoměru + outTempBatteryStatus = Baterie venkovního teplotního čidla + inTempBatteryStatus = Baterie vnitřního teplotního čidla + consBatteryVoltage = Baterie konzole + heatingVoltage = Napětí přehřívání + supplyVoltage = Napětí + referenceVoltage = Referenční napětí + batteryStatus1 = Baterie1 + batteryStatus2 = Baterie2 + batteryStatus3 = Baterie3 + batteryStatus4 = Baterie4 + batteryStatus5 = Baterie5 + batteryStatus6 = Baterie6 + batteryStatus7 = Baterie7 + batteryStatus8 = Baterie8 + signal1 = Signál1 + signal2 = Signál2 + signal3 = Signál3 + signal4 = Signál4 + signal5 = Signál5 + signal6 = Signál6 + signal7 = Signál7 + signal8 = Signál8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nov, Dorůstající srpek, První čtvrt, Dorůstající měsíc, Úplněk, Couvající měsíc, Poslední čtvrt, Couvající srpek + +[Texts] + "About this station" = "Informance o stanici" + "Always down" = "Vždy dole" + "Always up" = "Vždy nahoře" + "Average Wind" = "Průměrná rychlost větru" + "Azimuth" = "Azimut" + "Battery Status" = "Stav baterie" + "Celestial" = "Nebeská tělesa" + "Connectivity" = "Konektivita" + "Current Conditions" = "Aktuální podmínky" + "Current conditions, and daily, monthly, and yearly summaries" = "Aktuální podmínky, denní, měsíční, a roční souhrny" + "Daily summary as of" = "Denní správa o počasí" + "Day" = "Tento den" + "Daylight" = "Délka dne" + "days ago" = "před pár dny" + "Declination" = "Deklinace" + "End civil twilight" = "Konec civilního soumraku" + "Equinox" = "Rovnodennost" + "Evapotranspiration (daily total)" = "Odpar (denní úhrn)" + "Evapotranspiration (hourly total)" = "Odpar (hodinový úhrn)" + "Evapotranspiration (weekly total)" = "Odpar (týdenní úhrn)" + "from" = "ze" + "full" = "úplnost" # As in "The Moon is 21% full" + "Full moon" = "Úplněk" + "Hardware" = "Hardware" + "History" = "Historie" + "hours ago" = "před hodinami" + "Latitude" = "Zeměpisná šířka" + "less than yesterday" = "méně než včera" + "Lightning (daily total)" = "Počet úderů blesku (denní úhrn)" + "Lightning (hourly total)" = "Počet úderů blesku (hodinový úhrn)" + "Lightning (weekly total)" = "Počet úderů blesku (týdenní úhrn)" + "Longitude" = "Zeměpisná délka" + "LOW" = "slabý" + "Max Wind" = "Maximální rychlost větru" + "minutes ago" = "minutu před" + "Month" = "Tento měsíc" + "Monthly Reports" = "Měsíční výpis" + "Monthly summary as of" = "Měsíční správa počasí" + "Moon" = "Měsíc" + "Moon Phase" = "Fáze měsíce" + "more than yesterday" = "více než včera" + "New moon" = "Nový měsíc" + "never" = "nikdy" + "Phase" = "Fáze" + "OK" = "OK" + "Rain (daily total)" = "Srážky (denní úhrn)" + "Rain (hourly total)" = "Srážky (hodinový úhrn)" + "Rain (weekly total)" = "Srážky (týdenní úhrn)" + "Rain Today" = "Denní úhrn srážek" + "Rain Year" = "Dešťové období" + "Rain
Year" = "Déšť
Rok" + "Right ascension" = "Vzestup" + "Rise" = "Východ" + "RMS Wind" = "Efektivní hodnota větru" + "select month" = "Vyberte měsíc" + "select year" = "Vyberte rok" + "Sensor Status" = "Stav senzoru" + "Server uptime" = "Server uptime" + "Set" = "Západ" + "Solstice" = "Slunovrat" + "Start civil twilight" = "Začátek civilního soumraku" + "Statistics" = "Statistiky" + "Sun" = "Slunce" + "Sunrise" = "Východ slunce" + "Sunset" = "Západ slunce" + "Telemetry" = "Telemetry" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Tuto stanici spravuje WeeWX, experimentální software o počasí napsaný v Pythonu." + "Today" = "Dnes" + "Total daylight" = "Délka dne" + "Transit" = "Průchod" + "UNKNOWN" = "neurčeno" + "Vector Average" = "Průměrný vektor" + "Vector Direction" = "Směr vektoru" + "Voltage" = "Napětí" + "Weather Conditions" = "Povětrnostní podmínky" + "Weather Conditions at" = "Povětrnostní podmínky v" + "Week" = "Tento týden" + "WeeWX uptime" = "WeeWX uptime" + "WeeWX version" = "WeeWX verze" + "Year" = "Tento rok" + "Yearly Reports" = "Roční výpis" + "Yearly summary as of" = "Roční správa počasí" + + [[Geographical]] + "Altitude" = "Nadmořská výška" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Výška" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/de.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/de.conf new file mode 100644 index 0000000..82fbce9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/de.conf @@ -0,0 +1,225 @@ +############################################################################### +# Localization File --- Seasons skin # +# German # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Karen" # +############################################################################### + +# Generally want a metric system for the German language: +unit_system = metricwx + +[Units] + + # The following section overrides the label used for each type of unit + [[Labels]] + meter = " m" + day = " Tag", " Tage" + hour = " Stunde", " Stunden" + minute = " Minute", " Minuten" + second = " Sekunde", " Sekunden" + + [[Ordinates]] + + # Ordinal directions. The last one is for no wind direction + directions = N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Luftdruck (QNH) # QNH + altimeterRate = Luftdruckänderung + appTemp = gefühlte Temperatur + appTemp1 = gefühlte Temperatur + barometer = Luftdruck # QFF + barometerRate = Luftdruckänderung + cloudbase = Wolkenuntergrenze + dateTime = "Datum/Zeit" + dewpoint = Taupunkt + ET = Evapotranspiration + extraHumid1 = Feuchtigkeit1 + extraHumid2 = Feuchtigkeit2 + extraHumid3 = Feuchtigkeit3 + extraHumid4 = Feuchtigkeit4 + extraHumid5 = Feuchtigkeit5 + extraHumid6 = Feuchtigkeit6 + extraHumid7 = Feuchtigkeit7 + extraHumid8 = Feuchtigkeit8 + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + extraTemp4 = Temperatur4 + extraTemp5 = Temperatur5 + extraTemp6 = Temperatur6 + extraTemp7 = Temperatur7 + extraTemp8 = Temperatur8 + heatindex = Hitzeindex + inDewpoint = Raumtaupunkt + inHumidity = Raumluftfeuchte + inTemp = Raumtemperatur + interval = Intervall + leafTemp1 = Blatttemperatur1 + leafTemp2 = Blatttemperatur2 + leafWet1 = Blattnässe1 + leafWet2 = Blattnässe2 + lightning_distance = Blitzentfernung + lightning_strike_count = Blitzanzahl + luminosity = Helligkeit + outHumidity = Luftfeuchte + outTemp = Außentemperatur + pressure = abs. Luftdruck # QFE + pressureRate = Luftdruckänderung + radiation = Sonnenstrahlung + rain = Regen + rainRate = Regen-Rate + soilMoist1 = Bodenfeuchtigkeit1 + soilMoist2 = Bodenfeuchtigkeit2 + soilMoist3 = Bodenfeuchtigkeit3 + soilMoist4 = Bodenfeuchtigkeit4 + soilTemp1 = Bodentemperatur1 + soilTemp2 = Bodentemperatur2 + soilTemp3 = Bodentemperatur3 + soilTemp4 = Bodentemperatur4 + THSW = THSW-Index + UV = UV-Index + wind = Wind + windchill = Windchill + windDir = Windrichtung + windGust = Böen Geschwindigkeit + windGustDir = Böen Richtung + windgustvec = Böen-Vektor + windrun = Windverlauf + windSpeed = Windgeschwindigkeit + windvec = Wind-Vektor + + # used in Seasons skin but not defined + feel = gefühlte Temperatur + + # Sensor status indicators + consBatteryVoltage = Konsolenbatterie + heatingVoltage = Heizungsspannung + inTempBatteryStatus = Innentemperatursensor + outTempBatteryStatus = Außentemperatursensor + rainBatteryStatus = Regenmesser + referenceVoltage = Referenz + rxCheckPercent = Signalqualität + supplyVoltage = Versorgung + txBatteryStatus = Übertrager + windBatteryStatus = Anemometer + batteryStatus1 = Batterie1 + batteryStatus2 = Batterie2 + batteryStatus3 = Batterie3 + batteryStatus4 = Batterie4 + batteryStatus5 = Batterie5 + batteryStatus6 = Batterie6 + batteryStatus7 = Batterie7 + batteryStatus8 = Batterie8 + signal1 = Signal1 + signal2 = Signal2 + signal3 = Signal3 + signal4 = Signal4 + signal5 = Signal5 + signal6 = Signal6 + signal7 = Signal7 + signal8 = Signal8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Neumond, zunehmend, Halbmond, zunehmend, Vollmond, abnehmend, Halbmond, abnehmend + +[Texts] + "About this station" = "Stationsdaten" + "Always down" = "Polarnacht" + "Always up" = "Polartag" + "Average Wind" = "Windstärke (Durchschnitt)" + "Azimuth" = "Azimut" + "Battery Status" = "Batteriestatus" + "Celestial" = "Sonne und Mond" + "Connectivity" = "Verbindungsqualität" + "Current Conditions" = "Aktuelle Werte" + "Current conditions, and daily, monthly, and yearly summaries" = "Aktuelle Werte und Tages-, Monats- und Jahreszusammenfassung" + "Daily summary as of" = "Tageszusammenfassung für den" + "Day" = "Tag" + "Daylight" = "Tageslicht" + "days ago" = "Vor Tagen" + "Declination" = "Deklination" + "End civil twilight" = "Dämmerungsende" + "Equinox" = "Tagundnachtgleiche" # Äquinoktium + "Evapotranspiration (daily total)" = "Evapotranspiration (täglich gesamt)" + "Evapotranspiration (hourly total)" = "Evapotranspiration (stündlich gesamt)" + "Evapotranspiration (weekly total)" = "Evapotranspiration (wöchentlich gesamt)" + "from" = "aus" + "Full moon" = "Vollmond" + "full" = "sichtbar" # as in "Der Mond ist zu 21% sichtbar" + "Hardware" = "Hardware" + "History" = "Diagramme" + "hours ago" = "Vor Stunden" + "Latitude" = "geogr. Breite" + "less than yesterday" = "weniger als gestern" + "Lightning (daily total)" = "Blitzanzahl (täglich gesamt)" + "Lightning (hourly total)" = "Blitzanzahl (stündlich gesamt)" + "Lightning (weekly total)" = "Blitzanzahl (wöchentlich gesamt)" + "Longitude" = "geogr. Länge" + "LOW" = "gering" + "Max Wind" = "Windstärke (Maximum)" + "minutes ago" = "Vor ein paar Minuten" + "Month" = "Monat" + "Monthly Reports" = "Monatswerte" + "Monthly summary as of" = "Monatszusammenfassung zum" + "Moon" = "Mond" + "Moon Phase" = "Mond Phase" + "more than yesterday" = "mehr als gestern" + "New moon" = "Neumond" + "never" = "nimmer" + "OK" = "OK" + "Phase" = "Phase" + "Rain (daily total)" = "Regen (täglich gesamt)" + "Rain (hourly total)" = "Regen (stündlich gesamt)" + "Rain (weekly total)" = "Regen (wöchentlich gesamt)" + "Rain Today" = "Regen heute" + "Rain Year" = "Regenjahr" + "Rain
Year" = "Regen-
jahr" + "Right ascension" = "Rektaszension" + "Rise" = "Aufgang" + "RMS Wind" = "Windstärke (Effektivwert)" + "select month" = "Monat wählen" + "select year" = "Jahr wählen" + "Sensor Status" = "Sensorenstatus" + "Server uptime" = "Server-Laufzeit" + "Set" = "Untergang" + "Solstice" = "Sonnenwende" # Solstitium + "Start civil twilight" = "Dämmerungsbeginn" + "Statistics" = "Statistik" + "Sun" = "Sonne" + "Sunrise" = "Sonnenaufgang" + "Sunset" = "Sonnenuntergang" + "Telemetry" = "Telemetrie" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Diese Station wird von WeeWX gesteuert, einer experimentellen Wetter-Software, geschrieben in Python." + "Today" = "Heute" + "Total daylight" = "Tageslicht gesamt" + "Transit" = "Transit" + "Vector Average" = "Windstärke" + "Vector Direction" = "Windrichtung" + "Voltage" = "Spannung" + "UNKNOWN" = "unbestimmt" + "Weather Conditions" = "Wetterbedingungen" + "Weather Conditions at" = "Wetter am" + "Week" = "Woche" + "WeeWX uptime" = "WeeWX-Laufzeit" + "WeeWX version" = "WeeWX-Version" + "Year" = "Jahr" + "Yearly Reports" = "Jahreswerte" + "Yearly summary as of" = "Jahreszusammenfassung zum" + + [[Geographical]] + "Altitude" = "Höhe ü. NN" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Höhe" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/en.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/en.conf new file mode 100644 index 0000000..f7e7024 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/en.conf @@ -0,0 +1,229 @@ +############################################################################### +# Localization File --- Seasons skin # +# English # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +############################################################################### + +# Generally want the US system for the English language. +# Sorry, UK, but you invented the damn system. +unit_system = us + +[Units] + + [[Labels]] + + # These are singular, plural + meter = " meter", " meters" + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Altimeter # QNH + altimeterRate = Altimeter Change Rate + appTemp = Apparent Temperature + appTemp1 = Apparent Temperature + barometer = Barometer # QFF + barometerRate = Barometer Change Rate + cloudbase = Cloud Base + dateTime = Time + dewpoint = Dew Point + ET = Evapotranspiration + extraHumid1 = Humidity1 + extraHumid2 = Humidity2 + extraHumid3 = Humidity3 + extraHumid4 = Humidity4 + extraHumid5 = Humidity5 + extraHumid6 = Humidity6 + extraHumid7 = Humidity7 + extraHumid8 = Humidity8 + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + extraTemp4 = Temperature4 + extraTemp5 = Temperature5 + extraTemp6 = Temperature6 + extraTemp7 = Temperature7 + extraTemp8 = Temperature8 + heatindex = Heat Index + inDewpoint = Inside Dew Point + inHumidity = Inside Humidity + inTemp = Inside Temperature + interval = Interval + leafTemp1 = Leaf Temperature1 + leafTemp2 = Leaf Temperature2 + leafWet1 = Leaf Wetness1 + leafWet2 = Leaf Wetness2 + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + luminosity = Luminosity + outHumidity = Outside Humidity + outTemp = Outside Temperature + pressure = Pressure # QFE + pressureRate = Pressure Change Rate + radiation = Radiation + rain = Rain + rainRate = Rain Rate + soilMoist1 = Soil Moisture1 + soilMoist2 = Soil Moisture2 + soilMoist3 = Soil Moisture3 + soilMoist4 = Soil Moisture4 + soilTemp1 = Soil Temperature1 + soilTemp2 = Soil Temperature2 + soilTemp3 = Soil Temperature3 + soilTemp4 = Soil Temperature4 + THSW = THSW Index + UV = UV Index + wind = Wind + windchill = Wind Chill + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windgustvec = Gust Vector + windrun = Wind Run + windSpeed = Wind Speed + windvec = Wind Vector + + # used in Seasons skin, but not defined + feel = apparent temperature + + # Sensor status indicators + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + inTempBatteryStatus = Inside Temperature Battery + outTempBatteryStatus = Outside Temperature Battery + rainBatteryStatus = Rain Battery + referenceVoltage = Reference Voltage + rxCheckPercent = Signal Quality + supplyVoltage = Supply Voltage + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + batteryStatus1 = Battery1 + batteryStatus2 = Battery2 + batteryStatus3 = Battery3 + batteryStatus4 = Battery4 + batteryStatus5 = Battery5 + batteryStatus6 = Battery6 + batteryStatus7 = Battery7 + batteryStatus8 = Battery8 + signal1 = Signal1 + signal2 = Signal2 + signal3 = Signal3 + signal4 = Signal4 + signal5 = Signal5 + signal6 = Signal6 + signal7 = Signal7 + signal8 = Signal8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +[Texts] + "About this station" = "About this station" + "Always down" = "Always down" + "Always up" = "Always up" + "at" = "at" + "Average Wind" = "Average Wind" + "Azimuth" = "Azimuth" + "Battery Status" = "Battery Status" + "Celestial" = "Celestial" + "Connectivity" = "Connectivity" + "Current Conditions" = "Current Conditions" + "Current conditions, and daily, monthly, and yearly summaries" = "Current conditions, and daily, monthly, and yearly summaries" + "Daily summary as of" = "Daily summary as of" + "Day" = "Day" + "days ago" = "days ago" + "Daylight" = "Daylight" + "Declination" = "Declination" + "End civil twilight" = "End civil twilight" + "Equinox" = "Equinox" + "Evapotranspiration (daily total)" = "Evapotranspiration (daily total)" + "Evapotranspiration (hourly total)" = "Evapotranspiration (hourly total)" + "Evapotranspiration (weekly total)" = "Evapotranspiration (weekly total)" + "from" = "from" + "full" = "full" # As in "The Moon is 21% full" + "Full moon" = "Full moon" + "Hardware" = "Hardware" + "History" = "History" + "hours ago" = "hours ago" + "Latitude" = "Latitude" + "less than yesterday" = "less than yesterday" + "Lightning (daily total)" = "Lightning (daily total)" + "Lightning (hourly total)" = "Lightning (hourly total)" + "Lightning (weekly total)" = "Lightning (weekly total)" + "Longitude" = "Longitude" + "LOW" = "LOW" + "Max" = "Max" + "Max Wind" = "Max Wind" + "Min" = "Min" + "minutes ago" = "minutes ago" + "Month" = "Month" + "Monthly Reports" = "Monthly Reports" + "Monthly summary as of" = "Monthly summary as of" + "Moon" = "Moon" + "Moon Phase" = "Moon Phase" + "more than yesterday" = "more than yesterday" + "never" = "never" + "New moon" = "New moon" + "OK" = "OK" + "Phase" = "Phase" + "Rain (daily total)" = "Rain (daily total)" + "Rain (hourly total)" = "Rain (hourly total)" + "Rain (weekly total)" = "Rain (weekly total)" + "Rain Today" = "Rain Today" + "Rain Year" = "Rain Year" + "Rain
Year" = "Rain
Year" + "Right ascension" = "Right ascension" + "Rise" = "Rise" + "RMS Wind" = "RMS Wind" + "select month" = "Select Month" + "select year" = "Select Year" + "Sensor Status" = "Sensor Status" + "Server uptime" = "Server uptime" + "Set" = "Set" + "Skin" = "Skin" + "Solstice" = "Solstice" + "Start civil twilight" = "Start civil twilight" + "Statistics" = "Statistics" + "Sun" = "Sun" + "Sunrise" = "Sunrise" + "Sunset" = "Sunset" + "Telemetry" = "Telemetry" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "This station is controlled by WeeWX, an experimental weather software system written in Python." + "Today" = "Today" + "Total daylight" = "Total daylight" + "Transit" = "Transit" + "UNKNOWN" = "UNKNOWN" + "Vector Average" = "Vector Average" + "Vector Direction" = "Vector Direction" + "Voltage" = "Voltage" + "Weather Conditions" = "Weather Conditions" + "Weather Conditions at" = "Weather Conditions at" + "Week" = "Week" + "WeeWX uptime" = "WeeWX uptime" + "WeeWX version" = "WeeWX version" + "Year" = "Year" + "Yearly Reports" = "Yearly Reports" + "Yearly summary as of" = "Yearly summary as of" + + [[Geographical]] + "Altitude" = "Altitude" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Altitude" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/es.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/es.conf new file mode 100644 index 0000000..5c8ea9a --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/es.conf @@ -0,0 +1,226 @@ +############################################################################### +# Localization File --- Seasons skin # +# Español # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Alfredo" # +############################################################################### + +# Generally want a metric system for the Spanish language: +unit_system = metricwx + +[Units] + + [[Labels]] + + # These are singular, plural + meter = " metro", " metros" + day = " día", " días" + hour = " hora", " horas" + minute = " minuto", " minutos" + second = " segundo", " segundos" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction (Spanish "W" is "O" for "Oeste") + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, O + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Altímetro # QNH + altimeterRate = Tasa de cambio del altímetro + appTemp = Temperatura aparente # Synonym: Sensación térmica + appTemp1 = Temperatura aparente 1 # Synonym: Sensación térmica 1 + barometer = Barómetro # QFF + barometerRate = Tasa de cambio del Barómetro + cloudbase = Altura de la base de las nubes + dateTime = Tiempo + dewpoint = Punto de rocío + ET = Evapotranspiración + extraHumid1 = Humedad1 + extraHumid2 = Humedad2 + extraHumid3 = Humedad3 + extraHumid4 = Humedad4 + extraHumid5 = Humedad5 + extraHumid6 = Humedad6 + extraHumid7 = Humedad7 + extraHumid8 = Humedad8 + extraTemp1 = Temperatura1 + extraTemp2 = Temperatura2 + extraTemp3 = Temperatura3 + extraTemp4 = Temperatura4 + extraTemp5 = Temperatura5 + extraTemp6 = Temperatura6 + extraTemp7 = Temperatura7 + extraTemp8 = Temperatura8 + heatindex = Índice de calor + inDewpoint = Punto de rocío interior + inHumidity = Humedad interna + inTemp = Temperatura interna + interval = Intervalo + leafTemp1 = Temperatura de la hoja1 + leafTemp2 = Temperatura de la hoja2 + leafWet1 = Humedad de la hoja1 + leafWet2 = Humedad de la hoja2 + lightning_distance = Distancia de los rayos + lightning_strike_count = Número de rayos + luminosity = Luminosidad + outHumidity = Humedad externa + outTemp = Temperatura externa + pressure = Presión # QFE + pressureRate = Tasa de cambio de la presión + radiation = Radiación + rain = Lluvia + rainRate = Promedio de lluvia + soilMoist1 = Humedad del suelo1 + soilMoist2 = Humedad del suelo2 + soilMoist3 = Humedad del suelo3 + soilMoist4 = Humedad del suelo4 + soilTemp1 = Temperatura del suelo1 + soilTemp2 = Temperatura del suelo2 + soilTemp3 = Temperatura del suelo3 + soilTemp4 = Temperatura del suelo4 + THSW = Índice de temperatura-humedad-sol-viento (THSW) + UV = Índice ultravioleta (UV) + wind = Viento + windchill = Factor de sensación térmica + windDir = Dirección del viento + windGust = Velocidad de las ráfagas de viento + windGustDir = Dirección de las ráfagas de viento + windgustvec = Vector de las ráfagas de viento + windrun = Monto (cantidad) de viento + windSpeed = Velocidad del viento + windvec = Vector del viento + + # used in Seasons skin, but not defined + feel = temperatura aparente + + # Sensor status indicators + consBatteryVoltage = Voltaje de la batería de la consola + heatingVoltage = Voltaje de la batería de calefacción + inTempBatteryStatus = Estado de la batería para la temperatura interna + outTempBatteryStatus = Estado de la batería para la temperatura externa + rainBatteryStatus = Estado de la batería para la lluvia + referenceVoltage = Voltaje de referencia + rxCheckPercent = Calidad de la señal recibida + supplyVoltage = Voltaje de la fuente de potencia + txBatteryStatus = Estado de la batería del transmisor + windBatteryStatus = Estado de la batería del viento (la batería del anemómetro) + batteryStatus1 = Batería1 + batteryStatus2 = Batería2 + batteryStatus3 = Batería3 + batteryStatus4 = Batería4 + batteryStatus5 = Batería5 + batteryStatus6 = Batería6 + batteryStatus7 = Batería7 + batteryStatus8 = Batería8 + signal1 = Señal1 + signal2 = Señal2 + signal3 = Señal3 + signal4 = Señal4 + signal5 = Señal5 + signal6 = Señal6 + signal7 = Señal7 + signal8 = Señal8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nueva, Creciente, Cuarto creciente, Creciente gibosa, Llena, Menguante gibosa, Cuarto menguante, Menguante + +[Texts] + "About this station" = "Acerca de esta estación" + "Always down" = "Siempre debajo del horizonte" + "Always up" = "Siempre arriba del horizonte" + "Average Wind" = "Viento promedio" + "Azimuth" = "Azimuth" + "Battery Status" = "Estado de la batería" + "Celestial" = "Celestial" + "Connectivity" = "Conectividad" + "Current Conditions" = "Condiciones actuales" + "Current conditions, and daily, monthly, and yearly summaries" = "Condiciones actuales y sumarios diarios, mensuales y anuales" + "Daily summary as of" = "Resumen diario del tiempo al" + "Day" = "Día" + "Daylight" = "Luz del día" + "days ago" = "días atras" + "Declination" = "Declinación" + "End civil twilight" = "Fin del crepúsculo civil vespertino" + "Equinox" = "Equinoccio" + "Evapotranspiration (daily total)" = "Evapotranspiración (total diario)" + "Evapotranspiration (hourly total)" = "Evapotranspiración (total por hora)" + "Evapotranspiration (weekly total)" = "Evapotranspiración (total semanal)" + "from" = "de" # Context dependent: "de" o "desde" + "full" = "llena" # Applies to feminine nouns (such as Luna" for "Moon"). For masculine nouns, it's "lleno" + "Full moon" = "Luna llena" + "Hardware" = "Hardware" + "History" = "Historia" # Context dependent: For a plot, "Historia gráfica" would be more appropriate + "hours ago" = "horas atras" + "Latitude" = "Latitud" + "less than yesterday" = "menos que ayer" + "Lightning (daily total)" = "Número de rayos (total diario)" + "Lightning (hourly total)" = "Número de rayos (total por hora)" + "Lightning (weekly total)" = "Número de rayos (total semanal)" + "Longitude" = "Longitud" + "LOW" = "escaso" + "Max Wind" = "Viento máximo" + "minutes ago" = "hace minutos" + "Month" = "Mes" + "Monthly Reports" = "Reportes Mensuales" + "Monthly summary as of" = "Resumen mensual del tiempo al" + "Moon" = "Luna" + "Moon Phase" = "Fase lunar" + "more than yesterday" = "más que ayer" + "New moon" = "Luna Nueva" + "never" = "nunca" + "Phase" = "Fase" + "OK" = "OK" + "Rain (daily total)" = "Lluvia (total diario)" + "Rain (hourly total)" = "Lluvia (total por hora)" + "Rain (weekly total)" = "Lluvia (total semanal)" + "Rain Today" = "La lluvia de hoy" + "Rain Year" = "Lluvia anual" + "Rain
Year" = "Lluvia
Año" + "Right ascension" = "Ascensión recta" + "Rise" = "Salida" + "RMS Wind" = "Media cuadrática del viento" + "select month" = "Seleccione el mes" + "select year" = "Seleccione el año" + "Sensor Status" = "Estado del sensor" + "Server uptime" = "Tiempo de actividad del servidor" + "Set" = "Puesta" + "Solstice" = "Solsticio" + "Start civil twilight" = "Principio del crepúsculo civil vespertino" + "Statistics" = "Estadísticas" + "Sun" = "Sol" + "Sunrise" = "Salida del Sol" + "Sunset" = "Puesta del sol" + "Telemetry" = "Telemetría" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Esta estación está manejada por WeeWX, un sistema de softtware experimental para meteorología escrito en Python." + "Today" = "Hoy" + "Total daylight" = "Luz total del día" + "Transit" = "Tránsito" + "UNKNOWN" = "ignoto" + "Vector Average" = "Promedio vectorial" + "Vector Direction" = "Dirección vectorial" + "Voltage" = "Voltaje" + "Weather Conditions" = "Condiciones climáticas" + "Weather Conditions at" = "Condiciones climáticas al" + "Week" = "Semana" + "WeeWX uptime" = "Tiempo de actividad de WeeWX" + "WeeWX version" = "Versión de WeeWX" + "Year" = "Año" + "Yearly Reports" = "Reportes Anuales" + "Yearly summary as of" = "Resumen anual del clima al" + + [[Geographical]] + "Altitude" = "Altura sobre el nivel del mar" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Altitud sobre el horizonte" # As in angle above the horizon \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/fr.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/fr.conf new file mode 100644 index 0000000..b41a100 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/fr.conf @@ -0,0 +1,228 @@ +############################################################################### +# Localization File --- Seasons skin # +# French # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Cyril" # +# Revisité par Pascal Cambier le 30 mai 2022 (v2) # +# - modification de traductions foireuses # +# - ajout de nouveaux tags # +############################################################################### + +# Generally want a metric system for the French language: +unit_system = metricwx + +[Units] + + [[Labels]] + meter = " mètre", " mètres" + day = " jour", " jours" + hour = " heure", " heures" + minute = " minute", " minutes" + second = " seconde", " secondes" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, O + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Altimètre # QNH + altimeterRate = Taux d'Altimètre + appTemp = Température Ressentie + appTemp1 = Température Ressentie + barometer = Pression Atmosphérique # QFF + barometerRate = Tendance/Variation de Pression Atmosphèrique + cloudbase = Base de Nuages + dateTime = Heure + dewpoint = Point de Rosée + ET = Evapotranspiration + extraHumid1 = Hygrométrie1 + extraHumid2 = Hygrométrie2 + extraHumid3 = Hygrométrie3 + extraHumid4 = Hygrométrie4 + extraHumid5 = Hygrométrie5 + extraHumid6 = Hygrométrie6 + extraHumid7 = Hygrométrie7 + extraHumid8 = Hygrométrie8 + extraTemp1 = Température1 + extraTemp2 = Température2 + extraTemp3 = Température3 + extraTemp4 = Température4 + extraTemp5 = Température5 + extraTemp6 = Température6 + extraTemp7 = Température7 + extraTemp8 = Température8 + heatindex = Indice de Chaleur + inDewpoint = Point de Rosée Intérieure + inHumidity = Hygrométrie Intérieure + inTemp = Température Intérieure + interval = Intervalle + leafTemp1 = Température des feuilles1 + leafTemp2 = Température des feuilles2 + leafWet1 = Humidité des feuilles1 + leafWet2 = Humidité des feuilles2 + lightning_distance = Distance Éclair + lightning_strike_count = Nombre d'Éclairs + luminosity = Luminosité + outHumidity = Hygrométrie Extérieure + outTemp = Température Extérieure + pressure = Pression # QFE + pressureRate = Tendance/Variation de Pression + radiation = Rayonnement Solaire + rain = Précipitations + rainRate = Taux de Précipitations + soilMoist1 = Humidité du Sol1 + soilMoist2 = Humidité du Sol2 + soilMoist3 = Humidité du Sol3 + soilMoist4 = Humidité du Sol4 + soilTemp1 = Température du Sol1 + soilTemp2 = Température du Sol2 + soilTemp3 = Température du Sol3 + soilTemp4 = Température du Sol4 + THSW = THSW Index + UV = Index UV + wind = Vent + windchill = Refroidissement Éolien + windDir = Direction Vent + windGust = Vitesse Rafales + windGustDir = Direction Rafales + windgustvec = Vecteur Rafales + windrun = Parcours du Vent + windSpeed = Vitesse du Vent + windvec = Vecteur du Vent + + # used in Seasons skin, but not defined + feel = Température ressentie + + # Sensor status indicators + consBatteryVoltage = Batterie Console + heatingVoltage = Batterie Chauffage + inTempBatteryStatus = Température Intérieure + outTempBatteryStatus = Température Extérieure + rainBatteryStatus = Statut du Pluviomètre + referenceVoltage = Tension de Référence + rxCheckPercent = Qualité du Signal + supplyVoltage = Tension d'Alimentation + txBatteryStatus = État de la Batterie du Transmetteur + windBatteryStatus = État de la Batterie de l'Anémomètre + batteryStatus1 = État de la Batterie1 + batteryStatus2 = État de la Batterie2 + batteryStatus3 = État de la Batterie3 + batteryStatus4 = État de la Batterie4 + batteryStatus5 = État de la Batterie5 + batteryStatus6 = État de la Batterie6 + batteryStatus7 = État de la Batterie7 + batteryStatus8 = État de la Batterie8 + signal1 = Signal1 + signal2 = Signal2 + signal3 = Signal3 + signal4 = Signal4 + signal5 = Signal5 + signal6 = Signal6 + signal7 = Signal7 + signal8 = Signal8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nouvelle, Premier croissant, Premier quartier, Gibbeuse croissante, Pleine, Gibbeuse décroissante, Dernier quartier, Dernier croissant + +[Texts] + "About this station" = "À propos de cette station" + "Always down" = "Toujours bas" + "Always up" = "Toujours haut" + "Average Wind" = "Moyenne du Vent" + "Azimuth" = "Azimuth" + "Battery Status" = "Statut batterie" + "Celestial" = "Dans le ciel" + "Connectivity" = "Connectivité" + "Current Conditions" = "Conditions Actuelles" + "Current conditions, and daily, monthly, and yearly summaries" = "Conditions Météorologiques Actuelles et les résumés quotidiens, mensuels et annuels" + "Daily summary as of" = "Résumé météorologique quotidien en date du" + "Day" = "Jour" + "Daylight" = "Journée" + "days ago" = "quelques jours" + "Declination" = "Déclinaison" + "End civil twilight" = "Fin du crépuscule civil" + "Equinox" = "Équinoxe" + "Evapotranspiration (daily total)" = "Evapotranspiration (total quotidien)" + "Evapotranspiration (hourly total)" = "Evapotranspiration (total horaire)" + "Evapotranspiration (weekly total)" = "Evapotranspiration (total hebdomadaire)" + "from" = "de" + "full" = "pleine" + "Full moon" = "Pleine lune" + "Hardware" = "Matériel" + "History" = "Historique" + "hours ago" = "des heures" + "Latitude" = "Latitude" + "less than yesterday" = "de moins qu'hier" + "Lightning (daily total)" = "Nombre d'Éclairs (total quotidien)" + "Lightning (hourly total)" = "Nombre d'Éclairs (total horaire)" + "Lightning (weekly total)" = "Nombre d'Éclairs (total hebdomadaire)" + "Longitude" = "Longitude" + "LOW" = "faible" + "Max Wind" = "Vent Max" + "minutes ago" = "quelques minutes" + "Month" = "Mois" + "Monthly Reports" = "Rapports Mensuels" + "Monthly summary as of" = "Résumé météorologique mensuel en date du" + "Moon" = "Lune" + "Moon Phase" = "Phase de lune" + "more than yesterday" = "de plus qu'hier" + "never" = "jamais" + "New moon" = "Nouvelle lune" + "OK" = "acceptable" + "Phase" = "Phase" + "Rain (daily total)" = "Précipitations (total quotidien)" + "Rain (hourly total)" = "Précipitations (total horaire)" + "Rain (weekly total)" = "Précipitations (total hebdomadaire)" + "Rain Today" = "Précipitations Aujourd'hui" + "Rain Year" = "Précipitations d'un an" # Année de référence différente de l'année calendaire + "Rain
Year" = "Précipitations
Année" + "Right ascension" = "Ascension droite" + "Rise" = "Lever" + "RMS Wind" = "Valeur Efficace (RMS) Vent" + "select month" = "Choisir le mois" + "select year" = "Choisir l'année" + "Sensor Status" = "Statut capteur(s)" + "Server uptime" = "Serveur en service depuis" + "Set" = "Coucher" + "Solstice" = "Solstice" + "Start civil twilight" = "Début du crépuscule civil" + "Statistics" = "Statistiques" + "Sun" = "Soleil" + "Sunrise" = "Lever du soleil" + "Sunset" = "Coucher du soleil" + "Telemetry" = "Télémétrie" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Cette station est contrôlée par WeeWX, un logiciel météorologique expérimental écrit en Python." + "Today" = "Aujourd'hui" + "Total daylight" = "Total journée" + "Transit" = "Transit" + "UNKNOWN" = "indéterminé" + "Vector Average" = "Moyenne Vecteur Vent" + "Vector Direction" = "Direction Vecteur Vent" + "Voltage" = "Voltage" + "Weather Conditions" = "Conditions météorologiques" + "Weather Conditions at" = "Conditions météorologiques à" + "Week" = "Semaine" + "WeeWX uptime" = "WeeWX en service depuis" + "WeeWX version" = "Version de WeeWX" + "Year" = "Année" + "Yearly Reports" = "Rapports annuels" + "Yearly summary as of" = "Résumé météorologique annuel en date du" + + [[Geographical]] + "Altitude" = "Altitude" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Altitude" # As in angle above the horizon + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/gr.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/gr.conf new file mode 100644 index 0000000..4f0dd15 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/gr.conf @@ -0,0 +1,224 @@ +############################################################################### +# Localization File --- Seasons skin # +# Greek # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Δημήτρης" # +############################################################################### + +# Generally want a metric system for the Greek language: +unit_system = metricwx + +[Units] + + [[Labels]] + + day = " Ημέρα", " Ημέρες" + hour = " Ωρα", " Ωρες" + minute = " Λεπτό", " Λεπτά" + second = " Δευτερόλεπτο", " Δευτερόλεπτα" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = B, BBA, BA, ABA, A, ANA, NA, NNA, N, NNΔ, ΝΔ, ΔΝΔ, Δ, ΔΒΔ, ΒΔ, ΒΒΔ, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = Β, Ν, Α, Δ + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Υψόμετρο # QNH + altimeterRate = Τάση Αλλαγής υψόμετρου + appTemp = Aισθητή Θερμοκρασία + appTemp1 = Aισθητή Θερμοκρασία1 + barometer = Bαρόμετρο # QFF + barometerRate = Tάση Βαρόμετρου + cloudbase = Βάση Νεφών + dateTime = Ωρα + dewpoint = Σημείο Δρόσου + ET = EΔ + extraHumid1 = Υγρασία1 + extraHumid2 = Υγρασία2 + extraHumid3 = Υγρασία3 + extraHumid4 = Υγρασία4 + extraHumid5 = Υγρασία5 + extraHumid6 = Υγρασία6 + extraHumid7 = Υγρασία7 + extraHumid8 = Υγρασία8 + extraTemp1 = Αισθητήρας1 + extraTemp2 = Αισθητήρας2 + extraTemp3 = Αισθητήρας3 + extraTemp4 = Αισθητήρας4 + extraTemp5 = Αισθητήρας5 + extraTemp6 = Αισθητήρας6 + extraTemp7 = Αισθητήρας7 + extraTemp8 = Αισθητήρας8 + heatindex = Δείκτης Δυσφορίας + inDewpoint = Εσωτ. Σημείο Δρόσου + inHumidity = Εσωτ. Υγρασία + inTemp = Εσωτ. Θερμοκρασία + interval = Κάθε + leafTemp1 = Θερμοκρασία φύλλων1 + leafTemp2 = Θερμοκρασία φύλλων2 + leafWet1 = Υγρασία φύλλων1 + leafWet2 = Υγρασία φύλλων2 + lightning_distance = Απόσταση Εκκένωσης + lightning_strike_count = Συχνότητα ηλεκτρικών + luminosity = Φωτεινότητα + outHumidity = Υγρασία + outTemp = Θερμοκρασία + pressure = Ατμ. Πίεση # QFE + pressureRate = Ατμ. Πίεσης Τάση + radiation = Ακτινοβολία + rain = Υετός + rainRate = Εντ. Βροχής + soilMoist1 = Υγρασία εδάφους1 + soilMoist2 = Υγρασία εδάφους2 + soilMoist3 = Υγρασία εδάφους3 + soilMoist4 = Υγρασία εδάφους4 + soilTemp1 = Θερμοκρασία εδάφους1 + soilTemp2 = Θερμοκρασία εδάφους2 + soilTemp3 = Θερμοκρασία εδάφους3 + soilTemp4 = Θερμοκρασία εδάφους4 + THSW = THSW Δείκτης + UV = UV Δείκτης + wind = Ανεμος + windchill = Αίσθ. Ψύχους + windDir = Ανεμοδείκτης + windGust = Ταχ. Ανέμου + windGustDir = Διεύθ. Ριπής + windgustvec = Διάνυσμα Ριπής Ανέμου + windrun = Τρέξιμο Ανεμ. + windSpeed = Ταχ. Ανέμου + windvec = Διάνυσμα Ανέμου + + # used in Seasons skin, but not defined + feel = Aισθηση + + # Sensor status indicators + consBatteryVoltage = Βολτ Μπαταρίας Κονσόλας + heatingVoltage = Θερμότητα Μπαταρίας + inTempBatteryStatus = Θερμοκρασία Μπαταρίας Κονσόλας + outTempBatteryStatus = Θερμοκρασία Μπαταρία Αισθητήρα + rainBatteryStatus = Μπαταρία Βροχόμετρου + referenceVoltage = Ονομαστική Τάση + rxCheckPercent = Ισχύς Σήματος + supplyVoltage = Ονομαστική Τάση + txBatteryStatus = Μπαταρία Αισθητήρα + windBatteryStatus = Μπαταρία Ανεμόμετρου + batteryStatus1 = Μπαταρία1 + batteryStatus2 = Μπαταρία2 + batteryStatus3 = Μπαταρία3 + batteryStatus4 = Μπαταρία4 + batteryStatus5 = Μπαταρία5 + batteryStatus6 = Μπαταρία6 + batteryStatus7 = Μπαταρία7 + batteryStatus8 = Μπαταρία8 + signal1 = Σήμα1 + signal2 = Σήμα2 + signal3 = Σήμα3 + signal4 = Σήμα4 + signal5 = Σήμα5 + signal6 = Σήμα6 + signal7 = Σήμα7 + signal8 = Σήμα8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Νέα Σελήνη, Αύξων Μηνίσκος , Πρώτο τέταρτο, Αύξων Αμφίκυρτος, Πανσέληνος , Φθίνων Αμφίκυρτος, Τελευταίο τέταρτο , Φθίνων Μηνίσκος + +[Texts] + "About this station" = "Σχετικά με τον Σταθμό" + "Always down" = "Μόνιμα Κάτω" + "Always up" = "Μόνιμα Πάνω" + "Average Wind" = "Mέση . Ταχ. Ανέμου" + "Azimuth" = "Aζιμούθιο" + "Battery Status" = "Ισχύς Μπαταρίας" + "Celestial" = "Ηλιος/Σελήνη" + "Connectivity" = "Συνδεσιμότητα" + "Current Conditions" = "Τρέχουσες Συνθήκες" + "Current conditions, and daily, monthly, and yearly summaries" = "Τρέχουσες Συνθήκες, και ημερήσια, μηνιαία, και ετήσια στατιστικά" + "Daily summary as of" = "Ημερήσια Στατιστικά της" + "Day" = "Ημέρα" + "Daylight" = "Φώς Ημέρας" + "days ago" = "μέρες πριν" + "Declination" = "Απόκλιση" + "End civil twilight" = "Τελευταίο φως" + "Equinox" = "Ισημερία" + "Evapotranspiration (daily total)" = "EΔ (Ημερήσιο Σύνολο)" + "Evapotranspiration (hourly total)" = "EΔ (Ωριαίο Σύνολο)" + "Evapotranspiration (weekly total)" = "EΔ (Εβδομαδιαίο Σύνολο)" + "from" = "Από" + "full" = "Κάλυψη" + "Full moon" = "Πανσέληνος" + "Hardware" = "Τύπος ΜΣ" + "History" = "Διαγράματα" + "hours ago" = "ώρες πριν" + "Latitude" = "Βόρειο Πλάτος" + "less than yesterday" = "Λιγότερο από χθες" + "Lightning (daily total)" = "Συχνότητα ηλεκτρικών (Ημερήσιο Σύνολο)" + "Lightning (hourly total)" = "Συχνότητα ηλεκτρικών (Ωριαίο Σύνολο)" + "Lightning (weekly total)" = "Συχνότητα ηλεκτρικών (Εβδομαδιαίο Σύνολο)" + "Longitude" = "Ανατολικό Μήκος" + "LOW" = "χαμηλός" + "Max Wind" = "Mεγ . Ταχ. Ανέμου" + "minutes ago" = "λεπτά πριν" + "Month" = "Mήνας" + "Monthly Reports" = "NOAA Μήνα" + "Monthly summary as of" = "Mηνιαία Στατιστικά του" + "Moon" = "Σελήνη" + "Moon Phase" = "Φάση Σελήνης" + "more than yesterday" = "Περισότερο από χθες" + "New moon" = "Nέα Σελήνη" + "never" = "ποτέ" + "OK" = "Εντάξει" + "Phase" = "Φάση" + "Rain (daily total)" = "Yετός (Ημερήσιο Σύνολο)" + "Rain (hourly total)" = "Yετός (Ωριαίο Σύνολο)" + "Rain (weekly total)" = "Yετός (Εβδομαδιαίο Σύνολο)" + "Rain Today" = "Ημερήσιος υετός" + "Rain Year" = "Ετήσιος Υετός" # Not sure this is right... + "Rain
Year" = "Ετήσιος
Υετός" + "Right ascension" = "Δεξιά ανύψωση" + "Rise" = "Aνατολή" + "RMS Wind" = "RMS Ανέμου" + "select month" = "Επιλέξτε" + "select year" = "Επιλέξτε" + "Sensor Status" = "Κατάσταση Αισθητήρα" + "Server uptime" = "Server Ανοικτός" + "Set" = "Δύση" + "Solstice" = "Ηλιοστάσιο" + "Start civil twilight" = "Σούρουπο" + "Statistics" = "Στατιστικά Σταθμού" + "Sun" = "Ηλιος" + "Sunrise" = "Ανατολή" + "Sunset" = "Δύση" + "Telemetry" = "Μετρητές" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Tα δεδομένα του μετεωρολογικού σταθμού επεξεργάζεται το WeeWX, ένα πειραματικό πρόγραμμα γραμμένο σε γλώσσα Python." + "Today" = "Σήμερα" + "Total daylight" = "Διάρκεια φωτός" + "Transit" = "Μέγιστο ύψος" + "UNKNOWN" = "άγνωστος" + "Vector Average" = "Μέσο Διάνυσμα" + "Vector Direction" = "Μέση Διέυθυνση" + "Voltage" = "Βολτ" + "Weather Conditions" = "Καιρικές Συνθήκες" + "Weather Conditions at" = "Καιρικές Συνθήκες στις" + "Week" = "Εβδομάδα" + "WeeWX uptime" = "WeeWX Ανοικτό" + "WeeWX version" = "WeeWX Εκδοση" + "Year" = "Ετος" + "Yearly Reports" = "ΝΟΑΑ Ετους" + "Yearly summary as of" = "Ετήσια στατιστικά του" + + [[Geographical]] + "Altitude" = "Υψόμετρο" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Υψος" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/it.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/it.conf new file mode 100644 index 0000000..944d09c --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/it.conf @@ -0,0 +1,234 @@ +############################################################################### +# Localization File # +# Italiano # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Claudio", "itec" # +############################################################################### + +# Generally want a metric system for the Italian language: +unit_system = metricwx + +[Units] + + [[Labels]] + + # These are singular, plural + meter = " metro", " metri" + day = " giorno", " giorni" + hour = " ora", " ore" + minute = " minuto", " minuti" + second = " secondo", " secondi" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO, N.D. + + [[StringFormats]] + NONE = "n.d." + +[Labels] + + + + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, O + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Altimetro # QNH + altimeterRate = Freq. agg. altimetro + appTemp = Temperatura apparente + appTemp1 = Temperatura apparente1 + barometer = Pressione # QFF + barometerRate = Freq. agg. barometro + cloudbase = Base delle nubi + dateTime = Ora + dewpoint = Punto di rugiada + ET = ET + extraHumid1 = Umidità1 + extraHumid2 = Umidità2 + extraHumid3 = Umidità3 + extraHumid4 = Umidità4 + extraHumid5 = Umidità5 + extraHumid6 = Umidità6 + extraHumid7 = Umidità7 + extraHumid8 = Umidità8 + extraTemp1 = Temperatura1 + extraTemp2 = Temperatura2 + extraTemp3 = Temperatura3 + extraTemp4 = Temperatura4 + extraTemp5 = Temperatura5 + extraTemp6 = Temperatura6 + extraTemp7 = Temperatura7 + extraTemp8 = Temperatura8 + heatindex = Indice di calore + inDewpoint = Punto di rugiada interno + inHumidity = Umidità interna + inTemp = Temperatura interna + interval = Intervallo + leafTemp1 = Temperatura fogliare1 + leafTemp2 = Temperatura fogliare2 + leafWet1 = Umidità fogliare1 + leafWet2 = Umidità fogliare2 + lightning_distance = Distanza del fulmine + lightning_strike_count = Fulmini + luminosity = Lluminosità + outHumidity = Umidità + outTemp = Temperatura + pressure = Pressione # QFE + pressureRate = Freq. agg. pressione + radiation = Radiazione solare + rain = Pioggia + rainRate = Intensità pioggia + soilMoist1 = Umidità del suolo1 + soilMoist2 = Umidità del suolo2 + soilMoist3 = Umidità del suolo3 + soilMoist4 = Umidità del suolo4 + soilTemp1 = Temperatura del suolo1 + soilTemp2 = Temperatura del suolo2 + soilTemp3 = Temperatura del suolo3 + soilTemp4 = Temperatura del suolo4 + THSW = Indice THSV + UV = Indice UV + wind = Vento + windchill = Temperatura percepita + windDir = Direzione del vento + windGust = Velocità raffica + windGustDir = Direzione raffica + windgustvec = Vettore raffica + windrun = Vento filato + windSpeed = Velocità del vento + windvec = Vettore del vento + + # used in Seasons skin, but not defined + feel = Feel + + # Sensor status indicators + consBatteryVoltage = Console + heatingVoltage = Riscaldatore + inTempBatteryStatus = Termometro interno + outTempBatteryStatus = Termometro + rainBatteryStatus = Pluviometro + referenceVoltage = Riferimento + rxCheckPercent = Qualità del segnale + supplyVoltage = Alimentazione + txBatteryStatus = Trasmettitore + windBatteryStatus = Anemometro + batteryStatus1 = Batteria1 + batteryStatus2 = Batteria2 + batteryStatus3 = Batteria3 + batteryStatus4 = Batteria4 + batteryStatus5 = Batteria5 + batteryStatus6 = Batteria6 + batteryStatus7 = Batteria7 + batteryStatus8 = Batteria8 + signal1 = Segnale1 + signal2 = Segnale2 + signal3 = Segnale3 + signal4 = Segnale4 + signal5 = Segnale5 + signal6 = Segnale6 + signal7 = Segnale7 + signal8 = Segnale8 + +[Almanac] + + # The labels to be used for the phases of the moon: + # https://it.wikipedia.org/wiki/Fasi_lunari + moon_phases = Nuova, Crescente, Primo quarto, Gibbosa crescente, Piena, Gibbosa calante, Ultimo quarto, Calante + +[Texts] + "About this station" = "Dati stazione" + "Always down" = "Sempre sotto" #l'orizzonte + "Always up" = "Sempre sopra" #l'orizzonte + "Average Wind" = "Vento medio" + "Azimuth" = "Azimut" + "Battery Status" = "Stato della batteria" + "Celestial" = "Volta celeste" + "Connectivity" = "Connettività" + "Current Conditions" = "Condizioni attuali" + "Current conditions, and daily, monthly, and yearly summaries" = "Condizioni attuali, e giornaliere, mensili, e riassunto annuale" + "Daily summary as of" = "Sommario giornaliero dal" + "Day" = "Giorno" + "Daylight" = "Ore di luce" + "days ago" = "giorni fa" + "Declination" = "Declinazione" + "End civil twilight" = "Fine crepuscolo civile" + "Equinox" = "Equinozio" + "Evapotranspiration (daily total)" = "Evapotraspirazione (totale giornaliero)" + "Evapotranspiration (hourly total)" = "Evapotraspirazione (totale orario)" + "Evapotranspiration (weekly total)" = "Evapotraspirazione (totale settimanale)" + "from" = "da" + "full" = " di illuminazione" # As in "The Moon is 21% full" + "Full moon" = "Luna piena" + "Hardware" = "Stazione" + "History" = "Storico" + "hours ago" = "ore fa" + "Latitude" = "Latitudine" + "less than yesterday" = "meno di ieri" + "Lightning (daily total)" = "Fulmini (totale giornaliero)" + "Lightning (hourly total)" = "Fulmini (totale orario)" + "Lightning (weekly total)" = "Fulmini (totale settimanale)" + "Longitude" = "Longitudine" + "LOW" = "scarso" + "Max Wind" = "Vento massimo" + "minutes ago" = "minuti fa" + "Month" = "Mese" + "Monthly Reports" = "Report mensile" + "Monthly summary as of" = "Sommario mensile dal" + "Moon" = "Luna" + "Moon Phase" = "Fase Lunare" + "more than yesterday" = "più di ieri" + "New moon" = "Luna Nuova" + "never" = "mai" + "OK" = "OK" + "Phase" = "Fase" + "Rain (daily total)" = "Pioggia (totale giornaliero)" + "Rain (hourly total)" = "Pioggia (totale orario)" + "Rain (weekly total)" = "Pioggia (totale settimanale)" + "Rain Today" = "Pioggia odierna" + "Rain Year" = "Anno idrologico" #https://www.meteoregionelazio.it/2020/10/25/anno-idrologico-2019-2020-come-e-andato/ + "Rain
Year" = "Anno
idrologico" + "Right ascension" = "Ascensione retta" + "Rise" = "Sorge" + "RMS Wind" = "RMS vento" + "select month" = "Seleziona mese" + "select year" = "Seleziona anno" + "Sensor Status" = "Stato dei sensori" + "Server uptime" = "Server attivo da" + "Set" = "Tramonta" + "Solstice" = "Solstizio" + "Start civil twilight" = "Inizio crepuscolo civile" #https://it.wikipedia.org/wiki/Crepuscolo + "Statistics" = "Statistiche" + "Sun" = "Sole" + "Sunrise" = "Alba" + "Sunset" = "Tramonto" + "Telemetry" = "Telemetria" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Questa stazione è controllata da WeeWX, un software meteo scritto in Python." + "Today" = "Oggi" + "Total daylight" = "Totale luce solare" + "Transit" = "Culmina" + "UNKNOWN" = "INCERTO" + "Vector Average" = "Vettore medio" + "Vector Direction" = "Direzione vettore" + "Voltage" = "Voltaggio" + "Weather Conditions" = "Condizioni climatiche" + "Weather Conditions at" = "Condizioni climatiche a" + "Week" = "Settimana" + "WeeWX uptime" = "WeeWX attivo da" + "WeeWX version" = "Versione di Weewx" + "Year" = "Anno" + "Yearly Reports" = "Report annuale" + "Yearly summary as of" = "Sommario annuale dal" + + [[Geographical]] + "Altitude" = "Altitudine" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Altezza" # As in angle above the horizon + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/nl.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/nl.conf new file mode 100644 index 0000000..0222735 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/nl.conf @@ -0,0 +1,226 @@ +############################################################################### +# Localization File --- Seasons skin # +# Dutch # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# Translation by Eelco # +############################################################################### + +# Generally want a metric system for the Dutch language: +unit_system = metricwx + +[Units] + + [[Labels]] + + # These are singular, plural + meter = " meter", " meters" + day = " dag", " dagen" + hour = " uur", " uren" + minute = " minuut", " minuten" + second = " seconde", " seconden" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNO, NO, ONO, O, OZO, ZO, ZZO, Z, ZZW, ZW, WZW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, Z, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Luchtdruk (QNH) # QNH + altimeterRate = Luchtdruk Trend + appTemp = Gevoelstemperatuur + appTemp1 = Gevoelstemperauur + barometer = Barometer # QFF + barometerRate = Barometer Trend + cloudbase = Wolkenbasis + dateTime = Tijd + dewpoint = Dauwpunt + ET = Verdamping + extraHumid1 = Luchtvochtigheid1 + extraHumid2 = Luchtvochtigheid2 + extraHumid3 = Luchtvochtigheid3 + extraHumid4 = Luchtvochtigheid4 + extraHumid5 = Luchtvochtigheid5 + extraHumid6 = Luchtvochtigheid6 + extraHumid7 = Luchtvochtigheid7 + extraHumid8 = Luchtvochtigheid8 + extraTemp1 = Temperatuur1 + extraTemp2 = Temperatuur2 + extraTemp3 = Temperatuur3 + extraTemp4 = Temperatuur4 + extraTemp5 = Temperatuur5 + extraTemp6 = Temperatuur6 + extraTemp7 = Temperatuur7 + extraTemp8 = Temperatuur8 + heatindex = Hitte Index + inDewpoint = Dauwpunt Binnen + inHumidity = Luchtvochtigheid Binnen + inTemp = Temperatuur Binnen + interval = Interval + leafTemp1 = Blad temperatuur1 + leafTemp2 = Blad temperatuur2 + leafWet1 = Blad vocht1 + leafWet2 = Blad vocht2 + lightning_distance = Bliksem Afstand + lightning_strike_count = Bliksem Ontladingen + luminosity = Helderheid + outHumidity = Luchtvochtigheid Buiten + outTemp = Temperatuur Buiten + pressure = Luchtdruk (QFE) # QFE + pressureRate = Luchtdrukverandering + radiation = Zonnestraling + rain = Regen + rainRate = Regen Intensiteit + soilMoist1 = Bodemvocht1 + soilMoist2 = Bodemvocht2 + soilMoist3 = Bodemvocht3 + soilMoist4 = Bodemvocht4 + soilTemp1 = Bodemtemperatuur1 + soilTemp2 = Bodemtemperatuur2 + soilTemp3 = Bodemtemperatuur3 + soilTemp4 = Bodemtemperatuur4 + THSW = THSW Index + UV = UV Index + wind = Wind + windchill = Windchill + windDir = Wind Richting + windGust = Windvlaag Snelheid + windGustDir = Windvlaag Richting + windgustvec = Windvlaag Vector + windrun = Wind Run + windSpeed = Wind Snelheid + windvec = Wind Vector + + # used in Seasons skin, but not defined + feel = gevoelstemperatuur + + # Sensor status indicators + consBatteryVoltage = Console Batterij + heatingVoltage = Verwarming Batterij + inTempBatteryStatus = BinnenTemperatuur Batterij + outTempBatteryStatus = Buitentemperatuur Batterij + rainBatteryStatus = Regen Batterij + referenceVoltage = Referentie Voltage + rxCheckPercent = Signaalkwalitiet + supplyVoltage = Voeding Voltage + txBatteryStatus = Zender Batterij + windBatteryStatus = Wind Batterij + batteryStatus1 = Accu1 + batteryStatus2 = Accu2 + batteryStatus3 = Accu3 + batteryStatus4 = Accu4 + batteryStatus5 = Accu5 + batteryStatus6 = Accu6 + batteryStatus7 = Accu7 + batteryStatus8 = Accu8 + signal1 = Signaal1 + signal2 = Signaal2 + signal3 = Signaal3 + signal4 = Signaal4 + signal5 = Signaal5 + signal6 = Signaal6 + signal7 = Signaal7 + signal8 = Signaal8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nieuw, Jonge Maansikkel, Eerste Kwartier, Wassende Maan, Vol, Afnemende Maan, Laatste Kwartier, Asgrauwe Maan + +[Texts] + "About this station" = "Over dit station" + "Always down" = "Altijd onder" + "Always up" = "Altijd op" + "Average Wind" = "Gemiddelde Wind" + "Azimuth" = "Azimuth" + "Battery Status" = "Batterij Status" + "Celestial" = "Zon en Maan" + "Connectivity" = "Verbindingskwaliteit" + "Current Conditions" = "Actuele Condities" + "Current conditions, and daily, monthly, and yearly summaries" = "Actuele condities en dagelijkse, maandelijkse, and jaarlijkse samenvattingen" + "Daily summary as of" = "Dagelijkse Weersamenvatting van" + "Day" = "Dag" + "Daylight" = "Daglicht" + "days ago" = "dagen geleden" + "Declination" = "Declinatie" + "End civil twilight" = "Einde civiele schemering" + "Equinox" = "Equinox" + "Evapotranspiration (daily total)" = "Verdamping (dag totaal)" + "Evapotranspiration (hourly total)" = "Verdamping (uur totaal)" + "Evapotranspiration (weekly total)" = "Verdamping (week totaal)" + "from" = "uit" + "full" = "vol" + "Full moon" = "Volle maan" + "Hardware" = "Hardware" + "History" = "Historie" + "hours ago" = "uren geleden" + "Latitude" = "Breedtegraad" + "less than yesterday" = "minder dan gisteren" + "Lightning (daily total)" = "Bliksem (dag totaal)" + "Lightning (hourly total)" = "Bliksem (uur totaal)" + "Lightning (weekly total)" = "Bliksem (week totaal)" + "Longitude" = "Lengtegraad" + "LOW" = "LAAG" + "Max Wind" = "Max Wind" + "minutes ago" = "minuten geleden" + "Month" = "Maand" + "Monthly Reports" = "Maandelijkse rapporten" + "Monthly summary as of" = "Maandelijkse Weersamenvatting van" + "Moon" = "Maan" + "Moon Phase" = "Maan Fase" + "more than yesterday" = "meer dan gisteren" + "New moon" = "Nieuwe maan" + "never" = "nooit" + "OK" = "OKE" + "Phase" = "Fase" + "Rain (daily total)" = "Regen (dag totaal)" + "Rain (hourly total)" = "Regen (uur totaal)" + "Rain (weekly total)" = "Regen (week totaal)" + "Rain Today" = "Regen vandaag" + "Rain Year" = "Regen Jaar" + "Rain
Year" = "Regen
Jaar" + "Right ascension" = "Rechte Klimming" + "Rise" = "Opkomst" + "RMS Wind" = "RMS Wind" + "select month" = "Selecteer Maand" + "select year" = "Selecteer Jaar" + "Sensor Status" = "Sensor Status" + "Server uptime" = "Server uptime" + "Set" = "Ondergang" + "Solstice" = "Zonnewende" + "Start civil twilight" = "Start civiele schemering" + "Statistics" = "Statistiek" + "Sun" = "Zon" + "Sunrise" = "Zonsopkomst" + "Sunset" = "Zonsondergang" + "Telemetry" = "Telemetrie" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Dit station gebruikt WeeWX, een experimenteel weer software systeem geschreven in Python." + "Today" = "Vandaag" + "Total daylight" = "Totaal daglicht" + "Transit" = "Transit" + "UNKNOWN" = "ONBEKEND" + "Vector Average" = "Vector Gemiddeld" + "Vector Direction" = "Vector Richting" + "Voltage" = "Voltage" + "Weather Conditions" = "Weercondities" + "Weather Conditions at" = "Weercondities om" + "Week" = "Week" + "WeeWX uptime" = "WeeWX uptime" + "WeeWX version" = "WeeWX versie" + "Year" = "Jaar" + "Yearly Reports" = "Jaarlijkse rapporten" + "Yearly summary as of" = "Jaarlijkse Weersamenvatting van" + + [[Geographical]] + "Altitude" = "Hoogte" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Hoogte" # As in angle above the horizon + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/no.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/no.conf new file mode 100644 index 0000000..d22d6c7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/no.conf @@ -0,0 +1,251 @@ +############################################################################### +# Localization File --- Seasons skin # +# Norwegian # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +############################################################################### + +# Generally want a metric system for the norwegian language: +unit_system = metricwx + +[Units] + + # [[Groups]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_degree_day = degree_C_day # Options are 'degree_F_day' or 'degree_C_day' + # group_distance = km # Options are 'mile' or 'km' + # group_pressure = mBar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_speed = meter_per_second # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + # group_speed2 = meter_per_second2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + # group_temperature = degree_C # Options are 'degree_F' or 'degree_C' + + [[Labels]] + + # These are singular, plural + meter = " meter", " meter" + day = " dag", " dager" + hour = " time", " timer" + minute = " minutt", " minutter" + second = " sekund", " sekunder" + + cm_per_hour = " cm/t" + hPa_per_hour = " hPa/t" + inch_per_hour = " in/t" + inHg_per_hour = " inHg/t" + km_per_hour = " km/t" + km_per_hour2 = " km/t" + kPa_per_hour = " kPa/t", + mbar_per_hour = " mbar/t" + mm_per_hour = " mm/t" + mmHg_per_hour = " mmHg/t" + meter_per_second = " m/s" + meter_per_second2 = " m/s" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNØ, NØ, ØNØ, Ø, ØSØ, SØ, SSØ, S, SSV, SV, VSV, V, VNV, NV, NNV, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, Ø, V + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Høydemåling # QNH + altimeterRate = Endringshastighet høyde + appTemp = Føles som + appTemp1 = Føles som + barometer = Lufttrykk # QFF + barometerRate = Endringshastighet lufttrykk + cloudbase = Skybase + dateTime = Tidspunkt + dewpoint = Doggpunkt ute + ET = Fordampning + extraHumid1 = Fuktighet1 + extraHumid2 = Fuktighet2 + extraHumid3 = Fuktighet3 + extraHumid4 = Fuktighet4 + extraHumid5 = Fuktighet5 + extraHumid6 = Fuktighet6 + extraHumid7 = Fuktighet7 + extraHumid8 = Fuktighet8 + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + extraTemp4 = Temperatur4 + extraTemp5 = Temperatur5 + extraTemp6 = Temperatur6 + extraTemp7 = Temperatur7 + extraTemp8 = Temperatur8 + heatindex = Varmeindeks + inDewpoint = Doggpunkt inne + inHumidity = Fuktighet inne + inTemp = Temperatur inne + interval = Intervall + leafTemp1 = Bladtemperatur1 + leafTemp2 = Bladtemperatur2 + leafWet1 = Bladfuktighet1 + leafWet2 = Bladfuktighet2 + lightning_distance = Lynavstand + lightning_strike_count = Lynnedslag + luminosity = Lysstråling + outHumidity = Fuktighet ute + outTemp = Temperatur ute + pressure = Lufttrykk # QFE + pressureRate = Endringshastighet lufttrykk + radiation = Stråling + rain = Regn + rainRate = Regnintensitet + soilMoist1 = Jordfuktighet1 + soilMoist2 = Jordfuktighet2 + soilMoist3 = Jordfuktighet3 + soilMoist4 = Jordfuktighet4 + soilTemp1 = Jordtemperatur1 + soilTemp2 = Jordtemperatur2 + soilTemp3 = Jordtemperatur3 + soilTemp4 = Jordtemperatur4 + THSW = THSW indeks + UV = UV indeks + wind = Vind + windchill = Føles som + windDir = Vindretning + windGust = Vindkast + windGustDir = Vindkast retning + windgustvec = Vindkast vektor + windrun = Vinddistanse + windSpeed = Vindhastighet + windvec = Vindvektor + + # used in Seasons skin, but not defined + feel = følt temperatur + + # Sensor status indicators + consBatteryVoltage = Konsollbatteri + heatingVoltage = Varmebatteri + inTempBatteryStatus = Batteri innetemperatur + outTempBatteryStatus = Batteri utetemperatur + rainBatteryStatus = Regnbatteri + referenceVoltage = Referansespenning + rxCheckPercent = Signalkvalitet + supplyVoltage = Spennign strømforsyning + txBatteryStatus = Senderbatteri + windBatteryStatus = Vindbatteri + batteryStatus1 = Batteri1 + batteryStatus2 = Batteri2 + batteryStatus3 = Batteri3 + batteryStatus4 = Batteri4 + batteryStatus5 = Batteri5 + batteryStatus6 = Batteri6 + batteryStatus7 = Batteri7 + batteryStatus8 = Batteri8 + signal1 = Signal1 + signal2 = Signal2 + signal3 = Signal3 + signal4 = Signal4 + signal5 = Signal5 + signal6 = Signal6 + signal7 = Signal7 + signal8 = Signal8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nymåne, Voksende månesigd, Voksende halvmåne, Voksende fullmåne, Fullmåne, Minkende fullmåne, Minkende halvmåne, Minkende månesigd + +[Texts] + "About this station" = "Om denne stasjonen" + "Always down" = "Alltid nede" + "Always up" = "Alltid oppe" + "Average Wind" = "Gjennomsnittsvind" + "Azimuth" = "Asimut" + "Battery Status" = "Batteristatus" + "Celestial" = "Sol og måne" + "Connectivity" = "Forbindelse" + "Current Conditions" = "Vær nå" + "Current conditions, and daily, monthly, and yearly summaries" = "Oppsummering nåtilstand, daglig, månedlig og årlig" + "Daily summary as of" = "Oppsummering daglig vær den" + "Day" = "Dag" + "days ago" = "dager siden" + "Daylight" = "Dagslys" + "Declination" = "Deklinasjon" + "End civil twilight" = "Slutt skumring" + "Equinox" = "Jevndøgn" + "Evapotranspiration (daily total)" = "Fordampning (pr. dag)" + "Evapotranspiration (hourly total)" = "Fordampning (pr. time)" + "Evapotranspiration (weekly total)" = "Fordampning (pr. uke)" + "from" = "fra" + "full" = "full" # As in "The Moon is 21% full" + "Full moon" = "Fullmåne" + "Hardware" = "Maskinvare" + "History" = "Historie" + "hours ago" = "timer siden" + "Latitude" = "Breddegrad" + "less than yesterday" = "mindre enn i går" + "Lightning (daily total)" = "Lyn (pr. dag)" + "Lightning (hourly total)" = "Lyn (pr. time)" + "Lightning (weekly total)" = "Lyn (pr. uke)" + "Longitude" = "Lengdegrad" + "LOW" = "LOW" + "Max" = "Maks" + "Max Wind" = "Maks vind" + "Min" = "Min" + "minutes ago" = "minutter siden" + "Month" = "Måned" + "Monthly Reports" = "Månedsrapport" + "Monthly summary as of" = "Månedlig oppsummering for" + "Moon" = "Måne" + "Moon Phase" = "Månefase" + "more than yesterday" = "mer enn i går" + "never" = "aldri" + "New moon" = "Nymåne" + "OK" = "OK" + "Phase" = "Fase" + "Rain (daily total)" = "Regn (pr. dag)" + "Rain (hourly total)" = "Regn (pr. time)" + "Rain (weekly total)" = "Regn (pr. uke)" + "Rain Today" = "Regn i dag" + "Rain Year" = "Regnår" + "Rain
Year" = "Regn
År" + "Right ascension" = "Rektascensjon" + "Rise" = "Opp" + "RMS Wind" = "RMS vind" + "select month" = "Velg måned" + "select year" = "Velg år" + "Sensor Status" = "Sensorstatus" + "Server uptime" = "Server oppetid" + "Set" = "Ned" + "Skin" = "Variant" + "Solstice" = "Solverv" + "Start civil twilight" = "Start demring" + "Statistics" = "Statistikker" + "Sun" = "Sol" + "Sunrise" = "Soloppgang" + "Sunset" = "Solnedgang" + "Telemetry" = "Telemetri" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "Denne stasjonen kontrolleres av WeeWX, en eksperimentell værprogramvare skrevet i Python." + "Today" = "I dag" + "Total daylight" = "Sum dagslys" + "Transit" = "Transitt" + "UNKNOWN" = "Ukjent" + "Vector Average" = "Vektorhastighet" + "Vector Direction" = "Vektorretning" + "Voltage" = "Spenning" + "Weather Conditions" = "Værobservasjoner" + "Weather Conditions at" = "Værobservasjoner den" + "Week" = "Uke" + "WeeWX uptime" = "WeeWX oppetid" + "WeeWX version" = "WeeWX versjon" + "Year" = "År" + "Yearly Reports" = "Årsrapport" + "Yearly summary as of" = "Årsrapport for" + + [[Geographical]] + "Altitude" = "Høyde over havet" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Høydevinkel" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/th.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/th.conf new file mode 100644 index 0000000..3fc68f1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/th.conf @@ -0,0 +1,240 @@ +############################################################################### +# Localization File --- Seasons skin # +# Thai # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Chotechai" # +############################################################################### + +# Generally want a metric system for the Thai language: +unit_system = metricwx + +[ImageGenerator] + top_label_font_path = font/Kanit-Bold.ttf + top_label_font_size = 14 + + unit_label_font_path = font/Kanit-Bold.ttf + unit_label_font_size = 12 + + bottom_label_font_path = font/Kanit-Regular.ttf + bottom_label_font_size = 12 + + axis_label_font_path = font/Kanit-Regular.ttf + axis_label_font_size = 10 + + # Options for the compass rose, used for progressive vector plots + rose_label_font_path = font/Kanit-Regular.ttf + +[Units] + + [[Labels]] + + day = " วัน", " วัน" + hour = " ชั่วโมง", " ชั่วโมง" + minute = " นาที", " นาที" + second = " วินาที", " วินาที" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = น., ตอ.น.น., ตอ.น., ตอ.น.ตอ., ตอ., ตอ.ต.ตอ., ตอ.ต., ตอ.ต.ต., ต., ตต.ต.ต., ตต.ต., ตต.ต.ตต., ตต., ตต.น.ตต., ตต.น., ตต.น.น., ไม่ปรากฏ + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = น., ตต., ตอ., ต. + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = ความสูง # QNH + altimeterRate = ความสูง อัตราการมเปลี่ยนแปลง + appTemp = อุณหภูมิที่รู้สึก + appTemp1 = อุณหภูมิที่รู้สึก + barometer = บารอมิเตอร์ # QFF + barometerRate = ยารอมิเตอร์ อัตราการเปลี่ยนแปลง + cloudbase = ฐานเมฆ ระดับความสูง + dateTime = วันเวลา + dewpoint = จุดน้ำค้าง + ET = ปริมาณน้ำระเหย (ET) + extraHumid1 = ความชื้น1 + extraHumid2 = ความชื้น2 + extraHumid3 = ความชื้น3 + extraHumid4 = ความชื้น4 + extraHumid5 = ความชื้น5 + extraHumid6 = ความชื้น6 + extraHumid7 = ความชื้น7 + extraHumid8 = ความชื้น8 + extraTemp1 = อุณหภูมิ 1 + extraTemp2 = อุณหภูมิ 2 + extraTemp3 = อุณหภูมิ 3 + extraTemp4 = อุณหภูมิ 4 + extraTemp5 = อุณหภูมิ 5 + extraTemp6 = อุณหภูมิ 6 + extraTemp7 = อุณหภูมิ 7 + extraTemp8 = อุณหภูมิ 8 + heatindex = ดัชนีความร้อน + inDewpoint = จุดน้ำค้าง ภายใน + inHumidity = ความชื้นสัมพัทธ์ ภายใน + inTemp = อุณหภูมิ ภายใน + interval = ช่วงเวลา + leafTemp1 = อุณหภูมิใบ1 + leafTemp2 = อุณหภูมิใบ2 + leafWet1 = ความชื้นของใบ1 + leafWet2 = ความชื้นของใบ2 + lightning_distance = ฟ้าแลบ ระยะห่าง + lightning_strike_count = ฟ้าผ่า จำนวนครั้ง + luminosity = ความส่องสว่าง + outHumidity = ความชื้นสัมพัทธ์ ภายนอก + outTemp = อุณหภูมิ ภายนอก + pressure = ความกดอากาศ # QFE + pressureRate = ความกดอากาศ อัตราการเปลี่ยนแปลง + radiation = การแผ่รังสี + rain = ปริมาณฝน + rainRate = ปริมาณฝน อัตรา + soilMoist1 = ความชื้นในดิน1 + soilMoist2 = ความชื้นในดิน2 + soilMoist3 = ความชื้นในดิน3 + soilMoist4 = ความชื้นในดิน4 + soilTemp1 = อุณหภูมิดิน1 + soilTemp2 = อุณหภูมิดิน2 + soilTemp3 = อุณหภูมิดิน3 + soilTemp4 = อุณหภูมิดิน4 + THSW = ดัชนี THSW + UV = ดัชนีรังสียูวี + wind = ลม + windchill = ความเย็นลม (Wind Chill) + windDir = ทิศทางลม + windGust = ลมกระโชก ความเร็ว + windGustDir = ลมกระโชก ทิศทาง + windgustvec = ลมกระโชก ขนาดและทิศทาง + windrun = ระยะทางลม + windSpeed = ลม ความเร็ว + windvec = ลม ขนาดและทิศทาง + + # used in Seasons skin, but not defined + feel = อุณหภูมิที่รู้สึก + + # Sensor status indicators + consBatteryVoltage = สถานะแบ๊ตเตอรี่ จอเฝ้าคุม + heatingVoltage = สถานะแบ๊ตเตอรี่ เครื่องวัดความร้อน + inTempBatteryStatus = สถานะแบ๊ตเตอรี่ เครื่องวัดอุณหภูมิภายใน + outTempBatteryStatus = สถานะแบ๊ตเตอรี่ เครื่องวัดอุณหภูมิภายนอก + rainBatteryStatus = สถานะแบ๊ตเตอรี่ เครื่องวัดประมาณฝน + referenceVoltage = ระดับแรงดันไฟอ้างอิง + rxCheckPercent = คุณภาพสัญญาณ + supplyVoltage = ระดับแรงดันไฟจ่าย + txBatteryStatus = สถานะแบ๊เตเตอรี่ เครื่องส่ง + windBatteryStatus = สถานะแบ๊ตเตอรี่ เครื่องวัดลม + batteryStatus1 = แบตเตอรี่1 + batteryStatus2 = แบตเตอรี่2 + batteryStatus3 = แบตเตอรี่3 + batteryStatus4 = แบตเตอรี่4 + batteryStatus5 = แบตเตอรี่5 + batteryStatus6 = แบตเตอรี่6 + batteryStatus7 = แบตเตอรี่7 + batteryStatus8 = แบตเตอรี่8 + signal1 = สัญญาณ1 + signal2 = สัญญาณ2 + signal3 = สัญญาณ3 + signal4 = สัญญาณ4 + signal5 = สัญญาณ5 + signal6 = สัญญาณ6 + signal7 = สัญญาณ7 + signal8 = สัญญาณ8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = เดือนมืด, ข้างขึ้นเสี้ยว, ข้างขึ้นครึ่งดวง, ข้างขึ้นค่อนดวง, เต็มดวง, ข้างแรมค่อนดวง, ข้างแรมครึ่งดวง, ข้างแรมเสี้ยว + +[Texts] + "About this station" = "เกี่ยวกับสถานีอากาศนี้" + "Always down" = "ดวงอาทิตย์ตกตลอดวัน" + "Always up" = "ดวงอาทิตย์ขึ้นตลอดวัน" + "Average Wind" = "ความเร็วลมเฉลี่ย" + "Azimuth" = "อซิมัธ" + "Battery Status" = "สถานะแบ๊ตเตอรี่" + "Celestial" = "เทห์ฟากฟ้า" + "Connectivity" = "การเชื่อมต่อ" + "Current Conditions" = "สภาวะปัจจุบัน" + "Current conditions, and daily, monthly, and yearly summaries" = "ณ เวลานี้และสรุปรายวัน, รายเดือน, และรายปี" + "Daily summary as of" = "สรุปสภาพอากาศรายวัน จนถึง" + "Day" = "วัน" + "Daylight" = "กลางวัน" + "days ago" = "วันที่ผ่านมา" + "Declination" = "เดคลิเนชั่น" + "End civil twilight" = "แสงสนธยาทั่วไป สิ้นสุด" + "Equinox" = "อีควิน็อกซ์" + "Evapotranspiration (daily total)" = "ปริมาณน้ำระเหย (รายวัน)" + "Evapotranspiration (hourly total)" = "ปริมาณน้ำระเหย (รายชั่วโมง)" + "Evapotranspiration (weekly total)" = "ปริมาณน้ำระเหย (รายสัปดาห์)" + "from" = "พัดจากทิศ" + "full" = "?" # As in "The Moon is 21% full" + "Full moon" = "ขึ้น 15 ค่ำ" + "Hardware" = "ฮาร์ดแวร์" + "History" = "ข้อมูลอดีต" + "hours ago" = "ชั่วโมงที่แล้ว" + "Latitude" = "ละติจูด" + "less than yesterday" = "สั้นกว่าเมื่อวาน" + "Lightning (daily total)" = "ฟ้าผ่า จำนวนครั้ง (รายวัน)" + "Lightning (hourly total)" = "ฟ้าผ่า จำนวนครั้ง (รายชั่วโมง)" + "Lightning (weekly total)" = "ฟ้าผ่า จำนวนครั้ง (รายสัปดาห์)" + "Longitude" = "ลองจิจูด" + "LOW" = "ต่ำ" + "Max Wind" = "ความเร็วลมสูงสุด" + "minutes ago"= "นาทีที่แล้ว" + "Month" = "เดือน" + "Monthly Reports" = "รายงานประจำเดือน" + "Monthly summary as of" = "สรุปสภาพอากาศรายเดือน จนถึง" + "Moon" = "ดวงจันทร์" + "Moon Phase" = "ขนาดเสี้ยวดวงจันทร์" + "more than yesterday" = "ยาวนานกว่าเมื่อวาน" + "never" = "ไม่เคย" + "New moon" = "แรม 15 ค่ำ" + "OK" = "ตกลง" + "Phase" = "ขนาดเสี้ยวดวงจันทร์" + "Rain (daily total)" = "ปริมาณฝน (รายวัน)" + "Rain (hourly total)" = "ปริมาณฝน (รายชั่วโมง)" + "Rain (weekly total)" = "ปริมาณฝน (รายสัปดาห์)" + "Rain Today" = "ปริมาณฝน วันนี้" + "Rain Year" = "ปริมาณฝน ปี" + "Rain
Year" = "ปริมาณฝน
ปี" + "Right ascension" = "ไรท์แอสเซนชั่น" + "Rise" = "ขึ้น" + "RMS Wind" = "ความเร็วลมเฉลี่ยแบบ RMS" + "select month" = "เลือกเดือน" + "select year" = "เลือกปี" + "Sensor Status" = "สถานะเซ็นเซอร์" + "Server uptime" = "เซิร์ฟเวอร์ ช่วงเวลาให้บริการ" + "Set" = "ตก" + "Solstice" = "โซลสตีซ" + "Start civil twilight" = "แสงสนธยาทั่วไป เริ่มต้น" + "Statistics" = "สถิติ" + "Sun" = "ดวงอาทิตย"" + "Sunrise" = "เวลาพระอาทิตย์ขึ้น" + "Sunset" = "เวลาพระอาทิตย์ตก" + "Telemetry" = "ข้อมูลที่ได้รับ" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "สถานีนี้ควบคุมโดย WeeWX, ซอฟแวร์สภาพอากาศฉบับทดสอบ เขียนด้วยภาษา Python" + "Today" = "วันนี้" + "Total daylight" = "กลางวัน-เวลาทั้งหมด" + "Transit" = "ขึ้นสูงสุด" + "UNKNOWN" = "ไม่รู้จัก" + "Vector Average" = "ขนาดและทิศทางลมเฉลี่ย" + "Vector Direction" = "ขนาดและทิศทางลม" + "Voltage" = "โวลต์" + "Weather Conditions" = "สภาพอากาศ" + "Weather Conditions at" = "สภาพอากาศ ณ" + "Week" = "สัปดาห์" + "WeeWX uptime" = "WeeWX ช่วงเวลาให้บริการ" + "WeeWX version" = "WeeWX เวอร์ชั่น" + "Year" = "ปี" + "Yearly Reports" = "รายงานประจำปี" + "Yearly summary as of" = "สรุปสภาพอากาศรายปี จนถึง" + + [[Geographical]] + "Altitude" = "ระดับความสูง" # As in height above sea level + + [[Astronomical]] + "Altitude" = "อัลติจูด" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/zh.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/zh.conf new file mode 100644 index 0000000..c5cddaa --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/lang/zh.conf @@ -0,0 +1,231 @@ +############################################################################### +# Localization File --- Seasons skin # +# Traditional Chinese # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# Copyright (c) 2021 Johanna Karen Roedenbeck # +# See the file LICENSE.txt for your rights. # +# +# Translation by lyuxing +############################################################################### + +# Generally want a metric system for Chinese: +unit_system = metric + +[Units] + + [[Labels]] + + # These are singular, plural + meter = " 公尺", " 公尺" + day = " 天", " 天" + hour = " 時", " 時" + minute = " 分", " 分" + second = " 秒", " 秒" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = 北, 北北東, 東北, 東北東, 東, 東南東, 東南, 南南東, 南, 南南西, 西南, 西南西, 西, 西北西, 西北, 北北西, 無 + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = 高度計 # QNH + altimeterRate = 高度變化率 + appTemp = 體感溫度 + appTemp1 = 體感溫度 + barometer = 氣壓計 # QFF + barometerRate = 氣壓變化率 + cloudbase = 雲底高度 + dateTime = 時間 + dewpoint = 露點 + ET = 蒸散量 + extraHumid1 = 濕度1 + extraHumid2 = 濕度2 + extraHumid3 = 濕度3 + extraHumid4 = 濕度4 + extraHumid5 = 濕度5 + extraHumid6 = 濕度6 + extraHumid7 = 濕度7 + extraHumid8 = 濕度8 + extraTemp1 = 溫度1 + extraTemp2 = 溫度2 + extraTemp3 = 溫度3 + extraTemp4 = 溫度4 + extraTemp5 = 溫度5 + extraTemp6 = 溫度6 + extraTemp7 = 溫度7 + extraTemp8 = 溫度8 + heatindex = 體感溫度(熱) + inDewpoint = 室內露點 + inHumidity = 室內濕度 + inTemp = 室內溫度 + interval = 間隔 + leafTemp1 = 葉溫度1 + leafTemp2 = 葉溫度2 + leafWet1 = 葉濕度1 + leafWet2 = 葉濕度2 + lightning_distance = 閃電距離 + lightning_strike_count = 雷擊 + luminosity = 光度 + outHumidity = 戶外濕度 + outTemp = 戶外溫度 + pressure = 海平面氣壓 # QFE + pressureRate = 海平面氣壓變化率 + radiation = 全天空日射量 + rain = 降水量 + rainRate = 時雨量 + soilMoist1 = 土壤水分1 + soilMoist2 = 土壤水分2 + soilMoist3 = 土壤水分3 + soilMoist4 = 土壤水分4 + soilTemp1 = 土壤溫度1 + soilTemp2 = 土壤溫度2 + soilTemp3 = 土壤溫度3 + soilTemp4 = 土壤溫度4 + THSW = THSW指數 + UV = 紫外線指數 + wind = 風速/風向 + windchill = 體感溫度(冷) + windDir = 風向 + windGust = 最大陣風 + windGustDir = 陣風方向 + windgustvec = 陣風矢量 + windrun = 風程 + windSpeed = 風速 + windvec = 風矢量 + + # used in Seasons skin, but not defined + feel = 體感溫度 + + # Sensor status indicators + consBatteryVoltage = 控制台電壓 + heatingVoltage = 加熱器電壓 + inTempBatteryStatus = 內部溫度計電壓 + outTempBatteryStatus = 室外溫度計電壓 + rainBatteryStatus = 雨量計電壓 + referenceVoltage = 參考電壓 + rxCheckPercent = 訊號強度 + supplyVoltage = 備援電壓 + txBatteryStatus = 傳輸器電壓 + windBatteryStatus = 風速計電壓 + batteryStatus1 = 電池1 + batteryStatus2 = 電池2 + batteryStatus3 = 電池3 + batteryStatus4 = 電池4 + batteryStatus5 = 電池5 + batteryStatus6 = 電池6 + batteryStatus7 = 電池7 + batteryStatus8 = 電池8 + signal1 = 訊號1 + signal2 = 訊號2 + signal3 = 訊號3 + signal4 = 訊號4 + signal5 = 訊號5 + signal6 = 訊號6 + signal7 = 訊號7 + signal8 = 訊號8 + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = 新月, 娥眉月, 上弦月, 盈凸月, 滿月, 虧凸月, 下弦月, 殘月 + +[Texts] + "About this station" = "關於這個站台" + "Always down" = "永遠向下" + "Always up" = "永遠向上" + "at" = "在" + "Average Wind" = "平均風速" + "Azimuth" = "方位角" + "Battery Status" = "電池狀態" + "Celestial" = "天體" + "Connectivity" = "連接狀態" + "Current Conditions" = "當前狀況" + "Current conditions, and daily, monthly, and yearly summaries" = "當前狀況,以及每日、每月和每年的總結" + "Daily summary as of" = "每日摘要" + "Day" = "今日彙整" + "days ago" = "天前" + "Daylight" = "日照時數" + "Declination" = "赤緯" + "End civil twilight" = "民用曙暮光結束" + "Equinox" = "春秋分點" + "Evapotranspiration (daily total)" = "日蒸散量" + "Evapotranspiration (hourly total)" = "時蒸散量" + "Evapotranspiration (weekly total)" = "周蒸散量" + "from" = "從" + "full" = "滿盈" # As in "The Moon is 21% full" + "Full moon" = "滿月" + "Hardware" = "氣象站硬體" + "History" = "觀測資料" + "hours ago" = "小時前" + "Latitude" = "緯度" + "less than yesterday" = "比昨天少" + "Lightning (daily total)" = "每日雷擊數" + "Lightning (hourly total)" = "每小時雷擊" + "Lightning (weekly total)" = "本周雷擊" + "Longitude" = "經度" + "LOW" = "低" + "Max" = "最大" + "Max Wind" = "最大風速/風向" + "Min" = "最小" + "minutes ago" = "分前" + "Month" = "月彙整" + "Monthly Reports" = "月報表" + "Monthly summary as of" = "每月總結" + "Moon" = "月" + "Moon Phase" = "月象" + "more than yesterday" = "比昨天多" + "never" = "never" + "New moon" = "新月" + "OK" = "好的" + "Phase" = "月象" + "Rain (daily total)" = "日雨量" + "Rain (hourly total)" = "時雨量" + "Rain (weekly total)" = "周雨量" + "Rain Today" = "日雨量" + "Rain Year" = "年雨量" + "Rain
Year" = "年
雨量" + "Right ascension" = "赤經" + "Rise" = "升" + "RMS Wind" = "有效風速" + "select month" = "請選擇月份" + "select year" = "請選擇年度" + "Sensor Status" = "感測器狀態" + "Server uptime" = "服務器運行時間" + "Set" = "落" + "Skin" = "外觀介面" + "Solstice" = "二至點" + "Start civil twilight" = "民用曙暮光開始" + "Statistics" = "統計數據" + "Sun" = "太陽" + "Sunrise" = "日出" + "Sunset" = "日落" + "Telemetry" = "遙測" + "This station is controlled by WeeWX, an experimental weather software system written in Python." = "該站由 WeeWX 產生,這是一個用 Python 編寫的實驗性天氣系統。切勿根據本網站做出重要決定。" + "Today" = "今天" + "Total daylight" = "可照時數" + "Transit" = "中天時間" + "UNKNOWN" = "未知" + "Vector Average" = "平均風矢量" + "Vector Direction" = "平均風矢方向" + "Voltage" = "伏特" + "Weather Conditions" = "天候狀況" + "Weather Conditions at" = "天候狀況在" + "Week" = "周彙整" + "WeeWX uptime" = "WeeWX 運行時間" + "WeeWX version" = "WeeWX 版本" + "Year" = "年彙整" + "Yearly Reports" = "年報表" + "Yearly summary as of" = "每年摘要" + "N/A" = "無資料" + + [[Geographical]] + "Altitude" = "海拔" # As in height above sea level + + [[Astronomical]] + "Altitude" = "高度角" # As in angle above the horizon diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/map.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/map.inc new file mode 100644 index 0000000..663e55f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/map.inc @@ -0,0 +1,35 @@ +## map module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +#if 'google_map_apikey' in $Extras +
+
+ Location + +
+
+
+
+
+ + + +#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/radar.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/radar.inc new file mode 100644 index 0000000..c34db70 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/radar.inc @@ -0,0 +1,25 @@ +## radar module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +#if 'radar_img' in $Extras +
+
+ Radar + +
+ +
+ #if 'radar_url' in $Extras + + #end if + Radar + #if 'radar_url' in $Extras + + #end if +
+
+#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/rss.xml.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/rss.xml.tmpl new file mode 100644 index 0000000..bc35d76 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/rss.xml.tmpl @@ -0,0 +1,103 @@ + + + +#set $timespans = [{"span": $day, "label": $gettext("Daily summary as of")}, {"span": $month, "label": $gettext("Monthly summary as of")}, {"span": $year, "label": $gettext("Yearly summary as of")}] + +#set $observations = $to_list($DisplayOptions.get('observations_rss', ['outTemp', 'inTemp', 'barometer', 'windSpeed', 'rain', 'rainRate'])) +#set $obs_type_sum = $to_list($DisplayOptions.get('obs_type_sum', ['rain'])) +#set $obs_type_max = $to_list($DisplayOptions.get('obs_type_max', ['rainRate'])) + + + $gettext("Weather Conditions") : $station.location + $station.station_url + $gettext("Current conditions, and daily, monthly, and yearly summaries") + "$lang" + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + http://blogs.law.harvard.edu/tech/rss + weewx $station.version + $current.interval.string('') + + + $gettext("Weather Conditions at") $current.dateTime + $station.station_url + +#for $x in $observations + #if $getattr($current, $x).has_data + #if $x == 'windSpeed' + $obs.label.wind: $current.windSpeed $gettext("from") $current.windDir; + #else + $obs.label[$x]: $getattr($current, $x); + #end if + #end if +#end for + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + $station.latitude_f + $station.longitude_f + + $obs.label.dateTime: $current.dateTime
+#for $x in $observations + #if $getattr($current, $x).has_data + #if $x == 'windSpeed' + $obs.label.wind: $current.windSpeed $gettext("from") $current.windDir; + #else + $obs.label[$x]: $getattr($current, $x)
+ #end if + #end if +#end for +

+ ]]>
+
+ +#for $timespan in $timespans + + $timespan['label'] $current.dateTime + $station.station_url + + #for $x in $observations + #if $getattr($timespan['span'], $x).has_data + #if $x == 'windSpeed' + $gettext("Max") $obs.label.wind: $timespan['span'].wind.max $gettext("from") $timespan['span'].wind.gustdir $gettext("at") $timespan['span'].wind.maxtime; + #elif $x in $obs_type_sum + $obs.label[$x]: $getattr($timespan['span'], $x).sum; + #elif $x in $obs_type_max + $gettext("Max") $obs.label[$x]: $getattr($timespan['span'], $x).max; + #else + $gettext("Min") $obs.label[$x]: $getattr($timespan['span'], $x).min $gettext("at") $getattr($timespan['span'], $x).mintime; + $gettext("Max") $obs.label[$x]: $getattr($timespan['span'], $x).max $gettext("at") $getattr($timespan['span'], $x).maxtime; + #end if + #end if + #end for + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + $station.latitude_f + $station.longitude_f + + $gettext($timespan['label']) $timespan['span'].dateTime.format("%d %b %Y")
+ #for $x in $observations + #if $getattr($timespan['span'], $x).has_data + #if $x == 'windSpeed' + $gettext("Max") $obs.label.wind: $timespan['span'].wind.max $gettext("from") $timespan['span'].wind.gustdir $gettext("at") $timespan['span'].wind.maxtime
+ #elif $x in $obs_type_sum + $obs.label[$x]: $getattr($timespan['span'], $x).sum
+ #elif $x in $obs_type_max + $gettext("Max") $obs.label[$x]: $getattr($timespan['span'], $x).max
+ #else + $gettext("Min") $obs.label[$x]: $getattr($timespan['span'], $x).min $gettext("at") $getattr($timespan['span'], $x).mintime
+ $gettext("Max") $obs.label[$x]: $getattr($timespan['span'], $x).max $gettext("at") $getattr($timespan['span'], $x).maxtime
+ #end if + #end if + #end for +

+ ]]>
+
+#end for + +
+
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/satellite.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/satellite.inc new file mode 100644 index 0000000..6504177 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/satellite.inc @@ -0,0 +1,26 @@ +## satellite module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +#if 'satellite_img' in $Extras +
+
+ Satellite + +
+ +
+ #if 'satellite_url' in $Extras + #set $saturl = $Extras.satellite_url + #else + #set $saturl = $Extras.satellite_img + #end if + + Satellite + +
+
+#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/seasons.css b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/seasons.css new file mode 100644 index 0000000..96cbc67 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/seasons.css @@ -0,0 +1,304 @@ +/* CSS for the weewx Seasons skin + * Copyright (c) Tom Keffer, Matthew Wall + * Distributed under terms of GPLv3. See LICENSE.txt for your rights. + */ + +:root { + --background-color:#ffffff; + --title-background-color:#dddddd; + --button-background-color:#bbbbbb; + --element-background-color:#d2e8e8; + --section-border-color:#aaaaaa; + --cell-border-color:#dddddd; + --highlight-color:#4282b4; + --link-color:#4282b4; + --visited-color:#4282b4; + --hover-color:#4282b4; + --timestamp-color:#aaaaaa; + --hival-color:#aa4444; + --loval-color:#4444aa; + --ok-color:#44aa44; + --low-color:#aa4444; + --unknown-color:#dfdfdf; +} + +/* use the fonts from google */ +/* @import url('https://fonts.googleapis.com/css?family=Open+Sans'); */ + +/* use the local fonts */ +@font-face { + font-family: 'Open Sans'; + src: url('font/OpenSans.woff2') format('woff2'), + url('font/OpenSans.woff') format('woff'); +} + +body { + margin: 0; + padding: 0; + border: 0; + font-family: 'Open Sans', arial, sans-serif; + background-color: var(--background-color); +} + +a { + text-decoration: none; + cursor: pointer; + color: var(--link-color); +} + +a:link { + color: var(--link-color); +} +a:visited { + color: var(--visited-color); +} +a:hover { + color: var(--hover-color); +} + +#widget_group { + float: left; + margin-right: 40px; +} + +#plot_group { + overflow: hidden; +} + +#title_bar { + overflow: auto; + margin-bottom: 5px; + background-color: var(--element-background-color); + border: 1px solid var(--section-border-color); +} + +#contents { + clear: both; + margin: 20px; +} + +#title { + float: left; + margin-left: 10px; +} + +#rss_link { + float: right; + margin-top: 6px; + margin-right: 20px; + padding-left: 8px; + padding-right: 8px; + background-color: var(--background-color); + border: 1px solid var(--section-border-color); + webkit-radius: 5px; + moz-radius: 5px; + border-radius: 5px; +} + +#reports { + float: right; + margin-top: 5px; + margin-right: 10px; + margin-bottom: 5px; + text-align: right; +} + +.footnote { + font-size: 80%; + font-style: italic; + clear: both; + padding-left: 20px; +} + +.page_title { + font-size: 140%; + line-height: 50%; +} + +.lastupdate { + font-size: 80%; + line-height: 50%; +} + +.widget { + margin-bottom: 30px; + clear: both; +} + +.widget_title { + font-weight: bold; + padding: 2px 10px 2px 10px; + /* underlined titles */ + border-bottom: 2px solid var(--section-border-color); + /* outlined titles */ +/* + background-color: var(--title-background-color); + border: 1px solid var(--section-border-color); +*/ +} + +.label { + font-size: 80%; + vertical-align: top; + text-align: right; + padding-top: 4px; + padding-right: 5px; +} + +.data { + font-weight: bold; + font-size: 80%; + vertical-align: top; + text-align: left; + padding-top: 4px; +} + +.units { + font-size: 80%; + vertical-align: top; + padding-top: 4px; +} + +.timestamp { + font-size: 80%; + font-weight: normal; +} + +.hival { + color: var(--hival-color); +} + +.loval { + color: var(--loval-color); +} + +.status_ok { + color: var(--ok-color); +} + +.status_low { + color: var(--low-color); +} + +.status_unknown { + color: var(--unknown-color); +} + +.button { + cursor: pointer; + padding-left: 10px; + padding-right: 10px; + padding-top: 0px; + padding-bottom: 0px; + /* rounded box buttons */ +/* + border: 1px solid var(--section-border-color); + webkit-radius: 3px; + moz-radius: 3px; + border-radius: 3px; +*/ +} + +.button_selected { + cursor: pointer; + padding-left: 10px; + padding-right: 10px; + padding-top: 0px; + padding-bottom: 0px; + /* underlined buttons */ + border-bottom: 5px solid var(--highlight-color); + /* rounded box buttons */ +/* + background-color: var(--button-background-color); + border: 1px solid var(--section-border-color); + webkit-radius: 3px; + moz-radius: 3px; + border-radius: 3px; +*/ +} + +.widget_control { + float: right; + cursor: pointer; + margin-left: 20px; + margin-right: 5px; +} + +.widget img { + width: 350px; + border: 1px solid var(--section-border-color); + margin-top: 4px; +} + +.new_row { + border-top: 1px solid var(--cell-border-color); +} + +.celestial_body { + margin-bottom: 30px; + float: left; +} + +.widget table th { + font-weight: normal; + text-align: right; + border-bottom: 1px solid var(--cell-border-color); +} + +#hilo_widget table th { + font-size: 80%; + text-align: right; + border-bottom: none; +} + +#hilo_widget .data { + font-weight: bold; + font-size: 80%; + text-align: right; + padding-left: 10px; +} + +#totals_widget table th { + font-size: 80%; + text-align: right; + border-bottom: none; +} + +#totals_widget .data { + font-weight: bold; + font-size: 80%; + text-align: right; + padding-left: 10px; +} + +#sensors_widget table th { + padding-top: 10px; +} + +#history_widget img { + border: none; + margin-bottom: 10px; + width: 500px; /* should match the image width in skin.conf */ +} + +#history_widget.widget_title { + min-width: 500px; +} + +.plot_container { + margin-top: 4px; +} + +#map_canvas { + width: 350px; + height: 350px; + margin-top: 4px; +} + + +@media (max-width:800px) { + #plot_group { overflow: visible; float: left; } +} +@media (min-width:801px) { + #plot_group { overflow: hidden; float: none; } +} diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/seasons.js b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/seasons.js new file mode 100644 index 0000000..f35bfd1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/seasons.js @@ -0,0 +1,197 @@ +/* javascript for the weewx Seasons skin + * Copyright (c) Tom Keffer, Matthew Wall + * Distributed under terms of GPLv3. See LICENSE.txt for your rights. + */ + +const cookie_prefix = "weewx.seasons."; +let year_type = get_state('year_type', 'year'); + +function setup(widgets) { + // set the state of the history widget + const id = get_state('history', 'day'); + choose_history(id); + // if we got a list of widget names, then use it. otherwise, query the doc + // for every object with an id of *_widget, and use that as the name list. + if (!widgets) { + widgets = []; + const items = document.getElementsByClassName('widget'); + if (items) { + for (let i = 0; i < items.length; i++) { + if (items[i].id) { + const widget_name = items[i].id.replace('_widget', ''); + if (widget_name) { + widgets.push(widget_name); + } + } + } + } + } + // now set the toggle state for each widget based on what the cookies say + for (let i = 0; i < widgets.length; i++) { + const state = get_state(widgets[i] + '.state', 'expanded'); + toggle_widget(widgets[i], state); + } +} + +function choose_history(id) { + choose_div('history', id, ['day', 'week', 'month', 'year']); + choose_col('hilo', id, ['week', 'month', 'year', 'rainyear']); + choose_col('totals', id, ['week', 'month', 'year', 'rainyear']); + choose_rainyear(id); +} + +function choose_rainyear(id) { + if (id === 'year') { + choose_col('hilo', year_type, ['year', 'rainyear']); + choose_col('totals', year_type, ['year', 'rainyear']); + } +} + +function toggle_rainyear() { + if (year_type === 'year') { + year_type = 'rainyear'; + } else { + year_type = 'year'; + } + set_state('year_type', year_type); + const id = get_active_div('history', ['day', 'week', 'month', 'year'], 'day'); + choose_rainyear(id); +} + +function toggle_widget(id, state) { + const id_elements = document.getElementById(id + '_widget'); + if (id_elements) { + for (let i = 0; i < id_elements.childNodes.length; i++) { + if (id_elements.childNodes[i].className === 'widget_contents') { + if (state === undefined) { + // make it the opposite of the current state + state = id_elements.childNodes[i].style.display === 'block' ? 'collapsed' : 'expanded'; + } + id_elements.childNodes[i].style.display = (state === 'expanded') ? 'block' : 'none'; + } + } + set_state(id + '.state', state); + } +} + +function choose_col(group, selected_id, all_ids) { + for (let i = 0; i < all_ids.length; i++) { + let elements = document.getElementsByClassName(group + '_' + all_ids[i]); + if (elements) { + const display = selected_id === all_ids[i] ? '' : 'none'; + for (let j = 0; j < elements.length; j++) { + elements[j].style.display = display; + } + } + } +} + +function choose_div(group, selected_id, all_ids) { + for (let i = 0; i < all_ids.length; i++) { + const button = document.getElementById('button_' + group + '_' + all_ids[i]); + if (button) { + button.className = (all_ids[i] === selected_id) ? 'button_selected' : 'button'; + } + const element = document.getElementById(group + '_' + all_ids[i]); + if (element) { + element.style.display = (all_ids[i] === selected_id) ? 'block' : 'none'; + } + } + set_state(group, selected_id); +} + +/* if cookies are disabled, then we must look at page to get state */ +function get_active_div(group, all_ids, default_value) { + let id = default_value; + for (let i = 0; i < all_ids.length; i++) { + const button = document.getElementById('button_' + group + '_' + all_ids[i]); + if (button && button.className === 'button_selected') { + id = all_ids[i]; + } + } + return id; +} + +function set_state(name, value, dur) { + const full_name = cookie_prefix + name; +/* set_cookie(full_name, value, dur); */ + window.localStorage.setItem(full_name, value); +} + +function get_state(name, default_value) { + const full_name = cookie_prefix + name; +/* return get_cookie(name, default_value); */ + let value = window.localStorage.getItem(full_name); + if (value === undefined || value == null) { + value = default_value; + } + return value +} + +function set_cookie(name, value, dur) { + if (!dur) dur = 30; + const today = new Date(); + let expire = new Date(); + expire.setTime(today.getTime() + 24 * 3600000 * dur); + document.cookie = name + "=" + encodeURI(value) + ";expires=" + expire.toUTCString(); +} + +function get_cookie(name, default_value) { + if (name === "") return default_value; + const cookie = " " + document.cookie; + let i = cookie.indexOf(" " + name + "="); + if (i < 0) i = cookie.indexOf(";" + name + "="); + if (i < 0) return default_value; + let j = cookie.indexOf(";", i + 1); + if (j < 0) j = cookie.length; + return unescape(cookie.substring(i + name.length + 2, j)); +} + +function get_parameter(name) { + const query = window.location.search.substring(1); + if (query) { + const vars = query.split("&"); + for (let i = 0; i < vars.length; i++) { + const pair = vars[i].split("="); + if (pair[0] === name) { + return pair[1]; + } + } + } + return false; +} + +function load_file(div_id, var_name) { + let content; + const file = get_parameter(var_name); + if (file) { + content = "Loading " + file; + let xhr = new XMLHttpRequest(); + xhr.onload = function () { + let e = document.getElementById(div_id); + if (e) { + e.textContent = this.responseText; + } + }; + xhr.open('GET', file); + xhr.send(); + } else { + content = 'nothing specified'; + } + let e = document.getElementById(div_id); + if (e) { + e.innerHTML = content; + } +} + +function openNOAAFile(date) { + if (date.match(/^\d\d\d\d/)) { + window.location = "NOAA/NOAA-" + date + ".txt"; + } +} + +function openTabularFile(date) { + if (date.match(/^\d\d\d\d/)) { + window.location = "tabular.html?report=NOAA/NOAA-" + date + ".txt"; + } +} diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/sensors.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/sensors.inc new file mode 100644 index 0000000..c32da75 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/sensors.inc @@ -0,0 +1,138 @@ +## sensors module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +## this is a conditional display of sensor data, including connectivity, +## battery status, and various voltages. if there are no data available, +## then this degenerates to nothing displayed. + +## remember the current time - we will use it more than once. +#import time +#set $now = time.time() + +## use this span to determine whether there are any data to consider. +#set $recent=$span($day_delta=30, boundary='midnight') + +#def get_battery_status($x) +#if $x is None +$gettext('UNKNOWN') +#elif $x == 1 +$gettext('LOW') +#else +$gettext('OK') +#end if +#end def + +## provide an indication of how much time has passed since the last sensor +## reading. +#def get_time_delta($last_ts, $now) + #if $last_ts + #set $delta = int($now - $last_ts) + #if $delta < 60 + +#elif $delta < 3600 + #set $minutes = int($delta / 60) +$minutes $gettext('minutes ago') + #elif $delta < 86400 + #set $hours = int($delta / 3600) +$hours $gettext('hours ago') + #else + #set $days = int($delta / 86400) +$days $gettext('days ago') + #end if + #else +$gettext('never') + #end if +#end def + + +## Get the list of sensor observations from the configuration file, otherwise +## fallback to a very rudimentary set. +#set $sensor_connections = $to_list($DisplayOptions.get('sensor_connections', ['rxCheckPercent'])) +#set $sensor_batteries = $to_list($DisplayOptions.get('sensor_batteries', ['outTempBatteryStatus', 'inTempBatteryStatus', 'rainBatteryStatus', 'windBatteryStatus', 'uvBatteryStatus', 'txBatteryStatus'])) +#set $sensor_voltages = $to_list($DisplayOptions.get('sensor_voltages', ['consBatteryVoltage', 'heatingVoltage', 'supplyVoltage', 'referenceVoltage'])) + +## first see what sensor data are available + +#set $have_conn = 0 +#for $x in $sensor_connections + #if $getattr($recent, $x).has_data + #set $have_conn = 1 + #end if +#end for + +#set $have_battery = 0 +#for $x in $sensor_batteries + #if $getattr($recent, $x).has_data + #set $have_battery = 1 + #end if +#end for + +#set $have_voltage = 0 +#for $x in $sensor_voltages + #if $getattr($recent, $x).has_data + #set $have_voltage = 1 + #end if +#end for + + +## now display the available data only + +#if $have_conn or $have_battery or $have_voltage +
+ +
+ + +#if $have_conn + +#for $x in $sensor_connections + #if $getattr($recent, $x).has_data + + + +#set $lasttime = $getattr($recent, $x).lasttime.raw + + + #end if +#end for +#end if + +#if $have_battery + +#for $x in $sensor_batteries + #if $getattr($recent, $x).has_data + + + +#set $lasttime = $getattr($recent, $x).lasttime.raw + + + #end if +#end for +#end if + +#if $have_voltage + +#for $x in $sensor_voltages + #if $getattr($recent, $x).has_data + + + +#set $lasttime = $getattr($recent, $x).lasttime.raw + + + #end if +#end for +#end if + +
$gettext("Connectivity")
$obs.label[$x]$getVar('current.' + $x)$get_time_delta($lasttime, $now)
$gettext("Battery Status")
$obs.label[$x]$get_battery_status($getVar('current.%s.raw' % $x))$get_time_delta($lasttime, $now)
$gettext("Voltage")
$obs.label[$x]$getVar('current.' + $x)$get_time_delta($lasttime, $now)
+
+ +
+#end if diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/skin.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/skin.conf new file mode 100644 index 0000000..4f31730 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/skin.conf @@ -0,0 +1,831 @@ +############################################################################### +# SEASONS SKIN CONFIGURATION FILE # +# Copyright (c) 2018-2021 Tom Keffer and Matthew Wall # +# See the file LICENSE.txt for your rights. # +############################################################################### + +SKIN_NAME = Seasons +SKIN_VERSION = 5.0.2 + +############################################################################### + +# The following section is for any extra tags that you want to be available in +# the templates + +[Extras] + + # This radar image would be available as $Extras.radar_img + # radar_img = https://radblast.wunderground.com/cgi-bin/radar/WUNIDS_map?station=RTX&brand=wui&num=18&delay=15&type=N0R&frame=0&scale=1.000&noclutter=1&showlabels=1&severe=1 + # This URL will be used as the image hyperlink: + # radar_url = https://radar.weather.gov/?settings=v1_eyJhZ2VuZGEiOnsiaWQiOm51bGwsImNlbnRlciI6Wy0xMjEuOTE3LDQ1LjY2XSwiem9vbSI6OH0sImJhc2UiOiJzdGFuZGFyZCIsImNvdW50eSI6ZmFsc2UsImN3YSI6ZmFsc2UsInN0YXRlIjpmYWxzZSwibWVudSI6dHJ1ZSwic2hvcnRGdXNlZE9ubHkiOmZhbHNlfQ%3D%3D#/ + + # Similar to radar, but for satellite image. + #satellite_img = http://images.intellicast.com/WxImages/SatelliteLoop/hipacsat_None_anim.gif + #satellite_url = http://images.intellicast.com/WxImages/SatelliteLoop/hipacsat_None_anim.gif + + # To display a map, enter an API key for google maps + #google_maps_apikey = xxx + + # If you have a Google Analytics GA4 tag, uncomment and edit the next line, and + # the analytics code will be included in your generated HTML files: + #googleAnalyticsId = G-ABCDEFGHI + + +############################################################################### + +# The following section contains variables that determine which observations +# and plots will be shown in the template files, and their order. Like other +# configuration options, these can be overridden in the weewx config file. + +[DisplayOptions] + + # Show link to RSS feed? + show_rss = True + + # Show link to NOAA-style summary reports? + show_reports = True + + # This list determines which types will appear in the "current conditions" + # section, as well as in which order. + observations_current = outTemp, heatindex, windchill, dewpoint, outHumidity, barometer, windSpeed, rain, rainRate, UV, radiation, lightning_strike_count, inTemp, inHumidity, extraTemp1, extraHumid1, extraTemp2, extraHumid2, pm1_0, pm2_5, pm10_0 + + # This list determines which types will appear in the "statistics" and + # "statistical summary" sections, as well as in which order. + observations_stats = outTemp, heatindex, windchill, dewpoint, outHumidity, barometer, windSpeed, rain, rainRate, ET, hail, hailRate, snow, UV, radiation, lightning_strike_count, lightning_distance, inTemp, inHumidity, extraTemp1, extraHumid1, extraTemp2, extraHumid2, extraTemp3, extraHumid3, extraTemp4, extraHumid4, extraTemp5, extraHumid5, extraTemp6, extraHumid6, extraTemp7, extraHumid7, extraTemp8, extraHumid8, leafTemp1, leafTemp2, leafWet1, leafWet2, soilTemp1, soilTemp2, soilTemp3, soilTemp4, soilMoist1, soilMoist2, soilMoist3, soilMoist4, pm1_0, pm2_5, pm10_0, co, co2, nh3, no2, o3, so2 + + # This list determines which types will appear in the RSS feed. + observations_rss = outTemp, inTemp, barometer, windSpeed, rain, rainRate, windchill, heatindex, dewpoint, outHumidity, inHumidity + + # Some observations display a sum rather than min/max values + obs_type_sum = rain, ET, hail, snow, lightning_strike_count + + # Some observations display only the max value + obs_type_max = rainRate, hailRate, snowRate, UV + + # The sensor status information is used in the sensor pages. These lists + # determine which database fields will be shown, as well as the order in + # which they will be displayed. + sensor_connections = rxCheckPercent, signal1, signal2, signal3, signal4, signal5, signal6, signal7, signal8 + sensor_batteries = outTempBatteryStatus, inTempBatteryStatus, rainBatteryStatus, hailBatteryStatus, snowBatteryStatus, windBatteryStatus, uvBatteryStatus, txBatteryStatus, batteryStatus1, batteryStatus2, batteryStatus3, batteryStatus4, batteryStatus5, batteryStatus6, batteryStatus7, batteryStatus8 + sensor_voltages = consBatteryVoltage, heatingVoltage, supplyVoltage, referenceVoltage + + # This list determines which plots will be shown, as well as the order in + # which they will be displayed. The names refer to the plots defined in + # the ImageGenerator section, without any time span prefix. For example, + # the name 'wind' refers to 'daywind', 'weekwind', 'monthwind', and + # 'yearwind'. + plot_groups = barometer, tempdew, tempfeel, hum, wind, winddir, windvec, rain, ET, UV, radiation, lightning, tempin, humin, tempext, humext, tempext2, humext2, templeaf, wetleaf, tempsoil, moistsoil, pm + telemetry_plot_groups =rx, volt + + # The list of time spans used within the skin + periods = day, week, month, year + + +############################################################################### + +# The CheetahGenerator creates files from templates. This section +# specifies which files will be generated from which template. + +[CheetahGenerator] + + # Possible encodings include 'html_entities', 'strict_ascii', 'normalized_ascii', + # as well as those listed in https://docs.python.org/3/library/codecs.html#standard-encodings + encoding = html_entities + + [[SummaryByMonth]] + # Reports that summarize "by month" + [[[NOAA_month]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m.txt.tmpl + + [[SummaryByYear]] + # Reports that summarize "by year" + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y.txt.tmpl + + [[ToDate]] + # Reports that show statistics "to date", such as day-to-date, + # week-to-date, month-to-date, etc. + [[[index]]] + template = index.html.tmpl + [[[statistics]]] + template = statistics.html.tmpl + [[[telemetry]]] + template = telemetry.html.tmpl + [[[tabular]]] + template = tabular.html.tmpl + [[[celestial]]] + template = celestial.html.tmpl + # Uncomment the following to generate a celestial page only once + # an hour instead of every report cycle. + # stale_age = 3600 + [[[RSS]]] + template = rss.xml.tmpl + +############################################################################### + +# The CopyGenerator copies files from one location to another. + +[CopyGenerator] + + # List of files to be copied only the first time the generator runs + copy_once = seasons.css, seasons.js, favicon.ico, font/*.woff, font/*.woff2 + + # List of files to be copied each time the generator runs + # copy_always = + + +############################################################################### + +# The ImageGenerator creates image plots of data. + +[ImageGenerator] + + # This section lists all the images to be generated, what SQL types are to be included in them, + # along with many plotting options. There is a default for almost everything. Nevertheless, + # values for most options are included to make it easy to see and understand the options. + # + # Nearly all types in the wview-extended schema are included. However, because of the + # 'skip_if_empty' option, only the plots with non-null data will be actually produced. + # + # Fonts can be anything accepted by the Python Imaging Library (PIL), which includes truetype + # (.ttf), or PIL's own font format (.pil). Note that "font size" is only used with truetype + # (.ttf) fonts. For others, font size is determined by the bit-mapped size, usually encoded in + # the file name (e.g., courB010.pil). A relative path for a font is relative to the SKIN_ROOT. + # If a font cannot be found, then a default font will be used. + # + # Colors can be specified any of three ways: + # 1. Notation 0xBBGGRR; + # 2. Notation #RRGGBB; or + # 3. Using an English name, such as 'yellow', or 'blue'. + # So, 0xff0000, #0000ff, or 'blue' would all specify a pure blue color. + + image_width = 500 + image_height = 180 + image_background_color = "#ffffff" + + chart_background_color = "#ffffff" + chart_gridline_color = "#d0d0d0" + + # Setting to 2 or more might give a sharper image with fewer jagged edges + anti_alias = 1 + + top_label_font_path = font/OpenSans-Bold.ttf + top_label_font_size = 14 + + unit_label_font_path = font/OpenSans-Bold.ttf + unit_label_font_size = 12 + unit_label_font_color = "#787878" + + bottom_label_font_path = font/OpenSans-Regular.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#787878" + bottom_label_offset = 3 + + axis_label_font_path = font/OpenSans-Regular.ttf + axis_label_font_size = 10 + axis_label_font_color = "#787878" + + # Options for the compass rose, used for progressive vector plots + rose_label = N + rose_label_font_path = font/OpenSans-Regular.ttf + rose_label_font_size = 9 + rose_label_font_color = "#222222" + + # Default colors for the plot lines. These can be overridden for + # individual lines using option 'color'. + chart_line_colors = "#4282b4", "#b44242", "#42b442", "#42b4b4", "#b442b4" + + # Default fill colors for bar charts. These can be overridden for + # individual bar plots using option 'fill_color'. + chart_fill_colors = "#72b2c4", "#c47272", "#72c472", "#72c4c4", "#c472c4" + + # Type of line. Options are 'solid' or 'none'. + line_type = 'solid' + + # Size of marker in pixels + marker_size = 8 + # Type of marker. Options are 'cross', 'x', 'circle', 'box', or 'none'. + marker_type ='none' + + # The following option merits an explanation. The y-axis scale used for + # plotting can be controlled using option 'yscale'. It is a 3-way tuple, + # with values (ylow, yhigh, min_interval). If set to "None", a parameter is + # set automatically, otherwise the value is used. However, in the case of + # min_interval, what is set is the *minimum* y-axis tick interval. + yscale = None, None, None + + # For progressive vector plots, you can choose to rotate the vectors. + # Positive is clockwise. + # For my area, westerlies overwhelmingly predominate, so by rotating + # positive 90 degrees, the average vector will point straight up. + vector_rotate = 90 + + # This defines what fraction of the difference between maximum and minimum + # horizontal chart bounds is considered a gap in the samples and should not + # be plotted. + line_gap_fraction = 0.05 + + # This controls whether day/night bands will be shown. They only look good + # on plots wide enough to show individual days such as day and week plots. + show_daynight = true + # These control the appearance of the bands if they are shown. + # Here's a monochrome scheme: + daynight_day_color = "#fdfaff" + daynight_night_color = "#dfdfe2" + daynight_edge_color = "#e0d8d8" + # Here's an alternative, using a blue/yellow tint: + #daynight_day_color = "#fffff8" + #daynight_night_color = "#f8f8ff" + #daynight_edge_color = "#fff8f8" + + # Default will be a line plot of width 1, without aggregation. + # Can get overridden at any level. + plot_type = line + width = 1 + aggregate_type = none + + # Do not generate a plot if it does not have any non-null data: + skip_if_empty = year + + # What follows is a list of subsections, each specifying a time span, such as a day, week, + # month, or year. There's nothing special about them or their names: it's just a convenient way + # to group plots with a time span in common. You could add a time span [[biweek_images]] and + # add the appropriate time length, aggregation strategy, etc., without changing any code. + # + # Within each time span, each sub-subsection is the name of a plot to be generated for that + # time span. The generated plot will be stored using that name, in whatever directory was + # specified by option 'HTML_ROOT' in weewx.conf. + # + # With one final nesting (four brackets!) is the sql type of each line to be included within + # that plot. + # + # Unless overridden, leaf nodes inherit options from their parent. + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %x %X + time_length = 27h + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[daytempfeel]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[dayhum]]] + [[[[outHumidity]]]] + + [[[daytempin]]] + [[[[inTemp]]]] + + [[[dayhumin]]] + [[[[inHumidity]]]] + + # Plot extra temperatures. You can add more temperature sensors here. + # However, you might want to make a second plot if your system has more + # than 3 extra sensors, otherwise the plots can become rather busy. + [[[daytempext]]] + yscale = None, None, 0.5 + [[[[extraTemp1]]]] + [[[[extraTemp2]]]] + [[[[extraTemp3]]]] + [[[[extraTemp4]]]] + + [[[daytempext2]]] + yscale = None, None, 0.5 + [[[[extraTemp5]]]] + [[[[extraTemp6]]]] + [[[[extraTemp7]]]] + [[[[extraTemp8]]]] + + [[[dayhumext]]] + [[[[extraHumid1]]]] + [[[[extraHumid2]]]] + [[[[extraHumid3]]]] + [[[[extraHumid4]]]] + + [[[dayhumext2]]] + [[[[extraHumid5]]]] + [[[[extraHumid6]]]] + [[[[extraHumid7]]]] + [[[[extraHumid8]]]] + + [[[daytempleaf]]] + [[[[leafTemp1]]]] + [[[[leafTemp2]]]] + + [[[daywetleaf]]] + [[[[leafWet1]]]] + [[[[leafWet2]]]] + + [[[daytempsoil]]] + [[[[soilTemp1]]]] + [[[[soilTemp2]]]] + [[[[soilTemp3]]]] + [[[[soilTemp4]]]] + + [[[daymoistsoil]]] + [[[[soilMoist1]]]] + [[[[soilMoist2]]]] + [[[[soilMoist3]]]] + [[[[soilMoist4]]]] + + [[[daypm]]] + [[[[pm1_0]]]] + [[[[pm2_5]]]] + [[[[pm10_0]]]] + + [[[daylightning]]] + yscale = None, None, 1 + plot_type = bar + [[[[lightning_strike_count]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Lightning (hourly total) + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Rain (hourly total) + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[daywinddir]]] + # Hardwire in the y-axis scale for wind direction + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[windDir]]]] + + [[[daywindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[dayET]]] + # Make sure the y-axis increment is at least 0.02 for the ET plot + yscale = None, None, 0.02 + plot_type = bar + [[[[ET]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Evapotranspiration (hourly total) + + [[[dayUV]]] + # If your radiation sensor has a bounded scale, enforce that here. +# yscale = 0, 16, 1 + [[[[UV]]]] + + [[[dayradiation]]] + [[[[radiation]]]] + + [[[dayrx]]] + yscale = 0.0, 100.0, 25.0 + [[[[rxCheckPercent]]]] + + [[[dayvolt]]] + [[[[consBatteryVoltage]]]] + [[[[heatingVoltage]]]] + [[[[supplyVoltage]]]] + [[[[referenceVoltage]]]] + + [[week_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 604800 # 7 days + aggregate_type = avg + aggregate_interval = 1h + + [[[weekbarometer]]] + [[[[barometer]]]] + + [[[weektempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[weektempfeel]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[weektempin]]] + [[[[inTemp]]]] + + [[[weektempext]]] + yscale = None, None, 0.5 + [[[[extraTemp1]]]] + [[[[extraTemp2]]]] + [[[[extraTemp3]]]] + [[[[extraTemp4]]]] + + [[[weektempext2]]] + yscale = None, None, 0.5 + [[[[extraTemp5]]]] + [[[[extraTemp6]]]] + [[[[extraTemp7]]]] + [[[[extraTemp8]]]] + + [[[weekhum]]] + [[[[outHumidity]]]] + + [[[weekhumin]]] + [[[[inHumidity]]]] + + [[[weekhumext]]] + [[[[extraHumid1]]]] + [[[[extraHumid2]]]] + [[[[extraHumid3]]]] + [[[[extraHumid4]]]] + + [[[weekhumext2]]] + [[[[extraHumid5]]]] + [[[[extraHumid6]]]] + [[[[extraHumid7]]]] + [[[[extraHumid8]]]] + + [[[weektempleaf]]] + [[[[leafTemp1]]]] + [[[[leafTemp2]]]] + + [[[weekwetleaf]]] + [[[[leafWet1]]]] + [[[[leafWet2]]]] + + [[[weektempsoil]]] + [[[[soilTemp1]]]] + [[[[soilTemp2]]]] + [[[[soilTemp3]]]] + [[[[soilTemp4]]]] + + [[[weekmoistsoil]]] + [[[[soilMoist1]]]] + [[[[soilMoist2]]]] + [[[[soilMoist3]]]] + [[[[soilMoist4]]]] + + [[[weekpm]]] + [[[[pm1_0]]]] + [[[[pm2_5]]]] + [[[[pm10_0]]]] + + [[[weeklightning]]] + yscale = None, None, 1 + plot_type = bar + [[[[lightning_strike_count]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Lightning (daily total) + + [[[weekrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) + + [[[weekwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[weekwinddir]]] + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[wind]]]] + aggregate_type = vecdir + + [[[weekwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[weekET]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[ET]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Evapotranspiration (daily total) + + [[[weekUV]]] +# yscale = 0, 16, 1 + [[[[UV]]]] + + [[[weekradiation]]] + [[[[radiation]]]] + + [[[weekrx]]] + yscale = 0.0, 100.0, 25.0 + [[[[rxCheckPercent]]]] + + [[[weekvolt]]] + [[[[consBatteryVoltage]]]] + [[[[heatingVoltage]]]] + [[[[supplyVoltage]]]] + [[[[referenceVoltage]]]] + + [[month_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 2592000 # 30 days + aggregate_type = avg + aggregate_interval = 3h + show_daynight = false + + [[[monthbarometer]]] + [[[[barometer]]]] + + [[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[monthtempfeel]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[monthtempin]]] + [[[[inTemp]]]] + + [[[monthtempext]]] + yscale = None, None, 0.5 + [[[[extraTemp1]]]] + [[[[extraTemp2]]]] + [[[[extraTemp3]]]] + [[[[extraTemp4]]]] + + [[[monthtempext2]]] + yscale = None, None, 0.5 + [[[[extraTemp5]]]] + [[[[extraTemp6]]]] + [[[[extraTemp7]]]] + [[[[extraTemp8]]]] + + [[[monthhum]]] + [[[[outHumidity]]]] + + [[[monthhumin]]] + [[[[inHumidity]]]] + + [[[monthhumext]]] + [[[[extraHumid1]]]] + [[[[extraHumid2]]]] + [[[[extraHumid3]]]] + [[[[extraHumid4]]]] + + [[[monthhumext2]]] + [[[[extraHumid5]]]] + [[[[extraHumid6]]]] + [[[[extraHumid7]]]] + [[[[extraHumid8]]]] + + [[[monthtempleaf]]] + [[[[leafTemp1]]]] + [[[[leafTemp2]]]] + + [[[monthwetleaf]]] + [[[[leafWet1]]]] + [[[[leafWet2]]]] + + [[[monthtempsoil]]] + [[[[soilTemp1]]]] + [[[[soilTemp2]]]] + [[[[soilTemp3]]]] + [[[[soilTemp4]]]] + + [[[monthmoistsoil]]] + [[[[soilMoist1]]]] + [[[[soilMoist2]]]] + [[[[soilMoist3]]]] + [[[[soilMoist4]]]] + + [[[monthpm]]] + [[[[pm1_0]]]] + [[[[pm2_5]]]] + [[[[pm10_0]]]] + + [[[monthlightning]]] + yscale = None, None, 1 + plot_type = bar + [[[[lightning_strike_count]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Lightning (daily total) + + [[[monthrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) + + [[[monthwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[monthwinddir]]] + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[wind]]]] + aggregate_type = vecdir + + [[[monthwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[monthET]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[ET]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Evapotranspiration (daily total) + + [[[monthUV]]] +# yscale = 0, 16, 1 + [[[[UV]]]] + + [[[monthradiation]]] + [[[[radiation]]]] + + [[[monthrx]]] + yscale = 0.0, 100.0, 25.0 + [[[[rxCheckPercent]]]] + + [[[monthvolt]]] + [[[[consBatteryVoltage]]]] + [[[[heatingVoltage]]]] + [[[[supplyVoltage]]]] + [[[[referenceVoltage]]]] + + [[year_images]] + x_label_format = %m + bottom_label_format = %x %X + time_length = 31536000 # 365 days + aggregate_type = avg + aggregate_interval = 1d + show_daynight = false + + [[[yearbarometer]]] + [[[[barometer]]]] + + [[[yeartempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[yeartempfeel]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[yeartempin]]] + [[[[inTemp]]]] + + [[[yeartempext]]] + yscale = None, None, 0.5 + [[[[extraTemp1]]]] + [[[[extraTemp2]]]] + [[[[extraTemp3]]]] + [[[[extraTemp4]]]] + + [[[yeartempext2]]] + yscale = None, None, 0.5 + [[[[extraTemp5]]]] + [[[[extraTemp6]]]] + [[[[extraTemp7]]]] + [[[[extraTemp8]]]] + + [[[yearhum]]] + [[[[outHumidity]]]] + + [[[yearhumin]]] + [[[[inHumidity]]]] + + [[[yearhumext]]] + [[[[extraHumid1]]]] + [[[[extraHumid2]]]] + [[[[extraHumid3]]]] + [[[[extraHumid4]]]] + + [[[yearhumext2]]] + [[[[extraHumid5]]]] + [[[[extraHumid6]]]] + [[[[extraHumid7]]]] + [[[[extraHumid8]]]] + + [[[yeartempleaf]]] + [[[[leafTemp1]]]] + [[[[leafTemp2]]]] + + [[[yearwetleaf]]] + [[[[leafWet1]]]] + [[[[leafWet2]]]] + + [[[yeartempsoil]]] + [[[[soilTemp1]]]] + [[[[soilTemp2]]]] + [[[[soilTemp3]]]] + [[[[soilTemp4]]]] + + [[[yearmoistsoil]]] + [[[[soilMoist1]]]] + [[[[soilMoist2]]]] + [[[[soilMoist3]]]] + [[[[soilMoist4]]]] + + [[[yearpm]]] + [[[[pm1_0]]]] + [[[[pm2_5]]]] + [[[[pm10_0]]]] + + [[[yearlightning]]] + yscale = None, None, 1 + plot_type = bar + [[[[lightning_strike_count]]]] + aggregate_type = sum + aggregate_interval = 1w + label = Lightning (weekly total) + + [[[yearrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1w + label = Rain (weekly total) + + [[[yearwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[yearwinddir]]] + yscale = 0.0, 360.0, 45.0 + line_type = None + marker_type = box + marker_size = 2 + [[[[wind]]]] + aggregate_type = vecdir + + [[[yearwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[yearET]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[ET]]]] + aggregate_type = sum + aggregate_interval = 1w + label = Evapotranspiration (weekly total) + + [[[yearUV]]] +# yscale = 0, 16, 1 + [[[[UV]]]] + + [[[yearradiation]]] + [[[[radiation]]]] + + [[[yearrx]]] + yscale = 0.0, 100.0, 25.0 + [[[[rxCheckPercent]]]] + + [[[yearvolt]]] + [[[[consBatteryVoltage]]]] + [[[[heatingVoltage]]]] + [[[[supplyVoltage]]]] + [[[[referenceVoltage]]]] + + # This is how to generate a plot of high/low temperatures for the year: +# [[[yearhilow]]] +# [[[[hi]]]] +# data_type = outTemp +# aggregate_type = max +# label = High +# [[[[low]]]] +# data_type = outTemp +# aggregate_type = min +# label = Low Temperature + + +############################################################################### + +[Generators] + # The list of generators that are to be run: + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/statistics.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/statistics.html.tmpl new file mode 100644 index 0000000..2abfab5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/statistics.html.tmpl @@ -0,0 +1,55 @@ +## Copyright 2017 Tom Keffer, Matthew Wall +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 + + + + + $station.location Statistics + + + #if $station.station_url + + #end if + + + + + + #include "titlebar.inc" + +
+

❰ $gettext("Current Conditions")

+ +
+ #include "statistics.inc" +
+ + #include "identifier.inc" +
+ + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/statistics.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/statistics.inc new file mode 100644 index 0000000..cfc3f80 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/statistics.inc @@ -0,0 +1,119 @@ +## statistics module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +#set $time_tags = [$day, $week, $month, $year, $rainyear] + +## Get the list of observations from the configuration file, otherwise fallback +## to a very rudimentary set of observations. +#set $observations = $to_list($DisplayOptions.get('observations_stats', ['outTemp', 'windSpeed', 'rain'])) +#set $obs_type_sum = $to_list($DisplayOptions.get('obs_type_sum', ['rain'])) +#set $obs_type_max = $to_list($DisplayOptions.get('obs_type_max', ['rainRate'])) + +## use this span to determine whether there are any data to consider. +#set $recent=$span($day_delta=30, boundary='midnight') + +
+
+ $gettext("Statistics") +
+
+ + + + + + + + + + + + + +#for $x in $observations + #if getattr($recent, $x).has_data + #if $x == 'windSpeed' + + + + #for $time_tag in $time_tags + + #end for + + + + + #for $time_tag in $time_tags + + #end for + + + + + #for $time_tag in $time_tags + + #end for + + + + + + #for $time_tag in $time_tags + + #end for + + #else + + + + #if $x in $obs_type_sum + #for $time_tag in $time_tags + + #end for + #elif $x in $obs_type_max + #for $time_tag in $time_tags + + #end for + #else + #for $time_tag in $time_tags + + #end for + #end if + + #end if + #end if +#end for + + +
$gettext("Today")$gettext("Week")$gettext("Month")$gettext("Year")$gettext("Rain Year")
$gettext("Max Wind") + $unit.label.wind
+ $unit.label.windDir +
+ $time_tag.wind.max.format(add_label=False)
+ $time_tag.wind.gustdir.format(add_label=False)
+ $time_tag.wind.maxtime +
$gettext("Average Wind")$unit.label.wind + $time_tag.wind.avg.format(add_label=False)
$gettext("RMS Wind")$unit.label.wind$time_tag.wind.rms.format(add_label=False)
+ $gettext("Vector Average")
+ $gettext("Vector Direction") +
+ $unit.label.wind
+ $unit.label.windDir +
+ $time_tag.wind.vecavg.format(add_label=False)
+ $time_tag.wind.vecdir.format(add_label=False) +
$obs.label[$x]$getattr($unit.label, $x, '')$getattr($time_tag, $x).sum.format(add_label=False) + $getattr($time_tag, $x).max.format(add_label=False)
+ $getattr($time_tag, $x).maxtime +
+ $getattr($time_tag, $x).max.format(add_label=False)
+ $getattr($time_tag, $x).maxtime
+ $getattr($time_tag, $x).min.format(add_label=False)
+ $getattr($time_tag, $x).mintime +
+ +
+
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/sunmoon.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/sunmoon.inc new file mode 100644 index 0000000..4327838 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/sunmoon.inc @@ -0,0 +1,82 @@ +## sun/moon rise/set module for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +## If extended almanac information is available, do extra calculations. +#if $almanac.hasExtras + #set $sun_altitude = $almanac.sun.alt + #if $sun_altitude < 0 + #set $sun_None='%s' % $gettext("Always down") + #set $daylight_str = "00:00" + #else + #set $sun_None='%s' % $gettext("Always up") + #set $daylight_str = "24:00" + #end if + + #set $sunrise_ts = $almanac.sun.rise.raw + #set $sunset_ts = $almanac.sun.set.raw + #if $sunrise_ts and $sunset_ts + #set $daylight_s = $sunset_ts - $sunrise_ts + #set $daylight_hours = int($daylight_s / 3600) + #set $daylight_minutes = int(($daylight_s % 3600) / 60) + #set $daylight_str = "%02d:%02d" % ($daylight_hours, $daylight_minutes) + #end if +#end if + +
+ +
+
+ #if $almanac.hasExtras + + + + + + + + + + + + + + + + + + + + + + + +
$gettext("Rise")$almanac.sun.rise.format(None_string=$sun_None) $gettext("Rise")$almanac.moon.rise
$gettext("Set")$almanac.sun.set.format(None_string=$sun_None) $gettext("Set")$almanac.moon.set
$gettext("Daylight")$daylight_str $almanac.moon_phase
+ $almanac.moon_fullness%
+ #else + ## No extended almanac information available. Fall back to basic info. + + + + + + + + + + + + + +
$gettext("Sunrise")$almanac.sunrise
$gettext("Sunset")$almanac.sunset
$gettext("Moon Phase")$almanac.moon_phase
+ $almanac.moon_fullness%
+ #end if +
+
+
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/tabular.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/tabular.html.tmpl new file mode 100644 index 0000000..b152840 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/tabular.html.tmpl @@ -0,0 +1,37 @@ +## Copyright 2017 Tom Keffer, Matthew Wall +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 + + + + + + $station.location + + + #if $station.station_url + + #end if + + + + + + #include "titlebar.inc" + + + + + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/telemetry.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/telemetry.html.tmpl new file mode 100644 index 0000000..3c8a0be --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/telemetry.html.tmpl @@ -0,0 +1,73 @@ +## Copyright 2017 Tom Keffer, Matthew Wall +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 + +#set $periods = $to_list($DisplayOptions.get('periods', ['day', 'week', 'month', 'year'])) +#set $telemetry_plot_groups = $to_list($DisplayOptions.get('telemetry_plot_groups', ['rx'])) + + + + + + $station.location $gettext("Telemetry") + + + #if $station.station_url + + #end if + + + + + #include "titlebar.inc" + +
+

❰ $gettext("Current Conditions")

+ +
+ #include "sensors.inc" +
+ +
+
+ + +#for period in $periods + +#end for + +
+
+ + #include "identifier.inc" +
+ + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/titlebar.inc b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/titlebar.inc new file mode 100644 index 0000000..bd8dde9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Seasons/titlebar.inc @@ -0,0 +1,38 @@ +## titlebar for weewx skins +## Copyright Tom Keffer, Matthew Wall +## See LICENSE.txt for your rights +#errorCatcher Echo +#encoding UTF-8 + +#set $show_rss = $to_bool($DisplayOptions.get('show_rss', True)) +#set $show_reports = $to_bool($DisplayOptions.get('show_reports', True)) + +
+
+

$station.location

+

$current.dateTime

+
+#if $show_rss + +#end if +#if $show_reports +
+ $gettext("Monthly Reports"): + +
+ $gettext("Yearly Reports"): + +
+
+#end if +
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/barometer.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/barometer.html.tmpl new file mode 100644 index 0000000..fd97d5d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/barometer.html.tmpl @@ -0,0 +1,43 @@ +#encoding UTF-8 + + + + + $obs.label.barometer $gettext("in") $station.location + + + + + + +
+
+

$obs.label.barometer

+
+
+

$gettext("24h barometer")

+ +

+ $gettext("min"): $day.barometer.min $gettext("at") $day.barometer.mintime
+ $gettext("max"): $day.barometer.max $gettext("at") $day.barometer.maxtime +

+ +

$gettext("7-day barometer")

+ +

+ $gettext("min"): $week.barometer.min $gettext("at") $week.barometer.mintime
+ $gettext("max"): $week.barometer.max $gettext("at") $week.barometer.maxtime +

+ +

$gettext("30-day barometer")

+ +

+ $gettext("min"): $month.barometer.min $gettext("at") $month.barometer.mintime
+ $gettext("max"): $month.barometer.max $gettext("at") $month.barometer.maxtime +

+
## End 'content' +
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/custom.js b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/custom.js new file mode 100644 index 0000000..83de7c8 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/custom.js @@ -0,0 +1,4 @@ +$(document).bind("mobileinit", function(){ + $.mobile.defaultPageTransition = 'slide'; + $.mobile.page.prototype.options.addBackBtn = true; +}); \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/favicon.ico b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/favicon.ico differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/humidity.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/humidity.html.tmpl new file mode 100644 index 0000000..b309a04 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/humidity.html.tmpl @@ -0,0 +1,29 @@ +#encoding UTF-8 + + + + + $obs.label.outHumidity $gettext("in") $station.location + + + + + + +
+
+

$obs.label.outHumidity

+
+
+

$gettext("24h outside humidity")

+ +

$gettext("Last 7 days")

+ +

$gettext("Last 30 days")

+ +
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_ipad_x1.png b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_ipad_x1.png new file mode 100644 index 0000000..aec7f0d Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_ipad_x1.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_ipad_x2.png b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_ipad_x2.png new file mode 100644 index 0000000..3dd7f78 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_ipad_x2.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_iphone_x1.png b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_iphone_x1.png new file mode 100644 index 0000000..985337f Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_iphone_x1.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_iphone_x2.png b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_iphone_x2.png new file mode 100644 index 0000000..e2c54aa Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/icons/icon_iphone_x2.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/index.html.tmpl new file mode 100644 index 0000000..c6ba1f2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/index.html.tmpl @@ -0,0 +1,59 @@ +#encoding UTF-8 + + + + + $station.location Weather Conditions + + + + + + + + + + + + + + #end if +
+

WeeWX v$station.version

+
+ + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/de.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/de.conf new file mode 100644 index 0000000..855bc55 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/de.conf @@ -0,0 +1,39 @@ +############################################################################### +# Localization File # +# Deutsch # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + barometer = Luftdruck + dewpoint = Taupunkt + outHumidity = Außenluftfeuchte + outTemp = Außentemperatur + rain = Regen + rainRate = Regenrate + wind = Wind + windDir = Windrichtung + windGust = Windböen + windSpeed = Windstärke + +[Texts] + "7-day barometer" = "Luftdruck in den letzten 7 Tagen" + "24h barometer" = "Luftdruck in den letzten 24 Stunden" + "24h outside humidity" = "Luftfeuchte in den letzten 24 Stunden" + "24h outside temperature" = "Außentemperatur in den letzten 24 Stunden" + "24h rain" = "Regen in den letzten 24 Stunden" + "24h wind" = "Wind in den letzten 24 Stunden" + "at" = "um" # Time context. E.g., 15.1C "at" 12:22 + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "7-Tage" + "Last 30 days" = "30-Tage" + "Last update" = "letzte Aktualisierung" + "max gust" = "max. Böen" + "Max rate" = "Max. Rate" + "max" = "max" + "min" = "min" + "Rain (daily total)" = "Regen (täglich gesamt)" + "Rain (hourly total)" = "Regen (stündlich gesamt)" + "Total" = "gesamt" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/en.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/en.conf new file mode 100644 index 0000000..7719ef0 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/en.conf @@ -0,0 +1,40 @@ +############################################################################### +# Localization File # +# English # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Labels] + [[Generic]] + barometer = Barometer + dewpoint = Dew Point + outHumidity = Outside Humidity + outTemp = Outside Temperature + rain = Rain + rainRate = Rain Rate + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windSpeed = Wind Speed + +[Texts] + "7-day barometer" = "7-day barometer" + "24h barometer" = "24h barometer" + "24h outside humidity" = "24h outside humidity" + "24h outside temperature" = "24h outside temperature" + "24h rain" = "24h rain" + "24h wind" = "24h wind" + "at" = "at" # Time context. E.g., 15.1C "at" 12:22 + "from" = "from" # Direction context + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "Last 7 days" + "Last 30 days" = "Last 30 days" + "Last update" = "Last update" + "max gust" = "max gust" + "Max rate" = "Max rate" + "max" = "max" + "min" = "min" + "Rain (daily total)" = "Rain (daily total)" + "Rain (hourly total)" = "Rain (hourly total)" + "Total" = "Total" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/nl.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/nl.conf new file mode 100644 index 0000000..99c569f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/nl.conf @@ -0,0 +1,40 @@ +############################################################################### +# Localization File # +# Dutch # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# Translation by Eelco # +############################################################################### + +[Labels] + [[Generic]] + barometer = Barometer + dewpoint = Dauwpunt + outHumidity = Luchtvochtigheid Buiten + outTemp = Temperatuur Buiten + rain = Regen + rainRate = Regen Intensiteit + wind = Wind + windDir = Wind Richting + windGust = Windvlaag Snelheid + windSpeed = Wind Snelheid + +[Texts] + "7-day barometer" = "7-dagen barometer" + "24h barometer" = "24h barometer" + "24h outside humidity" = "24h luchtvochtigheid buiten" + "24h outside temperature" = "24h temperatuur buiten" + "24h rain" = "24h regen" + "24h wind" = "24h wind" + "at" = "om" # Time context. E.g., 15.1C "at" 12:22 + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "Laatste 7 dagen" + "Last 30 days" = "Laatste 30 dagen" + "Last update" = "Laatste update" + "max gust" = "max windvlaag" + "Max rate" = "Max rate" + "max" = "max" + "min" = "min" + "Rain (daily total)" = "Regen (dag totaal)" + "Rain (hourly total)" = "Regen (uur totaal)" + "Total" = "Totaal" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/no.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/no.conf new file mode 100644 index 0000000..464d20f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/lang/no.conf @@ -0,0 +1,82 @@ +############################################################################### +# Localization File # +# Norwegian # +# Copyright (c) 2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### +unit_system = metricwx + +[Units] + + # [[Groups]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_degree_day = degree_C_day # Options are 'degree_F_day' or 'degree_C_day' + # group_distance = km # Options are 'mile' or 'km' + # group_pressure = mBar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_speed = meter_per_second # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + # group_speed2 = meter_per_second2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + # group_temperature = degree_C # Options are 'degree_F' or 'degree_C' + + [[Labels]] + + # These are singular, plural + meter = " meter", " meter" + day = " dag", " dager" + hour = " time", " timer" + minute = " minutt", " minutter" + second = " sekund", " sekunder" + + cm_per_hour = " cm/t" + hPa_per_hour = " hPa/t" + inch_per_hour = " in/t" + inHg_per_hour = " inHg/t" + km_per_hour = " km/t" + km_per_hour2 = " km/t" + kPa_per_hour = " kPa/t", + mbar_per_hour = " mbar/t" + mm_per_hour = " mm/t" + mmHg_per_hour = " mmHg/t" + meter_per_second = " m/s" + meter_per_second2 = " m/s" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNØ, NØ, ØNØ, Ø, ØSØ, SØ, SSØ, S, SSV, SV, VSV, V, VNV, NV, NNV, N/A + +[Labels] + [[Generic]] + barometer = Lufttrykk + dewpoint = Doggpunkt ute + outHumidity = Fuktighet ute + outTemp = Temperatur ute + rain = Regn + rainRate = Regnintensitet + wind = Vind + windDir = Vindretning + windGust = Vindkast + windSpeed = Vindhastighet + +[Texts] + "30-day barometer" = "30-dagers lufttrykk" + "7-day barometer" = "7-dagers lufttrykk" + "24h barometer" = "24-timers lufttrykk" + "24h outside humidity" = "24-timers fuktighet ute" + "24h outside temperature" = "24-timers utetemperatur" + "24h rain" = "24-timers regn" + "24h wind" = "24-timers vind" + "at" = "" # Time context. E.g., 15.1C "at" 12:22 + "from" = "fra" # Direction context + "in" = "i" # Geographic context. E.g., Temperature "in" Boston. + "Last 7 days" = "Siste 7 dager" + "Last 30 days" = "Siste 30 dager" + "Last update" = "Sist oppdatert" + "max gust" = "Maks vindkast" + "Max rate" = "Maks regnmengde" + "max" = "Maks" + "min" = "Min" + "Rain (daily total)" = "Regn (pr. dag)" + "Rain (hourly total)" = "Regn (pr. time)" + "Total" = "Sum" diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/rain.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/rain.html.tmpl new file mode 100644 index 0000000..f930743 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/rain.html.tmpl @@ -0,0 +1,41 @@ +#encoding UTF-8 + + + + + $obs.label.rain $gettext("in") $station.location + + + + + + +
+
+

$obs.label.rain

+
+
+

$gettext("24h rain")

+ +

+ $gettext("Total"): $day.rain.sum
+ $gettext("Max rate"): $day.rainRate.max $gettext("at") $day.rainRate.maxtime +

+

$obs.label.rain $gettext("Last 7 days")

+ +

+ $gettext("Total"): $week.rain.sum
+ $gettext("Max rate"): $week.rainRate.max $gettext("at") $week.rainRate.maxtime +

+

$obs.label.rain $gettext("Last 30 days")

+ +

+ $gettext("Total"): $month.rain.sum
+ $gettext("Max rate"): $month.rainRate.max $gettext("at") $month.rainRate.maxtime +

+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/skin.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/skin.conf new file mode 100644 index 0000000..3c9823d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/skin.conf @@ -0,0 +1,272 @@ +# configuration file for Smartphone skin + +SKIN_NAME = Smartphone +SKIN_VERSION = 5.0.2 + +[Extras] + # Set this URL to display a radar image + #radar_img = http://radar.weather.gov/ridge/lite/N0R/RTX_loop.gif + # Set this URL for the radar image link + #radar_url = http://radar.weather.gov/ridge/radar.php?product=NCR&rid=RTX&loop=yes + +############################################################################### + +[Units] + [[Groups]] + # group_altitude = foot + # group_degree_day = degree_F_day + # group_pressure = inHg + # group_rain = inch + # group_rainrate = inch_per_hour + # group_speed = mile_per_hour + # group_temperature = degree_F + + [[Labels]] + # day = " day", " days" + # hour = " hour", " hours" + # minute = " minute", " minutes" + # second = " second", " seconds" + # NONE = "" + + [[TimeFormats]] + # day = %X + # week = %X (%A) + # month = %x %X + # year = %x %X + # rainyear = %x %X + # current = %x %X + # ephem_day = %X + # ephem_year = %x %X + + [[Ordinates]] + # directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +############################################################################### + +[Labels] + # Set to hemisphere abbreviations suitable for your location: + # hemispheres = N, S, E, W + + # Formats to be used for latitude whole degrees, longitude whole degrees, + # and minutes: + # latlon_formats = "%02d", "%03d", "%05.2f" + + [[Generic]] + # barometer = Barometer + # dewpoint = Dew Point + # heatindex = Heat Index + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # radiation = Radiation + # rain = Rain + # rainRate = Rain Rate + # rxCheckPercent = ISS Signal Quality + # UV = UV Index + # windDir = Wind Direction + # windGust = Gust Speed + # windGustDir = Gust Direction + # windSpeed = Wind Speed + # windchill = Wind Chill + # windgustvec = Gust Vector + # windvec = Wind Vector + +############################################################################### + +[Almanac] + # moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +############################################################################### + +[CheetahGenerator] + encoding = html_entities + + [[ToDate]] + [[[MobileSmartphone]]] + template = index.html.tmpl + + [[[MobileBarometer]]] + template = barometer.html.tmpl + + [[[MobileTemp]]] + template = temp.html.tmpl + + [[[MobileHumidity]]] + template = humidity.html.tmpl + + [[[MobileRain]]] + template = rain.html.tmpl + + [[[MobileWind]]] + template = wind.html.tmpl + +############################################################################### + +[CopyGenerator] + copy_once = favicon.ico, icons/*, custom.js + +############################################################################### + +[ImageGenerator] + + image_width = 300 + image_height = 180 + image_background_color = "#f5f5f5" + + chart_background_color = "#d8d8d8" + chart_gridline_color = "#a0a0a0" + + top_label_font_path = DejaVuSansCondensed-Bold.ttf + top_label_font_size = 10 + + unit_label_font_path = DejaVuSansCondensed-Bold.ttf + unit_label_font_size = 10 + unit_label_font_color = "#000000" + + bottom_label_font_path = DejaVuSansCondensed-Bold.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#000000" + bottom_label_offset = 3 + + axis_label_font_path = DejaVuSansCondensed-Bold.ttf + axis_label_font_size = 10 + axis_label_font_color = "#000000" + + rose_label = N + rose_label_font_path = DejaVuSansCondensed-Bold.ttf + rose_label_font_size = 10 + rose_label_font_color = "#000000" + + line_type = 'solid' + marker_size = 8 + marker_type ='none' + + chart_line_colors = "#4282b4", "#b44242", "#42b442" + chart_fill_colors = "#72b2c4", "#c47272", "#72c472" + + yscale = None, None, None + + vector_rotate = 90 + + line_gap_fraction = 0.05 + + show_daynight = true + daynight_day_color = "#dfdfdf" + daynight_night_color = "#bbbbbb" + daynight_edge_color = "#d0d0d0" + + plot_type = line + aggregate_type = none + width = 1 + time_length = 24h + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %x %X + time_length = 27h + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[dayhumidity]]] + [[[[outHumidity]]]] + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Rain (hourly total) + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[daywinddir]]] + # Hardwire in the y-axis scale for wind direction + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[week_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 1w + aggregate_type = avg + aggregate_interval = 1h + + [[[weekbarometer]]] + [[[[barometer]]]] + + [[[weekhumidity]]] + [[[[outHumidity]]]] + + [[[weektempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[weekrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) + + [[[weekwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[weekwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[month_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 2592000 # == 30 days + aggregate_type = avg + aggregate_interval = 3h + show_daynight = false + + [[[monthbarometer]]] + [[[[barometer]]]] + + [[[monthhumidity]]] + [[[[outHumidity]]]] + + [[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[monthrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) + + [[[monthwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[monthwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + +############################################################################### + +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/temp.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/temp.html.tmpl new file mode 100644 index 0000000..f2e223a --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/temp.html.tmpl @@ -0,0 +1,42 @@ +#encoding UTF-8 + + + + + $obs.label.outTemp $gettext("in") $station.location + + + + + + +
+
+

$obs.label.outTemp

+
+
+

$gettext("24h outside temperature")

+ +

+ $gettext("min"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
+ $gettext("max"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime +

+

$gettext("Last 7 days") $obs.label.outTemp

+ +

+ $gettext("min"): $week.outTemp.min $gettext("at") $week.outTemp.mintime
+ $gettext("max"): $week.outTemp.max $gettext("at") $week.outTemp.maxtime +

+

$gettext("Last 30 days") $obs.label.outTemp

+ +

+ $gettext("min"): $month.outTemp.min $gettext("at") $month.outTemp.mintime
+ $gettext("max"): $month.outTemp.max $gettext("at") $month.outTemp.maxtime +

+
+
+

WeeWX v$station.version

+
+
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/wind.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/wind.html.tmpl new file mode 100644 index 0000000..9a999c3 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Smartphone/wind.html.tmpl @@ -0,0 +1,44 @@ +#encoding UTF-8 + + + + + $obs.label.wind $gettext("in") $station.location + + + + + + +
+
+

$obs.label.wind

+
+
+

$gettext("24h wind")

+ + +

+ $gettext("max"): $day.windSpeed.max $gettext("at") $day.windSpeed.maxtime + $gettext("max gust"): $day.windGust.max $gettext("at") $day.windGust.maxtime +

+

$gettext("Last 7 days") $obs.label.wind

+ + +

+ $gettext("max"): $week.windSpeed.max $gettext("at") $week.windSpeed.maxtime + $gettext("max gust"): $week.windGust.max $gettext("at") $week.windGust.maxtime +

+

$gettext("Last 30 days") $obs.label.wind

+ + +

+ $gettext("max"): $month.windSpeed.max $gettext("at") $month.windSpeed.maxtime + $gettext("max gust"): $month.windGust.max $gettext("at") $month.windGust.maxtime +

+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/NOAA/NOAA-%Y-%m.txt.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/NOAA/NOAA-%Y-%m.txt.tmpl new file mode 100644 index 0000000..e9a751e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/NOAA/NOAA-%Y-%m.txt.tmpl @@ -0,0 +1,39 @@ +#errorCatcher Echo +#set $YM="%Y %m" +#set $D=" %d" +#set $M=" %b" +#set $Time=" %H:%M" +#set $NODAY=" N/A" +#set $Temp="%6.1f" +#set $Wind="%6.1f" +#set $Dir="%6.0f" +#set $Count="%6d" +#set $NONE=" N/A" +#if $unit.unit_type_dict.group_rain == "mm" +#set $Rain="%6.1f" +#else +#set $Rain="%6.2f" +#end if + MONTHLY CLIMATOLOGICAL SUMMARY for $month_name $year_name + + +NAME: $station.location +ELEV: $station.altitude LAT: $station.latitude[0]-$station.latitude[1] $station.latitude[2] LONG: $station.longitude[0]-$station.longitude[1] $station.longitude[2] + + + TEMPERATURE ($unit.label.outTemp.strip()), RAIN ($unit.label.rain.strip()), WIND SPEED ($unit.label.windSpeed.strip()) + + HEAT COOL AVG + MEAN DEG DEG WIND DOM +DAY TEMP HIGH TIME LOW TIME DAYS DAYS RAIN SPEED HIGH TIME DIR +--------------------------------------------------------------------------------------- +#for $day in $month.days +#if $day.outTemp.count.raw or $day.rain.count.raw or $day.wind.count.raw +$day.dateTime.format($D, add_label=False) $day.outTemp.avg.format($Temp,$NONE,add_label=False) $day.outTemp.max.format($Temp,$NONE,add_label=False) $day.outTemp.maxtime.format($Time,add_label=False) $day.outTemp.min.format($Temp,$NONE,add_label=False) $day.outTemp.mintime.format($Time,add_label=False) $day.heatdeg.sum.format($Temp,$NONE,add_label=False) $day.cooldeg.sum.format($Temp,$NONE,add_label=False) $day.rain.sum.format($Rain,$NONE,add_label=False) $day.wind.avg.format($Wind,$NONE,add_label=False) $day.wind.max.format($Wind,$NONE,add_label=False) $day.wind.maxtime.format($Time,add_label=False) $day.wind.vecdir.format($Dir,$NONE,add_label=False) +#else +$day.dateTime.format($D) +#end if +#end for +--------------------------------------------------------------------------------------- + $month.outTemp.avg.format($Temp,$NONE,add_label=False) $month.outTemp.max.format($Temp,$NONE,add_label=False) $month.outTemp.maxtime.format($D,add_label=False) $month.outTemp.min.format($Temp,$NONE,add_label=False) $month.outTemp.mintime.format($D,add_label=False) $month.heatdeg.sum.format($Temp,$NONE,add_label=False) $month.cooldeg.sum.format($Temp,$NONE,add_label=False) $month.rain.sum.format($Rain,$NONE,add_label=False) $month.wind.avg.format($Wind,$NONE,add_label=False) $month.wind.max.format($Wind,$NONE,add_label=False) $month.wind.maxtime.format($D,add_label=False) $month.wind.vecdir.format($Dir,add_label=False) + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/NOAA/NOAA-%Y.txt.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/NOAA/NOAA-%Y.txt.tmpl new file mode 100644 index 0000000..09baeae --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/NOAA/NOAA-%Y.txt.tmpl @@ -0,0 +1,96 @@ +#errorCatcher Echo +#set $YM="%Y %m" +#set $D=" %d" +#set $M=" %b" +#set $NODAY=" N/A" +#set $Temp="%6.1f" +#set $Wind="%6.1f" +#set $Dir="%6.0f" +#set $Count="%6d" +#set $NONE=" N/A" +#if $unit.unit_type_dict.group_temperature == "degree_F" +#set $Hot =(90.0,"degree_F") +#set $Cold =(32.0,"degree_F") +#set $VeryCold=(0.0, "degree_F") +#else +#set $Hot =(30.0,"degree_C") +#set $Cold =(0.0,"degree_C") +#set $VeryCold=(-20.0,"degree_C") +#end if +#if $unit.unit_type_dict.group_rain == "inch" +#set $Trace =(0.01,"inch") +#set $SomeRain =(0.1, "inch") +#set $Soak =(1.0, "inch") +#set $Rain="%6.2f" +#elif $unit.unit_type_dict.group_rain == "mm" +#set $Trace =(.3, "mm") +#set $SomeRain =(3, "mm") +#set $Soak =(30.0,"mm") +#set $Rain="%6.1f" +#else +#set $Trace =(.03,"cm") +#set $SomeRain =(.3, "cm") +#set $Soak =(3.0,"cm") +#set $Rain="%6.2f" +#end if +#def ShowInt($T) +$("%6d" % $T[0])#slurp +#end def +#def ShowFloat($R) +$("%6.2f" % $R[0])#slurp +#end def + CLIMATOLOGICAL SUMMARY for year $year_name + + +NAME: $station.location +ELEV: $station.altitude LAT: $station.latitude[0]-$station.latitude[1] $station.latitude[2] LONG: $station.longitude[0]-$station.longitude[1] $station.longitude[2] + + + TEMPERATURE ($unit.label.outTemp.strip()) + + HEAT COOL MAX MAX MIN MIN + MEAN MEAN DEG DEG >= <= <= <= + YR MO MAX MIN MEAN DAYS DAYS HI DAY LOW DAY $ShowInt($Hot) $ShowInt($Cold) $ShowInt($Cold) $ShowInt($VeryCold) +------------------------------------------------------------------------------------------------ +#for $month in $year.months +#if $month.outTemp.count.raw +$month.dateTime.format($YM) $month.outTemp.meanmax.format($Temp,$NONE,add_label=False) $month.outTemp.meanmin.format($Temp,$NONE,add_label=False) $month.outTemp.avg.format($Temp,$NONE,add_label=False) $month.heatdeg.sum.format($Temp,$NONE,add_label=False) $month.cooldeg.sum.format($Temp,$NONE,add_label=False) $month.outTemp.max.format($Temp,$NONE,add_label=False) $month.outTemp.maxtime.format($D,$NODAY) $month.outTemp.min.format($Temp,$NONE,add_label=False) $month.outTemp.mintime.format($D,$NODAY) $month.outTemp.max_ge($Hot).format($Count,$NONE,add_label=False) $month.outTemp.max_le($Cold).format($Count,$NONE,add_label=False) $month.outTemp.min_le($Cold).format($Count,$NONE,add_label=False) $month.outTemp.min_le($VeryCold).format($Count,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +------------------------------------------------------------------------------------------------ + $year.outTemp.meanmax.format($Temp,$NONE,add_label=False) $year.outTemp.meanmin.format($Temp,$NONE,add_label=False) $year.outTemp.avg.format($Temp,$NONE,add_label=False) $year.heatdeg.sum.format($Temp,$NONE,add_label=False) $year.cooldeg.sum.format($Temp,$NONE,add_label=False) $year.outTemp.max.format($Temp,$NONE,add_label=False) $year.outTemp.maxtime.format($M,$NODAY) $year.outTemp.min.format($Temp,$NONE,add_label=False) $year.outTemp.mintime.format($M,$NODAY) $year.outTemp.max_ge($Hot).format($Count,$NONE,add_label=False) $year.outTemp.max_le($Cold).format($Count,$NONE,add_label=False) $year.outTemp.min_le($Cold).format($Count,$NONE,add_label=False) $year.outTemp.min_le($VeryCold).format($Count,$NONE,add_label=False) + + + PRECIPITATION ($unit.label.rain.strip()) + + MAX ---DAYS OF RAIN--- + OBS. OVER + YR MO TOTAL DAY DATE $ShowFloat(Trace) $ShowFloat($SomeRain) $ShowFloat($Soak) +------------------------------------------------ +#for $month in $year.months +#if $month.rain.count.raw +$month.dateTime.format($YM) $month.rain.sum.format($Rain,$NONE,add_label=False) $month.rain.maxsum.format($Rain,$NONE,add_label=False) $month.rain.maxsumtime.format($D,$NODAY) $month.rain.sum_ge($Trace).format($Count,$NONE,add_label=False) $month.rain.sum_ge($SomeRain).format($Count,$NONE,add_label=False) $month.rain.sum_ge($Soak).format($Count,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +------------------------------------------------ + $year.rain.sum.format($Rain,$NONE,add_label=False) $year.rain.maxsum.format($Rain,$NONE,add_label=False) $year.rain.maxsumtime.format($M,$NODAY) $year.rain.sum_ge($Trace).format($Count,$NONE,add_label=False) $year.rain.sum_ge($SomeRain).format($Count,$NONE,add_label=False) $year.rain.sum_ge($Soak).format($Count,$NONE,add_label=False) + + + WIND SPEED ($unit.label.windSpeed.strip()) + + DOM + YR MO AVG HI DATE DIR +----------------------------------- +#for $month in $year.months +#if $month.wind.count.raw +$month.dateTime.format($YM) $month.wind.avg.format($Wind,$NONE,add_label=False) $month.wind.max.format($Wind,$NONE,add_label=False) $month.wind.maxtime.format($D,$NODAY) $month.wind.vecdir.format($Dir,$NONE,add_label=False) +#else +$month.dateTime.format($YM) +#end if +#end for +----------------------------------- + $year.wind.avg.format($Wind,$NONE,add_label=False) $year.wind.max.format($Wind,$NONE,add_label=False) $year.wind.maxtime.format($M,$NODAY) $year.wind.vecdir.format($Dir,$NONE,add_label=False) diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/RSS/weewx_rss.xml.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/RSS/weewx_rss.xml.tmpl new file mode 100644 index 0000000..6a24d66 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/RSS/weewx_rss.xml.tmpl @@ -0,0 +1,136 @@ + + + + $station.location, $gettext("Weather Conditions") + $station.station_url + $gettext("Current conditions, and daily, monthly, and yearly summaries") + "$lang" + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + http://blogs.law.harvard.edu/tech/rss + weewx $station.version + $current.interval.string('') + + + $gettext("Weather Conditions at") $current.dateTime + $station.station_url + + $obs.label.outTemp: $current.outTemp; + $obs.label.barometer: $current.barometer; + $obs.label.wind: $current.windSpeed $gettext("from") $current.windDir; + $obs.label.rainRate: $current.rainRate; + $obs.label.inTemp: $current.inTemp + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + $station.latitude_f + $station.longitude_f + + $obs.label.dateTime: $current.dateTime
+ $obs.label.outTemp: $current.outTemp
+ $obs.label.inTemp: $current.inTemp
+ $obs.label.windchill: $current.windchill
+ $obs.label.heatindex: $current.heatindex
+ $obs.label.dewpoint: $current.dewpoint
+ $obs.label.outHumidity: $current.outHumidity
+ $obs.label.barometer: $current.barometer
+ $obs.label.wind: $current.windSpeed $gettext("from") $current.windDir
+ $obs.label.rainRate: $current.rainRate
+

+ ]]>
+
+ + + $gettext("Daily Weather Summary as of") $current.dateTime + $station.station_url + + $gettext("Min outside temperature"): $day.outTemp.min $gettext("at") $day.outTemp.mintime; + $gettext("Max outside temperature"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime; + $gettext("Min inside temperature"): $day.inTemp.min $gettext("at") $day.inTemp.mintime; + $gettext("Max inside temperature"): $day.inTemp.max $gettext("at") $day.inTemp.maxtime; + $gettext("Min barometer"): $day.barometer.min $gettext("at") $day.barometer.mintime; + $gettext("Max barometer"): $day.barometer.max $gettext("at") $day.barometer.maxtime; + $gettext("Max wind") : $day.wind.max $gettext("from") $day.wind.gustdir $gettext("at") $day.wind.maxtime; + $gettext("Rain today"): $day.rain.sum + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + $station.latitude_f + $station.longitude_f + + $gettext("Day"): $day.dateTime.format("%d %b %Y")
+ $gettext("Min outside temperature"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
+ $gettext("Max outside temperature"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime
+ $gettext("Min inside temperature"): $day.inTemp.min $gettext("at") $day.inTemp.mintime
+ $gettext("Max inside temperature"): $day.inTemp.max $gettext("at") $day.inTemp.maxtime
+ $gettext("Min barometer"): $day.barometer.min $gettext("at") $day.barometer.mintime
+ $gettext("Max barometer"): $day.barometer.max $gettext("at") $day.barometer.maxtime
+ $gettext("Max wind") : $day.wind.max $gettext("from") $day.wind.gustdir $gettext("at") $day.wind.maxtime
+ $gettext("Rain today"): $day.rain.sum
+

+ ]]>
+
+ + + $gettext("Monthly Weather Summary as of") $current.dateTime + $station.station_url + + $gettext("Min outside temperature"): $month.outTemp.min $gettext("at") $month.outTemp.mintime; + $gettext("Max outside temperature"): $month.outTemp.max $gettext("at") $month.outTemp.maxtime; + $gettext("Min inside temperature"): $month.inTemp.min $gettext("at") $month.inTemp.mintime; + $gettext("Max inside temperature"): $month.inTemp.max $gettext("at") $month.inTemp.maxtime; + $gettext("Min barometer"): $month.barometer.min $gettext("at") $month.barometer.mintime; + $gettext("Max barometer"): $month.barometer.max $gettext("at") $month.barometer.maxtime; + $gettext("Max wind") : $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime; + $gettext("Rain total for month"): $month.rain.sum + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $gettext("Month"): $month.dateTime.format("%B %Y")
+ $gettext("Max outside temperature"): $month.outTemp.max $gettext("at") $month.outTemp.maxtime
+ $gettext("Min outside temperature"): $month.outTemp.min $gettext("at") $month.outTemp.mintime
+ $gettext("Max inside temperature"): $month.inTemp.max $gettext("at") $month.inTemp.maxtime
+ $gettext("Min inside temperature"): $month.inTemp.min $gettext("at") $month.inTemp.mintime
+ $gettext("Min barometer"): $month.barometer.min $gettext("at") $month.barometer.mintime
+ $gettext("Max barometer"): $month.barometer.max $gettext("at") $month.barometer.maxtime
+ $gettext("Max wind") : $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime
+ $gettext("Rain total for month"): $month.rain.sum
+

+ ]]>
+
+ + + $gettext("Yearly Weather Summary as of") $current.dateTime + $station.station_url + + $gettext("Min outside temperature"): $year.outTemp.min $gettext("at") $year.outTemp.mintime; + $gettext("Max outside temperature"): $year.outTemp.max $gettext("at") $year.outTemp.maxtime; + $gettext("Min inside temperature"): $year.inTemp.min $gettext("at") $year.inTemp.mintime; + $gettext("Max inside temperature"): $year.inTemp.max $gettext("at") $year.inTemp.maxtime; + $gettext("Min barometer"): $year.barometer.min $gettext("at") $year.barometer.mintime; + $gettext("Max barometer"): $year.barometer.max $gettext("at") $year.barometer.maxtime; + $gettext("Max wind") : $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime; + $gettext("Rain total for year"): $year.rain.sum + + $current.dateTime.format("%a, %d %b %Y %H:%M:%S %Z") + + $gettext("Year"): $year.dateTime.format("%Y")
+ $gettext("Max outside temperature"): $year.outTemp.max $gettext("at") $year.outTemp.maxtime
+ $gettext("Min outside temperature"): $year.outTemp.min $gettext("at") $year.outTemp.mintime
+ $gettext("Max inside temperature"): $year.inTemp.max $gettext("at") $year.inTemp.maxtime
+ $gettext("Min inside temperature"): $year.inTemp.min $gettext("at") $year.inTemp.mintime
+ $gettext("Min barometer"): $year.barometer.min $gettext("at") $year.barometer.mintime
+ $gettext("Max barometer"): $year.barometer.max $gettext("at") $year.barometer.maxtime
+ $gettext("Max wind") : $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime
+ $gettext("Rain total for year"): $year.rain.sum
+

+ ]]>
+
+ +
+
diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/band.gif b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/band.gif new file mode 100644 index 0000000..fdfd5cd Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/band.gif differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/butterfly.jpg b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/butterfly.jpg new file mode 100644 index 0000000..e33a26f Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/butterfly.jpg differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/drops.gif b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/drops.gif new file mode 100644 index 0000000..6d3814e Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/drops.gif differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/flower.jpg b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/flower.jpg new file mode 100644 index 0000000..4b33f6d Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/flower.jpg differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/leaf.jpg b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/leaf.jpg new file mode 100644 index 0000000..dd3b02c Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/leaf.jpg differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/night.gif b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/night.gif new file mode 100644 index 0000000..e6a1774 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/backgrounds/night.gif differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/favicon.ico b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/favicon.ico new file mode 100644 index 0000000..bd0f996 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/favicon.ico differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/font/DejaVuSansMono-Bold.ttf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/font/DejaVuSansMono-Bold.ttf new file mode 100644 index 0000000..09d4279 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/font/DejaVuSansMono-Bold.ttf differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/index.html.tmpl new file mode 100644 index 0000000..8cee051 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/index.html.tmpl @@ -0,0 +1,521 @@ +## Copyright 2009-2022 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Current Weather Conditions") + + + #if $station.station_url + + #end if + + ## If a Google Analytics GA4 code has been specified, include it. + #if 'googleAnalyticsId' in $Extras + + + #end if + + + +
+
+

$station.location

+

$gettext("Current Weather Conditions")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("Current Conditions") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $day.UV.has_data + + + + + #end if + #if $day.ET.has_data and $day.ET.sum.raw is not None and $day.ET.sum.raw > 0.0 + + + + + #end if + #if $day.radiation.has_data + + + + + #end if + +
$obs.label.outTemp$current.outTemp
$obs.label.windchill$current.windchill
$obs.label.heatindex$current.heatindex
$obs.label.dewpoint$current.dewpoint
$obs.label.outHumidity$current.outHumidity
$obs.label.barometer$current.barometer
$obs.label.barometerRate ($trend.time_delta.hour.format("%.0f"))$trend.barometer
$obs.label.wind$current.windSpeed $gettext("from") $current.windDir ($current.windDir.ordinal_compass)
$obs.label.rainRate$current.rainRate
$obs.label.inTemp$current.inTemp
$obs.label.UV$current.UV
$obs.label.ET$current.ET
$obs.label.radiation$current.radiation
+
+ +

 

+ +
+
+ $gettext("Since Midnight") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $day.UV.has_data + + + + + #end if + #if $day.ET.has_data and $day.ET.sum.raw is not None and $day.ET.sum.raw >0.0 + + + + + #end if + #if $day.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $day.outTemp.max $gettext("at") $day.outTemp.maxtime
+ $day.outTemp.min $gettext("at") $day.outTemp.mintime +
+ $gettext("High Heat Index")
+ $gettext("Low Wind Chill") +
+ $day.heatindex.max $gettext("at") $day.heatindex.maxtime
+ $day.windchill.min $gettext("at") $day.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $day.outHumidity.max $gettext("at") $day.outHumidity.maxtime
+ $day.outHumidity.min $gettext("at") $day.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $day.dewpoint.max $gettext("at") $day.dewpoint.maxtime
+ $day.dewpoint.min $gettext("at") $day.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $day.barometer.max $gettext("at") $day.barometer.maxtime
+ $day.barometer.min $gettext("at") $day.barometer.mintime +
$gettext("Today's Rain")$day.rain.sum
$gettext("High Rain Rate")$day.rainRate.max $gettext("at") $day.rainRate.maxtime
+ $gettext("High Wind") + + $day.wind.max $gettext("from") $day.wind.gustdir $gettext("at") $day.wind.maxtime +
+ $gettext("Average Wind") + + $day.wind.avg +
+ $gettext("RMS Wind") + + $day.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $day.wind.vecavg
+ $day.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $day.inTemp.max $gettext("at") $day.inTemp.maxtime
+ $day.inTemp.min $gettext("at") $day.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $day.UV.max $gettext("at") $day.UV.maxtime
+ $day.UV.min $gettext("at") $day.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $day.ET.max $gettext("at") $day.ET.maxtime
+ $day.ET.min $gettext("at") $day.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $day.radiation.max $gettext("at") $day.radiation.maxtime
+ $day.radiation.min $gettext("at") $day.radiation.mintime +
+
+ +

 

+ + #if 'radar_img' in $Extras +
+ #if 'radar_url' in $Extras + + #end if + Radar + #if 'radar_url' in $Extras + +

$gettext("Click image for expanded radar loop")

+ #end if +
+ #end if + +
+ +
+
+
+ $gettext("About this weather station"): +
+ + + + + + + + + + + + + + +
$gettext("Location")
$gettext("Latitude"):$station.latitude[0]° $station.latitude[1]' $station.latitude[2]
$gettext("Longitude"):$station.longitude[0]° $station.longitude[1]' $station.longitude[2]
$pgettext("Geographical", "Altitude"):$station.altitude
+

+ $gettext("This station uses a") + $station.hardware, + $gettext("controlled by 'WeeWX',") + $gettext("an experimental weather software system written in Python.") + $gettext("Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts.") +

+

$gettext("RSS feed")

+

$gettext("Smartphone formatted")

+

$gettext("WeeWX uptime"): $station.uptime.long_form
+ $gettext("Server uptime"): $station.os_uptime.long_form
+ weewx v$station.version

+
+ +
+
+ $gettext("Today's Almanac") +
+
+ #if $almanac.hasExtras + ## Extended almanac information is available. Do the full set of tables. + #set $sun_altitude = $almanac.sun.alt + #if $sun_altitude < 0 + #set $sun_None="(" + $gettext("Always down") + ")" + #else + #set $sun_None="(" + $gettext("Always up") + ")" + #end if +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $almanac.next_equinox.raw < $almanac.next_solstice.raw + ## The equinox is before the solstice. Display them in order. + + + + + + + + + #else + ## The solstice is before the equinox. Display them in order. + + + + + + + + + #end if +
$gettext("Sun")
$gettext("Start civil twilight"):$almanac(horizon=-6).sun(use_center=1).rise
$gettext("Sunrise"):$almanac.sun.rise.string($sun_None)
$gettext("Transit"):$almanac.sun.transit
$gettext("Sunset"):$almanac.sun.set.string($sun_None)
$gettext("End civil twilight"):$almanac(horizon=-6).sun(use_center=1).set
$gettext("Azimuth"):$("%.1f°" % $almanac.sun.az)
$pgettext("Astronomical", "Altitude"):$("%.1f°" % $sun_altitude)
$gettext("Right ascension"):$("%.1f°" % $almanac.sun.ra)
$gettext("Declination"):$("%.1f°" % $almanac.sun.dec)
$gettext("Equinox"):$almanac.next_equinox
$gettext("Solstice"):$almanac.next_solstice
$gettext("Solstice"):$almanac.next_solstice
$gettext("Equinox"):$almanac.next_equinox
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $almanac.next_full_moon.raw < $almanac.next_new_moon.raw + + + + + + + + + #else + + + + + + + + + #end if + + + + +
$gettext("Moon")
$gettext("Rise"):$almanac.moon.rise
$gettext("Transit"):$almanac.moon.transit
$gettext("Set"):$almanac.moon.set
$gettext("Azimuth"):$("%.1f°" % $almanac.moon.az)
$pgettext("Astronomical", "Altitude"):$("%.1f°" % $almanac.moon.alt)
$gettext("Right ascension"):$("%.1f°" % $almanac.moon.ra)
$gettext("Declination"):$("%.1f°" % $almanac.moon.dec)
$gettext("Full moon"):$almanac.next_full_moon
$gettext("New moon"):$almanac.next_new_moon
$gettext("New moon"):$almanac.next_new_moon
$gettext("Full moon"):$almanac.next_full_moon
$gettext("Phase"):$almanac.moon_phase
($almanac.moon_fullness% full)
+
+ #else + ## No extended almanac information available. Fall back to a simple table. + + + + + + + + + + + + + +
$gettext("Sunrise"):$almanac.sunrise
$gettext("Sunset"):$almanac.sunset
$gettext("Moon Phase"):$almanac.moon_phase
($almanac.moon_fullness% full)
+ #end if +
+
+ +
+ temperatures + heatchill + outside humidity + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $day.radiation.has_data + Radiation + #end if + #if $day.UV.has_data + UV Index + #end if + #if $day.rxCheckPercent.has_data + day rx percent + #end if +
+
+ + +
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/de.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/de.conf new file mode 100644 index 0000000..87ae422 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/de.conf @@ -0,0 +1,220 @@ +############################################################################### +# Localization File --- Standard skin # +# German # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Karen" # +############################################################################### + +[Units] + + [[Labels]] + + day = " Tag", " Tage" + hour = " Stunde", " Stunden" + minute = " Minute", " Minuten" + second = " Sekunde", " Sekunden" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + altimeter = Luftdruck (QNH) # QNH + altimeterRate = Luftdruckänderung + appTemp = gefühlte Temperatur + appTemp1 = gefühlte Temperatur + barometer = Luftdruck # QFF + barometerRate = Luftdruckänderung + cloudbase = Wolkenuntergrenze + dateTime = "Datum/Zeit" + dewpoint = Taupunkt + ET = ET + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + heatindex = Hitzeindex + inDewpoint = Raumtaupunkt + inHumidity = Raumluftfeuchte + inTemp = Raumtemperatur + interval = Intervall + lightning_distance = Blitzentfernung + lightning_strike_count = Blitzanzahl + outHumidity = Luftfeuchte + outTemp = Außentemperatur + pressure = abs. Luftdruck # QFE + pressureRate = Luftdruckänderung + radiation = Sonnenstrahlung + rain = Regen + rainRate = Regen-Rate + THSW = THSW-Index + UV = UV-Index + wind = Wind + windchill = Windchill + windDir = Windrichtung + windGust = Böen Geschwindigkeit + windGustDir = Böen Richtung + windgustvec = Böen-Vektor + windrun = Windverlauf + windSpeed = Windgeschwindigkeit + windvec = Wind-Vektor + + # used in Seasons skin but not defined + feel = gefühlte Temperatur + + # Sensor status indicators + consBatteryVoltage = Konsolenbatterie + heatingVoltage = Heizungsspannung + inTempBatteryStatus = Innentemperatursensor + outTempBatteryStatus = Außentemperatursensor + rainBatteryStatus = Regenmesser + referenceVoltage = Referenz + rxCheckPercent = Signalqualität + supplyVoltage = Versorgung + txBatteryStatus = Übertrager + windBatteryStatus = Anemometer + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Neumond, zunehmend, Halbmond, zunehmend, Vollmond, abnehmend, Halbmond, abnehmend + +[Texts] + "7-day" = "7-Tage" + "24h" = "24h" + "About this weather station" = "Über diese Wetterstation" + "Always down" = "Polarnacht" + "Always up" = "Polartag" + "an experimental weather software system written in Python." = "einer experimentellen Wettersoftware geschrieben in Python." + "at" = "um" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Durchschn. Wind" + "Azimuth" = "Azimut" + "Big Page" = "für PC formatiert" + "Calendar Year" = "Kalenderjahr" + "controlled by 'WeeWX'," = "gesteuert von 'weewx'," + "Current Conditions" = "aktuelle Werte" + "Current conditions, and daily, monthly, and yearly summaries" = "Momentanwerte und Tages-, Monats- und Jahreszusammenfassung" + "Current Weather Conditions" = "aktuelle Wetterwerte" + "Daily Weather Summary as of" = "Tageszusammenfassung vom" + "Day" = "Tag" + "Declination" = "Deklination" + "End civil twilight" = "Ende der bürgerlichen Dämmerung" + "Equinox" = "Tagundnachtgleiche" + "from" = "aus" # Direction context. The wind is "from" the NE + "Full moon" = "Vollmond" + "High Barometer" = "Luftdruck max." + "High Dewpoint" = "Taupunkt max." + "High ET" = "ET max." + "High Heat Index" = "Hitzeindex max." + "High Humidity" = "Luftfeuchte max." + "High Inside Temperature" = "Innentemp. max." + "High Radiation" = "Sonnenstrahlg. max." + "High Rain Rate" = "Regenrate max." + "High Temperature" = "Temperatur max." + "High UV" = "UV max." + "High Wind" = "Wind max." + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "der letzten 30 Tage" + "Latitude" = "Breite" + "Location" = "Lage" + "Longitude" = "Länge" + "Low Barometer" = "Luftdruck min." + "Low Dewpoint" = "Taupunkt min." + "Low ET" = "ET min." + "Low Humidity" = "Luftfeuchte min." + "Low Inside Temperature" = "Innentemp. min." + "Low Radiation" = "Sonnenstrahlg. min." + "Low Temperature" = "Temperatur min." + "Low UV" = "UV min" + "Low Wind Chill" = "Wind-Chill min." + "Max barometer" = "Maximaler Luftdruck" + "Max inside temperature" = "Maximale Innentemperatur" + "Max outside temperature" = "Maximale Außentemperatur" + "Max rate" = "Maximale Rate" + "Max wind" = "Maximale Windstärke" + "Min barometer" = "Minimaler Luftdruck" + "Min inside temperature" = "Minimale Innentemperatur" + "Min outside temperature" = "Minimale Außentemperatur" + "Min rate" = "Minimale Rate" + "Month" = "Monat" + "Monthly Statistics and Plots" = "Monatsstatistiken und -diagramme" + "Monthly summary" = "Monatswerte" + "Monthly Weather Summary as of" = "Monatszusammenfassung vom" + "Monthly Weather Summary" = "Wetterübersicht für diesen Monat" + "Moon Phase" = "Mondphase" + "Moon" = "Mond" + "New moon" = "Neumond" + "Phase" = "Phase" + "Radar" = "Radar" + "Rain (daily total)" = "Regen (täglich gesamt)" + "Rain (hourly total)" = "Regen (stündlich gesamt)" + "Rain (weekly total)" = "Regen (wöchentlich gesamt)" + "Rain today" = "Regen heute" + "Rain total for month" = "Regen gesamt Monat" + "Rain total for year" = "Regen gesamt Jahr" + "Rain Total" = "Regen gesamt" + "Rain Year Total" = "Regen gesamt" + "Rain Year" = "Regenjahr" + "Right ascension" = "Rektaszension" + "Rise" = "Aufgang" + "RMS Wind" = "Wind Effektivwert" + "RSS feed" = "RSS-Feed" + "Select month" = "Monat wählen" + "Select year" = "Jahr wählen" + "Server uptime" = "Serverlaufzeit" + "Set" = "Untergang" + "Since Midnight" = "seit Mitternacht" + "Smartphone formatted" = "für Smartphone formatiert" + "Solstice" = "Sonnenwende" + "Sorry, no radar image" = "Kein Radarbild verfügbar" + "Start civil twilight" = "Beginn der bürglicherlichen Dämmerung" + "Sun" = "Sonne" + "Sunrise" = "Aufgang" + "Sunset" = "Untergang" + "This Month" = "Diesen Monat" + "This station uses a" = "Diese Station verwendet" + "This Week" = "Diese Woche" + "This week's max" = "Maximalwert dieser Woche" + "This week's min" = "Minimalwert dieser Woche" + "Time" = "Zeit" + "Today's Almanac" = "Sonne & Mond heute" + "Today's max" = "Maximalwert heute" + "Today's min" = "Minimalwert heute" + "Today's Rain" = "Regen heute" + "Transit" = "Transit" + "Vector Average Direction" = "Durchschn. Windrichtung" + "Vector Average Speed" = "Durchschn. Windstärke" + "Weather Conditions at" = "Wetter am" + "Weather Conditions" = "Wetter" + "Week" = "Woche" + "Weekly Statistics and Plots" = "Wochenstatistiken und -diagramme" + "Weekly Weather Summary" = "Wetterübersicht für diese Woche" + "WeeWX uptime" = "WeeWX-Laufzeit" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Grundsätze der Entwicklung von Weewx waren Einfachheit, Schnelligkeit und leichte Verständlichkeit durch Anwendung moderner Programmierkonzepte." + "Year" = "Jahr" + "Yearly Statistics and Plots" = "Jahresstatistiken und -diagramme" + "Yearly summary" = "Jahreswerte" + "Yearly Weather Summary as of" = "Jahreszusammenfassung vom" + "Yearly Weather Summary" = "Wetterübersicht für dieses Jahr" + + [[Geographical]] + "Altitude" = "Höhe" + + [[Astronomical]] + "Altitude" = "Höhe" + + [[Buttons]] + # Labels used in buttons + "Current" = " Jetzt " + "Month" = " Monat " + "Week" = " Woche " + "Year" = " Jahr " diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/en.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/en.conf new file mode 100644 index 0000000..0e94249 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/en.conf @@ -0,0 +1,222 @@ +############################################################################### +# Localization File --- Standard skin # +# English # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +[Units] + + [[Labels]] + + day = " day", " days" + hour = " hour", " hours" + minute = " minute", " minutes" + second = " second", " seconds" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, W + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Time + interval = Interval + altimeter = Altimeter # QNH + altimeterRate = Altimeter Trend + barometer = Barometer # QFF + barometerRate = Barometer Trend + pressure = Pressure # QFE + pressureRate = Pressure Trend + dewpoint = Dew Point + ET = ET + heatindex = Heat Index + inHumidity = Inside Humidity + inTemp = Inside Temperature + inDewpoint = Inside Dew Point + outHumidity = Humidity + outTemp = Outside Temperature + radiation = Radiation + rain = Rain + rainRate = Rain Rate + UV = UV Index + wind = Wind + windDir = Wind Direction + windGust = Gust Speed + windGustDir = Gust Direction + windSpeed = Wind Speed + windchill = Wind Chill + windgustvec = Gust Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperature1 + extraTemp2 = Temperature2 + extraTemp3 = Temperature3 + appTemp = Apparent Temperature + appTemp1 = Apparent Temperature + THSW = THSW Index + lightning_distance = Lightning Distance + lightning_strike_count = Lightning Strikes + cloudbase = Cloud Base + + # used in Seasons skin, but not defined + feel = apparent temperature + + # Sensor status indicators + rxCheckPercent = Signal Quality + txBatteryStatus = Transmitter Battery + windBatteryStatus = Wind Battery + rainBatteryStatus = Rain Battery + outTempBatteryStatus = Outside Temperature Battery + inTempBatteryStatus = Inside Temperature Battery + consBatteryVoltage = Console Battery + heatingVoltage = Heating Battery + supplyVoltage = Supply Voltage + referenceVoltage = Reference Voltage + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = New, Waxing crescent, First quarter, Waxing gibbous, Full, Waning gibbous, Last quarter, Waning crescent + +[Texts] + "7-day" = "7-day" + "24h" = "24h" + "About this weather station" = "About this weather station" + "Always down" = "Always down" + "Always up" = "Always up" + "an experimental weather software system written in Python." = "an experimental weather software system written in Python." + "at" = "at" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Average Wind" + "Azimuth" = "Azimuth" + "Big Page" = "Big Page" + "Calendar Year" = "Calendar Year" + "Click image for expanded radar loop" = "Click image for expanded radar loop" + "controlled by 'WeeWX'," = "controlled by 'WeeWX'," + "Current Conditions" = "Current Conditions" + "Current conditions, and daily, monthly, and yearly summaries" = "Current conditions, and daily, monthly, and yearly summaries" + "Current Weather Conditions" = "Current Weather Conditions" + "Daily Weather Summary as of" = "Daily Weather Summary as of" + "Day" = "Day" + "Declination" = "Declination" + "End civil twilight" = "End civil twilight" + "Equinox" = "Equinox" + "from" = "from" # Direction context. The wind is "from" the NE + "Full moon" = "Full moon" + "High Barometer" = "High Barometer" + "High Dewpoint" = "High Dewpoint" + "High ET" = "High ET" + "High Heat Index" = "High Heat Index" + "High Humidity" = "High Humidity" + "High Inside Temperature" = "High Inside Temperature" + "High Radiation" = "High Radiation" + "High Rain Rate" = "High Rain Rate" + "High Temperature" = "High Temperature" + "High UV" = "High UV" + "High Wind" = "High Wind" + "High" = "High" + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "last 30 days" + "Latitude" = "Latitude" + "Location" = "Location" + "Longitude" = "Longitude" + "Low Barometer" = "Low Barometer" + "Low Dewpoint" = "Low Dewpoint" + "Low ET" = "Low ET" + "Low Humidity" = "Low Humidity" + "Low Inside Temperature" = "Low Inside Temperature" + "Low Radiation" = "Low Radiation" + "Low Temperature" = "Low Temperature" + "Low UV" = "Low UV" + "Low Wind Chill" = "Low Wind Chill" + "Max barometer" = "Max barometer" + "Max inside temperature" = "Max inside temperature" + "Max outside temperature" = "Max outside temperature" + "Max rate" = "Max rate" + "Max wind" = "Max wind" + "max" = "max" + "Min barometer" = "Min barometer" + "Min inside temperature" = "Min inside temperature" + "Min outside temperature" = "Min outside temperature" + "Min rate" = "Min rate" + "Month" = "Month" + "Monthly Statistics and Plots" = "Monthly Statistics and Plots" + "Monthly summary" = "Monthly summary" + "Monthly Weather Summary as of" = "Monthly Weather Summary as of" + "Monthly Weather Summary" = "Monthly Weather Summary" + "Moon Phase" = "Moon Phase" + "Moon" = "Moon" + "New moon" = "New moon" + "Phase" = "Phase" + "Radar" = "Radar" + "Rain (daily total)" = "Rain (daily total)" + "Rain (hourly total)" = "Rain (hourly total)" + "Rain (weekly total)" = "Rain (weekly total)" + "Rain today" = "Rain today" + "Rain total for month" = "Rain total for month" + "Rain total for year" = "Rain total for year" + "Rain Total" = "Rain Total" + "Rain Year Total" = "Rain Year Total" + "Rain Year" = "Rain Year" + "Right ascension" = "Right ascension" + "Rise" = "Rise" + "RMS Wind" = "RMS Wind" + "RSS feed" = "RSS feed" + "Select month" = "Select month" + "Select year" = "Select year" + "Server uptime" = "Server uptime" + "Set" = "Set" + "Since Midnight" = "Since Midnight" + "Smartphone formatted" = "Smartphone formatted" + "Solstice" = "Solstice" + "Sorry, no radar image" = "Sorry, no radar image" + "Start civil twilight" = "Start civil twilight" + "Sun" = "Sun" + "Sunrise" = "Sunrise" + "Sunset" = "Sunset" + "This Month" = "This Month" + "This station uses a" = "This station uses a" + "This Week" = "This Week" + "This week's max" = "This week's max" + "This week's min" = "This week's min" + "Time" = "Time" + "Today's Almanac" = "Today's Almanac" + "Today's data" = "Today's data" + "Today's max" = "Today's max" + "Today's min" = "Today's min" + "Today's Rain" = "Today's Rain" + "Transit" = "Transit" + "Vector Average Direction" = "Vector Average Direction" + "Vector Average Speed" = "Vector Average Speed" + "Weather Conditions at" = "Weather Conditions at" + "Weather Conditions" = "Weather Conditions" + "Week" = "Week" + "Weekly Statistics and Plots" = "Weekly Statistics and Plots" + "Weekly Weather Summary" = "Weekly Weather Summary" + "WeeWX uptime" = "WeeWX uptime" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." + "Year" = "Year" + "Yearly Statistics and Plots" = "Yearly Statistics and Plots" + "Yearly summary" = "Yearly summary" + "Yearly Weather Summary as of" = "Yearly Weather Summary as of" + "Yearly Weather Summary" = "Yearly Weather Summary" + + [[Geographical]] + "Altitude" = "Altitude" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Altitude" # As in angle above the horizon + + [[Buttons]] + # Labels used in buttons + "Current" = " Current " + "Month" = " Month " + "Week" = " Week " + "Year" = " Year " diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/fr.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/fr.conf new file mode 100644 index 0000000..f66295c --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/fr.conf @@ -0,0 +1,220 @@ +############################################################################### +# Localization File --- Standard skin # +# French # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# # +# Translation by "Gérard" # +############################################################################### + +[Units] + + [[Labels]] + + day = " jour", " jours" + hour = " heure", " heures" + minute = " minute", " minutes" + second = " seconde", " secondes" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, E, O + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Datation + interval = Intervalle + altimeter = Altimètre # QNH + altimeterRate = Tendance Altimétrique + barometer = Baromètre # QFF + barometerRate = Tendance Barométrique + pressure = Pression # QFE + pressureRate = Tendance de la Pression + dewpoint = Point de Rosée + ET = Evapotranspiration + heatindex = Indice de Chaleur + inHumidity = Humidité Intérieure + inTemp = Température Intérieure + inDewpoint = Point de Rosée Intérieur + outHumidity = Humidité + outTemp = Température Extérieure + radiation = Rayonnement + rain = Pluie + rainRate = Taux de Pluie + UV = Indice UV + wind = Vent + windDir = Direction du Vent + windGust = Rafales + windGustDir = Direction des Rafales + windSpeed = Vitesse du Vent + windchill = Refroidissement Eolien + windgustvec = Vecteur Rafales + windvec = Vecteur Vent + windrun = Parcours du Vent + extraTemp1 = Température1 + extraTemp2 = Température2 + extraTemp3 = Température3 + appTemp = Température Apparente + appTemp1 = Température Apparente + THSW = Indice THSW + lightning_distance = Distance de la Foudre + lightning_strike_count = Impacts de Foudre + cloudbase = Base des Nuages + + # used in Seasons skin, but not defined + feel = Température Apparente + + # Sensor status indicators + rxCheckPercent = Qualité du Signal + txBatteryStatus = Batterie Emetteur + windBatteryStatus = Batterie Vent + rainBatteryStatus = Batterie Pluie + outTempBatteryStatus = Batterie Température Extérieure + inTempBatteryStatus = Batterie Température Intérieure + consBatteryVoltage = Tension Batterie Console + heatingVoltage = Tension Chauffage + supplyVoltage = Tension Alimentation + referenceVoltage = Tension Référence + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nouvelle, Premier Croissant, Premier Quartier, Gibbeuse Croissante, Pleine, Gibbeuse Décroissante, Dernier Quartier, Dernier Croissant + +[Texts] + "7-day" = "7-jours" + "24h" = "24h" + "About this weather station" = "A Propos de cette station" + "Always down" = "Toujours couché" + "Always up" = "Toujours levé" + "an experimental weather software system written in Python." = "un logiciel de système météorologique expérimental écrit en Python." + "at" = "à" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Vent Moyen" + "Azimuth" = "Azimut" + "Big Page" = "Grande Page" + "Calendar Year" = "Année Calendaire" + "controlled by 'WeeWX'," = "controllé par 'weewx'," + "Current Conditions" = "Conditions Actuelles" + "Current conditions, and daily, monthly, and yearly summaries" = "Conditions actuelles, et résumés quotidiens, mensuels et annuels" + "Current Weather Conditions" = "Conditions Météo Actuelles" + "Daily Weather Summary as of" = "Résumé Météo Quotidien au" + "Day" = "Jour" + "Declination" = "Déclinaison" + "End civil twilight" = "Crépuscule civil" + "Equinox" = "Equinoxe" + "from" = "du" # Direction context. The wind is "from" the NE + "Full moon" = "Pleine lune" + "High Barometer" = "Baromètre Haut" + "High Dewpoint" = "Point de Rosée Haut" + "High ET" = "ET Haute" + "High Heat Index" = "Indice de Chaleur Haut" + "High Humidity" = "Humidité Haute" + "High Inside Temperature" = "Température Intérieure Haute" + "High Radiation" = "Rayonnement Haut" + "High Rain Rate" = "Taux de Pluie Haut" + "High Temperature" = "Température Haute" + "High UV" = "UV Haut" + "High Wind" = "Vent Haut" + "in" = "à" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "30 derniers jours" + "Latitude" = "Latitude" + "Location" = "Lieu" + "Longitude" = "Longitude" + "Low Barometer" = "Baromètre Bas" + "Low Dewpoint" = "Point de Rosée Bas" + "Low ET" = "ET Basse" + "Low Humidity" = "Humidité Basse" + "Low Inside Temperature" = "Température Intérieure Basse" + "Low Radiation" = "Rayonnement Bas" + "Low Temperature" = "Température Basse" + "Low UV" = "UV Bas" + "Low Wind Chill" = "Refroidissement Eolien Bas" + "Max barometer" = "Baromètre max" + "Max inside temperature" = "Température intérieure max" + "Max outside temperature" = "Température extérieure max" + "Max rate" = "Taux max" + "Max wind" = "Vent max" + "Min barometer" = "Baromètre min" + "Min inside temperature" = "Température intérieure min" + "Min outside temperature" = "Température extérieure min" + "Min rate" = "Taux min" + "Month" = "Mois" + "Monthly Statistics and Plots" = "Graphiques et Statistiques Mensuels" + "Monthly summary" = "Résumé mensuel" + "Monthly Weather Summary as of" = "Résumé Météo Mensuel au" + "Monthly Weather Summary" = "Résumé Météo Mensuel" + "Moon Phase" = "Phase lunaire" + "Moon" = "Lune" + "New moon" = "Nouvelle lune" + "Phase" = "Phase" + "Radar" = "Radar" + "Rain (daily total)" = "Précipitations (total quotidien)" + "Rain (hourly total)" = "Précipitations (total horaire)" + "Rain (weekly total)" = "Précipitations (total hebdomadaire)" + "Rain today" = "Pluie du jour" + "Rain total for month" = "Pluie totale du mois" + "Rain total for year" = "Pluie totale de l'année" + "Rain Total" = "Pluie Totale" + "Rain Year Total" = "Pluie Annuelle Totale" + "Rain Year" = "Pluie Annuelle" + "Right ascension" = "Ascension droite" + "Rise" = "Lever" + "RMS Wind" = "Vent RMS" + "RSS feed" = "Flux RSS" + "Select month" = "Sélection mois" + "Select year" = "Sélection année" + "Server uptime" = "Disponibilité du serveur" + "Set" = "Coucher" + "Since Midnight" = "Depuis Minuit" + "Smartphone formatted" = "Format mobile multifonction" + "Solstice" = "Solstice" + "Sorry, no radar image" = "Désolé, pas d'image radar" + "Start civil twilight" = "Aube civile" + "Sun" = "Soleil" + "Sunrise" = "Lever" + "Sunset" = "Coucher" + "This Month" = "Ce Mois" + "This station uses a" = "Cette station utilise un" + "This Week" = "Cette Semaine" + "This week's max" = "Max cette semaine" + "This week's min" = "Min cette semaine" + "Time" = "Heure" + "Today's Almanac" = "Almanach du jour" + "Today's max" = "Max aujourd'hui" + "Today's min" = "Min aujourd'hui" + "Today's Rain" = "Pluie du Jour" + "Transit" = "Méridien" + "Vector Average Direction" = "Direction Moyenne du Vecteur" + "Vector Average Speed" = "Vitesse Moyenne du Vecteur" + "Weather Conditions at" = "Conditions météo à" + "Weather Conditions" = "Conditions Météo" + "Week" = "Semaine" + "Weekly Statistics and Plots" = "Graphiques et Statistiques Hebdomadaires" + "Weekly Weather Summary" = "Résumé Météo Hebdomadaire" + "WeeWX uptime" = "Disponibilité de WeeWX" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Weewx a été conçu pour être simple, rapide et facile à comprendre par l'exploitation de concepts logiciel modernes." + "Year" = "Année" + "Yearly Statistics and Plots" = "Graphiques et Statistiques Annuels" + "Yearly summary" = "Résumé annuel" + "Yearly Weather Summary as of" = "Résumé Météo Annuel au" + "Yearly Weather Summary" = "Résumé Météo Annuel" + + [[Geographical]] + "Altitude" = "Altitude" + + [[Astronomical]] + "Altitude" = "Site" + + [[Buttons]] + # Labels used in buttons + "Current" = " Jour " + "Month" = " Mois " + "Week" = " Semaine " + "Year" = " Année " diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/nl.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/nl.conf new file mode 100644 index 0000000..901c833 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/nl.conf @@ -0,0 +1,219 @@ +############################################################################### +# Localization File --- Standard skin # +# Dutch # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +# Translation by Eelco # +############################################################################### + +[Units] + + [[Labels]] + + day = " dag", " dagen" + hour = " uur", " uren" + minute = " minuut", " minuten" + second = " seconde", " seconden" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNO, NO, ONO, O, OZO, ZO, ZZO, Z, ZZW, ZW, WZW, W, WNW, NW, NNW, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, Z, O, W + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Tiijd + interval = Interval + altimeter = Luchtdruk (QNH) # QNH + altimeterRate = Luchtdruk Trend + barometer = Barometer # QFF + barometerRate = Barometer Trend + pressure = Luchtdruk (QFE) # QFE + pressureRate = Luchtdruk Trend + dewpoint = Dauwpunt + ET = ET + heatindex = Hitte Index + inHumidity = Luchtvochtigheid Binnen + inTemp = Temperatuur Binnen + inDewpoint = Dauwpunt Binnen + outHumidity = Luchtvochtigheid Buiten + outTemp = Temperatuur Buiten + radiation = Zonnestraling + rain = Regen + rainRate = Regen Intensiteit + UV = UV Index + wind = Wind + windDir = Wind Richting + windGust = Windvlaag Snelheid + windGustDir = Windvlaag Richting + windSpeed = Wind Snelheid + windchill = Wind Chill + windgustvec = Windvlaag Vector + windvec = Wind Vector + windrun = Wind Run + extraTemp1 = Temperatuur1 + extraTemp2 = Temperatuur2 + extraTemp3 = Temperatuur3 + appTemp = Gevoelstemperatuur + appTemp1 = Gevoelstemperatuur + THSW = THSW Index + lightning_distance = Bliksem Afstand + lightning_strike_count = Bliksem Ontladingen + cloudbase = Wolkenbasis + + # used in Seasons skin, but not defined + feel = gevoelstemperatuur + + # Sensor status indicators + rxCheckPercent = Signaalkwaliteit + txBatteryStatus = Zender Batterij + windBatteryStatus = Wind Batterij + rainBatteryStatus = Regen Batterij + outTempBatteryStatus = Buitentemperatuur Batterij + inTempBatteryStatus = Binnentemperatuur Batterij + consBatteryVoltage = Console Batterij + heatingVoltage = Verwarming Battery + supplyVoltage = Voeding Voltage + referenceVoltage = Referentie Voltage + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nieuw, Jonge Maansikkel, Eerste Kwartier, Wassende Maan, Vol, Afnemende Maan, Laatste Kwartier, Asgrauwe Maan + +[Texts] + "7-day" = "7-dagen" + "24h" = "24h" + "About this weather station" = "Over dit weerstation" + "Always down" = "Altijd onder" + "Always up" = "Altijd op" + "an experimental weather software system written in Python." = "een experimenteel weer software systeem geschreven in Python." + "at" = "om" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Gemiddelde Wind" + "Azimuth" = "Azimuth" + "Big Page" = "Grote Pagina" + "Calendar Year" = "Kalender Jaar" + "controlled by 'WeeWX'," = "onder 'weewx'," + "Current Conditions" = "Actuele Condities" + "Current conditions, and daily, monthly, and yearly summaries" = "Actuele conditions, and dagelijkse, maandelijkse en jaarlijkse samenvattingen" + "Current Weather Conditions" = "Actuele Weer Condities" + "Daily Weather Summary as of" = "Dagelijkse weersamenvatting van" + "Day" = "Dag" + "Declination" = "Declinatie" + "End civil twilight" = "Einde civiele schemering" + "Equinox" = "Equinox" + "from" = "uit" # Direction context. The wind is "from" the NE + "Full moon" = "Volle Maan" + "High Barometer" = "Max Barometer" + "High Dewpoint" = "Max Dauwpunt" + "High ET" = "Max ET" + "High Heat Index" = "Max Hitte Index" + "High Humidity" = "Max Luchtvochtigheid" + "High Inside Temperature" = "Max Temperatuur Binnen" + "High Radiation" = "Max Straling" + "High Rain Rate" = "Max Regen Intensiteit" + "High Temperature" = "Max Temperatuur" + "High UV" = "Max UV" + "High Wind" = "Max Wind" + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "laatste 30 dagen" + "Latitude" = "Breedtegraad" + "Location" = "Locatie" + "Longitude" = "Lengtegraad" + "Low Barometer" = "Min Barometer" + "Low Dewpoint" = "Min Dauwpunt" + "Low ET" = "Min ET" + "Low Humidity" = "Min Luchtvochtigheid" + "Low Inside Temperature" = "Min Binnen Temperatuur" + "Low Radiation" = "Min Straling" + "Low Temperature" = "Min Temperatuur" + "Low UV" = "Min UV" + "Low Wind Chill" = "Min Wind Chill" + "Max barometer" = "Max barometer" + "Max inside temperature" = "Max temperatuur binnen" + "Max outside temperature" = "Max temperatuur buiten" + "Max rate" = "Max rate" + "Max wind" = "Max wind" + "Min barometer" = "Min barometer" + "Min inside temperature" = "Min temperatuur binnen" + "Min outside temperature" = "Min temperatuur buiten" + "Min rate" = "Min rate" + "Month" = "Maand" + "Monthly Statistics and Plots" = "Maandelijkse Statistieken en Plots" + "Monthly summary" = "Maandelijkse Samenvatting" + "Monthly Weather Summary as of" = "Maandelijkse weersamenvatting van" + "Monthly Weather Summary" = "Maandelijkse Weersamenvatting" + "Moon Phase" = "Maan Fase" + "Moon" = "Maan" + "New moon" = "Nieuwe maan" + "Phase" = "Fase" + "Radar" = "Radar" + "Rain (daily total)" = "Regen (dag totaal)" + "Rain (hourly total)" = "Regen (uur totaal)" + "Rain (weekly total)" = "Regen (week totaal)" + "Rain today" = "Regen vandaag" + "Rain total for month" = "Regen totaal voor maand" + "Rain total for year" = "Regen totaal voor jaar" + "Rain Total" = "Regen Totaal" + "Rain Year Total" = "Regen Jaar Totaal" + "Rain Year" = "Regen Jaar" + "Right ascension" = "Rechte klimming" + "Rise" = "Opkomst" + "RMS Wind" = "RMS Wind" + "RSS feed" = "RSS feed" + "Select month" = "Selekteer Maand" + "Select year" = "Selekteer Jaar" + "Server uptime" = "Server uptime" + "Set" = "Ondergang" + "Since Midnight" = "Sinds Middernacht" + "Smartphone formatted" = "Smartphone formaat" + "Solstice" = "Zonnewende" + "Sorry, no radar image" = "Sorry, geen radarbeeld beschikbaar" + "Start civil twilight" = "Start civiele schemering" + "Sun" = "Zon" + "Sunrise" = "Zonsopkomst" + "Sunset" = "Zonsondergang" + "This Month" = "Deze Maand" + "This station uses a" = "Dit station gebruikt" + "This Week" = "Deze Week" + "This week's max" = "Max deze week" + "This week's min" = "Min deze week" + "Time" = "Tijd" + "Today's Almanac" = "Almanak Vandaag" + "Today's max" = "Max vandaag" + "Today's min" = "Min vandaag" + "Today's Rain" = "Regen Vandaag" + "Transit" = "Transit" + "Vector Average Direction" = "Vector Gemiddelde Richting" + "Vector Average Speed" = "Vector Gemiddelde Snelheid" + "Weather Conditions at" = "Weercondities om" + "Weather Conditions" = "Weer Condities" + "Week" = "Week" + "Weekly Statistics and Plots" = "Wekelijkse Statistieken en Plots" + "Weekly Weather Summary" = "Wekelijkse Weersamenvatting" + "WeeWX uptime" = "WeeWX uptime" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "Doelstelling van WeeWx is een simpel, snel en makkelijk te begrijpen systeem te zijn door het gebruik van moderne software concepten." + "Year" = "Jaar" + "Yearly Statistics and Plots" = "Jaarlijkse Statistieken en Plots" + "Yearly summary" = "Jaaroverzicht" + "Yearly Weather Summary as of" = "Jaarlijkse Weersamenvatting van" + "Yearly Weather Summary" = "Jaarlijkse Weersamenvatting" + + [[Geographical]] + "Altitude" = "Hoogte" + + [[Astronomical]] + "Altitude" = "Hoogte" + + [[Buttons]] + # Labels used in buttons + "Current" = " Actueel " + "Month" = " Maand " + "Week" = " Week " + "Year" = " Jaar " diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/no.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/no.conf new file mode 100644 index 0000000..300feb4 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/lang/no.conf @@ -0,0 +1,249 @@ +############################################################################### +# Localization File --- Standard skin # +# Norwegian # +# Copyright (c) 2018-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +# Generally want a metric system for the norwegian language: +unit_system = metricwx + +[Units] + + # [[Groups]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_degree_day = degree_C_day # Options are 'degree_F_day' or 'degree_C_day' + # group_distance = km # Options are 'mile' or 'km' + # group_pressure = mBar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_speed = meter_per_second # Options are 'mile_per_hour', 'km_per_hour', 'knot', or 'meter_per_second' + # group_speed2 = meter_per_second2 # Options are 'mile_per_hour2', 'km_per_hour2', 'knot2', or 'meter_per_second2' + # group_temperature = degree_C # Options are 'degree_F' or 'degree_C' + + [[Labels]] + + # These are singular, plural + meter = " meter", " meter" + day = " dag", " dager" + hour = " time", " timer" + minute = " minutt", " minutter" + second = " sekund", " sekunder" + + cm_per_hour = " cm/t" + hPa_per_hour = " hPa/t" + inch_per_hour = " in/t" + inHg_per_hour = " inHg/t" + km_per_hour = " km/t" + km_per_hour2 = " km/t" + kPa_per_hour = " kPa/t", + mbar_per_hour = " mbar/t" + mm_per_hour = " mm/t" + mmHg_per_hour = " mmHg/t" + + [[Ordinates]] + + # Ordinal directions. The last one should be for no wind direction + directions = N, NNE, NØ, ØNØ, Ø, ØSØ, SØ, SSØ, S, SSV, SV, VSV, V, VNV, NV, NNV, N/A + +[Labels] + + # Set to hemisphere abbreviations suitable for your location: + hemispheres = N, S, Ø, V + + # Generic labels, keyed by an observation type. + [[Generic]] + dateTime = Tid + interval = Intervall + altimeter = Altimeter # QNH + altimeterRate = Altimeter trend + barometer = Barometer # QFF + barometerRate = Barometer trend + pressure = Trykk # QFE + pressureRate = Trykk trend + dewpoint = Doggpunkt ute + ET = Fordampning + heatindex = Varmeindeks + inHumidity = Fuktighet inne + inTemp = Temperatur inne + inDewpoint = Doggpunkt inne + outHumidity = Fuktighet ute + outTemp = Temperatur ute + radiation = Stråling + rain = Regn + rainRate = Regnintensitet + UV = UV-indeks + wind = Vind + windDir = Vindretning + windGust = Vindkast + windGustDir = Vindkast retning + windSpeed = Vindhastighet + windchill = Føles som + windgustvec = Kastvektor + windvec = Vindvektor + windrun = Vind Run + extraTemp1 = Temperatur1 + extraTemp2 = Temperatur2 + extraTemp3 = Temperatur3 + appTemp = Føles som + appTemp1 = Føles som + THSW = THSW Indeks + lightning_distance = Lynavstand + lightning_strike_count = Lynnedslag + cloudbase = Skybase + + # used in Seasons skin, but not defined + feel = følt temperatur + + # Sensor status indicators + rxCheckPercent = Signalkvalitet + txBatteryStatus = Senderbatteri + windBatteryStatus = Vindbatteri + rainBatteryStatus = Regnbatteri + outTempBatteryStatus = Utetemperatur batteri + inTempBatteryStatus = Innetemperatur batteri + consBatteryVoltage = Konsollbatteri + heatingVoltage = Varmebatteri + supplyVoltage = Spenning strømforsyning + referenceVoltage = Referansespenning + + +[Almanac] + + # The labels to be used for the phases of the moon: + moon_phases = Nymåne, Voksende månesigd, Halvmåne første kvarter, Voksende måne (ny), Fullmåne, Minkende måne (ne), Halvmåne siste kvarter, Minkende månesigd + +[Texts] + "7-day" = "7-dager" + "24h" = "24t" + "About this weather station" = "Om denne værstasjonen" + "Always down" = "Alltid nede" + "Always up" = "Alltid oppe" + "an experimental weather software system written in Python." = "en eksperimentell værprogramvare skrevet i Python." + "at" = "" # Time context. E.g., 15.1C "at" 12:22 + "Average Wind" = "Gjennomsnittvind" + "Azimuth" = "Asimut" + "Big Page" = "Formattert for PC" + "Calendar Year" = "Kalenderår" + "Click image for expanded radar loop" = "Trykk på bildet for lokal værradar" + "controlled by 'WeeWX'," = "kontrollert av 'WeeWX'," + "Current Conditions" = "Været nå" + "Current conditions, and daily, monthly, and yearly summaries" = "Oppsummering været nå, daglig, månedlig og årlig" + "Current Weather Conditions" = "Været nå" + "Daily Weather Summary as of" = "Oppsummering daglig vær den" + "Day" = "Daglig" + "Declination" = "Deklinasjon" + "End civil twilight" = "Slutt skumring" + "Equinox" = "Jevndøgn" + "from" = "fra" # Direction context. The wind is "from" the NE + "Full moon" = "Fullmåne" + "High Barometer" = "Høyeste lufttrykk" + "High Dewpoint" = "Høyeste doggpunkt" + "High ET" = "Høyeste fordampning" + "High Heat Index" = "Høyeste varmeindeks" + "High Humidity" = "Høyeste fuktighet" + "High Inside Temperature" = "Høyeste innetemperatur" + "High Radiation" = "Høyeste stråling" + "High Rain Rate" = "Høyeste regnhastighet" + "High Temperature" = "Høyeste temperatur" + "High UV" = "Høyeste UV" + "High Wind" = "Høyeste vind" + "High" = "Høy" + "in" = "in" # Geographic context. E.g., Temperature "in" Boston. + "last 30 days" = "siste 30 dager" + "Latitude" = "Bredde" + "Location" = "Plassering" + "Longitude" = "Lengde" + "Low Barometer" = "Laveste lufttrykk" + "Low Dewpoint" = "Laveste dokkpunkt" + "Low ET" = "Laveste fordampning" + "Low Humidity" = "Laveste fuktighet" + "Low Inside Temperature" = "Laveste innetemperatur" + "Low Radiation" = "Laveste stråling" + "Low Temperature" = "Laveste temperatur" + "Low UV" = "Laveste UV" + "Low Wind Chill" = "Laveste følte temperatur" + "Max barometer" = "Maks lufttrykk" + "Max inside temperature" = "Maks innetemperatur" + "Max outside temperature" = "Maks utetemperatur" + "Max rate" = "Maks regn" + "Max wind" = "Maks vind" + "max" = "maks" + "Min barometer" = "Min lufttrykk" + "Min inside temperature" = "Min innetemperatur" + "Min outside temperature" = "Min utetemperatur" + "Min rate" = "Min regn" + "Month" = "Måned" + "Monthly Statistics and Plots" = "Månedlig statistikk og grafikk" + "Monthly summary" = "Månedlig oppsummering" + "Monthly Weather Summary as of" = "Månedlig væroppsummering for" + "Monthly Weather Summary" = "Månedlig væroppsummering" + "Moon Phase" = "Månedfase" + "Moon" = "Måne" + "New moon" = "Nåmåne" + "Phase" = "Fase" + "Radar" = "Radar" + "Rain (daily total)" = "Regn (daglig total)" + "Rain (hourly total)" = "Regn (timestotal)" + "Rain (weekly total)" = "Regn (ukestotal)" + "Rain today" = "Regn i dag" + "Rain total for month" = "Regn totalt for måned" + "Rain total for year" = "Regn totalt for år" + "Rain Total" = "Regn totalt" + "Rain Year Total" = "Regn årstotal" + "Rain Year" = "Regnår" + "Right ascension" = "Rektascensjon" + "Rise" = "Opp" + "RMS Wind" = "RMS vind" + "RSS feed" = "RSS feed" + "Select month" = "Velg måned" + "Select year" = "Velg år" + "Server uptime" = "Oppetid server" + "Set" = "Ned" + "Since Midnight" = "Siden midnatt" + "Smartphone formatted" = "Formattert for smarttelefon" + "Solstice" = "Solverv" + "Sorry, no radar image" = "Beklager, mangler værradar" + "Start civil twilight" = "Start demring" + "Sun" = "Sol" + "Sunrise" = "Soloppgang" + "Sunset" = "Solnedgang" + "This Month" = "Denne måneden" + "This station uses a" = "Denne stasjonen bruker en" + "This Week" = "Denne uken" + "This week's max" = "Maks denne uken" + "This week's min" = "Min denne uken" + "Time" = "Tid" + "Today's Almanac" = "Sol og måne i dag" + "Today's data" = "Dagens vær" + "Today's max" = "Dagens maks" + "Today's min" = "Dagens min" + "Today's Rain" = "Dagens regn" + "Transit" = "Transit" + "Vector Average Direction" = "Gjennomsnittsvind retning" + "Vector Average Speed" = "Gjennomsnittsvind hastighet" + "Weather Conditions at" = "Værtilstand den" + "Weather Conditions" = "Værtilstand" + "Week" = "Uke" + "Weekly Statistics and Plots" = "Ukentlig statistikk og plott" + "Weekly Weather Summary" = "Ukentlig væroppsummering" + "WeeWX uptime" = "WeeWX oppetid" + "Weewx was designed to be simple, fast, and easy to understand by leveraging modern software concepts." = "
Weewx ble utformet for å være enkel, hurtig og lett å bruke ved hjelp av moderne programmeringsmetoder." + "Year" = "År" + "Yearly Statistics and Plots" = "Årlig statistikk og plott" + "Yearly summary" = "Årlig oppsummering" + "Yearly Weather Summary as of" = "Årlig væroppsummering for" + "Yearly Weather Summary" = "Årlig væroppsummering" + + [[Geographical]] + "Altitude" = "Høyde" # As in height above sea level + + [[Astronomical]] + "Altitude" = "Vinkelhøyde" # As in angle above the horizon + + [[Buttons]] + # Labels used in buttons + "Current" = " Nåværende " + "Month" = " Måned " + "Week" = " Uke " + "Year" = " År " diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/month.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/month.html.tmpl new file mode 100644 index 0000000..bab0abc --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/month.html.tmpl @@ -0,0 +1,414 @@ +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Monthly Weather Summary") + + + #if $station.station_url + + #end if + + ## If a Google Analytics GA4 code has been specified, include it. + #if 'googleAnalyticsId' in $Extras + + + #end if + + + +
+
+

$station.location

+

$gettext("Monthly Weather Summary")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("This Month") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $month.UV.has_data + + + + + #end if + #if $month.ET.has_data and $month.ET.sum.raw > 0.0 + + + + + #end if + #if $month.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $month.outTemp.max $gettext("at") $month.outTemp.maxtime
+ $month.outTemp.min $gettext("at") $month.outTemp.mintime +
+ $gettext("High Heat Index") + + $month.heatindex.max $gettext("at") $month.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $month.windchill.min $gettext("at") $month.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $month.outHumidity.max $gettext("at") $month.outHumidity.maxtime
+ $month.outHumidity.min $gettext("at") $month.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $month.dewpoint.max $gettext("at") $month.dewpoint.maxtime
+ $month.dewpoint.min $gettext("at") $month.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $month.barometer.max $gettext("at") $month.barometer.maxtime
+ $month.barometer.min $gettext("at") $month.barometer.mintime +
+ $gettext("Rain Total") + + $month.rain.sum +
+ $gettext("High Rain Rate") + + $month.rainRate.max $gettext("at") $month.rainRate.maxtime +
+ $gettext("High Wind") + + $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime +
+ $gettext("Average Wind") + + $month.wind.avg +
+ $gettext("RMS Wind") + + $month.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $month.wind.vecavg
+ $month.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $month.inTemp.max $gettext("at") $month.inTemp.maxtime
+ $month.inTemp.min $gettext("at") $month.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $month.UV.max $gettext("at") $month.UV.maxtime
+ $month.UV.min $gettext("at") $month.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $month.ET.max $gettext("at") $month.ET.maxtime
+ $month.ET.min $gettext("at") $month.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $month.radiation.max $gettext("at") $month.radiation.maxtime
+ $month.radiation.min $gettext("at") $month.radiation.mintime +
+
+ +

 

+ +
+
+ $gettext("Calendar Year") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $year.UV.has_data + + + + + #end if + #if $year.ET.has_data and $year.ET.sum.raw >0.0 + + + + + #end if + #if $year.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $year.outTemp.max $gettext("at") $year.outTemp.maxtime
+ $year.outTemp.min $gettext("at") $year.outTemp.mintime +
+ $gettext("High Heat Index") + + $year.heatindex.max $gettext("at") $year.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $year.windchill.min $gettext("at") $year.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $year.outHumidity.max $gettext("at") $year.outHumidity.maxtime
+ $year.outHumidity.min $gettext("at") $year.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $year.dewpoint.max $gettext("at") $year.dewpoint.maxtime
+ $year.dewpoint.min $gettext("at") $year.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $year.barometer.max $gettext("at") $year.barometer.maxtime
+ $year.barometer.min $gettext("at") $year.barometer.mintime +
+ $gettext("Rain Total") + + $year.rain.sum +
+ $gettext("High Rain Rate") + + $year.rainRate.max $gettext("at") $year.rainRate.maxtime +
+ $gettext("High Wind") + + $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime +
+ $gettext("Average Wind") + + $year.wind.avg +
+ $gettext("RMS Wind") + + $year.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $year.wind.vecavg
+ $year.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $year.inTemp.max $gettext("at") $year.inTemp.maxtime
+ $year.inTemp.min $gettext("at") $year.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $year.UV.max $gettext("at") $year.UV.maxtime
+ $year.UV.min $gettext("at") $year.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $year.ET.max $gettext("at") $year.ET.maxtime
+ $year.ET.min $gettext("at") $year.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $year.radiation.max $gettext("at") $year.radiation.maxtime
+ $year.radiation.min $gettext("at") $year.radiation.mintime +
+
+ +
+ +
+ +
+

$gettext("Monthly Statistics and Plots")

+
+
+ temperatures + heatchill + outside humidity + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $month.radiation.has_data + Radiation + #end if + #if $month.UV.has_data + UV Index + #end if + #if $month.rxCheckPercent.has_data + month rx percent + #end if +
+
+ + +
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/skin.conf b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/skin.conf new file mode 100644 index 0000000..90fb557 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/skin.conf @@ -0,0 +1,505 @@ +############################################################################### +# STANDARD SKIN CONFIGURATION FILE # +# Copyright (c) 2010-2021 Tom Keffer # +# See the file LICENSE.txt for your rights. # +############################################################################### + +SKIN_NAME = Standard +SKIN_VERSION = 5.0.2 + +############################################################################### + +# The following section is for any extra tags that you want to be available in the templates +[Extras] + + # This radar image would be available as $Extras.radar_img + # radar_img = https://radblast.wunderground.com/cgi-bin/radar/WUNIDS_map?station=RTX&brand=wui&num=18&delay=15&type=N0R&frame=0&scale=1.000&noclutter=1&showlabels=1&severe=1 + # This URL will be used as the image hyperlink: + # radar_url = https://radar.weather.gov/?settings=v1_eyJhZ2VuZGEiOnsiaWQiOm51bGwsImNlbnRlciI6Wy0xMjEuOTE3LDQ1LjY2XSwiem9vbSI6OH0sImJhc2UiOiJzdGFuZGFyZCIsImNvdW50eSI6ZmFsc2UsImN3YSI6ZmFsc2UsInN0YXRlIjpmYWxzZSwibWVudSI6dHJ1ZSwic2hvcnRGdXNlZE9ubHkiOmZhbHNlfQ%3D%3D#/ + + # If you have a Google Analytics GA4 tag, uncomment and edit the next line, and + # the analytics code will be included in your generated HTML files: + #googleAnalyticsId = G-ABCDEFGHI + +############################################################################### + +# The CheetahGenerator creates files from templates. This section +# specifies which files will be generated from which template. + +[CheetahGenerator] + + # Possible encodings include 'html_entities', 'utf8', 'strict_ascii', or 'normalized_ascii', + # as well as those listed in https://docs.python.org/3/library/codecs.html#standard-encodings + encoding = html_entities + + [[SummaryByMonth]] + # Reports that summarize "by month" + [[[NOAA_month]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y-%m.txt.tmpl + + [[SummaryByYear]] + # Reports that summarize "by year" + [[[NOAA_year]]] + encoding = normalized_ascii + template = NOAA/NOAA-%Y.txt.tmpl + + [[ToDate]] + # Reports that show statistics "to date", such as day-to-date, + # week-to-date, month-to-date, etc. + [[[day]]] + template = index.html.tmpl + + [[[week]]] + template = week.html.tmpl + + [[[month]]] + template = month.html.tmpl + + [[[year]]] + template = year.html.tmpl + + [[[RSS]]] + template = RSS/weewx_rss.xml.tmpl + + [[[MobileSmartphone]]] + template = smartphone/index.html.tmpl + + [[[MobileTempOutside]]] + template = smartphone/temp_outside.html.tmpl + + [[[MobileRain]]] + template = smartphone/rain.html.tmpl + + [[[MobileBarometer]]] + template = smartphone/barometer.html.tmpl + + [[[MobileWind]]] + template = smartphone/wind.html.tmpl + + [[[MobileRadar]]] + template = smartphone/radar.html.tmpl + +############################################################################### + +[CopyGenerator] + + # This section is used by the generator CopyGenerator + + # List of files to be copied only the first time the generator runs + copy_once = backgrounds/*, weewx.css, mobile.css, favicon.ico, smartphone/icons/*, smartphone/custom.js + + # List of files to be copied each time the generator runs + # copy_always = + + +############################################################################### + +[ImageGenerator] + + # This section lists all the images to be generated, what SQL types are to be included in them, + # along with many plotting options. There is a default for almost everything. Nevertheless, + # values for most options are included to make it easy to see and understand the options. + # + # Fonts can be anything accepted by the Python Imaging Library (PIL), which includes truetype + # (.ttf), or PIL's own font format (.pil). Note that "font size" is only used with truetype + # (.ttf) fonts. For others, font size is determined by the bit-mapped size, usually encoded in + # the file name (e.g., courB010.pil). A relative path for a font is relative to the SKIN_ROOT. + # If a font cannot be found, then a default font will be used. + # + # Colors can be specified any of three ways: + # 1. Notation 0xBBGGRR; + # 2. Notation #RRGGBB; or + # 3. Using an English name, such as 'yellow', or 'blue'. + # So, 0xff0000, #0000ff, or 'blue' would all specify a pure blue color. + + image_width = 300 + image_height = 180 + image_background_color = "#f5f5f5" + + chart_background_color = "#d8d8d8" + chart_gridline_color = "#a0a0a0" + + # Setting to 2 or more might give a sharper image with fewer jagged edges. + anti_alias = 1 + + top_label_font_path = font/DejaVuSansMono-Bold.ttf + top_label_font_size = 10 + + unit_label_font_path = font/DejaVuSansMono-Bold.ttf + unit_label_font_size = 10 + unit_label_font_color = "#000000" + + bottom_label_font_path = font/DejaVuSansMono-Bold.ttf + bottom_label_font_size = 12 + bottom_label_font_color = "#000000" + bottom_label_offset = 3 + + axis_label_font_path = font/DejaVuSansMono-Bold.ttf + axis_label_font_size = 10 + axis_label_font_color = "#000000" + + # Options for the compass rose, used for progressive vector plots + rose_label = N + rose_label_font_path = font/DejaVuSansMono-Bold.ttf + rose_label_font_size = 10 + rose_label_font_color = "#000000" + + # Default colors for the plot lines. These can be overridden for + # individual lines using option 'color' + chart_line_colors = "#4282b4", "#b44242", "#42b442" + + # Type of line. Only 'solid' or 'none' is offered now + line_type = 'solid' + + # Size of marker in pixels + marker_size = 8 + # Type of marker. Pick one of 'cross', 'x', 'circle', 'box', or 'none' + marker_type ='none' + + # Default fill colors for bar charts. These can be overridden for + # individual bar plots using option 'fill_color' + chart_fill_colors = "#72b2c4", "#c47272", "#72c472" + + # The following option merits an explanation. The y-axis scale used for + # plotting can be controlled using option 'yscale'. It is a 3-way tuple, + # with values (ylow, yhigh, min_interval). If set to "None", a parameter is + # set automatically, otherwise the value is used. However, in the case of + # min_interval, what is set is the *minimum* y-axis tick interval. + yscale = None, None, None + + # For progressive vector plots, you can choose to rotate the vectors. + # Positive is clockwise. + # For my area, westerlies overwhelmingly predominate, so by rotating + # positive 90 degrees, the average vector will point straight up. + vector_rotate = 90 + + # This defines what fraction of the difference between maximum and minimum + # horizontal chart bounds is considered a gap in the samples and should not + # be plotted. + line_gap_fraction = 0.05 + + # This controls whether day/night bands will be shown. They only look good + # on the day and week plots. + show_daynight = true + # These control the appearance of the bands if they are shown. + # Here's a monochrome scheme: + daynight_day_color = "#dfdfdf" + daynight_night_color = "#bbbbbb" + daynight_edge_color = "#d0d0d0" + # Here's an alternative, using a blue/yellow tint: + #daynight_day_color = "#fffff8" + #daynight_night_color = "#f8f8ff" + #daynight_edge_color = "#fff8f8" + + # Default will be a line plot of width 1, without aggregation. + # Can get overridden at any level. + plot_type = line + width = 1 + aggregate_type = none + time_length = 86400 # == 24 hours + + # What follows is a list of subsections, each specifying a time span, such as a day, week, + # month, or year. There's nothing special about them or their names: it's just a convenient way + # to group plots with a time span in common. You could add a time span [[biweek_images]] and + # add the appropriate time length, aggregation strategy, etc., without changing any code. + # + # Within each time span, each sub-subsection is the name of a plot to be generated for that + # time span. The generated plot will be stored using that name, in whatever directory was + # specified by option 'HTML_ROOT' in weewx.conf. + # + # With one final nesting (four brackets!) is the sql type of each line to be included within + # that plot. + # + # Unless overridden, leaf nodes inherit options from their parent. + + [[day_images]] + x_label_format = %H:%M + bottom_label_format = %x %X + time_length = 27h + + [[[daybarometer]]] + [[[[barometer]]]] + + [[[daytempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[daytempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[dayhumidity]]] + [[[[outHumidity]]]] + + [[[dayrain]]] + # Make sure the y-axis increment is at least 0.02 for the rain plot + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1h + label = Rain (hourly total) + + [[[dayrx]]] + [[[[rxCheckPercent]]]] + + [[[daywind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + + [[[dayinside]]] + [[[[inTemp]]]] + + [[[daywinddir]]] + # Hardwire in the y-axis scale for wind direction + yscale = 0.0, 360.0, 45.0 + [[[[windDir]]]] + + [[[daywindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[dayradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + aggregate_interval = 1h + label = max + + [[[dayuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + aggregate_interval = 1h + label = max + + [[week_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 1w + aggregate_type = avg + aggregate_interval = 1h + + [[[weekbarometer]]] + [[[[barometer]]]] + + [[[weektempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[weektempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[weekhumidity]]] + [[[[outHumidity]]]] + + [[[weekrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) + + [[[weekrx]]] + [[[[rxCheckPercent]]]] + + [[[weekwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[weekinside]]] + [[[[inTemp]]]] + + [[[weekwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[wind]]]] + aggregate_type = vecdir + + [[[weekwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[weekradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + label = max + + [[[weekuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + label = max + + [[month_images]] + x_label_format = %d + bottom_label_format = %x %X + time_length = 30d + aggregate_type = avg + aggregate_interval = 3h + show_daynight = false + + [[[monthbarometer]]] + [[[[barometer]]]] + + [[[monthtempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[monthtempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[monthhumidity]]] + [[[[outHumidity]]]] + + [[[monthrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1d + label = Rain (daily total) + + [[[monthrx]]] + [[[[rxCheckPercent]]]] + + [[[monthwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[monthinside]]] + [[[[inTemp]]]] + + [[[monthwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[wind]]]] + aggregate_type = vecdir + + [[[monthwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[monthradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + label = max + + [[[monthuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + label = max + + [[year_images]] + x_label_format = %m/%d + bottom_label_format = %x %X + time_length = 365d + aggregate_type = avg + aggregate_interval = 1d + show_daynight = false + + [[[yearbarometer]]] + [[[[barometer]]]] + + + [[[yeartempdew]]] + [[[[outTemp]]]] + [[[[dewpoint]]]] + + [[[yearhumidity]]] + [[[[outHumidity]]]] + + # Daily high/lows: + [[[yearhilow]]] + [[[[hi]]]] + data_type = outTemp + aggregate_type = max + label = High + [[[[low]]]] + data_type = outTemp + aggregate_type = min + label = Low Temperature + + [[[yearwind]]] + [[[[windSpeed]]]] + [[[[windGust]]]] + aggregate_type = max + + [[[yeartempchill]]] + [[[[windchill]]]] + [[[[heatindex]]]] + + [[[yearrain]]] + yscale = None, None, 0.02 + plot_type = bar + [[[[rain]]]] + aggregate_type = sum + aggregate_interval = 1w + label = Rain (weekly total) + + [[[yearrx]]] + [[[[rxCheckPercent]]]] + + [[[yearinside]]] + [[[[inTemp]]]] + + [[[yearwinddir]]] + yscale = 0.0, 360.0, 45.0 + [[[[wind]]]] + aggregate_type = vecdir + + [[[yearwindvec]]] + [[[[windvec]]]] + plot_type = vector + + [[[yearradiation]]] + [[[[radiation]]]] + [[[[radiation_max]]]] + data_type = radiation + aggregate_type = max + label = max + + [[[yearuv]]] + yscale = 0, 16, 1 + [[[[UV]]]] + [[[[UV_max]]]] + data_type = UV + aggregate_type = max + label = max + + # A progressive vector plot of daily gust vectors overlayed + # with the daily wind average would look something like this: +# [[[yeargustvec]]] +# [[[[windvec]]]] +# plot_type = vector +# aggregate_type = avg +# [[[[windgustvec]]]] +# plot_type = vector +# aggregate_type = max + + +############################################################################### + +# +# The list of generators that are to be run: +# +[Generators] + generator_list = weewx.cheetahgenerator.CheetahGenerator, weewx.imagegenerator.ImageGenerator, weewx.reportengine.CopyGenerator + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/barometer.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/barometer.html.tmpl new file mode 100644 index 0000000..0c7f6e6 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/barometer.html.tmpl @@ -0,0 +1,35 @@ +#encoding UTF-8 + + + + $obs.label.barometer $gettext("in") $station.location + + + + + + +
+
+

$obs.label.barometer

+
+
+

$gettext("24h") $obs.label.barometer

+ +
    +
  • $gettext("Today's min"): $day.barometer.min $gettext("at") $day.barometer.mintime
  • +
  • $gettext("Today's max"): $day.barometer.max $gettext("at") $day.barometer.maxtime
  • +
+ +

$gettext("7-day") $obs.label.barometer

+ +
    +
  • $gettext("This week's min"): $week.barometer.min $gettext("at") $week.barometer.mintime
  • +
  • $gettext("This week's max"): $week.barometer.max $gettext("at") $week.barometer.maxtime
  • +
+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/custom.js b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/custom.js new file mode 100644 index 0000000..83de7c8 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/custom.js @@ -0,0 +1,4 @@ +$(document).bind("mobileinit", function(){ + $.mobile.defaultPageTransition = 'slide'; + $.mobile.page.prototype.options.addBackBtn = true; +}); \ No newline at end of file diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/humidity.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/humidity.html.tmpl new file mode 100644 index 0000000..70ea30d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/humidity.html.tmpl @@ -0,0 +1,27 @@ +#encoding UTF-8 + + + + $obs.label.outHumidity $gettext("in") $station.location + + + + + + +
+
+

$obs.label.humidity

+
+
+

$gettext("24h") $obs.label.outHumidity

+ + +

$gettext("7-day") $obs.label.outHumidity

+ +
+
+

WeeWX vstation.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_ipad_x1.png b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_ipad_x1.png new file mode 100644 index 0000000..aec7f0d Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_ipad_x1.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_ipad_x2.png b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_ipad_x2.png new file mode 100644 index 0000000..3dd7f78 Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_ipad_x2.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_iphone_x1.png b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_iphone_x1.png new file mode 100644 index 0000000..985337f Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_iphone_x1.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_iphone_x2.png b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_iphone_x2.png new file mode 100644 index 0000000..e2c54aa Binary files /dev/null and b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/icons/icon_iphone_x2.png differ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/index.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/index.html.tmpl new file mode 100644 index 0000000..07f1b08 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/index.html.tmpl @@ -0,0 +1,42 @@ +#encoding UTF-8 + + + + $station.location $gettext("Current Weather Conditions") + + + + + + + + + + + + + + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/radar.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/radar.html.tmpl new file mode 100644 index 0000000..7ffd345 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/radar.html.tmpl @@ -0,0 +1,28 @@ +#encoding UTF-8 + + + + $station.location $gettext("Radar") + + + + + + +
+
+

$gettext("Radar")

+
+
+ #if 'radar_img' in $Extras + + Radar + #else + $gettext("Sorry, no radar image"). + #end if +
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/rain.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/rain.html.tmpl new file mode 100644 index 0000000..0a0c0de --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/rain.html.tmpl @@ -0,0 +1,34 @@ +#encoding UTF-8 + + + + + $obs.label.rain $gettext("in") $station.location + + + + + + +
+
+

$obs.label.rain

+
+
+

$gettext("24h") $obs.label.rain

+ +
    +
  • Today's data
  • +
  • $gettext("Today's Rain"): $day.rain.sum
  • +
  • $gettext("Min rate"): $day.rainRate.min $gettext("at") $day.rainRate.mintime
  • +
  • $gettext("Max rate"): $day.rainRate.max $gettext("at") $day.rainRate.maxtime
  • +
+ +

$obs.label.rain $gettext("last 30 days")

+ +
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/temp_outside.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/temp_outside.html.tmpl new file mode 100644 index 0000000..78b35dc --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/temp_outside.html.tmpl @@ -0,0 +1,35 @@ +#encoding UTF-8 + + + + $obs.label.outTemp $gettext("in") $station.location + + + + + + +
+
+

$obs.label.outTemp

+
+
+

$gettext("24h") $obs.label.outTemp

+ +
    +
  • $gettext("Today's min"): $day.outTemp.min $gettext("at") $day.outTemp.mintime
  • +
  • $gettext("Today's max"): $day.outTemp.max $gettext("at") $day.outTemp.maxtime
  • +
+

$gettext("7-day") $obs.label.outTemp

+ +
    +
  • $gettext("This week's min"): $week.outTemp.min $gettext("at") $week.outTemp.mintime
  • +
  • $gettext("This week's max"): $week.outTemp.max $gettext("at") $week.outTemp.maxtime
  • +
+
+
+

WeeWX v$station.version

+
+
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/wind.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/wind.html.tmpl new file mode 100644 index 0000000..5e17762 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/smartphone/wind.html.tmpl @@ -0,0 +1,37 @@ +#encoding UTF-8 + + + + $obs.label.wind $gettext("in") $station.location + + + + + + +
+
+

$obs.label.wind

+
+
+

$gettext("24h") $obs.label.wind

+ + +
    +
  • $gettext("Today's min"): $day.windSpeed.min $gettext("at") $day.windSpeed.mintime
  • +
  • $gettext("Today's max"): $day.windSpeed.max $gettext("at") $day.windSpeed.maxtime
  • +
+ +

$gettext("7-day") $obs.label.wind

+ + +
    +
  • $gettext("This week's min"): $week.windSpeed.min $gettext("at") $week.windSpeed.mintime
  • +
  • $gettext("This week's max"): $week.windSpeed.max $gettext("at") $week.windSpeed.maxtime
  • +
+
+
+

WeeWX v$station.version

+
+
+ diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/week.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/week.html.tmpl new file mode 100644 index 0000000..471c2d7 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/week.html.tmpl @@ -0,0 +1,414 @@ +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Weekly Weather Summary") + + + #if $station.station_url + + #end if + + ## If a Google Analytics GA4 code has been specified, include it. + #if 'googleAnalyticsId' in $Extras + + + #end if + + + +
+
+

$station.location

+

$gettext("Weekly Weather Summary")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("This Week") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $week.UV.has_data + + + + + #end if + #if $week.ET.has_data and $week.ET.sum.raw > 0.0 + + + + + #end if + #if $week.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $week.outTemp.max $gettext("at") $week.outTemp.maxtime
+ $week.outTemp.min $gettext("at") $week.outTemp.mintime +
+ $gettext("High Heat Index") + + $week.heatindex.max $gettext("at") $week.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $week.windchill.min $gettext("at") $week.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $week.outHumidity.max $week.outHumidity.maxtime
+ $week.outHumidity.min $week.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $week.dewpoint.max $week.dewpoint.maxtime
+ $week.dewpoint.min $week.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $week.barometer.max $gettext("at") $week.barometer.maxtime
+ $week.barometer.min $gettext("at") $week.barometer.mintime +
+ $gettext("Rain Total") + + $week.rain.sum +
+ $gettext("High Rain Rate") + + $week.rainRate.max $gettext("at") $week.rainRate.maxtime +
+ $gettext("High Wind") + + $week.wind.max $gettext("from") $week.wind.gustdir $gettext("at") $week.wind.maxtime +
+ $gettext("Average Wind") + + $week.wind.avg +
+ $gettext("RMS Wind") + + $week.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $week.wind.vecavg
+ $week.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $week.inTemp.max $gettext("at") $week.inTemp.maxtime
+ $week.inTemp.min $gettext("at") $week.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $week.UV.max $gettext("at") $week.UV.maxtime
+ $week.UV.min $gettext("at") $week.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $week.ET.max $gettext("at") $week.ET.maxtime
+ $week.ET.min $gettext("at") $week.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $week.radiation.max $gettext("at") $week.radiation.maxtime
+ $week.radiation.min $gettext("at") $week.radiation.mintime +
+
+ +

 

+ +
+
+ $gettext("This Month") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $month.UV.has_data + + + + + #end if + #if $month.ET.has_data and $month.ET.sum.raw >0.0 + + + + + #end if + #if $month.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $month.outTemp.max $gettext("at") $month.outTemp.maxtime
+ $month.outTemp.min $gettext("at") $month.outTemp.mintime +
+ $gettext("High Heat Index") + + $month.heatindex.max $gettext("at") $month.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $month.windchill.min $gettext("at") $month.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $month.outHumidity.max $gettext("at") $month.outHumidity.maxtime
+ $month.outHumidity.min $gettext("at") $month.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $month.dewpoint.max $gettext("at") $month.dewpoint.maxtime
+ $month.dewpoint.min $gettext("at") $month.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $month.barometer.max $gettext("at") $month.barometer.maxtime
+ $month.barometer.min $gettext("at") $month.barometer.mintime +
+ $gettext("Rain Total") + + $month.rain.sum +
+ $gettext("High Rain Rate") + + $month.rainRate.max $gettext("at") $month.rainRate.maxtime +
+ $gettext("High Wind") + + $month.wind.max $gettext("from") $month.wind.gustdir $gettext("at") $month.wind.maxtime +
+ $gettext("Average Wind") + + $month.wind.avg +
+ $gettext("RMS Wind") + + $month.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $month.wind.vecavg
+ $month.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $month.inTemp.max $gettext("at") $month.inTemp.maxtime
+ $month.inTemp.min $gettext("at") $month.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $month.UV.max $gettext("at") $month.UV.maxtime
+ $month.UV.min $gettext("at") $month.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $month.ET.max $gettext("at") $month.ET.maxtime
+ $month.ET.min $gettext("at") $month.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $month.radiation.max $gettext("at") $month.radiation.maxtime
+ $month.radiation.min $gettext("at") $month.radiation.mintime +
+
+ +
+ +
+ +
+

$gettext("Weekly Statistics and Plots")

+
+
+ temperatures + heatchill + outside humidity + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $week.radiation.has_data + Radiation + #end if + #if $week.UV.has_data + UV Index + #end if + #if $week.rxCheckPercent.has_data + week rx percent + #end if +
+
+ + +
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/weewx.css b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/weewx.css new file mode 100644 index 0000000..1908b8e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/weewx.css @@ -0,0 +1,210 @@ +/* CSS for the weewx Standard skin + * + * Copyright (c) 2015 Tom Keffer + * + * See the file LICENSE.txt for your rights. + */ + +/* Global */ + +body { + margin: 0; + padding: 0; + border: 0; + font-family: Verdana, Arial, Helvetica, sans-serif; + font-size: 10pt; + background-color: #f2f2f7; + background-image: url('backgrounds/band.gif'); + background-repeat: repeat; + background-attachment: scroll; +} + +#container { + margin: 0; + padding: 0; + border: 0; +} + +/* + * This is the big header at the top of the page + */ +#masthead { + margin: 1% 1% 0 1%; + padding: 5px; + text-align: center; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; +} + +#masthead h1 { + color: #3d6c87; +} +#masthead h3 { + color: #5f8ea9; +} + +/* + * This holds the statistics (daily high/low, etc.) on the left: + */ +#stats_group { + width: 30%; + min-height: 500px; + margin: 1%; + padding: 5px; + float: left; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; +} + +.stats table { + border: thin solid #000000; + width: 100%; +} +.stats td { + border: thin solid #000000; + padding: 2px; +} + +.stats_header { + background-color: #000000; + color: #a8b8c8; + font-size: 14pt; + font-weight: bolder; +} + +.stats_label { + color: green; +} + +.stats_data { + color: red; +} + +/* + * This holds the "About", "Almanac", and plots on the right + */ +#content { + width: 62%; + min-height: 500px; + margin: 1%; + padding: 5px; + float: right; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; + text-align: center; +} + +#content .header { + font-size: 14pt; + font-weight: bolder; + color: #3d6c87; + margin-bottom: 10px; +} + + +#content .caption { + font-weight: bold; + color: #3d6c87; +} + +#content table { + text-align: center; + width: 100%; +} + +#content td { + width: 50%; +} + +#content .label { + text-align: right; + font-style: italic; +} + +#content .data { + text-align: left; +} + +#about, #almanac { + width: 90%; + margin-left: auto; + margin-right: auto; + margin-bottom: 30px; +} + +.celestial_group { +} + +.celestial_body { + width: 48%; + vertical-align: top; + display:inline-block; +} + +#plots { + width: 90%; + display: block; + margin-left: auto; + margin-right: auto; +} + +#plots img { + border: thin solid #3d6c87; + margin: 3px; + padding: 3px; +} + +#radar_img { + width: 100%; + display: block; + margin-left: auto; + margin-right: auto; + margin: 3px; + padding: 3px; +} + +#radar_img img { + margin-left: auto; + margin-right: auto; + width: 90%; + margin: 3px; + padding: 3px; +} + +#radar_img p { + width: 90%; + font-style: italic; + font-size: smaller; + text-align: center; + margin-top: 0; +} + +/* + * Navigation bar (week, month, etc.) at the bottom + */ +#navbar { + margin: 0 1% 1% 1%; + padding: 5px; + text-align: center; + clear: both; + border-top: 1px solid #dcdcdc; + border-right: 1px solid #a9a9a9; + border-bottom: 1px solid #808080; + border-left: 1px solid #a9a9a9; + background-color: #fafaff; +} + +/*************** Global Styles ***************/ + +h2, h3, h4, h5, h6 { + color: #3d6c87; +} diff --git a/dist/weewx-5.0.2/src/weewx_data/skins/Standard/year.html.tmpl b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/year.html.tmpl new file mode 100644 index 0000000..2fdf7b2 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/skins/Standard/year.html.tmpl @@ -0,0 +1,277 @@ +## Copyright 2009-2021 Tom Keffer +## Distributed under terms of GPLv3. See LICENSE.txt for your rights. +#errorCatcher Echo +#encoding UTF-8 +## + + + + ## Specifying an encoding of UTF-8 is usually safe: + + $station.location $gettext("Yearly Weather Summary") + + + #if $station.station_url + + #end if + + ## If a Google Analytics GA4 code has been specified, include it. + #if 'googleAnalyticsId' in $Extras + + + #end if + + + +
+
+

$station.location

+

$gettext("Yearly Weather Summary")

+

$current.dateTime

+
+ +
+ +
+
+ $gettext("Calendar Year") +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #if $year.UV.has_data + + + + + #end if + #if $year.ET.has_data and $year.ET.sum.raw >0.0 + + + + + #end if + #if $year.radiation.has_data + + + + + #end if + +
+ $gettext("High Temperature")
+ $gettext("Low Temperature") +
+ $year.outTemp.max $gettext("at") $year.outTemp.maxtime
+ $year.outTemp.min $gettext("at") $year.outTemp.mintime +
+ $gettext("High Heat Index") + + $year.heatindex.max $gettext("at") $year.heatindex.maxtime +
+ $gettext("Low Wind Chill") + + $year.windchill.min $gettext("at") $year.windchill.mintime +
+ $gettext("High Humidity")
+ $gettext("Low Humidity") +
+ $year.outHumidity.max $year.outHumidity.maxtime
+ $year.outHumidity.min $year.outHumidity.mintime +
+ $gettext("High Dewpoint")
+ $gettext("Low Dewpoint") +
+ $year.dewpoint.max $year.dewpoint.maxtime
+ $year.dewpoint.min $year.dewpoint.mintime +
+ $gettext("High Barometer")
+ $gettext("Low Barometer") +
+ $year.barometer.max $gettext("at") $year.barometer.maxtime
+ $year.barometer.min $gettext("at") $year.barometer.mintime +
+ $gettext("Rain Total") + + $year.rain.sum +
+ $gettext("High Rain Rate") + + $year.rainRate.max $gettext("at") $year.rainRate.maxtime +
+ $gettext("High Wind") + + $year.wind.max $gettext("from") $year.wind.gustdir $gettext("at") $year.wind.maxtime +
+ $gettext("Average Wind") + + $year.wind.avg +
+ $gettext("RMS Wind") + + $year.wind.rms +
+ $gettext("Vector Average Speed")
+ $gettext("Vector Average Direction") +
+ $year.wind.vecavg
+ $year.wind.vecdir +
+ $gettext("High Inside Temperature")
+ $gettext("Low Inside Temperature") +
+ $year.inTemp.max $gettext("at") $year.inTemp.maxtime
+ $year.inTemp.min $gettext("at") $year.inTemp.mintime +
+ $gettext("High UV")
+ $gettext("Low UV") +
+ $year.UV.max $gettext("at") $year.UV.maxtime
+ $year.UV.min $gettext("at") $year.UV.mintime +
+ $gettext("High ET")
+ $gettext("Low ET") +
+ $year.ET.max $gettext("at") $year.ET.maxtime
+ $year.ET.min $gettext("at") $year.ET.mintime +
+ $gettext("High Radiation")
+ $gettext("Low Radiation") +
+ $year.radiation.max $gettext("at") $year.radiation.maxtime
+ $year.radiation.min $gettext("at") $year.radiation.mintime +
+
+ +

 

+ +
+
+ $gettext("Rain Year") (1-$station.rain_year_str start) +
+ + + + + + + + + + + + +
+ $gettext("Rain Year Total") + + $rainyear.rain.sum +
+ $gettext("High Rain Rate") + + $rainyear.rainRate.max $gettext("at") $rainyear.rainRate.maxtime +
+
+ +
+ +
+ +
+

$gettext("Yearly Statistics and Plots")

+
+
+ temperatures + heatchill + outside humidity + Daily highs and lows for the year + rain + wind + barometer + Hi Wind + Inside + Wind Vector + #if $year.radiation.has_data + Radiation + #end if + #if $year.UV.has_data + UV Index + #end if + #if $year.rxCheckPercent.has_data + year rx percent + #end if +
+
+ + +
+ + diff --git a/dist/weewx-5.0.2/src/weewx_data/util/apache/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/apache/weewx.conf new file mode 100644 index 0000000..e394fe5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/apache/weewx.conf @@ -0,0 +1,16 @@ +# Use a configuration like this to make WeeWX reports show up in an Apache +# web server. This makes the URL '/weewx' serve files from the directory +# '/home/weewx/public_html' - adjust as appropriate for your WeeWX install. +# Place this file in the appropriate place within your Apache web server +# configuration, typically the 'conf.d' or 'conf-enabled' directory. + +Alias /weewx /home/weewx/public_html + + Options FollowSymlinks + AllowOverride None +# This is apache 2.4 syntax + Require all granted +# This is apache 2.2 syntax (also supported by 2.4 with compatibility enabled) +# Order allow,deny +# Allow from all + diff --git a/dist/weewx-5.0.2/src/weewx_data/util/default/weewx b/dist/weewx-5.0.2/src/weewx_data/util/default/weewx new file mode 100644 index 0000000..a7fb5b1 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/default/weewx @@ -0,0 +1,10 @@ +# WeeWX parameters that are used in startup and init scripts +WEEWX_PYTHON=python3 +WEEWX_PYTHON_ARGS= +WEEWX_BINDIR=/usr/share/weewx +WEEWX_CFGDIR=/etc/weewx +WEEWX_RUNDIR=/var/lib/weewx +WEEWX_USER=weewx +WEEWX_GROUP=weewx +WEEWX_INSTANCES="weewx" +WEEWX_CFG=weewx.conf diff --git a/dist/weewx-5.0.2/src/weewx_data/util/i18n/i18n-report b/dist/weewx-5.0.2/src/weewx_data/util/i18n/i18n-report new file mode 100755 index 0000000..6a8fec8 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/i18n/i18n-report @@ -0,0 +1,239 @@ +#!/usr/bin/env python +# Copyright (c) 2022 Matthew Wall +"""Utility for managing translated strings in skins.""" + +# FIXME: improve the dotted notation to find pgettext instances + +import sys +import glob +import os +from optparse import OptionParser +import re +import configobj + +__version__ = '0.2' + +usagestr = """Usage: i18n-report --skin=PATH_TO_SKIN [--help] [--version] + +Examples: + i18n-report --skin=PATH_TO_SKIN + i18n-report --skin=PATH_TO_SKIN --action translations --lang=XX + i18n-report --skin=PATH_TO_SKIN --action strings + + Utility to manage translated strings in WeeWX skins.""" + +def main(): + parser = OptionParser(usage=usagestr) + parser.add_option("--version", action="store_true", dest="version", + help="Display version then exit") + parser.add_option("--skin", dest="skin_dir", type=str, metavar="SKIN", + help="Specify the path to the desired skin") + parser.add_option("--lang", type=str, metavar="LANGUAGE", + help="Specify two-letter language identifier") + parser.add_option("--action", type=str, metavar="ACTION", default='all', + help="Options include: all, languages, translations, strings") + + options, args = parser.parse_args() + + if options.version: + print(__version__) + sys.exit(0) + + # figure out which skin we should look at + skin_dir = options.skin_dir + if not skin_dir: + print("No skin specified") + sys.exit(1) + while skin_dir.endswith('/'): + skin_dir = skin_dir.rstrip('/') + if not os.path.isdir(skin_dir): + print("No skin found at %s" % skin_dir) + sys.exit(1) + print("i18n translation report for skin: %s" % skin_dir) + + # check for the skin config file + skin_conf = "%s/skin.conf" % skin_dir + if not os.path.isfile(skin_conf): + print("No skin configuration found at %s" % skin_conf) + sys.exit(0) + + # check for the lang files in the specified skin + lang_dir = "%s/lang" % skin_dir + if not os.path.isdir(lang_dir): + print("No language directory found at %s" % lang_dir) + sys.exit(0) + en_conf = "%s/en.conf" % lang_dir + if not os.path.isfile(en_conf): + print("No en.conf found at %s" % en_conf) + sys.exit(0) + + action = options.action + + # list all the lang files that we find + if action == 'all' or action == 'languages': + confs = glob.glob("%s/*.conf" % lang_dir) + print("language files found:") + for f in confs: + print(" %s" % f) + + # report any untranslated strings. if a lang was specified, only report + # about that language. otherwise report on every language that is found. + if action == 'all' or action == 'translations': + lang = options.lang + confs = [] + if lang: + confs.append("%s/%s.conf" % (lang_dir, lang)) + else: + confs = glob.glob("%s/*.conf" % lang_dir) + results = dict() + for f in confs: + if f == en_conf: + continue + a, b = compare_files(en_conf, f) + if a or b: + results[f] = dict() + if a: + results[f]["found only in %s:" % en_conf] = a + if b: + results[f]["found only in %s:" % f] = b + prettyp(results) + + # report any mismatched strings, i.e., strings that are used in the skin + # but not enumerated in the base language file en.conf. the strings in + # the skin could come from gettext() invocations or plot labels. + # + # TODO: report *all* strings, not just those in gettext - this might not + # be feasible, since html/xml and other formats might use strings + # that should not be part of translation. of course if we find + # strings within xml delimiters we could ignore those... + if action == 'all' or action == 'strings': + known_strings = read_texts(en_conf) + ext_list = ['tmpl', 'inc'] + str_list = set() + for x in get_gettext_strings(skin_dir, ext_list): + str_list.add(x) + for x in read_image_labels(skin_conf): + str_list.add(x) + unused = set() # strings in en.conf that are not in any skin files + unlisted = set() # strings in skin files that are not in en.conf + for x in str_list: + if not x in known_strings: + unlisted.add(x) + for x in known_strings: + if not x in str_list: + unused.add(x) + if unused: + print("strings in en.conf but not found in the skin:") + for x in unused: + print(" %s" % x) + if unlisted: + print("strings in skin but not found in en.conf:") + for x in unlisted: + print(" %s" % x) + + sys.exit(0) + + +def compare_files(fn1, fn2): + """Print discrepancies between two files.""" + cfg_dict1 = configobj.ConfigObj(fn1, file_error=True, + encoding='utf-8', default_encoding='utf-8') + cfg_dict2 = configobj.ConfigObj(fn2, file_error=True, + encoding='utf-8', default_encoding='utf-8') + a_only = dict() # {label: a_val, ...} + b_only = dict() # {label: b_val, ...} + diffs = dict() # {label: (a_val, b_val), ...} + compare_dicts('', cfg_dict1, cfg_dict2, a_only, b_only, diffs) + return (a_only, b_only) + +def compare_dicts(section_name, a, b, a_only, b_only, diffs): + for x in a.sections: + label = "%s.%s" % (section_name, x) if section_name else x + compare_dicts(label, a[x], b.get(x), a_only, b_only, diffs) + + found = [] + for x in a.scalars: + label = "%s.%s" % (section_name, x) if section_name else x + if x in b: + found.append(x) + if a[x] != b[x]: + diffs[label] = (a[x], b[x]) + else: + a_only[label] = a[x] + + for x in b.scalars: + if x not in found: + label = "%s.%s" % (section_name, x) if section_name else x + b_only[label] = b[x] + +def prettyp(d, indent=0): + for key, value in d.items(): + if isinstance(value, dict): + print(' ' * indent + str(key)) + prettyp(value, indent+1) + else: + print(' ' * indent + "%s=%s" % (key, value)) + +def get_gettext_strings(dir_name, ext_list, string_list=set()): + """get all the gettext strings from a skin""" + for f in os.listdir(dir_name): + fn = os.path.join(dir_name, f) + if os.path.isfile(fn): + found = False + for e in ext_list: + if f.endswith(".%s" % e): + found = True + if found: + with open(fn) as file: + for line in file: + for m in re.findall(r'\$pgettext\(\s*\"([^\"]*)\",\s*\"([^\"]*)\"\s*\)', line): + string_list.add(m[1]) + for m in re.findall(r'\$gettext\(\s*[\'\"]([^\)]*)[\'\"]\s*\)', line): + string_list.add(m) + elif os.path.isdir(fn): + get_gettext_strings(fn, ext_list, string_list) + + return string_list + +def read_texts(fn): + """ + return set of strings from Texts section. + + NOT IMPLEMENTED: + return set of strings from Texts section. format using dotted notation + e.g., Text.name = value, or Text.Specialization.name = value + """ + cfg_dict = configobj.ConfigObj(fn, file_error=True, + encoding='utf-8', default_encoding='utf-8') + texts = cfg_dict.get('Texts', {}) + str_list = texts.scalars + for x in texts.sections: + [str_list.append(s) for s in texts[x].scalars] + return str_list + +# texts = cfg_dict.get('Texts', {}) +# str_list = ['Texts.%s' % s for s in texts.scalars] +# for x in texts.sections: +# [str_list.append("Texts.%s.%s" % (x, s)) for s in texts[x].scalars] +# return str_list + +def read_image_labels(fn): + """return set of strings that are labels for plots in the imagegenerator""" + cfg_dict = configobj.ConfigObj(fn, file_error=True, + encoding='utf-8', default_encoding='utf-8') + imggen_dict = cfg_dict.get('ImageGenerator', {}) + # FIXME: this assumes that the images will be defined using the standard + # pattern for images. anything other than xxx_images will not be found. + str_list = [] + for period in ['day', 'week', 'month', 'year']: + plot_dicts = imggen_dict.get("%s_images" % period, {}) + if not plot_dicts: + continue + for plot_name in plot_dicts.sections: + for series in plot_dicts[plot_name].sections: + if 'label' in plot_dicts[plot_name][series].scalars: + label = plot_dicts[plot_name][series]['label'] + str_list.append(label) + return str_list + +main() diff --git a/dist/weewx-5.0.2/src/weewx_data/util/import/csv-example.conf b/dist/weewx-5.0.2/src/weewx_data/util/import/csv-example.conf new file mode 100644 index 0000000..8b88018 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/import/csv-example.conf @@ -0,0 +1,236 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM CSV FILES +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# WeatherCat - import obs from a one or more WeatherCat monthly .cat files +# Format is: +# source = (CSV | WU | Cumulus | WD | WeatherCat) +source = CSV + +############################################################################## + +[CSV] + # Parameters used when importing from a CSV file + + # Path and name of our CSV source file. Format is: + # file = full path and filename + file = /var/tmp/data.csv + + # Specify the character used to separate fields. The character must be + # enclosed in quotes. Format is: + # delimiter = '' + # Default is ',' (comma). + delimiter = ',' + + # Specify the character used as the decimal point. The character must be + # enclosed in quotes. + # Format is: + # decimal = '' + # or + # Default is '.' (period). + decimal = '.' + + # If there is no mapped interval field how will the interval field be + # determined for the imported records. Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will cause + # the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is best + # used if the records to be imported have been produced by WeeWX + # or some other means with the same archive interval as set in + # weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # + # Note: If there is a mapped interval field this setting will be ignored. + # Format is: + # interval = (derive | conf | x) + # Default is derive. + interval = derive + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + # Default is True. + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + # Default is True. + calc_missing = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of 'tranche' + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + # Default is 250. + tranche = 250 + + # Specify whether a UV sensor was used to produce UV observation data. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV field will be set to None/NULL. + # For a CSV import UV_sensor should be set to False if a UV sensor was + # NOT present when the import data was created. Otherwise it may be set to + # True or omitted. Format is: + # UV_sensor = (True | False) + # Default is True. + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce solar + # radiation observation data. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation field will be set to + # None/NULL. + # For a CSV import solar_sensor should be set to False if a solar radiation + # sensor was NOT present when the import data was created. Otherwise it may + # be set to True or omitted. Format is: + # solar_sensor = (True | False) + # Default is True. + solar_sensor = True + + # Date-time format of CSV field from which the WeeWX archive record + # dateTime field is to be extracted. The import utility first attempts to + # interpret date-time data in this format, if this fails it then attempts + # to interpret it as a timestamp and if this fails an error is raised. Uses + # Python strptime() format codes. Format is: + # raw_datetime_format = Python strptime() format string + raw_datetime_format = %Y-%m-%d %H:%M:%S + + # Lower and upper bounds for imported wind direction. It is possible, + # particularly for a calculated direction, to have a value (eg -45) outside + # of the WeeWX limits (0 to 360 inclusive). Format is: + # + # wind_direction = lower,upper + # + # where : + # lower is the lower limit of acceptable wind direction in degrees + # (may be negative) + # upper is the upper limit of acceptable wind direction in degrees + # + # Imported values from lower to upper will be normalised to the range 0 to + # 360. Values outside of the parameter range will be stored as None. + # Default is -360,360. + wind_direction = -360,360 + + # Map CSV record fields to WeeWX archive fields. Format for each map entry + # is: + # + # [[[weewx_archive_field_name]]] + # source_field = csv_field_name + # unit = weewx_unit_name + # is_cumulative = True | False + # is_text = True | False + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # source_field - Config option specifying the CSV field being + # mapped. + # csv_field_name - The name of a field from the CSV file. + # unit - Config option specifying the unit used by + # the CSV field being mapped. + # weewx_unit_name - The WeeWX unit name for the the units used + # by csv_field_name. + # is_cumulative - Config option specifying whether the CSV + # field being mapped is cumulative, + # e.g: dayrain. Optional, default value is + # False. + # is_text - Config option specifying whether the CSV + # field being mapped is text. Optional, + # default value is False. + # For example, + # [[[outTemp]]] + # source_field = Temp + # unit = degree_C + # would map the CSV field 'Temp', in degrees C, to the WeeWX archive field + # 'outTemp'. + # + # A mapping for WeeWX field 'dateTime' is mandatory and the WeeWX unit name + # for the 'dateTime' mapping must be 'unix_epoch'. For example, + # [[[dateTime]]] + # source_field = csv_date_and_time + # unit = unix_epoch + # would map the CSV field 'csv_date_and_time' to the WeeWX 'dateTime' field + # with the 'csv_date_and_time' field being interpreted first using the + # format specified at the 'raw_datetime_format' config option and if that + # fails as a unix epoch timestamp. + # + # If the CSV data contains a field with WeeWX 'usUnits' data the field may + # be mapped to WeeWX field 'usUnits' and this value will be used to + # determine the units used for each CSV field. If a 'usUnits' mapping is + # included the 'unit' option may be omitted as the 'usUnits' value is + # unitless. If the 'unit' option is set it will be ignored. If a 'usUnits' + # mapping is included the 'unit' option for all other fields may be + # omitted. + # + # WeeWX archive fields that do not exist in the CSV data may be omitted. + # Any omitted fields that are derived (eg 'dewpoint') may be calculated + # during import using the equivalent of the WeeWX StdWXCalculate service + # through setting the 'calc-missing' parameter above. + [[FieldMap]] + [[[dateTime]]] + source_field = timestamp + unit = unix_epoch + [[[barometer]]] + source_field = barometer + unit = inHg + [[[outTemp]]] + source_field = Temp + unit = degree_F + [[[outHumidity]]] + source_field = humidity + unit = percent + [[[windSpeed]]] + source_field = windspeed + unit = mile_per_hour + [[[windDir]]] + source_field = wind + unit = degree_compass + [[[windGust]]] + source_field = gust + unit = mile_per_hour + [[[windGustDir]]] + source_field = gustDir + unit = degree_compass + [[[rainRate]]] + source_field = rate + unit = inch_per_hour + [[[rain]]] + source_field = dayrain + unit = inch + is_cumulative = True diff --git a/dist/weewx-5.0.2/src/weewx_data/util/import/cumulus-example.conf b/dist/weewx-5.0.2/src/weewx_data/util/import/cumulus-example.conf new file mode 100644 index 0000000..a12348e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/import/cumulus-example.conf @@ -0,0 +1,235 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM CUMULUS +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# WeatherCat - import obs from one or more WeatherCat monthly .cat files +# Format is: +# source = (CSV | WU | Cumulus | WD | WeatherCat) +source = Cumulus + +############################################################################## + +[Cumulus] + # Parameters used when importing Cumulus monthly log files + # + # Directory containing Cumulus monthly log files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp/cumulus + + # Specify the character used as the date separator as Cumulus monthly log + # files may not always use a solidus to separate date fields in the monthly + # log files. The character must be enclosed in quotes. Must not be the same + # as the delimiter setting below. Format is: + # separator = '' + # Default is '/' (solidus). + separator = '/' + + # Specify the character used as the field delimiter as Cumulus monthly log + # files may not always use a comma to delimit fields in the monthly log + # files. The character must be enclosed in quotes. Must not be the same + # as the decimal setting below. Format is: + # delimiter = '' + # Default is ',' (comma). + delimiter = ',' + + # Specify the character used as the decimal point. Cumulus monthly log + # files may not always use a period as the decimal point. The + # character must be enclosed in quotes. Must not be the same as the + # delimiter setting. Format is: + # decimal = '' + # Default is '.' (period). + decimal = '.' + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # To import Cumulus records it is recommended that the interval setting + # be set to the value used in Cumulus as the 'data log interval' in minutes. + # Format is: + # interval = (derive | conf | x) + # Default is derive. + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + # Default is True. + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + # Default is True. + calc_missing = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of 'tranche' + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + # Default is 250. + tranche = 250 + + # Specify whether a UV sensor was used to produce UV observation data. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV field will be set to None/NULL. + # For a Cumulus monthly log file import UV_sensor should be set to False if + # a UV sensor was NOT present when the import data was created. Otherwise + # it may be set to True or omitted. Format is: + # UV_sensor = (True | False) + # Default is True. + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce solar + # radiation observation data. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation field will be set to + # None/NULL. + # For a Cumulus monthly log file import solar_sensor should be set to False + # if a solar radiation sensor was NOT present when the import data was + # created. Otherwise it may be set to True or omitted. Format is: + # solar_sensor = (True | False) + # Default is True. + solar_sensor = True + + # Map Cumulus fields to WeeWX archive fields. Format for each map entry is: + # + # [[[weewx_archive_field_name]]] + # source_field = cumulus_field_name + # unit = weewx_unit_name + # is_cumulative = True | False + # is_text = True | False + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # source_field - Config option specifying the Cumulus field + # being mapped. + # cumulus_field_name - The name of a field from the Cumulus log file. + # unit - Config option specifying the unit used by + # the Cumulus field being mapped. + # weewx_unit_name - The WeeWX unit name for the the units used + # by cumulus_field_name. + # is_cumulative - Config option specifying whether the Cumulus + # field being mapped is cumulative, + # e.g: dayrain. Optional, default value is + # False. + # is_text - Config option specifying whether the Cumulus + # field being mapped is text. Optional, + # default value is False. + # For example, + # [[[outTemp]]] + # source_field = cur_out_temp + # unit = degree_C + # would map the Cumulus field 'cur_out_temp', in degrees C, to the WeeWX + # archive field 'outTemp'. + # + # A mapping for WeeWX field 'dateTime' is mandatory and the WeeWX unit name + # for the 'dateTime' mapping must be 'unix_epoch'. For example, + # [[[dateTime]]] + # source_field = datetime + # unit = unix_epoch + # would map the Cumulus field 'datetime' to the WeeWX 'dateTime' field. + # + # WeeWX archive fields that do not exist in the Cumulus data may be + # omitted. Any omitted fields that are derived (eg 'dewpoint') may be + # calculated during import using the equivalent of the WeeWX StdWXCalculate + # service through setting the 'calc-missing' parameter above. + # + # An example field map stanza is provided below. Once the units are + # adjusted this field map should suit most users but can be tailored to + # suit specific needs. + [[FieldMap]] + [[[dateTime]]] + source_field = datetime + unit = unix_epoch + [[[outTemp]]] + source_field = cur_out_temp + unit = degree_C + [[[inTemp]]] + source_field = cur_in_temp + unit = degree_C + [[[outHumidity]]] + source_field = cur_out_hum + unit = percent + [[[inHumidity]]] + source_field = cur_in_hum + unit = percent + [[[dewpoint]]] + source_field = cur_dewpoint + unit = degree_C + [[[heatindex]]] + source_field = cur_heatindex + unit = degree_C + [[[windchill]]] + source_field = cur_windchill + unit = degree_C + [[[appTemp]]] + source_field = cur_app_temp + unit = degree_C + [[[barometer]]] + source_field = cur_slp + unit = hPa + [[[rain]]] + source_field = midnight_rain + unit = mm + is_cumulative = True + [[[rainRate]]] + source_field = cur_rain_rate + unit = mm_per_hour + [[[windSpeed]]] + source_field = avg_wind_speed + unit = km_per_hour + [[[windDir]]] + source_field = avg_wind_bearing + unit = degree_compass + [[[windGust]]] + source_field = gust_wind_speed + unit = km_per_hour + [[[radiation]]] + source_field = cur_solar + unit = watt_per_meter_squared + [[[UV]]] + source_field = cur_uv + unit = uv_index diff --git a/dist/weewx-5.0.2/src/weewx_data/util/import/wd-example.conf b/dist/weewx-5.0.2/src/weewx_data/util/import/wd-example.conf new file mode 100644 index 0000000..fe36c81 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/import/wd-example.conf @@ -0,0 +1,304 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM WEATHER DISPLAY (WD) +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# WeatherCat - import obs from one or more WeatherCat monthly .cat files +# Format is: +# source = (CSV | WU | Cumulus | WD | WeatherCat) +source = WD + +############################################################################## + +[WD] + # Parameters used when importing WD monthly log files + # + # Directory containing WD monthly log files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp/WD + + # WD uses multiple log files some of which are in space delimited text + # format, some are in csv format and some in both. wee_import can process + # the following WD log files (actual log file names have 5 or 6 digits + # prepended representing a 1 or 2 digit month and a 4 digit year, these + # digits have been omitted for clarity): + # - lg.txt (same content as lgcsv.csv) + # - lgcsv.csv (same content as lg.txt) + # - vantageextrasensorslog.csv + # - vantagelog.txt (same content as vantagelogcsv.csv) + # - vantagelogcsv.csv (same content as vantagelog.txt) + # Specify log files to be imported. Format is a comma separated list + # including at least one of the supported log files. Do not include + # prepended month and year digits. Default is lg.txt, vantagelog.txt + # and vantageextrasensorslog.csv. + logs_to_process = lg.txt, vantagelog.txt, vantageextrasensorslog.csv + + # Specify the character used as the field delimiter in .txt monthly log + # files. Normally set to the space character. The character must be + # enclosed in quotes. Must not be the same as the decimal setting below. + # Format is: + # txt_delimiter = '' + # Default is ' ' (space). + txt_delimiter = ' ' + + # Specify the character used as the field delimiter in .csv monthly log + # files. Normally set to a comma. The character must be enclosed in + # quotes. Must not be the same as the decimal setting below. Format is: + # csv_delimiter = '' + # Default is ',' (comma). + csv_delimiter = ',' + + # Specify the character used as the decimal point. WD monthly log files + # normally use a periodr as the decimal point. The character must be + # enclosed in quotes. Must not be the same as the txt_delimiter or + # csv_delimiter settings. Format is: + # decimal = '' + # Default is '.' (period). + decimal = '.' + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # To import WD monthly log data it is recommended that the interval setting + # be set to 1. + # Format is: + # interval = (derive | conf | x) + # Default is 1. + interval = 1 + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + # Default is True. + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + # Default is True. + calc_missing = True + + # Specify whether missing log files are to be ignored or abort the import. + # WD log files are complete in themselves and a missing log file will have + # no effect on any other records (eg rain as a delta). + # Format is: + # ignore_missing_log = (True | False) + # Default is True + ignore_missing_log = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + # Default is 250. + tranche = 250 + + # Specify whether a UV sensor was used to produce UV observation data. + # Available options are: + # True - UV sensor was used and UV data will be imported. + # False - UV sensor was not used and any UV data will not be imported. + # UV field will be set to None/NULL. + # For a WD monthly log file import UV_sensor should be set to False if a UV + # sensor was NOT present when the import data was created. Otherwise it may + # be set to True or omitted. Format is: + # UV_sensor = (True | False) + # The default is True. + UV_sensor = True + + # Specify whether a solar radiation sensor was used to produce solar + # radiation observation data. Available options are: + # True - Solar radiation sensor was used and solar radiation data will + # be imported. + # False - Solar radiation sensor was not used and any solar radiation + # data will not be imported. radiation field will be set to + # None/NULL. + # For a WD monthly log file import solar_sensor should be set to False if a + # solar radiation sensor was NOT present when the import data was created. + # Otherwise it may be set to True or omitted. Format is: + # solar_sensor = (True | False) + # The default is True. + solar_sensor = True + + # Specify whether to ignore temperature and humidity reading of 255.0. + # WD logs can include values of 255.0 or 255. These values are usually + # associated with an absent or disconnected senor. In WeeWX the lack of a + # sensor/sensor data results in the value None (or null in SQL) being + # recorded. If ignore_extreme_temp_hum is set to True temperature and + # humidity values of 255 are ignored. Format is: + # ignore_extreme_temp_hum = (True | False) + # The default is True + ignore_extreme_temp_hum = True + + # Map WD fields to WeeWX archive fields. Format for each map entry is: + # + # [[[weewx_archive_field_name]]] + # source_field = wd_field_name + # unit = weewx_unit_name + # is_cumulative = True | False + # is_text = True | False + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # source_field - Config option specifying the WD field being + # mapped. + # wd_field_name - The name of a field from the WD log file. + # unit - Config option specifying the unit used by + # the WD field being mapped. + # weewx_unit_name - The WeeWX unit name for the the units used + # by wd_field_name. + # is_cumulative - Config option specifying whether the WD + # field being mapped is cumulative, + # e.g: dayrain. Optional, default value is + # False. + # is_text - Config option specifying whether the WD + # field being mapped is text. Optional, + # default value is False. + # For example, + # [[[outTemp]]] + # source_field = temperature + # unit = degree_C + # would map the WD field 'temperature', in degrees C, to the WeeWX archive + # field 'outTemp'. + # + # A mapping for WeeWX field 'dateTime' is mandatory and the WeeWX unit name + # for the 'dateTime' mapping must be 'unix_epoch'. For example, + # [[[dateTime]]] + # source_field = datetime + # unit = unix_epoch + # would map the WD field 'datetime' to the WeeWX 'dateTime' field. + # + # WeeWX archive fields that do not exist in the WD data may be omitted. Any + # omitted fields that are derived (eg 'dewpoint') may be calculated during + # import using the equivalent of the WeeWX StdWXCalculate service through + # setting the 'calc-missing' parameter above. + # + # An example field map stanza is provided below. Once the units are + # adjusted this field map should suit most users but can be tailored to + # suit specific needs. + [[FieldMap]] + [[[dateTime]]] + source_field = datetime + unit = unix_epoch + [[[outTemp]]] + source_field = temperature + unit = degree_C + [[[outHumidity]]] + source_field = humidity + unit = percent + [[[dewpoint]]] + source_field = dewpoint + unit = degree_C + [[[heatindex]]] + source_field = heatindex + unit = degree_C + [[[barometer]]] + source_field = barometer + unit = hPa + [[[windSpeed]]] + source_field = windspeed + unit = km_per_hour + [[[windDir]]] + source_field = direction + unit = degree_compass + [[[windGust]]] + source_field = gustspeed + unit = km_per_hour + [[[rain]]] + source_field = rainlastmin + unit = mm + [[[radiation]]] + source_field = radiation + unit = watt_per_meter_squared + [[[UV]]] + source_field = uv + unit = uv_index + [[[ET]]] + source_field = dailyet + unit = mm + cumulative = True + [[[extraTemp1]]] + source_field = temp1 + unit = degree_C + [[[extraTemp2]]] + source_field = temp2 + unit = degree_C + [[[extraTemp3]]] + source_field = temp3 + unit = degree_C + [[[extraTemp4]]] + source_field = temp4 + unit = degree_C + [[[extraTemp5]]] + source_field = temp5 + unit = degree_C + [[[extraTemp6]]] + source_field = temp6 + unit = degree_C + [[[extraTemp7]]] + source_field = temp7 + unit = degree_C + [[[extraHumid1]]] + source_field = hum1 + unit = percent + [[[extraHumid2]]] + source_field = hum2 + unit = percent + [[[extraHumid3]]] + source_field = hum3 + unit = percent + [[[extraHumid4]]] + source_field = hum4 + unit = percent + [[[extraHumid5]]] + source_field = hum5 + unit = percent + [[[extraHumid6]]] + source_field = hum6 + unit = percent + [[[extraHumid7]]] + source_field = hum7 + unit = percent + [[[soilTemp1]]] + source_field = soiltemp + unit = degree_C + [[[soilMoist1]]] + source_field = soilmoist + unit = centibar diff --git a/dist/weewx-5.0.2/src/weewx_data/util/import/weathercat-example.conf b/dist/weewx-5.0.2/src/weewx_data/util/import/weathercat-example.conf new file mode 100644 index 0000000..394e3a9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/import/weathercat-example.conf @@ -0,0 +1,209 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM WEATHERCAT +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# WeatherCat - import obs from one or more WeatherCat monthly .cat files +# Format is: +# source = (CSV | WU | Cumulus | WD | WeatherCat) +source = WeatherCat + +############################################################################## + +[WeatherCat] + # Parameters used when importing WeatherCat monthly .cat files + # + # Directory containing WeatherCat year folders that contain the monthly + # .cat files to be imported. Format is: + # directory = full path without trailing / + directory = /var/tmp + + # Specify the character used as the decimal point. WeatherCat monthly .cat + # files may not always use a period as the decimal point. The character + # must be enclosed in quotes. Format is: + # decimal = '.' + # Default is '.' (period). + decimal = '.' + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will + # cause the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is + # best used if the records to be imported have been produced by + # WeeWX or some other means with the same archive interval as + # set in weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # To import WeatherCat records it is recommended the interval setting be + # set to the Sampling Rate setting used by WeatherCat as set on the Misc2 + # tab under WeatherCat Preferences unless the Adaptive Sampling Rate was + # used in which case interval = derive should be used. + # Format is: + # interval = (derive | conf | x) + interval = x + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + # Default is True. + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + # Default is True. + calc_missing = True + + # Imported records are written to archive in transactions of tranche + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + # Default is 250. + tranche = 250 + + # Map WeatherCat fields to WeeWX archive fields. Format for each map entry + # is: + # + # [[[weewx_archive_field_name]]] + # source_field = wcat_field_name + # unit = weewx_unit_name + # is_cumulative = True | False + # is_text = True | False + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # source_field - Config option specifying the WeatherCat + # field being mapped. + # wcat_field_name - The name of a field from the WeatherCat log + # file. + # unit - Config option specifying the unit used by + # the WeatherCat field being mapped. + # weewx_unit_name - The WeeWX unit name for the the units used + # by wcat_field_name. + # is_cumulative - Config option specifying whether the + # WeatherCat field being mapped is cumulative, + # e.g: dayrain. Optional, default value is + # False. + # is_text - Config option specifying whether the + # WeatherCat field being mapped is text. + # Optional, default value is False. + # For example, + # [[[outTemp]]] + # source_field = T + # unit = degree_C + # would map the WeatherCat field 'T', in degrees C, to the WeeWX archive + # field 'outTemp'. + # + # An example field map stanza is provided below. Once the units are + # adjusted this field map should suit most users but can be tailored to + # suit specific needs. + [[FieldMap]] + [[[dateTime]]] + source_field = datetime + unit = unix_epoch + [[[outTemp]]] + source_field = T + unit = degree_C + [[[outHumidity]]] + source_field = H + unit = percent + [[[dewpoint]]] + source_field = D + unit = degree_C + [[[windchill]]] + source_field = Wc + unit = degree_C + [[[barometer]]] + source_field = Pr + unit = hPa + [[[windSpeed]]] + source_field = W + unit = km_per_hour + [[[windDir]]] + source_field = Wd + unit = degree_compass + [[[gustSpeed]]] + source_field = Wg + unit = km_per_hour + [[[rain]]] + source_field = P + unit = mm + [[[radiation]]] + source_field = S + unit = watt_per_meter_squared + [[[UV]]] + source_field = U + unit = uv_index + [[[extraTemp1]]] + source_field = T1 + unit = degree_C + [[[extraTemp2]]] + source_field = T2 + unit = degree_C + [[[extraTemp3]]] + source_field = T3 + unit = degree_C + [[[extraHumid1]]] + source_field = H1 + unit = percent + [[[extraHumid2]]] + source_field = H2 + unit = percent + [[[soilMoist1]]] + source_field = Sm1 + unit = centibar + [[[soilMoist2]]] + source_field = Sm2 + unit = centibar + [[[soilMoist3]]] + source_field = Sm3 + unit = centibar + [[[soilMoist4]]] + source_field = Sm4 + unit = centibar + [[[leafWet1]]] + source_field = Lw1 + unit = count + [[[leafWet2]]] + source_field = Lw2 + unit = count + [[[soilTemp1]]] + source_field = St1 + unit = degree_C + [[[soilTemp2]]] + source_field = St2 + unit = degree_C + [[[soilTemp3]]] + source_field = St3 + unit = degree_C + [[[soilTemp4]]] + source_field = St4 + unit = degree_C + [[[leafTemp1]]] + source_field = Lt1 + unit = degree_C + [[[leafTemp2]]] + source_field = Lt2 + unit = degree_C diff --git a/dist/weewx-5.0.2/src/weewx_data/util/import/wu-example.conf b/dist/weewx-5.0.2/src/weewx_data/util/import/wu-example.conf new file mode 100644 index 0000000..cbb833a --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/import/wu-example.conf @@ -0,0 +1,172 @@ +# EXAMPLE CONFIGURATION FILE FOR IMPORTING FROM THE WEATHER UNDERGROUND +# +# Copyright (c) 2009-2024 Tom Keffer and Gary Roderick. +# See the file LICENSE.txt for your rights. + +############################################################################## + +# Specify the source. Available options are: +# CSV - import obs from a single CSV format file +# WU - import obs from a Weather Underground PWS history +# Cumulus - import obs from a one or more Cumulus monthly log files +# WD - import obs from a one or more WD monthly log files +# WeatherCat - import obs from one or more WeatherCat monthly .cat files +# Format is: +# source = (CSV | WU | Cumulus | WD | WeatherCat) +source = WU + +############################################################################## + +[WU] + # Parameters used when importing from a WU PWS + + # WU PWS Station ID to be used for import. + station_id = XXXXXXXX123 + + # WU API key to be used for import. + api_key = XXXXXXXXXXXXXXXXXXXXXX1234567890 + + # How will the interval field be determined for the imported records. + # Available options are: + # derive - Derive the interval field from the timestamp of successive + # records. This setting is best used when there are no missing + # records from period being imported. Missing records will cause + # the interval field to be incorrectly calculated for some + # records. + # conf - Use the interval setting from weewx.conf. This setting is best + # used if the records to be imported have been produced by WeeWX + # or some other means with the same archive interval as set in + # weewx.conf on this machine. + # x - Use a fixed interval of 'x' minutes for every record where 'x' + # is a number. This setting is best used if the records to be + # imported are equally spaced in time but there are some missing + # records. + # + # Due to WU frequently missing uploaded records, use of 'derive' may give + # incorrect or inconsistent interval values. Better results may be achieved + # by using the 'conf' setting (if WeeWX has been doing the WU uploading and + # the WeeWX archive_interval matches the WU observation spacing in time) or + # setting the interval to a fixed value (eg 5). The most appropriate + # setting will depend on the completeness and (time) accuracy of the WU + # data being imported. + # Format is: + # interval = (derive | conf | x) + # Default is derive. + interval = derive + + # Should the [StdQC] max/min limits in weewx.conf be applied to the + # imported data. This may be useful if the source has extreme values that + # are clearly incorrect for some observations. This is particularly useful + # for WU imports where WU often records clearly erroneous values against + # obs that are not reported. Available options are: + # True - weewx.conf [StdQC] max/min limits are applied. + # False - weewx.conf [StdQC] max/min limits are not applied. + # Format is: + # qc = (True | False) + # Default is True. + qc = True + + # Should any missing derived observations be calculated from the imported + # data if possible. Available options are: + # True - Any missing derived observations are calculated. + # False - Any missing derived observations are not calculated. + # Format is: + # calc_missing = (True | False) + # Default is True. + calc_missing = True + + # Specify how imported data fields that contain invalid data (eg a numeric + # field containing non-numeric data) are handled. Available options are: + # True - The invalid data is ignored, the WeeWX target field is set to + # None and the import continues. + # False - The import is halted. + # Format is: + # ignore_invalid_data = (True | False) + # Default is True. + ignore_invalid_data = True + + # Imported records are written to archive in transactions of 'tranche' + # records at a time. Increase for faster throughput, decrease to reduce + # memory requirements. Format is: + # tranche = x + # where x is an integer + # Default is 250. + tranche = 250 + + # Lower and upper bounds for imported wind direction. It is possible, + # particularly for a calculated direction, to have a value (eg -45) outside + # of the WeeWX limits (0 to 360 inclusive). Format is: + # + # wind_direction = lower,upper + # + # where : + # lower is the lower limit of acceptable wind direction in degrees + # (may be negative) + # upper is the upper limit of acceptable wind direction in degrees + # + # WU has at times been known to store large values (eg -9999) for wind + # direction, often no wind direction was uploaded to WU. The wind_direction + # parameter sets a lower and upper bound for valid wind direction values. + # Values inside these bounds are normalised to the range 0 to 360. Values + # outside of the bounds will be stored as None. Default is 0,360 + # Default is -360,360. + wind_direction = 0,360 + + # Simplified map of WU fields to WeeWX archive fields. Format for each map + # entry is: + # + # [[[weewx_archive_field_name]]] + # source_field = wu_field_name + # + # where: + # weewx_archive_field_name - An observation name in the WeeWX database + # schema. + # source_field - Config option specifying the WU field being + # mapped. + # wu_field_name - The name of a WU field. + # For example, + # [[[outTemp]]] + # source_field = tempAvg + # would map the WU field 'tempAvg' to the WeeWX archive field 'outTemp'. + # + # A mapping for WeeWX field 'dateTime' is mandatory. For example, + # [[[dateTime]]] + # source_field = epoch + # would map the WU field 'epoch' to the WeeWX 'dateTime' field. + # + # WeeWX archive fields that do not exist in the WU data may be omitted. Any + # omitted fields that are derived (eg 'dewpoint') may be calculated during + # import using the equivalent of the WeeWX StdWXCalculate service through + # setting the 'calc-missing' parameter above. + # + # An example field map stanza is provided below that should suit most users + # but can be tailored to suit specific needs. + [[FieldMap]] + [[[dateTime]]] + source_field = epoch + [[[outTemp]]] + source_field = tempAvg + [[[outHumidity]]] + source_field = humidityAvg + [[[dewpoint]]] + source_field = dewptAvg + [[[heatindex]]] + source_field = heatindexAvg + [[[windchill]]] + source_field = windchillAvg + [[[barometer]]] + source_field = pressureAvg + [[[rain]]] + source_field = precipTotal + [[[rainRate]]] + source_field = precipRate + [[[windSpeed]]] + source_field = windspeedAvg + [[[windDir]]] + source_field = winddirAvg + [[[windGust]]] + source_field = windgustHigh + [[[radiation]]] + source_field = solarRadiationHigh + [[[UV]]] + source_field = uvHigh diff --git a/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx b/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx new file mode 100755 index 0000000..8aa7a2f --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx @@ -0,0 +1,56 @@ +#!/bin/sh +# +# Generic SysV startup script. Put this file in the system's init script +# directory, then create appropriate symlinks for your system runlevels. +# To modify the behavior of this script, adjust the values in the file: +# bsd: /etc/defaults/weewx +# linux: /etc/default/weewx + +WEEWX_PYTHON=python3 +WEEWX_BINDIR=/usr/share/weewx +WEEWX_CFGDIR=/etc/weewx +WEEWX_RUNDIR=/var/lib/weewx +WEEWX_CFG=weewx.conf + +# Read configuration variable file if it is present +[ -r /etc/default/weewx ] && . /etc/default/weewx + +WEEWXD=$WEEWX_BINDIR/weewxd.py +WEEWX_PID=$WEEWX_RUNDIR/weewx.pid + +# ensure that the rundir exists +if [ ! -d $WEEWX_RUNDIR ]; then + mkdir -p $WEEWX_RUNDIR +fi + +case "$1" in + "start") + echo "Starting weewx..." + ${WEEWX_PYTHON} ${WEEWXD} ${WEEWX_CFGDIR}/${WEEWX_CFG} & + echo $! > ${WEEWX_PID} + echo "done" + ;; + + "stop") + echo "Stopping weewx..." + if [ -f ${WEEWX_PID} ] ; then + kill `cat ${WEEWX_PID}` + rm ${WEEWX_PID} + echo "done" + else + echo "not running?" + fi + ;; + + "restart") + echo "Restarting weewx..." + $0 stop + sleep 2 + $0 start + ;; + + *) + echo "$0 [start|stop|restart]" + ;; + +esac diff --git a/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx-multi b/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx-multi new file mode 100755 index 0000000..6912283 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx-multi @@ -0,0 +1,251 @@ +#! /bin/sh +# Copyright 2016-2024 Matthew Wall, all rights reserved +# init script to run multiple instances of weewx +# +# each weewx instance is identified by name. that name is used to identify the +# configuration and pid files. if no list of instances is specified, then run +# a single instance of weewxd using the configuration file weewx.conf. +# +# to configure the script, override variables in /etc/default/weewx +# for example: +# +# WEEWX_INSTANCES="vantage acurite" +# WEEWX_PYTHON=python3 +# WEEWX_BINDIR=/opt/weewx +# WEEWX_CFGDIR=/etc/weewx +# WEEWX_RUNDIR=/var/run/weewx +# WEEWX_USER=weewx +# WEEWX_GROUP=weewx + +### BEGIN INIT INFO +# Provides: weewx-multi +# Required-Start: $local_fs $remote_fs $syslog $time +# Required-Stop: $local_fs $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: weewx-multi +# Description: Manages multiple instances of weewx +### END INIT INFO + +# Try to keep systemd from screwing everything up +export SYSTEMCTL_SKIP_REDIRECT=1 + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC=weewx + +WEEWX_INSTANCES="weewx" +WEEWX_PYTHON=python3 +WEEWX_BINDIR=/usr/share/weewx +WEEWX_CFGDIR=/etc/weewx +WEEWX_RUNDIR=/var/lib/weewx +WEEWX_USER=root +WEEWX_GROUP=root + +# Read configuration variable file if it is present +[ -r /etc/default/weewx ] && . /etc/default/weewx + +WEEWXD=$WEEWX_BINDIR/weewxd.py + +# the start-stop-daemon requires a full path to the DAEMON. prefer to use +# the wrapper in /usr/bin, but if that is not available, then use the direct +# python invocation of the weewxd entry script. +DAEMON=/usr/bin/weewxd +if [ ! -x $DAEMON ]; then + DAEMON="$WEEWX_PYTHON $WEEWXD" +fi + +# Exit if the package is not installed +if [ ! -f "$WEEWXD" ]; then + echo "The $DESC daemon is not installed at $WEEWXD" + exit 0 +fi + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +# ensure that the rundir exists and is writable by the user running weewx +if [ ! -d $WEEWX_RUNDIR ]; then + mkdir -p $WEEWX_RUNDIR + chown $WEEWX_USER $WEEWX_RUNDIR + chgrp $WEEWX_GROUP $WEEWX_RUNDIR +fi + +# start the daemon +# 0 if daemon has been started +# 1 if daemon was already running +# 2 if daemon could not be started +do_start() { + INSTANCE=$1 + PROCNAME=$(get_procname $INSTANCE) + PIDFILE=$WEEWX_RUNDIR/$PROCNAME.pid + CFGFILE=$WEEWX_CFGDIR/$INSTANCE.conf + DAEMON_ARGS="--daemon --log-label=$LOGNAME --pidfile=$PIDFILE $CFGFILE" + + if [ ! -f "$CFGFILE" ]; then + echo "The instance $INSTANCE does not have a config at $CFGFILE" + return 2 + fi + + NPROC=$(count_procs $INSTANCE) + if [ $NPROC != 0 ]; then + return 1 + fi + start-stop-daemon --start --chuid $WEEWX_USER --pidfile $PIDFILE --exec $DAEMON -- $DAEMON_ARGS || return 2 + return 0 +} + +# stop the daemon +# 0 if daemon has been stopped +# 1 if daemon was already stopped +# 2 if daemon could not be stopped +# other if a failure occurred +do_stop() { + INSTANCE=$1 + PROCNAME=$(get_procname $INSTANCE) + PIDFILE=$WEEWX_RUNDIR/$PROCNAME.pid + + # bail out if the app is not running + NPROC=$(count_procs $INSTANCE) + if [ $NPROC = 0 ]; then + return 1 + fi + # bail out if there is no pid file + if [ ! -f $PIDFILE ]; then + return 1 + fi + start-stop-daemon --stop --user $WEEWX_USER --pidfile $PIDFILE + # we cannot trust the return value from start-stop-daemon + RC=2 + c=0 + while [ $c -lt 24 -a "$RC" = "2" ]; do + c=`expr $c + 1` + # it may take awhile for the process to complete, so check it + NPROC=$(count_procs $INSTANCE) + if [ $NPROC = 0 ]; then + RC=0 + else + echo -n "." + sleep 5 + fi + done + if [ "$RC" = "0" -o "$RC" = "1" ]; then + # delete the pid file just in case + rm -f $PIDFILE + fi + return "$RC" +} + +# send a SIGHUP to the daemon +do_reload() { + INSTANCE=$1 + PROCNAME=$(get_procname $INSTANCE) + PIDFILE=$WEEWX_RUNDIR/$PROCNAME.pid + + start-stop-daemon --stop --signal 1 --quiet --user $WEEWX_USER --pidfile $PIDFILE + return 0 +} + +do_status() { + INSTANCE=$1 + NPROC=$(count_procs $INSTANCE) + echo -n "$INSTANCE is " + if [ $NPROC = 0 ]; then + echo -n "not " + fi + echo "running." +} + +count_procs() { + INSTANCE=$1 + PROCNAME=$(get_procname $INSTANCE) + PIDFILE=$PROCNAME.pid + NPROC=`ps ax | grep $WEEWXD | grep $PIDFILE | wc -l` + echo $NPROC +} + +get_procname() { + INSTANCE=$1 + LABEL=weewxd + if [ "$INSTANCE" != "weewx" ]; then + LABEL=weewxd-$INSTANCE + fi + echo -n $LABEL +} + +CMD=$1 +if [ "$1" != "" ]; then + shift +fi +INSTANCES="$@" +if [ "$INSTANCES" = "" ]; then + INSTANCES=$WEEWX_INSTANCES +fi + + +RETVAL=0 +case "$CMD" in + start) + for i in $INSTANCES; do + log_daemon_msg "Starting $DESC" "$i" + do_start $i + case "$?" in + 0) log_end_msg 0 ;; + 1) log_action_cont_msg " already running" && log_end_msg 0 ;; + 2) log_end_msg 1; RETVAL=1 ;; + esac + done + ;; + stop) + for i in $INSTANCES; do + log_daemon_msg "Stopping $DESC" "$i" + do_stop $i + case "$?" in + 0) log_end_msg 0 ;; + 1) log_action_cont_msg " not running" && log_end_msg 0 ;; + 2) log_end_msg 1; RETVAL=1 ;; + esac + done + ;; + status) + for i in $INSTANCES; do + do_status "$i" + done + ;; + reload|force-reload) + for i in $INSTANCES; do + log_daemon_msg "Reloading $DESC" "$i" + do_reload $i + log_end_msg $? + done + ;; + restart) + for i in $INSTANCES; do + log_daemon_msg "Restarting $DESC" "$i" + do_stop $i + case "$?" in + 0|1) + do_start $i + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1; RETVAL=1 ;; # Old process is still running + *) log_end_msg 1; RETVAL=1 ;; # Failed to start + esac + ;; + *) + log_end_msg 1 + RETVAL=1 + ;; + esac + done + ;; + *) + echo "Usage: $0 {start|stop|restart|reload} [instance]" >&2 + exit 3 + ;; +esac + +exit $RETVAL diff --git a/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx.bsd b/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx.bsd new file mode 100755 index 0000000..39b048d --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/init.d/weewx.bsd @@ -0,0 +1,63 @@ +#!/bin/sh +# +# PROVIDE: weewx +# KEYWORD: shutdown +# +# install this file as /usr/local/etc/rc.d/weewx +# +# to enable it: +# sudo sysrc weewx_enable=YES +# +# to start/stop it: +# sudo service weewx start +# sudo service weewx stop + +WEEWX_PYTHON=/usr/local/bin/python3 +WEEWX_BINDIR=/usr/local/weewx/src +WEEWX_CFGDIR=/usr/local/etc/weewx +WEEWX_RUNDIR=/var/run +WEEWX_CFG=weewx.conf + +# Read configuration variable file if it is present +[ -r /etc/defaults/weewx.conf ] && . /etc/defaults/weewx.conf + +WEEWXD=${WEEWX_BINDIR}/weewxd.py + +. /etc/rc.subr + +name="weewx" +rcvar=weewx_enable + +load_rc_config $name + +weewx_pid=${WEEWX_RUNDIR}/weewx.pid +weewx_config=${WEEWX_CFGDIR}/${WEEWX_CFG} + +start_cmd=weewx_start +stop_cmd=weewx_stop +extra_commands=status +status_cmd=weewx_status + +weewx_start() { + echo "starting ${name}" + ${WEEWX_PYTHON} ${WEEWXD} --daemon --pidfile=${weewx_pid} ${weewx_config} +} + +weewx_stop() { + if [ -f ${weewx_pid} ]; then + echo "stopping ${name}" + kill `cat ${weewx_pid}` + else + echo "${name} is not running" + fi +} + +weewx_status() { + if [ -f ${weewx_pid} ]; then + echo "${name} is running with PID `cat ${weewx_pid}`" + else + echo "${name} is not running" + fi +} + +run_rc_command "$1" diff --git a/dist/weewx-5.0.2/src/weewx_data/util/launchd/com.weewx.weewxd.plist b/dist/weewx-5.0.2/src/weewx_data/util/launchd/com.weewx.weewxd.plist new file mode 100644 index 0000000..a0b79dc --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/launchd/com.weewx.weewxd.plist @@ -0,0 +1,26 @@ + + + + + + + + + + + Label + com.weewx.weewxd + Disabled + + RunAtLoad + + ProgramArguments + + /usr/bin/python3 + /Users/Shared/weewx/src/weewxd.py + /Users/Shared/weewx/weewx.conf + + StandardErrorPath + /var/log/weewx_err.log + + diff --git a/dist/weewx-5.0.2/src/weewx_data/util/logrotate.d/weewx b/dist/weewx-5.0.2/src/weewx_data/util/logrotate.d/weewx new file mode 100644 index 0000000..d8ab658 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/logrotate.d/weewx @@ -0,0 +1,9 @@ +/var/log/weewx/*.log { + weekly + missingok + rotate 4 + compress + delaycompress # do not compress the most recently rotated file + copytruncate # copy the file, then truncate + notifempty +} diff --git a/dist/weewx-5.0.2/src/weewx_data/util/logwatch/conf/logfiles/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/logwatch/conf/logfiles/weewx.conf new file mode 100644 index 0000000..32c1468 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/logwatch/conf/logfiles/weewx.conf @@ -0,0 +1,4 @@ +LogFile = /var/log/syslog +LogFile = syslog.? +Archive = syslog.?.gz +*ApplyStdDate = diff --git a/dist/weewx-5.0.2/src/weewx_data/util/logwatch/conf/services/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/logwatch/conf/services/weewx.conf new file mode 100644 index 0000000..b2d5c0e --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/logwatch/conf/services/weewx.conf @@ -0,0 +1,2 @@ +Title = "weewx" +LogFile = weewx diff --git a/dist/weewx-5.0.2/src/weewx_data/util/logwatch/scripts/services/weewx b/dist/weewx-5.0.2/src/weewx_data/util/logwatch/scripts/services/weewx new file mode 100755 index 0000000..87e333a --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/logwatch/scripts/services/weewx @@ -0,0 +1,1210 @@ +#!/usr/bin/perl +# logwatch script to process weewx log files +# Copyright 2013 Matthew Wall + +# FIXME: break this into modules instead of a single, monolithic blob + +use strict; + +my %counts; +my %errors; + +# keys for individual counts +my $STARTUPS = 'engine: startups'; +my $HUP_RESTARTS = 'engine: restart from HUP'; +my $KBD_INTERRUPTS = 'engine: keyboard interrupts'; +my $RESTARTS = 'engine: restarts'; +my $GARBAGE = 'engine: Garbage collected'; +my $ARCHIVE_RECORDS_ADDED = 'archive: records added'; +my $IMAGES_GENERATED = 'imagegenerator: images generated'; +my $FILES_GENERATED = 'filegenerator: files generated'; +my $FILES_COPIED = 'copygenerator: files copied'; +my $RECORDS_PUBLISHED = 'restful: records published'; +my $RECORDS_SKIPPED = 'restful: records skipped'; +my $RECORDS_FAILED = 'restful: publish failed'; +my $FORECAST_RECORDS = 'forecast: records generated'; +my $FORECAST_PRUNINGS = 'forecast: prunings'; +my $FORECAST_DOWNLOADS = 'forecast: downloads'; +my $FORECAST_SAVED = 'forecast: records saved'; +my $FTP_UPLOADS = 'ftp: files uploaded'; +my $FTP_FAILS = 'ftp: failures'; +my $RSYNC_UPLOADS = 'rsync: files uploaded'; +my $RSYNC_FAILS = 'rsync: failures'; +my $FOUSB_UNSTABLE_READS = 'fousb: unstable reads'; +my $FOUSB_MAGIC_NUMBERS = 'fousb: unrecognised magic number'; +my $FOUSB_RAIN_COUNTER = 'fousb: rain counter decrement'; +my $FOUSB_SUSPECTED_BOGUS = 'fousb: suspected bogus data'; +my $FOUSB_LOST_LOG_SYNC = 'fousb: lost log sync'; +my $FOUSB_LOST_SYNC = 'fousb: lost sync'; +my $FOUSB_MISSED_DATA = 'fousb: missed data'; +my $FOUSB_STATION_SYNC = 'fousb: station sync'; +my $WS23XX_CONNECTION_CHANGE = 'ws23xx: connection change'; +my $WS23XX_INVALID_WIND = 'ws23xx: invalid wind reading'; +my $ACURITE_DODGEY_DATA = 'acurite: R1: ignoring dodgey data'; +my $ACURITE_BAD_R1_LENGTH = 'acurite: R1: bad length'; +my $ACURITE_BAD_R2_LENGTH = 'acurite: R2: bad length'; +my $ACURITE_FAILED_USB_CONNECT = 'acurite: Failed attempt'; +my $ACURITE_STALE_DATA = 'acurite: R1: ignoring stale data'; +my $ACURITE_NO_SENSORS = 'acurite: R1: no sensors found'; +my $ACURITE_BOGUS_STRENGTH = 'acurite: R1: bogus signal strength'; + +# BARO CHARGER DOWNLOAD DST HEADER LOGINT MAX MEM MIN NOW STATION TIME UNITS RAIN VERSION + +my $CC3000_VALUES_HEADER_MISMATCHES = 'cc3000: Values/Header mismatch'; + +# BARO=XX +my $CC3000_BARO_SET_CMD_ECHO_TIMED_OUT = 'cc3000: BARO=XX cmd echo timed out'; +my $CC3000_BARO_SET_MISSING_COMMAND_ECHO = 'cc3000: BARO=XX echoed as empty string'; +my $CC3000_BARO_SET_SUCCESSFUL_RETRIES = 'cc3000: BARO=XX successful retries'; +my $CC3000_BARO_SET_FAILED_RETRIES = 'cc3000: BARO=XX failed retries'; + +# BARO +my $CC3000_BARO_CMD_ECHO_TIMED_OUT = 'cc3000: BARO cmd echo timed out'; +my $CC3000_BARO_MISSING_COMMAND_ECHO = 'cc3000: BARO echoed as empty string'; +my $CC3000_BARO_SUCCESSFUL_RETRIES = 'cc3000: BARO successful retries'; +my $CC3000_BARO_FAILED_RETRIES = 'cc3000: BARO failed retries'; + +# CHARGER +my $CC3000_CHARGER_CMD_ECHO_TIMED_OUT = 'cc3000: CHARGER cmd echo timed out'; +my $CC3000_CHARGER_MISSING_COMMAND_ECHO = 'cc3000: CHARGER echoed as empty string'; +my $CC3000_CHARGER_SUCCESSFUL_RETRIES = 'cc3000: CHARGER successful retries'; +my $CC3000_CHARGER_FAILED_RETRIES = 'cc3000: CHARGER failed retries'; + +# DOWNLOAD=XX +my $CC3000_DOWNLOAD_XX_CMD_ECHO_TIMED_OUT = 'cc3000: DOWNLOAD=XX cmd echo timed out'; +my $CC3000_DOWNLOAD_XX_MISSING_COMMAND_ECHO = 'cc3000: DOWNLOAD=XX echoed as empty string'; +my $CC3000_DOWNLOAD_XX_SUCCESSFUL_RETRIES = 'cc3000: DOWNLOAD=XX successful retries'; +my $CC3000_DOWNLOAD_XX_FAILED_RETRIES = 'cc3000: DOWNLOAD=XX failed retries'; + +# DOWNLOAD +my $CC3000_DOWNLOAD_CMD_ECHO_TIMED_OUT = 'cc3000: DOWNLOAD cmd echo timed out'; +my $CC3000_DOWNLOAD_MISSING_COMMAND_ECHO = 'cc3000: DOWNLOAD echoed as empty string'; +my $CC3000_DOWNLOAD_SUCCESSFUL_RETRIES = 'cc3000: DOWNLOAD successful retries'; +my $CC3000_DOWNLOAD_FAILED_RETRIES = 'cc3000: DOWNLOAD failed retries'; + +# DST=? +my $CC3000_DST_CMD_ECHO_TIMED_OUT = 'cc3000: DST=? cmd echo timed out'; +my $CC3000_DST_MISSING_COMMAND_ECHO = 'cc3000: DST=? echoed as empty string'; +my $CC3000_DST_SUCCESSFUL_RETRIES = 'cc3000: DST=? successful retries'; +my $CC3000_DST_FAILED_RETRIES = 'cc3000: DST=? failed retries'; + +# DST=XX +my $CC3000_DST_SET_CMD_ECHO_TIMED_OUT = 'cc3000: DST=XX cmd echo timed out'; +my $CC3000_DST_SET_MISSING_COMMAND_ECHO = 'cc3000: DST=XX echoed as empty string'; +my $CC3000_DST_SET_SUCCESSFUL_RETRIES = 'cc3000: DST=XX successful retries'; +my $CC3000_DST_SET_FAILED_RETRIES = 'cc3000: DST=XX failed retries'; + +# ECHO=? +my $CC3000_ECHO_QUERY_CMD_ECHO_TIMED_OUT = 'cc3000: ECHO=? cmd echo timed out'; +my $CC3000_ECHO_QUERY_MISSING_COMMAND_ECHO = 'cc3000: ECHO=? echoed as empty string'; +my $CC3000_ECHO_QUERY_SUCCESSFUL_RETRIES = 'cc3000: ECHO=? successful retries'; +my $CC3000_ECHO_QUERY_FAILED_RETRIES = 'cc3000: ECHO=? failed retries'; + +# ECHO=XX +my $CC3000_ECHO_XX_CMD_ECHO_TIMED_OUT = 'cc3000: ECHO=XX cmd echo timed out'; +my $CC3000_ECHO_XX_MISSING_COMMAND_ECHO = 'cc3000: ECHO=XX echoed as empty string'; +my $CC3000_ECHO_XX_SUCCESSFUL_RETRIES = 'cc3000: ECHO=XX successful retries'; +my $CC3000_ECHO_XX_FAILED_RETRIES = 'cc3000: ECHO=XX failed retries'; + +# HEADER +my $CC3000_HEADER_CMD_ECHO_TIMED_OUT = 'cc3000: HEADER cmd echo timed out'; +my $CC3000_HEADER_MISSING_COMMAND_ECHO = 'cc3000: HEADER echoed as empty string'; +my $CC3000_HEADER_SUCCESSFUL_RETRIES = 'cc3000: HEADER successful retries'; +my $CC3000_HEADER_FAILED_RETRIES = 'cc3000: HEADER failed retries'; + +# LOGINT=? +my $CC3000_LOGINT_CMD_ECHO_TIMED_OUT = 'cc3000: LOGINT=? cmd echo timed out'; +my $CC3000_LOGINT_MISSING_COMMAND_ECHO = 'cc3000: LOGINT=? echoed as empty string'; +my $CC3000_LOGINT_SUCCESSFUL_RETRIES = 'cc3000: LOGINT=? successful retries'; +my $CC3000_LOGINT_FAILED_RETRIES = 'cc3000: LOGINT=? failed retries'; + +# LOGINT=XX +my $CC3000_LOGINT_SET_CMD_ECHO_TIMED_OUT = 'cc3000: LOGINT=XX cmd echo timed out'; +my $CC3000_LOGINT_SET_MISSING_COMMAND_ECHO = 'cc3000: LOGINT=XX echoed as empty string'; +my $CC3000_LOGINT_SET_SUCCESSFUL_RETRIES = 'cc3000: LOGINT=XX successful retries'; +my $CC3000_LOGINT_SET_FAILED_RETRIES = 'cc3000: LOGINT=XX failed retries'; + +# MAX=? +my $CC3000_MAX_CMD_ECHO_TIMED_OUT = 'cc3000: MAX=? cmd echo timed out'; +my $CC3000_MAX_MISSING_COMMAND_ECHO = 'cc3000: MAX=? echoed as empty string'; +my $CC3000_MAX_SUCCESSFUL_RETRIES = 'cc3000: MAX=? successful retries'; +my $CC3000_MAX_FAILED_RETRIES = 'cc3000: MAX=? failed retries'; + +# MAX=RESET +my $CC3000_MAX_RESET_CMD_ECHO_TIMED_OUT = 'cc3000: MAX=RESET cmd echo timed out'; +my $CC3000_MAX_RESET_MISSING_COMMAND_ECHO = 'cc3000: MAX=RESET echoed as empty string'; +my $CC3000_MAX_RESET_SUCCESSFUL_RETRIES = 'cc3000: MAX=RESET successful retries'; +my $CC3000_MAX_RESET_FAILED_RETRIES = 'cc3000: MAX=RESET failed retries'; + +# MEM=? +my $CC3000_MEM_CMD_ECHO_TIMED_OUT = 'cc3000: MEM=? cmd echo timed out'; +my $CC3000_MEM_MISSING_COMMAND_ECHO = 'cc3000: MEM=? echoed as empty string'; +my $CC3000_MEM_SUCCESSFUL_RETRIES = 'cc3000: MEM=? successful retries'; +my $CC3000_MEM_FAILED_RETRIES = 'cc3000: MEM=? failed retries'; + +# MEM=CLEAR +my $CC3000_MEM_CLEAR_CMD_ECHO_TIMED_OUT = 'cc3000: MEM=CLEAR cmd echo timed out'; +my $CC3000_MEM_CLEAR_MISSING_COMMAND_ECHO = 'cc3000: MEM=CLEAR echoed as empty string'; +my $CC3000_MEM_CLEAR_SUCCESSFUL_RETRIES = 'cc3000: MEM=CLEAR successful retries'; +my $CC3000_MEM_CLEAR_FAILED_RETRIES = 'cc3000: MEM=CLEAR failed retries'; + +# MIN=? +my $CC3000_MIN_CMD_ECHO_TIMED_OUT = 'cc3000: MIN=? cmd echo timed out'; +my $CC3000_MIN_MISSING_COMMAND_ECHO = 'cc3000: MIN=? echoed as empty string'; +my $CC3000_MIN_SUCCESSFUL_RETRIES = 'cc3000: MIN=? successful retries'; +my $CC3000_MIN_FAILED_RETRIES = 'cc3000: MIN=? failed retries'; + +# MIN=RESET +my $CC3000_MIN_RESET_CMD_ECHO_TIMED_OUT = 'cc3000: MIN=RESET cmd echo timed out'; +my $CC3000_MIN_RESET_MISSING_COMMAND_ECHO = 'cc3000: MIN=RESET echoed as empty string'; +my $CC3000_MIN_RESET_SUCCESSFUL_RETRIES = 'cc3000: MIN=RESET successful retries'; +my $CC3000_MIN_RESET_FAILED_RETRIES = 'cc3000: MIN=RESET failed retries'; + +# NOW +my $CC3000_NOW_CMD_ECHO_TIMED_OUT = 'cc3000: NOW cmd echo timed out'; +my $CC3000_NOW_MISSING_COMMAND_ECHO = 'cc3000: NOW echoed as empty string'; +my $CC3000_NOW_SUCCESSFUL_RETRIES = 'cc3000: NOW successful retries'; +my $CC3000_NOW_FAILED_RETRIES = 'cc3000: NOW failed retries'; + +# RAIN=RESET +my $CC3000_RAIN_RESET_CMD_ECHO_TIMED_OUT = 'cc3000: RAIN=RESET cmd echo timed out'; +my $CC3000_RAIN_RESET_MISSING_COMMAND_ECHO = 'cc3000: RAIN=RESET echoed as empty string'; +my $CC3000_RAIN_RESET_SUCCESSFUL_RETRIES = 'cc3000: RAIN=RESET successful retries'; +my $CC3000_RAIN_RESET_FAILED_RETRIES = 'cc3000: RAIN=RESET failed retries'; + +# RAIN +my $CC3000_RAIN_CMD_ECHO_TIMED_OUT = 'cc3000: RAIN cmd echo timed out'; +my $CC3000_RAIN_MISSING_COMMAND_ECHO = 'cc3000: RAIN echoed as empty string'; +my $CC3000_RAIN_SUCCESSFUL_RETRIES = 'cc3000: RAIN successful retries'; +my $CC3000_RAIN_FAILED_RETRIES = 'cc3000: RAIN failed retries'; + +# STATION=X +my $CC3000_STATION_SET_CMD_ECHO_TIMED_OUT = 'cc3000: STATION=X cmd echo timed out'; +my $CC3000_STATION_SET_MISSING_COMMAND_ECHO = 'cc3000: STATION=X echoed as empty string'; +my $CC3000_STATION_SET_SUCCESSFUL_RETRIES = 'cc3000: STATION=X successful retries'; +my $CC3000_STATION_SET_FAILED_RETRIES = 'cc3000: STATION=X failed retries'; + +# STATION +my $CC3000_STATION_CMD_ECHO_TIMED_OUT = 'cc3000: STATION cmd echo timed out'; +my $CC3000_STATION_MISSING_COMMAND_ECHO = 'cc3000: STATION echoed as empty string'; +my $CC3000_STATION_SUCCESSFUL_RETRIES = 'cc3000: STATION successful retries'; +my $CC3000_STATION_FAILED_RETRIES = 'cc3000: STATION failed retries'; + +# TIME=? +my $CC3000_TIME_CMD_ECHO_TIMED_OUT = 'cc3000: TIME=? cmd echo timed out'; +my $CC3000_TIME_MISSING_COMMAND_ECHO = 'cc3000: TIME=? echoed as empty string'; +my $CC3000_TIME_SUCCESSFUL_RETRIES = 'cc3000: TIME=? successful retries'; +my $CC3000_TIME_FAILED_RETRIES = 'cc3000: TIME=? failed retries'; + +# TIME=XX +my $CC3000_TIME_SET_CMD_ECHO_TIMED_OUT = 'cc3000: TIME=XX cmd echo timed out'; +my $CC3000_TIME_SET_MISSING_COMMAND_ECHO = 'cc3000: TIME=XX echoed as empty string'; +my $CC3000_TIME_SET_SUCCESSFUL_RETRIES = 'cc3000: TIME=XX successful retries'; +my $CC3000_TIME_SET_FAILED_RETRIES = 'cc3000: TIME=XX failed retries'; + +# UNITS=? +my $CC3000_UNITS_CMD_ECHO_TIMED_OUT = 'cc3000: UNITS=? cmd echo timed out'; +my $CC3000_UNITS_MISSING_COMMAND_ECHO = 'cc3000: UNITS=? echoed as empty string'; +my $CC3000_UNITS_SUCCESSFUL_RETRIES = 'cc3000: UNITS=? successful retries'; +my $CC3000_UNITS_FAILED_RETRIES = 'cc3000: UNITS=? failed retries'; + +# UNITS=XX +my $CC3000_UNITS_SET_CMD_ECHO_TIMED_OUT = 'cc3000: UNITS=XX cmd echo timed out'; +my $CC3000_UNITS_SET_MISSING_COMMAND_ECHO = 'cc3000: UNITS=XX echoed as empty string'; +my $CC3000_UNITS_SET_SUCCESSFUL_RETRIES = 'cc3000: UNITS=XX successful retries'; +my $CC3000_UNITS_SET_FAILED_RETRIES = 'cc3000: UNITS=XX failed retries'; + +# VERSION +my $CC3000_VERSION_CMD_ECHO_TIMED_OUT = 'cc3000: VERSION cmd echo timed out'; +my $CC3000_VERSION_MISSING_COMMAND_ECHO = 'cc3000: VERSION echoed as empty string'; +my $CC3000_VERSION_SUCCESSFUL_RETRIES = 'cc3000: VERSION successful retries'; +my $CC3000_VERSION_FAILED_RETRIES = 'cc3000: VERSION failed retries'; + +my $CC3000_GET_TIME_FAILED = 'cc3000: TIME get failed'; + +my $CC3000_SET_TIME_SUCCEEDED = 'cc3000: TIME set succeeded'; + +my $CC3000_MEM_CLEAR_SUCCEEDED = 'cc3000: MEM clear succeeded'; + +my $CC3000_FAILED_CMD = 'cc3000: FAILED to Get Data'; + +my $RSYNC_REPORT_CONN_TIMEOUT = 'rsync: report: connection timeout'; +my $RSYNC_REPORT_NO_ROUTE_TO_HOST = 'rsync: report: no route to host'; + +my $RSYNC_GAUGE_DATA_NO_ROUTE_TO_HOST = 'rsync: gauge-data: no route to host'; +my $RSYNC_GAUGE_DATA_CANT_RESOLVE_HOST = 'rsync: gauge-data: cannot resolve host'; +my $RSYNC_GAUGE_DATA_CONN_REFUSED = 'rsync: gauge-data: connection_refused'; +my $RSYNC_GAUGE_DATA_CONN_TIMEOUT = 'rsync: gauge-data: connection timeouts'; +my $RSYNC_GAUGE_DATA_IO_TIMEOUT = 'rsync: gauge-data: IO timeout-data'; +my $RSYNC_GAUGE_DATA_SKIP_PACKET = 'rsync: gauge-data: skipped packets'; +my $RSYNC_GAUGE_DATA_WRITE_ERRORS = 'rsync: gauge-data: write errors'; +my $RSYNC_GAUGE_DATA_REMOTE_CLOSED = 'rsync: gauge-data: closed by remote host'; + +# any lines that do not match the patterns we define +my @unmatched = (); + +# track individual publishing counts +my %publish_counts = (); +my %publish_fails = (); + +# track individual forecast stats +my %forecast_records = (); +my %forecast_prunings = (); +my %forecast_downloads = (); +my %forecast_saved = (); + +my %summaries = ( + 'counts', \%counts, + 'errors', \%errors, + 'uploads', \%publish_counts, + 'upload failures', \%publish_fails, + 'forecast records generated', \%forecast_records, + 'forecast prunings', \%forecast_prunings, + 'forecast downloads', \%forecast_downloads, + 'forecast records saved', \%forecast_saved + ); + +# track upload errors to help diagnose network/server issues +my @upload_errors = (); + +# keep details of ws23xx behavior +my @ws23xx_conn_change = (); +my @ws23xx_invalid_wind = (); + +# keep details of fine offset behavior +my @fousb_station_status = (); +my @fousb_unstable_reads = (); +my @fousb_magic_numbers = (); +my @fousb_rain_counter = (); +my @fousb_suspected_bogus = (); + +# keep details of acurite behavior +my @acurite_dodgey_data = (); +my @acurite_r1_length = (); +my @acurite_r2_length = (); +my @acurite_failed_usb = (); +my @acurite_stale_data = (); +my @acurite_no_sensors = (); +my @acurite_bogus_strength = (); + +# keep details of PurpleAir messages +my @purpleair = (); + +# keep details of rain counter resets +my @rain_counter_reset = (); + +# keep details of cc3000 behavior +my @cc3000_timings = (); +my @cc3000_mem_clear_info = (); +my @cc3000_retry_info = (); + +my %itemized = ( + 'upload errors', \@upload_errors, + 'fousb station status', \@fousb_station_status, + 'fousb unstable reads', \@fousb_unstable_reads, + 'fousb magic numbers', \@fousb_magic_numbers, + 'fousb rain counter', \@fousb_rain_counter, + 'fousb suspected bogus data', \@fousb_suspected_bogus, + 'ws23xx connection changes', \@ws23xx_conn_change, + 'ws23xx invalid wind', \@ws23xx_invalid_wind, + 'acurite dodgey data', \@acurite_dodgey_data, + 'acurite bad R1 length', \@acurite_r1_length, + 'acurite bad R2 length', \@acurite_r2_length, + 'acurite failed usb connect', \@acurite_failed_usb, + 'acurite stale_data', \@acurite_stale_data, + 'acurite no sensors', \@acurite_no_sensors, + 'acurite bogus signal strength', \@acurite_bogus_strength, + 'rain counter reset', \@rain_counter_reset, + 'PurpleAir messsages', \@purpleair, + 'cc3000 Retry Info', \@cc3000_retry_info, + 'cc3000 Command Timings: flush/cmd/echo/values', \@cc3000_timings, + 'cc3000 Mem Clear Info', \@cc3000_mem_clear_info, + ); + +my $clocksum = 0; +my $clockmin = 0; +my $clockmax = 0; +my $clockcount = 0; + +while(defined($_ = )) { + chomp; + if (/engine: Starting up weewx version/) { + $counts{$STARTUPS} += 1; + } elsif (/engine: Received signal HUP/) { + $counts{$HUP_RESTARTS} += 1; + } elsif (/engine: Keyboard interrupt/) { + $counts{$KBD_INTERRUPTS} += 1; + } elsif (/engine: retrying/) { + $counts{$RESTARTS} += 1; + } elsif (/engine: Garbage collected (\d+) objects/) { + $counts{$GARBAGE} += $1; + } elsif (/engine: Clock error is ([0-9,.-]+)/) { + $clocksum += $1; + $clockmin = $1 if $1 < $clockmin; + $clockmax = $1 if $1 > $clockmax; + $clockcount += 1; + } elsif (/manager: Added record/ || /archive: added record/) { + $counts{$ARCHIVE_RECORDS_ADDED} += 1; + } elsif (/imagegenerator: Generated (\d+) images/) { + $counts{$IMAGES_GENERATED} += $1; + } elsif (/imagegenerator: aggregate interval required for aggregate type/ || + /imagegenerator: line type \S+ skipped/) { + $errors{$_} = $errors{$_} ? $errors{$_} + 1 : 1; + } elsif (/cheetahgenerator: Generated (\d+)/ || + /cheetahgenerator: generated (\d+)/ || + /filegenerator: generated (\d+)/) { + $counts{$FILES_GENERATED} += $1; + } elsif (/reportengine: Copied (\d+) files/) { + $counts{$FILES_COPIED} += $1; + } elsif (/restful: Skipped record/) { + $counts{$RECORDS_SKIPPED} += 1; + } elsif (/restful: Published record/) { + $counts{$RECORDS_PUBLISHED} += 1; + } elsif (/ftpupload: Uploaded file/) { + $counts{$FTP_UPLOADS} += 1; + } elsif (/ftpupload: Failed to upload file/) { + $counts{$FTP_FAILS} += 1; + } elsif (/rsync\'d (\d+) files/) { + $counts{$RSYNC_UPLOADS} += $1; + } elsif (/rsyncupload: rsync reported errors/) { + $counts{$RSYNC_FAILS} += 1; + } elsif (/restful: Unable to publish record/) { + if (/restful: Unable to publish record \d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \S\S\S \(\d+\) to (\S+)/) { + $publish_fails{$1} += 1; + } + $counts{$RECORDS_FAILED} += 1; + push @upload_errors, $_; + } elsif (/restx: ([^:]*): Published record/) { + $publish_counts{$1} += 1; + $counts{$RECORDS_PUBLISHED} += 1; + } elsif (/restx: ([^:]*): Failed to publish/) { + $publish_fails{$1} += 1; + $counts{$RECORDS_FAILED} += 1; + push @upload_errors, $_; + } elsif (/fousb: station status/) { + push @fousb_station_status, $_; + } elsif (/fousb: unstable read: blocks differ/) { + push @fousb_unstable_reads, $_; + $counts{$FOUSB_UNSTABLE_READS} += 1; + } elsif (/fousb: unrecognised magic number/) { + push @fousb_magic_numbers, $_; + $counts{$FOUSB_MAGIC_NUMBERS} += 1; + } elsif (/fousb: rain counter decrement/ || + /fousb: ignoring spurious rain counter decrement/) { + push @fousb_rain_counter, $_; + $counts{$FOUSB_RAIN_COUNTER} += 1; + } elsif (/fousb:.*ignoring suspected bogus data/) { + push @fousb_suspected_bogus, $_; + $counts{$FOUSB_SUSPECTED_BOGUS} += 1; + } elsif (/fousb: lost log sync/) { + $counts{$FOUSB_LOST_LOG_SYNC} += 1; + } elsif (/fousb: lost sync/) { + $counts{$FOUSB_LOST_SYNC} += 1; + } elsif (/fousb: missed data/) { + $counts{$FOUSB_MISSED_DATA} += 1; + } elsif (/fousb: synchronising to the weather station/) { + $counts{$FOUSB_STATION_SYNC} += 1; + } elsif (/ws23xx: connection changed from/) { + push @ws23xx_conn_change, $_; + $counts{$WS23XX_CONNECTION_CHANGE} += 1; + } elsif (/ws23xx: invalid wind reading/) { + push @ws23xx_invalid_wind, $_; + $counts{$WS23XX_INVALID_WIND} += 1; + } elsif (/acurite: R1: ignoring dodgey data/) { + push @acurite_dodgey_data, $_; + $counts{$ACURITE_DODGEY_DATA} += 1; + } elsif (/acurite: R1: bad length/) { + push @acurite_r1_length, $_; + $counts{$ACURITE_BAD_R1_LENGTH} += 1; + } elsif (/acurite: R2: bad length/) { + push @acurite_r2_length, $_; + $counts{$ACURITE_BAD_R2_LENGTH} += 1; + } elsif (/acurite: Failed attempt/) { + push @acurite_failed_usb, $_; + $counts{$ACURITE_FAILED_USB_CONNECT} += 1; + } elsif (/acurite: R1: ignoring stale data/) { + push @acurite_stale_data, $_; + $counts{$ACURITE_STALE_DATA} += 1; + } elsif (/acurite: R1: no sensors found/) { + push @acurite_no_sensors, $_; + $counts{$ACURITE_NO_SENSORS} += 1; + } elsif (/acurite: R1: bogus signal strength/) { + push @acurite_bogus_strength, $_; + $counts{$ACURITE_BOGUS_STRENGTH} += 1; + } elsif (/purpleair: /) { + push @purpleair, $_; + } elsif (/cc3000: Values\/header mismatch/) { + $counts{$CC3000_VALUES_HEADER_MISMATCHES} += 1; + + # BARO CHARGER DOWNLOAD DST HEADER LOGINT MAX MEM MIN NOW STATION TIME UNITS RAIN VERSION + # Example + # cc3000: NOW: times: 0.000050 0.000091 1.027548 -retrying- + # cc3000: NOW: Reading cmd echo timed out (1.027548 seconds), retrying. + # cc3000: NOW: Accepting empty string as cmd echo. + # cc3000: NOW: Retry worked. Total tries: 2 + + # BARO=XX + } elsif (/cc3000: BARO=.*: Reading cmd echo timed out/) { + $counts{$CC3000_BARO_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: BARO=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_BARO_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: BARO=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_BARO_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: BARO=.*: Retry failed./) { + $counts{$CC3000_BARO_SET_FAILED_RETRIES} += 1; + + # BARO + } elsif (/cc3000: BARO: Reading cmd echo timed out/) { + $counts{$CC3000_BARO_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: BARO: Accepting empty string as cmd echo./) { + $counts{$CC3000_BARO_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: BARO: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_BARO_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: BARO: Retry failed./) { + $counts{$CC3000_BARO_FAILED_RETRIES} += 1; + + # CHARGER + } elsif (/cc3000: CHARGER: Reading cmd echo timed out/) { + $counts{$CC3000_CHARGER_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: CHARGER: Accepting empty string as cmd echo./) { + $counts{$CC3000_CHARGER_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: CHARGER: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_CHARGER_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: CHARGER: Retry failed./) { + $counts{$CC3000_CHARGER_FAILED_RETRIES} += 1; + + # DOWNLOAD=XX + } elsif (/cc3000: DOWNLOAD=.*: Reading cmd echo timed out/) { + $counts{$CC3000_DOWNLOAD_XX_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DOWNLOAD=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_DOWNLOAD_XX_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DOWNLOAD=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DOWNLOAD_XX_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DOWNLOAD=.*: Retry failed./) { + $counts{$CC3000_DOWNLOAD_XX_FAILED_RETRIES} += 1; + + # DOWNLOAD + } elsif (/cc3000: DOWNLOAD: Reading cmd echo timed out/) { + $counts{$CC3000_DOWNLOAD_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DOWNLOAD: Accepting empty string as cmd echo./) { + $counts{$CC3000_DOWNLOAD_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DOWNLOAD: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DOWNLOAD_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DOWNLOAD: Retry failed./) { + $counts{$CC3000_DOWNLOAD_FAILED_RETRIES} += 1; + + # DST=? + } elsif (/cc3000: DST=\?: Reading cmd echo timed out/) { + $counts{$CC3000_DST_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DST=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_DST_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DST=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DST_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DST=\?: Retry failed./) { + $counts{$CC3000_DST_FAILED_RETRIES} += 1; + + # DST=XX + } elsif (/cc3000: DST=.*: Reading cmd echo timed out/) { + $counts{$CC3000_DST_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: DST=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_DST_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: DST=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_DST_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: DST=.*: Retry failed./) { + $counts{$CC3000_DST_FAILED_RETRIES} += 1; + + # ECHO=? + } elsif (/cc3000: ECHO=\?: Reading cmd echo timed out/) { + $counts{$CC3000_ECHO_QUERY_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: ECHO=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_ECHO_QUERY_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: ECHO=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_ECHO_QUERY_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: ECHO=\?: Retry failed./) { + $counts{$CC3000_ECHO_QUERY_FAILED_RETRIES} += 1; + + # ECHO=XX + } elsif (/cc3000: ECHO=.*: Reading cmd echo timed out/) { + $counts{$CC3000_ECHO_XX_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: ECHO=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_ECHO_XX_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: ECHO=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_ECHO_XX_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: ECHO=.*: Retry failed./) { + $counts{$CC3000_ECHO_XX_FAILED_RETRIES} += 1; + + # HEADER + } elsif (/cc3000: HEADER: Reading cmd echo timed out/) { + $counts{$CC3000_HEADER_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: HEADER: Accepting empty string as cmd echo./) { + $counts{$CC3000_HEADER_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: HEADER: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_HEADER_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: HEADER: Retry failed./) { + $counts{$CC3000_HEADER_FAILED_RETRIES} += 1; + + # LOGINT=? + } elsif (/cc3000: LOGINT=\?: Reading cmd echo timed out/) { + $counts{$CC3000_LOGINT_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: LOGINT=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_LOGINT_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: LOGINT=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_LOGINT_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: LOGINT=\?: Retry failed./) { + $counts{$CC3000_LOGINT_FAILED_RETRIES} += 1; + + # LOGINT=XX + } elsif (/cc3000: LOGINT=.*: Reading cmd echo timed out/) { + $counts{$CC3000_LOGINT_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: LOGINT=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_LOGINT_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: LOGINT=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_LOGINT_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: LOGINT=.*: Retry failed./) { + $counts{$CC3000_LOGINT_SET_FAILED_RETRIES} += 1; + + # MAX=? + } elsif (/cc3000: MAX=\?: Reading cmd echo timed out/) { + $counts{$CC3000_MAX_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MAX=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_MAX_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MAX=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MAX_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MAX=\?: Retry failed./) { + $counts{$CC3000_MAX_FAILED_RETRIES} += 1; + + # MAX=RESET + } elsif (/cc3000: MAX=RESET: Reading cmd echo timed out/) { + $counts{$CC3000_MAX_RESET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MAX=RESET: Accepting empty string as cmd echo./) { + $counts{$CC3000_MAX_RESET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MAX=RESET: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MAX_RESET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MAX=RESET: Retry failed./) { + $counts{$CC3000_MAX_RESET_FAILED_RETRIES} += 1; + + # MEM=? + } elsif (/cc3000: MEM=\?: Reading cmd echo timed out/) { + $counts{$CC3000_MEM_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MEM=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_MEM_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MEM=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MEM_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MEM=\?: Retry failed./) { + $counts{$CC3000_MEM_FAILED_RETRIES} += 1; + + # MEM=CLEAR + } elsif (/cc3000: MEM=CLEAR: Reading cmd echo timed out/) { + $counts{$CC3000_MEM_CLEAR_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MEM=CLEAR: Accepting empty string as cmd echo./) { + $counts{$CC3000_MEM_CLEAR_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MEM=CLEAR: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MEM_CLEAR_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MEM=CLEAR: Retry failed./) { + $counts{$CC3000_MEM_CLEAR_FAILED_RETRIES} += 1; + + # MIN=? + } elsif (/cc3000: MIN=\?: Reading cmd echo timed out/) { + $counts{$CC3000_MIN_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MIN=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_MIN_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MIN=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MIN_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MIN=\?: Retry failed./) { + $counts{$CC3000_MIN_FAILED_RETRIES} += 1; + + # MIN=RESET + } elsif (/cc3000: MIN=RESET: Reading cmd echo timed out/) { + $counts{$CC3000_MIN_RESET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: MIN=RESET: Accepting empty string as cmd echo./) { + $counts{$CC3000_MIN_RESET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: MIN=RESET: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_MIN_RESET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: MIN=RESET: Retry failed./) { + $counts{$CC3000_MIN_RESET_FAILED_RETRIES} += 1; + + # NOW + } elsif (/cc3000: NOW: Reading cmd echo timed out/) { + $counts{$CC3000_NOW_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: NOW: Accepting empty string as cmd echo./) { + $counts{$CC3000_NOW_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: NOW: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_NOW_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: NOW: Retry failed./) { + $counts{$CC3000_NOW_FAILED_RETRIES} += 1; + + # RAIN=RESET + } elsif (/cc3000: RAIN=RESET: Reading cmd echo timed out/) { + $counts{$CC3000_RAIN_RESET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: RAIN=RESET: Accepting empty string as cmd echo./) { + $counts{$CC3000_RAIN_RESET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: RAIN=RESET: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_RAIN_RESET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: RAIN=RESET: Retry failed./) { + $counts{$CC3000_RAIN_RESET_FAILED_RETRIES} += 1; + + # RAIN + } elsif (/cc3000: RAIN: Reading cmd echo timed out/) { + $counts{$CC3000_RAIN_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: RAIN: Accepting empty string as cmd echo./) { + $counts{$CC3000_RAIN_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: RAIN: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_RAIN_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: RAIN: Retry failed./) { + $counts{$CC3000_RAIN_FAILED_RETRIES} += 1; + + # STATION=X + } elsif (/cc3000: STATION=.*: Reading cmd echo timed out/) { + $counts{$CC3000_STATION_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: STATION=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_STATION_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: STATION=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_STATION_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: STATION=.*: Retry failed./) { + $counts{$CC3000_STATION_SET_FAILED_RETRIES} += 1; + + # STATION + } elsif (/cc3000: STATION: Reading cmd echo timed out/) { + $counts{$CC3000_STATION_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: STATION: Accepting empty string as cmd echo./) { + $counts{$CC3000_STATION_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: STATION: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_STATION_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: STATION: Retry failed./) { + $counts{$CC3000_STATION_FAILED_RETRIES} += 1; + + # TIME=? + } elsif (/cc3000: TIME=\?: Reading cmd echo timed out/) { + $counts{$CC3000_TIME_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: TIME=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_TIME_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: TIME=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_TIME_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: TIME=\?: Retry failed./) { + $counts{$CC3000_TIME_FAILED_RETRIES} += 1; + + # TIME=XX + } elsif (/cc3000: TIME=.*: Reading cmd echo timed out/) { + $counts{$CC3000_TIME_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: TIME=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_TIME_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: TIME=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_TIME_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: TIME=.*: Retry failed./) { + $counts{$CC3000_TIME_SET_FAILED_RETRIES} += 1; + + # UNITS=? + } elsif (/cc3000: UNITS=\?: Reading cmd echo timed out/) { + $counts{$CC3000_UNITS_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: UNITS=\?: Accepting empty string as cmd echo./) { + $counts{$CC3000_UNITS_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: UNITS=\?: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_UNITS_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: UNITS=\?: Retry failed./) { + $counts{$CC3000_UNITS_FAILED_RETRIES} += 1; + + # UNITS=XX + } elsif (/cc3000: UNITS=.*: Reading cmd echo timed out/) { + $counts{$CC3000_UNITS_SET_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: UNITS=.*: Accepting empty string as cmd echo./) { + $counts{$CC3000_UNITS_SET_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: UNITS=.*: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_UNITS_SET_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: UNITS=.*: Retry failed./) { + $counts{$CC3000_UNITS_SET_FAILED_RETRIES} += 1; + + # VERSION + } elsif (/cc3000: VERSION: Reading cmd echo timed out/) { + $counts{$CC3000_VERSION_CMD_ECHO_TIMED_OUT} += 1; + } elsif (/cc3000: VERSION: Accepting empty string as cmd echo./) { + $counts{$CC3000_VERSION_MISSING_COMMAND_ECHO} += 1; + } elsif (/cc3000: VERSION: Retry worked./) { + push @cc3000_retry_info, $_; + $counts{$CC3000_VERSION_SUCCESSFUL_RETRIES} += 1; + } elsif (/cc3000: VERSION: Retry failed./) { + $counts{$CC3000_VERSION_FAILED_RETRIES} += 1; + + } elsif (/engine: Error reading time: Failed to get time/) { + $counts{$CC3000_GET_TIME_FAILED} += 1; + + } elsif (/cc3000: Set time to /) { + $counts{$CC3000_SET_TIME_SUCCEEDED} += 1; + + } elsif (/cc3000: MEM=CLEAR succeeded./) { + $counts{$CC3000_MEM_CLEAR_SUCCEEDED} += 1; + + } elsif (/cc3000: Failed attempt .* of .* to get data: command: Command failed/) { + $counts{$CC3000_FAILED_CMD} += 1; + + # cc3000: NOW: times: 0.000036 0.000085 0.022008 0.027476 + } elsif (/cc3000: .*: times: .*/) { + push @cc3000_timings, $_; + + # cc3000: Logger is at 11475 records, logger clearing threshold is 10000 + } elsif (/cc3000: Logger is at.*/) { + push @cc3000_mem_clear_info, $_; + # cc3000: Clearing all records from logger + } elsif (/cc3000: Clearing all records from logger/) { + push @cc3000_mem_clear_info, $_; + # cc3000: MEM=CLEAR: The resetting of timeout to 20 took 0.001586 seconds. + # cc3000: MEM=CLEAR: The resetting of timeout to 1 took 0.001755 seconds. + } elsif (/cc3000: MEM=CLEAR: The resetting of timeout to .*/) { + push @cc3000_mem_clear_info, $_; + + } elsif (/Rain counter reset detected:/) { + push @rain_counter_reset, $_; + + } elsif (/html.*Connection timed out\. rsync: connection unexpectedly closed/) { + $counts{$RSYNC_REPORT_CONN_TIMEOUT} += 1; + } elsif (/public_html.*No route to host. rsync: connection unexpectedly closed/) { + $counts{$RSYNC_REPORT_NO_ROUTE_TO_HOST} += 1; + + } elsif (/gauge-data\.txt.*No route to host.*rsync: connection unexpectedly closed/) { + $counts{$RSYNC_GAUGE_DATA_NO_ROUTE_TO_HOST} += 1; + } elsif (/gauge-data.txt.*Could not resolve hostname/) { + $counts{$RSYNC_GAUGE_DATA_CANT_RESOLVE_HOST} += 1; + } elsif (/gauge-data\.txt.*Connection refused.*rsync: connection unexpectedly closed/) { + $counts{$RSYNC_GAUGE_DATA_CONN_REFUSED} += 1; + } elsif (/gauge-data\.txt.*Connection timed out.*rsync: connection unexpectedly closed/) { + $counts{$RSYNC_GAUGE_DATA_CONN_TIMEOUT} += 1; + } elsif (/gauge-data\.txt.*rsync error: timeout in data send\/receive/) { + $counts{$RSYNC_GAUGE_DATA_IO_TIMEOUT} += 1; + } elsif (/rsync_data: skipping packet .* with age:/) { + $counts{$RSYNC_GAUGE_DATA_SKIP_PACKET} += 1; + } elsif (/rsyncupload:.*gauge-data\.txt'.*reported errors: rsync:.*write error:/) { + $counts{$RSYNC_GAUGE_DATA_WRITE_ERRORS} += 1; + } elsif (/rsyncupload:.*gauge-data.*closed by remote host/) { + $counts{$RSYNC_GAUGE_DATA_REMOTE_CLOSED} += 1; + + } elsif (/forecast: .*Thread: ([^:]+): generated 1 forecast record/) { + $forecast_records{$1} += 1; + $counts{$FORECAST_RECORDS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): got (\d+) forecast records/) { + $forecast_records{$1} += $2; + $counts{$FORECAST_RECORDS} += $2; + } elsif (/forecast: .*Thread: ([^:]+): deleted forecasts/) { + $forecast_prunings{$1} += 1; + $counts{$FORECAST_PRUNINGS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): downloading forecast/) { + $forecast_downloads{$1} += 1; + $counts{$FORECAST_DOWNLOADS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): download forecast/) { + $forecast_downloads{$1} += 1; + $counts{$FORECAST_DOWNLOADS} += 1; + } elsif (/forecast: .*Thread: ([^:]+): saving (\d+) forecast records/) { + $forecast_saved{$1} += $2; + $counts{$FORECAST_SAVED} += $2; + } elsif (/awekas: Failed upload to (AWEKAS)/ || + /cosm: Failed upload to (COSM)/ || + /emoncms: Failed upload to (EmonCMS)/ || + /owm: Failed upload to (OpenWeatherMap)/ || + /seg: Failed upload to (SmartEnergyGroups)/ || + /wbug: Failed upload to (WeatherBug)/) { + $publish_fails{$1} += 1; + push @upload_errors, $_; + } elsif (/last message repeated/ || + /archive: Created and initialized/ || + /reportengine: Running reports for latest time/ || + /reportengine: Found configuration file/ || + /ftpgenerator: FTP upload not requested/ || + /reportengine: Running report / || # only when debug=1 + /rsyncgenerator: rsync upload not requested/ || + /restful: station will register with/ || + /restful: Registration interval/ || + /\*\*\*\* Registration interval/ || + /restful: Registration successful/ || + /restful: Attempting to register/ || + /stats: Back calculated schema/ || + /stats: Backfilling stats database/ || + /stats: backfilled \d+ days of statistics/ || + /stats: stats database up to date/ || + /stats: Created schema for statistical database/ || + /stats: Schema exists with/ || + /\*\*\*\* \'station\'/ || + /\*\*\*\* required parameter \'\'station\'\'/ || + /\*\*\*\* Waiting 60 seconds then retrying/ || + /engine: The archive interval in the configuration file/ || + /engine: Station does not support reading the time/ || + /engine: Starting main packet loop/ || + /engine: Shut down StdReport thread/ || + /engine: Shut down StdRESTful thread/ || + /engine: Shutting down StdReport thread/ || + /engine: Loading service/ || + /engine: Finished loading service/ || + /engine: Using archive interval of/ || + /engine: Using archive database/ || + /engine: Using configuration file/ || + /engine: Using stats database/ || + /engine: Using station hardware archive interval/ || + /engine: Using config file archive interval of/ || + /engine: Record generation will be attempted in/ || + /engine: StdConvert target unit is/ || + /engine: Data will not be posted to/ || + /engine: Data will be posted to / || + /engine: Started thread for RESTful upload sites./ || + /engine: No RESTful upload sites/ || + /engine: Loading station type/ || + /engine: Initializing weewx version/ || + /engine: Initializing engine/ || + /engine: Using Python/ || + /engine: Terminating weewx version/ || + /engine: PID file is / || + /engine: Use LOOP data in/ || + /engine: Received signal/ || + /engine: Daily summaries up to date/ || + /engine: Using binding/ || + /engine: Archive will use/ || + /engine: Platform/ || + /engine: Locale is/ || + /wxservices: The following values will be calculated:/ || + /wxservices: The following algorithms will be used for calculations:/ || + /manager: Starting backfill of daily summaries/ || + /manager: Created daily summary tables/ || + /cheetahgenerator: Running / || + /cheetahgenerator: skip/ || + /VantagePro: Catch up complete/ || + /VantagePro: successfully woke up console/ || + /VantagePro: Getting archive packets since/ || + /VantagePro: Retrieving/ || + /VantagePro: DMPAFT complete/ || + /VantagePro: Requesting \d+ LOOP packets/ || + /VantagePro: Clock set to/ || + /VantagePro: Opened up serial port/ || + /owfss: interface is/ || + /owfss: sensor map is/ || + /owfss: sensor type map is/ || + /acurite: driver version is/ || + /fousb: driver version is/ || + /fousb: found station on USB/ || + /fousb: altitude is/ || + /fousb: archive interval is/ || + /fousb: pressure offset is/ || + /fousb: polling mode is/ || + /fousb: polling interval is/ || + /fousb: using \S+ polling mode/ || + /fousb: ptr changed/ || + /fousb: new ptr/ || + /fousb: new data/ || + /fousb: live synchronised/ || + /fousb: log synchronised/ || + /fousb: log extended/ || + /fousb: delay/ || + /fousb: avoid/ || + /fousb: setting sensor clock/ || + /fousb: setting station clock/ || + /fousb: estimated log time/ || + /fousb: returning archive record/ || + /fousb: packet timestamp/ || + /fousb: log timestamp/ || + /fousb: found \d+ archive records/ || + /fousb: get \d+ records since/ || + /fousb: synchronised to/ || + /fousb: pressures:/ || + /fousb: status / || + /ws28xx: MainThread: driver version is/ || + /ws28xx: MainThread: frequency is/ || + /ws28xx: MainThread: altitude is/ || + /ws28xx: MainThread: pressure offset is/ || + /ws28xx: MainThread: found transceiver/ || + /ws28xx: MainThread: manufacturer: LA CROSSE TECHNOLOGY/ || + /ws28xx: MainThread: product: Weather Direct Light Wireless/ || + /ws28xx: MainThread: interface/ || + /ws28xx: MainThread: base frequency/ || + /ws28xx: MainThread: frequency correction/ || + /ws28xx: MainThread: adjusted frequency/ || + /ws28xx: MainThread: transceiver identifier/ || + /ws28xx: MainThread: transceiver serial/ || + /ws28xx: MainThread: execute/ || + /ws28xx: MainThread: setState/ || + /ws28xx: MainThread: setPreamPattern/ || + /ws28xx: MainThread: setRX/ || + /ws28xx: MainThread: readCfgFlash/ || + /ws28xx: MainThread: setFrequency/ || + /ws28xx: MainThread: setDeviceID/ || + /ws28xx: MainThread: setTransceiverSerialNumber/ || + /ws28xx: MainThread: setCommModeInterval/ || + /ws28xx: MainThread: frequency registers/ || + /ws28xx: MainThread: initTransceiver/ || + /ws28xx: MainThread: startRFThread/ || + /ws28xx: MainThread: stopRFThread/ || + /ws28xx: MainThread: detach kernel driver/ || + /ws28xx: MainThread: release USB interface/ || + /ws28xx: MainThread: claiming USB interface/ || + /ws28xx: MainThread: CCommunicationService.init/ || + /ws28xx: MainThread: Scanning historical records/ || + /ws28xx: MainThread: Scanned/ || + /ws28xx: MainThread: Found/ || + /ws28xx: RFComm: console is paired to device/ || + /ws28xx: RFComm: starting rf communication/ || + /ws28xx: RFComm: stopping rf communication/ || + /ws28xx: RFComm: setTX/ || + /ws28xx: RFComm: setRX/ || + /ws28xx: RFComm: setState/ || + /ws28xx: RFComm: getState/ || + /ws28xx: RFComm: setFrame/ || + /ws28xx: RFComm: getFrame/ || + /ws28xx: RFComm: InBuf/ || + /ws28xx: RFComm: OutBuf/ || + /ws28xx: RFComm: generateResponse: sleep/ || + /ws28xx: RFComm: generateResponse: id/ || + /ws28xx: RFComm: handleCurrentData/ || + /ws28xx: RFComm: handleHistoryData/ || + /ws28xx: RFComm: handleNextAction/ || + /ws28xx: RFComm: handleConfig/ || + /ws28xx: RFComm: buildACKFrame/ || + /ws28xx: RFComm: buildTimeFrame/ || + /ws28xx: RFComm: buildConfigFrame/ || + /ws28xx: RFComm: setCurrentWeather/ || + /ws28xx: RFComm: setHistoryData/ || + /ws28xx: RFComm: setDeviceCS/ || + /ws28xx: RFComm: setRequestType/ || + /ws28xx: RFComm: setResetMinMaxFlags/ || + /ws28xx: RFComm: setLastStatCache/ || + /ws28xx: RFComm: setLastConfigTime/ || + /ws28xx: RFComm: setLastHistoryIndex/ || + /ws28xx: RFComm: setLastHistoryDataTime/ || + /ws28xx: RFComm: CCurrentWeatherData.read/ || + /ws28xx: RFComm: CWeatherStationConfig.read/ || + /ws28xx: RFComm: CHistoryDataSet.read/ || + /ws28xx: RFComm: testConfigChanged/ || + /ws28xx: RFComm: SetTime/ || + /ws23xx: driver version is / || + /ws23xx: polling interval is / || + /ws23xx: station archive interval is / || + /ws23xx: using computer clock with / || + /ws23xx: using \d+ sec\S* polling interval/ || + /ws23xx: windchill will be / || + /ws23xx: dewpoint will be / || + /ws23xx: pressure offset is / || + /ws23xx: serial port is / || + /ws23xx: downloading \d+ records from station/ || + /ws23xx: count is \d+ to satisfy timestamp/ || + /ws23xx: windchill: / || + /ws23xx: dewpoint: / || + /ws23xx: station clock is / || + /te923: driver version is / || + /te923: polling interval is / || + /te923: windchill will be / || + /te923: sensor map is / || + /te923: battery map is / || + /te923: Found device on USB/ || + /te923: TMP\d / || + /te923: UVX / || + /te923: PRS / || + /te923: WGS / || + /te923: WSP / || + /te923: WDR / || + /te923: RAIN / || + /te923: WCL / || + /te923: STT / || + /te923: Bad read \(attempt \d of/ || # normal when debugging + /te923: usb error.* No data available/ || # normal when debug=1 + /cc3000: Archive interval:/ || + /cc3000: Calculated checksum/ || + /cc3000: Channel:/ || + /cc3000: Charger status:/ || + /cc3000: Clear logger at/ || + /cc3000: Clear memory/ || + /cc3000: Close serial port/ || + /cc3000: Downloaded \d+ new records/ || + /cc3000: Downloaded \d+ records, yielded \d+/ || + /cc3000: Downloading new records/ || + /cc3000: Driver version is/ || + /cc3000: Firmware:/ || + /cc3000: Found checksum at/ || + /cc3000: Flush input bugger/ || + /cc3000: Flush output bugger/ || + /cc3000: gen_records: Requested \d+ latest of \d+ records/ || + /cc3000: gen_records_since_ts: Asking for \d+ records/ || + /cc3000: GenStartupRecords: since_ts/ || + /cc3000: Get baro/ || + /cc3000: Get channel/ || + /cc3000: Get charger/ || + /cc3000: Get daylight saving/ || + /cc3000: Get firmware version/ || + /cc3000: Get header/ || + /cc3000: Get logging interval/ || + /cc3000: Get memory status/ || + /cc3000: Get rain total/ || + /cc3000: Get time/ || + /cc3000: Get units/ || + /cc3000: Header:/ || + /cc3000: Memory:/ || + /cc3000: No rain in packet:/ || + /cc3000: Open serial port/ || + /cc3000: Packet:/ || + /cc3000: Parsed:/ || + /cc3000: Polling interval is/ || + /cc3000: Read:/ || + /cc3000: Reset rain counter/ || + /cc3000: Sensor map is/ || + /cc3000: Set barometer offset to/ || + /cc3000: Set channel to/ || + /cc3000: Set DST to/ || + /cc3000: Set echo to/ || + /cc3000: Set logging interval to/ || + /cc3000: Set units to/ || + /cc3000: Units:/ || + /cc3000: Using computer time/ || + /cc3000: Using serial port/ || + /cc3000: Using station time/ || + /cc3000: Values:/ || + /cc3000: Write:/ || + /owfs: driver version is / || + /owfs: interface is / || + /owfs: polling interval is / || + /owfs: sensor map is / || + /cmon: service version is/ || + /cmon: cpuinfo: / || + /cmon: sysinfo: / || + /cmon: Skipping record/ || + /forecast: .* starting thread/ || + /forecast: .* terminating thread/ || + /forecast: .* not yet time to do the forecast/ || + /forecast: .* last forecast issued/ || + /forecast: .* using table/ || + /forecast: .* tstr=/ || + /forecast: .* interval=\d+ max_age=/ || + /forecast: .* deleted forecasts/ || + /forecast: .* saved \d+ forecast records/ || + /forecast: ZambrettiThread: Zambretti: generating/ || + /forecast: ZambrettiThread: Zambretti: pressure/ || + /forecast: ZambrettiThread: Zambretti: code is/ || + /forecast: NWSThread: NWS: forecast matrix/ || + /forecast: XTideThread: XTide: tide matrix/ || + /forecast: XTideThread: XTide: generating tides/ || + /forecast: XTideThread: XTide: got no tidal events/ || + /forecast: .*: forecast version/ || + /restx: StationRegistry: Station will/ || + /restx: StationRegistry: Registration not requested/ || + /restx: .*Data will be uploaded/ || + /restx: .*wait interval/ || + /restx: Shut down/ || + /restx: \S+: Data will not be posted/ || + /restx: \S+: service version is/ || + /restx: \S+: skipping upload/ || + /restx: \S+: desired unit system is/ || + /restx: AWEKAS: Posting not enabled/ || + /restx: Wunderground: Posting not enabled/ || + /restx: PWSweather: Posting not enabled/ || + /restx: CWOP: Posting not enabled/ || + /restx: WOW: Posting not enabled/ || + /restx: AWEKAS: Data for station/ || + /restx: Wunderground: Data for station/ || + /restx: PWSweather: Posting not enabled/ || + /restx: CWOP: Posting not enabled/ || + /restx: WOW: Data for station/ || + /restx: AWEKAS: url/ || + /restx: EmonCMS: url/ || + /restx: EmonCMS: prefix is/ || + /restx: EmonCMS: desired unit system is/ || + /restx: EmonCMS: using specified input map/ || + /restx: MQTT: Topic is/ || + /restx: OWM: data/ || + /restx: SEG: data/ || + /restx: Twitter: binding is/ || + /restx: Twitter: Data will be tweeted/ || + /restx: WeatherBug: url/ || + /restx: Xively: data/ || + /restx: Xively: url/ || + /ftpupload: attempt/ || + /ftpupload: Made directory/ || + /rsyncupload: rsync executed in/ || + /awekas: code/ || + /awekas: read/ || + /awekas: url/ || + /awekas: data/ || + /cosm: code/ || + /cosm: read/ || + /cosm: url/ || + /cosm: data/ || + /emoncms: code/ || + /emoncms: read/ || + /emoncms: data/ || + /emoncms: url/ || + /owm: code/ || + /owm: read/ || + /owm: url/ || + /owm: data/ || + /seg: code/ || + /seg: read/ || + /seg: url/ || + /seg: data/ || + /wbug: code/ || + /wbug: read/ || + /wbug: url/ || + /wbug: data/ || + /GaugeGenerator:/) { + # ignore + } elsif (! /weewx/) { + # ignore + } else { + push @unmatched, $_; + } +} + +if($clockcount > 0) { + my $clockskew = $clocksum / $clockcount; + print "average station clock skew: $clockskew\n"; + print " min: $clockmin max: $clockmax samples: $clockcount\n"; + print "\n"; +} + +foreach my $slabel (sort keys %summaries) { + my $s = $summaries{$slabel}; + if(scalar(keys %$s)) { + print "$slabel:\n"; + foreach my $k (sort keys %$s) { + next if $s->{$k} == 0; + printf(" %-45s %6d\n", $k, $s->{$k}); + } + print "\n"; + } +} + +foreach my $k (sort keys %itemized) { + report($k, $itemized{$k}) if scalar @{$itemized{$k}} > 0; +} + +report("Unmatched Lines", \@unmatched) if $#unmatched >= 0; + +exit 0; + +sub report { + my($label, $aref, $href) = @_; + print "\n$label:\n"; + foreach my $x (@$aref) { + my $str = $x; + if ($href && $href->{$x} > 1) { + $str .= " ($href->{$x} times)"; + } + print " $str\n"; + } +} diff --git a/dist/weewx-5.0.2/src/weewx_data/util/newsyslog.d/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/newsyslog.d/weewx.conf new file mode 100644 index 0000000..b303abb --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/newsyslog.d/weewx.conf @@ -0,0 +1,8 @@ +# weewx newsyslog configuration file for bsd +# +# this file controls log rotation +# +# put this in /usr/local/etc/newsyslog.d/weewx.conf +# +/var/log/weewx.log 644 5 * $D0 +/var/log/weewx_error.log 644 5 * $W0 diff --git a/dist/weewx-5.0.2/src/weewx_data/util/nginx/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/nginx/weewx.conf new file mode 100644 index 0000000..d782d18 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/nginx/weewx.conf @@ -0,0 +1,10 @@ +# Use a configuration like this to make WeeWX reports show up in an nginx +# web server. This makes the URL '/weewx' serve files from the directory +# '/home/weewx/public_html' - adjust as appropriate for your WeeWX install. +# Place this file in the appropriate place within your nginx web server +# configuration, typically the 'conf.d' or 'conf-enabled' directory. + +location /weewx { + root /home/weewx/public_html; + index index.html; +} diff --git a/dist/weewx-5.0.2/src/weewx_data/util/rsyslog.d/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/rsyslog.d/weewx.conf new file mode 100644 index 0000000..00ce3a9 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/rsyslog.d/weewx.conf @@ -0,0 +1,12 @@ +# Put messages from WeeWX into file(s) separate from the system log file + +# If you want log messages from each application in a separate file, +# then uncomment the following two lines, and comment the weewx.log line. +#$template WEEWX_LOGFILE,"/var/log/weewx/%programname%.log" +#if $programname startswith 'wee' then ?WEEWX_LOGFILE + +# Put log messages from all WeeWX applications into a single file +if $programname startswith 'wee' then /var/log/weewx/weewx.log + +# Finish the WeeWX rules +if $programname startswith 'wee' then stop diff --git a/dist/weewx-5.0.2/src/weewx_data/util/solaris/weewx-smf.xml b/dist/weewx-5.0.2/src/weewx_data/util/solaris/weewx-smf.xml new file mode 100644 index 0000000..dd19a16 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/solaris/weewx-smf.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dist/weewx-5.0.2/src/weewx_data/util/syslog.d/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/syslog.d/weewx.conf new file mode 100644 index 0000000..526c434 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/syslog.d/weewx.conf @@ -0,0 +1,11 @@ +# weewx syslog configuration file for bsd +# +# ensure that weewx messages at every log level are sent to file, since by +# default freebsd only sends notice and higher to file. +# +# put this in /usr/local/etc/syslog.d/weewx.conf +# then run "sudo service syslogd reload" +# +!weewxd,weectl +*.* /var/log/weewx.log +!* diff --git a/dist/weewx-5.0.2/src/weewx_data/util/systemd/weewx.service b/dist/weewx-5.0.2/src/weewx_data/util/systemd/weewx.service new file mode 100644 index 0000000..ff16cba --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/systemd/weewx.service @@ -0,0 +1,19 @@ +# systemd service configuration file for WeeWX + +[Unit] +Description=WeeWX weather system +Documentation=https://weewx.com/docs +Requires=time-sync.target +After=time-sync.target +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart=WEEWX_PYTHON WEEWXD WEEWX_CFGDIR/weewx.conf +StandardOutput=null +StandardError=journal+console +User=WEEWX_USER +Group=WEEWX_GROUP + +[Install] +WantedBy=multi-user.target diff --git a/dist/weewx-5.0.2/src/weewx_data/util/systemd/weewx@.service b/dist/weewx-5.0.2/src/weewx_data/util/systemd/weewx@.service new file mode 100644 index 0000000..2450f4b --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/systemd/weewx@.service @@ -0,0 +1,28 @@ +# systemd service template file for running multiple instances of weewxd +# +# Each instance XXX must have its own config, database, and HTML_ROOT: +# +# item name where to specify +# -------- ----------------------------- ---------------------------- +# config ~/weewx-data/XXX.conf configuration directory +# database_name ~/weewx-data/archive/XXX.sdb specified in XXX.conf +# HTML_ROOT ~/weewx-data/public_html/XXX specified in XXX.conf + +[Unit] +Description=WeeWX %i +Documentation=https://weewx.com/docs +Requires=time-sync.target +After=time-sync.target +Wants=network-online.target +After=network-online.target +PartOf=weewx.service + +[Service] +ExecStart=WEEWX_PYTHON WEEWXD --log-label weewxd-%i WEEWX_CFGDIR/%i.conf +StandardOutput=null +StandardError=journal+console +User=WEEWX_USER +Group=WEEWX_GROUP + +[Install] +WantedBy=multi-user.target diff --git a/dist/weewx-5.0.2/src/weewx_data/util/tmpfiles.d/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/util/tmpfiles.d/weewx.conf new file mode 100644 index 0000000..36e7ec8 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/tmpfiles.d/weewx.conf @@ -0,0 +1 @@ +d /run/weewx 0755 weewx weewx - - diff --git a/dist/weewx-5.0.2/src/weewx_data/util/udev/rules.d/weewx.rules b/dist/weewx-5.0.2/src/weewx_data/util/udev/rules.d/weewx.rules new file mode 100644 index 0000000..4df1199 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/util/udev/rules.d/weewx.rules @@ -0,0 +1,42 @@ +# udev rules for hardware recognized by weewx +# The rules in this file make recognized devices accessible to non-root users. +# +# Copy this file to /etc/udev/rules.d/60-weewx.rules +# +# To load the rules manually: +# sudo udevadm control --reload-rules +# sudo udevadm trigger +# If udevadm does not work, you may have to unplug then replug the USB device +# +# To inspect the attributes of devices: +# lsusb +# sudo udevadm info --attribute-walk --name /dev/bus/usb/XXX/YYY +# Where XXX and YYY are obtained from the lsusb output + +# acurite +SUBSYSTEM=="usb",ATTRS{idVendor}=="24c0",ATTRS{idProduct}=="0003",MODE="0666" + +# fine offset usb +SUBSYSTEM=="usb",ATTRS{idVendor}=="1941",ATTRS{idProduct}=="8021",MODE="0666" + +# te923 +SUBSYSTEM=="usb",ATTRS{idVendor}=="1130",ATTRS{idProduct}=="6801",MODE="0666" + +# oregon scientific wmr100 +SUBSYSTEM=="usb",ATTRS{idVendor}=="0FDE",ATTRS{idProduct}=="CA01",MODE="0666" + +# oregon scientific wmr200 +SUBSYSTEM=="usb",ATTRS{idVendor}=="0FDE",ATTRS{idProduct}=="CA01",MODE="0666" + +# oregon scientific wmr300 +SUBSYSTEM=="usb",ATTRS{idVendor}=="0FDE",ATTRS{idProduct}=="CA08",MODE="0666" + +# ws28xx transceiver +SUBSYSTEM=="usb",ATTRS{idVendor}=="6666",ATTRS{idProduct}=="5555",MODE="0666" + +# rainwise cc3000 connected via usb-serial +SUBSYSTEM=="tty",ATTRS{idVendor}=="0403",ATTRS{idProduct}=="6001",MODE="0666",SYMLINK+="cc3000" + +# davis vantage connected via usb-serial +SUBSYSTEM=="tty",ATTRS{idVendor}=="10c4",ATTRS{idProduct}=="ea60",MODE="0666",SYMLINK+="vantage" +SUBSYSTEM=="tty",ATTRS{idVendor}=="10c4",ATTRS{idProduct}=="ea61",MODE="0666",SYMLINK+="vantage" diff --git a/dist/weewx-5.0.2/src/weewx_data/weewx.conf b/dist/weewx-5.0.2/src/weewx_data/weewx.conf new file mode 100644 index 0000000..42f7931 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewx_data/weewx.conf @@ -0,0 +1,505 @@ +# WEEWX CONFIGURATION FILE +# +# Copyright (c) 2009-2024 Tom Keffer +# See the file LICENSE.txt for your rights. + +############################################################################## + +# This section is for general configuration information. + +# Set to 1 for extra debug info, otherwise comment it out or set to zero. +debug = 0 + +# Whether to log successful operations. May get overridden below. +log_success = True + +# Whether to log unsuccessful operations. May get overridden below. +log_failure = True + +# This configuration file was created by ... +version = 5.0.2 + +############################################################################## + +# This section is for information about the station. + +[Station] + + # Description of the station location, such as your town. + location = WeeWX station + + # Latitude in decimal degrees. Negative for southern hemisphere. + latitude = 0.0 + # Longitude in decimal degrees. Negative for western hemisphere. + longitude = 0.0 + + # Altitude of the station, with the unit it is in. This is used only + # if the hardware cannot supply a value. + altitude = 0, foot # Choose 'foot' or 'meter' for unit + + # Set to type of station hardware. There must be a corresponding stanza + # in this file, which includes a value for the 'driver' option. + station_type = Simulator + + # If you have a website, you may specify an URL. The URL is required if you + # intend to register your station. The URL must include the scheme, for + # example, "http://" or "https://" + #station_url = https://www.example.com + + # The start of the rain year (1=January; 10=October, etc.). This is + # downloaded from the station if the hardware supports it. + rain_year_start = 1 + + # Start of week (0=Monday, 6=Sunday) + week_start = 6 + +############################################################################## + +[Simulator] + # This section is for the weewx weather station simulator. + + # The time (in seconds) between LOOP packets. + loop_interval = 2.5 + + # The simulator mode can be either 'simulator' or 'generator'. + # Real-time simulator. Sleep between each LOOP packet. + mode = simulator + # Generator. Emit LOOP packets as fast as possible (useful for testing). + #mode = generator + + # The start time. Format is YYYY-mm-ddTHH:MM. If not specified, the + # default is to use the present time. + #start = 2011-01-01T00:00 + + # The driver to use. + driver = weewx.drivers.simulator + +############################################################################## + +# This section is for uploading data to Internet sites + +[StdRESTful] + + # Uncomment and change to override logging for uploading services. + # log_success = True + # log_failure = True + + [[StationRegistry]] + # To register this weather station at weewx.com, set this to true, and + # set option 'station_url', located in the [Station] section above. + register_this_station = False + + [[AWEKAS]] + # This section is for configuring posts to AWEKAS. + + # If you wish to post to AWEKAS, set the option 'enable' to true, then + # specify a username and password. + # Use quotes around the password to guard against parsing errors. + enable = false + username = replace_me + password = replace_me + + [[CWOP]] + # This section is for configuring posts to CWOP. + + # If you wish to post to CWOP, set the option 'enable' to true, + # then specify the station ID (e.g., CW1234). + enable = false + station = replace_me + # If this is an APRS (radio amateur) station, specify the + # passcode (e.g., 12345). Otherwise, ignore. + passcode = replace_me + + [[PWSweather]] + # This section is for configuring posts to PWSweather.com. + + # If you wish to post to PWSweather.com, set the option 'enable' to + # true, then specify a station and password. + # Use quotes around the password to guard against parsing errors. + enable = false + station = replace_me + password = replace_me + + [[WOW]] + # This section is for configuring posts to WOW. + + # If you wish to post to WOW, set the option 'enable' to true, then + # specify a station and password. + # Use quotes around the password to guard against parsing errors. + enable = false + station = replace_me + password = replace_me + + [[Wunderground]] + # This section is for configuring posts to the Weather Underground. + + # If you wish to post to the Weather Underground, set the option + # 'enable' to true, then specify a station (e.g., 'KORHOODR3'). Use + # the station key (find it at + # https://www.wunderground.com/member/devices) for the password. + enable = false + station = replace_me + password = replace_me + + # Set the following to True to have weewx use the WU "Rapidfire" + # protocol. Not all hardware can support it. See the User's Guide. + rapidfire = False + +############################################################################## + +# This section specifies what reports, using which skins, to generate. + +[StdReport] + + # Where the skins reside, relative to WEEWX_ROOT + SKIN_ROOT = skins + + # Where the generated reports should go, relative to WEEWX_ROOT + HTML_ROOT = public_html + + # Uncomment and change to override logging for reports. + # log_success = True + # log_failure = True + + # The database binding indicates which data should be used in reports. + data_binding = wx_binding + + # Each of the following subsections defines a report that will be run. + # See the customizing guide to change the units, plot types and line + # colors, modify the fonts, display additional sensor data, and other + # customizations. Many of those changes can be made here by overriding + # parameters, or by modifying templates within the skin itself. + + [[SeasonsReport]] + # The SeasonsReport uses the 'Seasons' skin, which contains the + # images, templates and plots for the report. + skin = Seasons + enable = true + + [[SmartphoneReport]] + # The SmartphoneReport uses the 'Smartphone' skin, and the images and + # files are placed in a dedicated subdirectory. + skin = Smartphone + enable = false + HTML_ROOT = public_html/smartphone + + [[MobileReport]] + # The MobileReport uses the 'Mobile' skin, and the images and files + # are placed in a dedicated subdirectory. + skin = Mobile + enable = false + HTML_ROOT = public_html/mobile + + [[StandardReport]] + # This is the old "Standard" skin. By default, it is not enabled. + skin = Standard + enable = false + + [[FTP]] + # FTP'ing the results to a webserver is treated as just another report, + # albeit one with an unusual report generator! + skin = Ftp + + # If you wish to use FTP, set "enable" to "true", then fill out the + # next four lines. + # Use quotes around the password to guard against parsing errors. + enable = false + user = replace_me + password = replace_me + server = replace_me # The ftp server name, e.g, www.myserver.org + path = replace_me # The destination directory, e.g., /weather + + # Set to True for an FTP over TLS (FTPS) connection. Not all servers + # support this. + secure_ftp = False + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Most FTP servers use port 21. + port = 21 + + # Set to 1 to use passive mode, zero for active mode + passive = 1 + + [[RSYNC]] + # rsync'ing to a webserver is treated as just another report. + skin = Rsync + + # If you wish to use rsync, you must configure passwordless ssh using + # public/private key authentication from the user account that weewx + # runs to the user account on the remote machine where the files + # will be copied. + # + # If you wish to use rsync, set "enable" to "true", then + # fill out server, user, and path. + # The server should appear in your .ssh/config file. + # The user is the username used in the identity file. + # The path is the destination directory, such as /var/www/html/weather. + # Be sure that the user has write permissions on the destination! + enable = false + server = replace_me + user = replace_me + path = replace_me + + # To upload files from something other than what HTML_ROOT is set + # to above, specify a different HTML_ROOT here. + #HTML_ROOT = public_html + + # Rsync can be configured to remove files from the remote server if + # they don't exist under HTML_ROOT locally. USE WITH CAUTION: if you + # make a mistake in the remote path, you could could unintentionally + # cause unrelated files to be deleted. Set to 1 to enable remote file + # deletion, zero to allow files to accumulate remotely. + delete = 0 + + # Options in the [[Defaults]] section below will apply to all reports. + # What follows are a few of the more popular options you may want to + # uncomment, then change. + [[Defaults]] + + # Which language to use for all reports. Not all skins support all + # languages. You can override this for individual reports. + lang = en + + # Which unit system to use for all reports. Choices are 'us', 'metric', + # or 'metricwx'. You can override this for individual reports. + unit_system = us + + [[[Units]]] + + # Option "unit_system" above sets the general unit system, but + # overriding specific unit groups is possible. These are popular + # choices. Uncomment and set as appropriate. The unit is always + # in the singular, e.g., 'mile_per_hour', NOT 'miles_per_hour' + [[[[Groups]]]] + # group_altitude = meter # Options are 'foot' or 'meter' + # group_pressure = mbar # Options are 'inHg', 'mmHg', 'mbar', or 'hPa' + # group_rain = mm # Options are 'inch', 'cm', or 'mm' + # group_rainrate = mm_per_hour # Options are 'inch_per_hour', 'cm_per_hour', or 'mm_per_hour' + # group_temperature = degree_C # Options are 'degree_C', 'degree_F', or 'degree_K' + # The following line is used to keep the above lines indented + # properly. It can be ignored. + unused = unused + + # Uncommenting the following section frequently results in more + # attractive formatting of times and dates, but may not work in + # your locale. + [[[[TimeFormats]]]] + # day = %H:%M + # week = %H:%M on %A + # month = %d-%b-%Y %H:%M + # year = %d-%b-%Y %H:%M + # rainyear = %d-%b-%Y %H:%M + # current = %d-%b-%Y %H:%M + # ephem_day = %H:%M + # ephem_year = %d-%b-%Y %H:%M + # The following line is used to keep the above lines indented + # properly. It can be ignored. + unused = unused + + [[[Labels]]] + # Users frequently change the labels for these observation types. + [[[[Generic]]]] + # inHumidity = Inside Humidity + # inTemp = Inside Temperature + # outHumidity = Outside Humidity + # outTemp = Outside Temperature + # extraTemp1 = Temperature1 + # extraTemp2 = Temperature2 + # extraTemp3 = Temperature3 + # The following line is used to keep the above lines indented + # properly. It can be ignored. + unused = unused + +############################################################################## + +# This service converts the unit system coming from the hardware to a unit +# system in the database. + +[StdConvert] + + # The target_unit affects only the unit system in the database. Once + # chosen it cannot be changed without converting the entire database. + # Modification of target_unit after starting weewx will result in + # corrupt data - the database will contain a mix of US and METRIC data. + # + # The value of target_unit does not affect the unit system for + # reporting - reports can display US, Metric, or any combination of units. + # + # In most cases, target_unit should be left as the default: US + # + # In particular, those migrating from a standard wview installation + # should use US since that is what the wview database contains. + + # DO NOT MODIFY THIS VALUE UNLESS YOU KNOW WHAT YOU ARE DOING! + target_unit = US # Options are 'US', 'METRICWX', or 'METRIC' + +############################################################################## + +# This section can adjust data using calibration expressions. + +[StdCalibrate] + + [[Corrections]] + # For each type, an arbitrary calibration expression can be given. + # It should be in the units defined in the StdConvert section. + # Example: + foo = foo + 0.2 + +############################################################################## + +# This section is for quality control checks. If units are not specified, +# values must be in the units defined in the StdConvert section. + +[StdQC] + + [[MinMax]] + barometer = 26, 32.5, inHg + pressure = 24, 34.5, inHg + outTemp = -40, 120, degree_F + inTemp = 10, 120, degree_F + outHumidity = 0, 100 + inHumidity = 0, 100 + windSpeed = 0, 120, mile_per_hour + rain = 0, 10, inch + +############################################################################## + +# This section controls the origin of derived values. + +[StdWXCalculate] + + [[Calculations]] + # How to calculate derived quantities. Possible values are: + # hardware - use the value provided by hardware + # software - use the value calculated by weewx + # prefer_hardware - use value provide by hardware if available, + # otherwise use value calculated by weewx + + pressure = prefer_hardware + altimeter = prefer_hardware + appTemp = prefer_hardware + barometer = prefer_hardware + cloudbase = prefer_hardware + dewpoint = prefer_hardware + ET = prefer_hardware + heatindex = prefer_hardware + humidex = prefer_hardware + inDewpoint = prefer_hardware + maxSolarRad = prefer_hardware + rainRate = prefer_hardware + windchill = prefer_hardware + windrun = prefer_hardware + +############################################################################## + +# For hardware that supports it, this section controls how often the +# onboard clock gets updated. + +[StdTimeSynch] + + # How often to check the weather station clock for drift (in seconds) + clock_check = 14400 + + # How much it can drift before we will correct it (in seconds) + max_drift = 5 + +############################################################################## + +# This section is for configuring the archive service. + +[StdArchive] + + # If the station hardware supports data logging then the archive interval + # will be downloaded from the station. Otherwise, specify it (in seconds). + archive_interval = 300 + + # If possible, new archive records are downloaded from the station + # hardware. If the hardware does not support this, then new archive + # records will be generated in software. + # Set the following to "software" to force software record generation. + record_generation = hardware + + # Whether to include LOOP data in hi/low statistics. + loop_hilo = True + + # Uncomment and change to override logging for archive operations. + # log_success = True + # log_failure = True + + # The data binding used to save archive records. + data_binding = wx_binding + +############################################################################## + +# This section binds a data store to a database. + +[DataBindings] + + [[wx_binding]] + # The database must match one of the sections in [Databases]. + # This is likely to be the only option you would want to change. + database = archive_sqlite + # The name of the table within the database. + table_name = archive + # The manager handles aggregation of data for historical summaries. + manager = weewx.manager.DaySummaryManager + # The schema defines the structure of the database. + # It is *only* used when the database is created. + schema = schemas.wview_extended.schema + +############################################################################## + +# This section defines various databases. + +[Databases] + + # A SQLite database is simply a single file. + [[archive_sqlite]] + database_name = weewx.sdb + database_type = SQLite + + # MySQL + [[archive_mysql]] + database_name = weewx + database_type = MySQL + +############################################################################## + +# This section defines defaults for the different types of databases. + +[DatabaseTypes] + + # Defaults for SQLite databases. + [[SQLite]] + driver = weedb.sqlite + # Directory in which database files are located, relative to WEEWX_ROOT + SQLITE_ROOT = archive + + # Defaults for MySQL databases. + [[MySQL]] + driver = weedb.mysql + # The host where the database is located. + host = localhost + # The user name for logging in to the host. + user = weewx + # Use quotes around the password to guard against parsing errors. + password = weewx + +############################################################################## + +# This section configures the internal weewx engine. + +[Engine] + + # This section specifies which services should be run and in what order. + [[Services]] + prep_services = weewx.engine.StdTimeSynch + data_services = , + process_services = weewx.engine.StdConvert, weewx.engine.StdCalibrate, weewx.engine.StdQC, weewx.wxservices.StdWXCalculate + xtype_services = weewx.wxxtypes.StdWXXTypes, weewx.wxxtypes.StdPressureCooker, weewx.wxxtypes.StdRainRater, weewx.wxxtypes.StdDelta + archive_services = weewx.engine.StdArchive + restful_services = weewx.restx.StdStationRegistry, weewx.restx.StdWunderground, weewx.restx.StdPWSweather, weewx.restx.StdCWOP, weewx.restx.StdWOW, weewx.restx.StdAWEKAS + report_services = weewx.engine.StdPrint, weewx.engine.StdReport diff --git a/dist/weewx-5.0.2/src/weewxd.py b/dist/weewx-5.0.2/src/weewxd.py new file mode 100644 index 0000000..4fcdfb5 --- /dev/null +++ b/dist/weewx-5.0.2/src/weewxd.py @@ -0,0 +1,265 @@ +# +# Copyright (c) 2009-2024 Tom Keffer +# +# See the file LICENSE.txt for your rights. +# +"""Entry point to the weewx weather system.""" + +import argparse +import locale +import logging +import os +import os.path +import platform +import signal +import sys +import time + +import configobj + +import weecfg +import weedb +import weeutil.logger +import weeutil.startup +import weewx.engine +from weeutil.weeutil import to_bool, to_float +from weewx import daemon + +description = """The main entry point for WeeWX. This program will gather data from your +station, archive its data, then generate reports.""" + +usagestr = """%(prog)s --help + %(prog)s --version + %(prog)s [FILENAME|--config=FILENAME] + [--daemon] + [--pidfile=PIDFILE] + [--exit] + [--loop-on-init] + [--log-label=LABEL] +""" + +epilog = "Specify either the positional argument FILENAME, " \ + "or the optional argument using --config, but not both." + + +# =============================================================================== +# Main entry point +# =============================================================================== + +def main(): + parser = argparse.ArgumentParser(description=description, usage=usagestr, epilog=epilog) + parser.add_argument("--config", dest="config_option", metavar="FILENAME", + help="Use configuration file FILENAME") + parser.add_argument("-d", "--daemon", action="store_true", dest="daemon", + help="Run as a daemon") + parser.add_argument("-p", "--pidfile", dest="pidfile", metavar="PIDFILE", + default="/var/run/weewx.pid", + help="Store the process ID in PIDFILE") + parser.add_argument("-v", "--version", action="store_true", dest="version", + help="Display version number then exit") + parser.add_argument("-x", "--exit", action="store_true", dest="exit", + help="Exit on I/O and database errors instead of restarting") + parser.add_argument("-r", "--loop-on-init", action="store_true", dest="loop_on_init", + help="Retry forever if device is not ready on startup") + parser.add_argument("-n", "--log-label", dest="log_label", metavar="LABEL", default="weewxd", + help="Label to use in syslog entries") + parser.add_argument("config_arg", nargs='?', metavar="FILENAME") + + # Get the command line options and arguments: + namespace = parser.parse_args() + + if namespace.version: + print(weewx.__version__) + sys.exit(0) + + # User can specify the config file as either a positional argument, or as + # an option argument, but not both. + if namespace.config_option and namespace.config_arg: + print(epilog, file=sys.stderr) + sys.exit(weewx.CMD_ERROR) + + # Read the configuration file + try: + config_path, config_dict = weecfg.read_config(namespace.config_arg, + [namespace.config_option]) + except (IOError, configobj.ConfigObjError) as e: + print(f"Error parsing config file: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(weewx.CONFIG_ERROR) + + # Customize the logging with user settings. + try: + weeutil.logger.setup(namespace.log_label, config_dict) + except Exception as e: + print(f"Unable to set up logger: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(weewx.CONFIG_ERROR) + + # Get a logger. This one will have the requested configuration. + log = logging.getLogger(__name__) + # Announce the startup + log.info("Initializing weewxd version %s", weewx.__version__) + log.info("Command line: %s", ' '.join(sys.argv)) + + # Add USER_ROOT to PYTHONPATH, read user.extensions: + weewx_root, user_module = weeutil.startup.initialize(config_dict) + + # Log key bits of information. + log.info("Using Python %s", sys.version) + log.info("Located at %s", sys.executable) + log.info("Platform %s", platform.platform()) + log.info("Locale: '%s'", locale.setlocale(locale.LC_ALL)) + log.info("Entry path: %s", __file__) + log.info("WEEWX_ROOT: %s", weewx_root) + log.info("Configuration file: %s", config_path) + log.info("User module: %s", user_module) + log.info("Debug: %s", weewx.debug) + + # If no command line --loop-on-init was specified, look in the config file. + if namespace.loop_on_init is None: + loop_on_init = to_bool(config_dict.get('loop_on_init', False)) + else: + loop_on_init = namespace.loop_on_init + + # Save the current working directory. A service might + # change it. In case of a restart, we need to change it back. + cwd = os.getcwd() + + # Make sure the system time is not out of date (a common problem with the Raspberry Pi). + # Do this by making sure the system time is later than the creation time of this file. + sane = os.stat(__file__).st_ctime + n = 0 + while weewx.launchtime_ts < sane: + # Log any problems every minute. + if n % 10 == 0: + log.info("Waiting for sane time. File time is %s; current time is %s", + weeutil.weeutil.timestamp_to_string(sane), + weeutil.weeutil.timestamp_to_string(weewx.launchtime_ts)) + n += 1 + time.sleep(0.5) + weewx.launchtime_ts = time.time() + + # Set up a handler for a termination signal + signal.signal(signal.SIGTERM, sigTERMhandler) + + if namespace.daemon: + log.info("PID file is %s", namespace.pidfile) + daemon.daemonize(pidfile=namespace.pidfile) + + # Main restart loop + while True: + + os.chdir(cwd) + + try: + log.debug("Initializing engine") + + # Create and initialize the engine + engine = weewx.engine.StdEngine(config_dict) + + log.info("Starting up weewx version %s", weewx.__version__) + + # Start the engine. It should run forever unless an exception + # occurs. Log it if the function returns. + engine.run() + log.critical("Unexpected exit from main loop. Program exiting.") + + # Catch any console initialization error: + except weewx.engine.InitializationError as e: + # Log it: + log.critical("Unable to load driver: %s", e) + # See if we should loop, waiting for the console to be ready. + # Otherwise, just exit. + if loop_on_init: + wait_time = to_float(config_dict.get('retry_wait', 60.0)) + log.critical(f" **** Waiting {wait_time:.1f} seconds then retrying...") + time.sleep(wait_time) + log.info("retrying...") + else: + log.critical(" **** Exiting...") + sys.exit(weewx.IO_ERROR) + + # Catch any recoverable weewx I/O errors: + except weewx.WeeWxIOError as e: + # Caught an I/O error. Log it, wait 60 seconds, then try again + log.critical("Caught WeeWxIOError: %s", e) + if namespace.exit: + log.critical(" **** Exiting...") + sys.exit(weewx.IO_ERROR) + wait_time = to_float(config_dict.get('retry_wait', 60.0)) + log.critical(f" **** Waiting {wait_time:.1f} seconds then retrying...") + time.sleep(wait_time) + log.info("retrying...") + + # Catch any database connection errors: + except (weedb.CannotConnectError, weedb.DisconnectError) as e: + # No connection to the database server. Log it, wait 60 seconds, then try again + log.critical("Database connection exception: %s", e) + if namespace.exit: + log.critical(" **** Exiting...") + sys.exit(weewx.DB_ERROR) + log.critical(" **** Waiting 60 seconds then retrying...") + time.sleep(60) + log.info("retrying...") + + except weedb.OperationalError as e: + # Caught a database error. Log it, wait 120 seconds, then try again + log.critical("Database OperationalError exception: %s", e) + if namespace.exit: + log.critical(" **** Exiting...") + sys.exit(weewx.DB_ERROR) + log.critical(" **** Waiting 2 minutes then retrying...") + time.sleep(120) + log.info("retrying...") + + except OSError as e: + # Caught an OS error. Log it, wait 10 seconds, then try again + log.critical("Caught OSError: %s", e) + weeutil.logger.log_traceback(log.critical, " **** ") + log.critical(" **** Waiting 10 seconds then retrying...") + time.sleep(10) + log.info("retrying...") + + except Terminate: + log.info("Terminating weewx version %s", weewx.__version__) + weeutil.logger.log_traceback(log.debug, " **** ") + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(0, signal.SIGTERM) + + # Catch any keyboard interrupts and log them + except KeyboardInterrupt: + log.critical("Keyboard interrupt.") + # Reraise the exception (this should cause the program to exit) + raise + + # Catch any non-recoverable errors. Log them, exit + except Exception as ex: + # Caught unrecoverable error. Log it, exit + log.critical("Caught unrecoverable exception:") + log.critical(" **** %s" % ex) + # Include a stack traceback in the log: + weeutil.logger.log_traceback(log.critical, " **** ") + log.critical(" **** Exiting.") + # Reraise the exception (this should cause the program to exit) + raise + + +# ============================================================================== +# Signal handlers +# ============================================================================== + +class Terminate(Exception): + """Exception raised when terminating the engine.""" + + +def sigTERMhandler(signum, _frame): + log = logging.getLogger(__name__) + log.info("Received signal TERM (%s).", signum) + raise Terminate + + +if __name__ == "__main__": + # Start up the program + main() diff --git a/extensions/weewx-belchertown-release.1.3.1.tar.gz b/extensions/weewx-belchertown-release.1.3.1.tar.gz new file mode 100644 index 0000000..a13f58a Binary files /dev/null and b/extensions/weewx-belchertown-release.1.3.1.tar.gz differ diff --git a/run-test.sh b/run-test.sh new file mode 100755 index 0000000..0ae9a49 --- /dev/null +++ b/run-test.sh @@ -0,0 +1,12 @@ +IMAGE_VERSION=5.1.0-1 +docker pull mitct02/weewx:$IMAGE_VERSION + +export TZ=America/New_York + +docker run -it --rm \ + -e TZ=$TZ \ + -v $(pwd)/weewx.conf:/home/weewx/weewx-data/weewx.conf \ + -v $(pwd)/public_html:/home/weewx/weewx-data/public_html \ + -v $(pwd)/archive:/home/weewx/weewx-data/archive \ + -v $(pwd)/keys:/home/weewx/.ssh \ + mitct02/weewx:$IMAGE_VERSION $1