Compare commits

...

107 commits

Author SHA1 Message Date
Lance Edgar f56cb41e69 docs: update project links, kallithea -> forgejo 2024-09-14 12:12:40 -05:00
Lance Edgar 07dda66bae docs: use markdown for readme file 2024-09-13 18:22:48 -05:00
Lance Edgar 949c9ee5a1 bump: version 0.4.5 → 0.4.6 2024-08-19 08:44:10 -05:00
Lance Edgar fa4cb5dc9a fix: avoid deprecated base class for config extension 2024-08-16 10:10:13 -05:00
Lance Edgar 7fe5e9aea6 bump: version 0.4.4 → 0.4.5 2024-07-02 01:23:02 -05:00
Lance Edgar 8021ac818e fix: fix signature for calls to get_engines() 2024-07-02 01:22:37 -05:00
Lance Edgar 55c84c6efe bump: version 0.4.3 → 0.4.4 2024-07-02 00:28:18 -05:00
Lance Edgar 56d7a48e45 fix: avoid deprecated function for engine config 2024-07-02 00:27:59 -05:00
Lance Edgar fe0840d3e0 bump: version 0.4.2 → 0.4.3 2024-07-01 23:22:07 -05:00
Lance Edgar f36759dc48 fix: remove references, dependency for six package 2024-07-01 16:40:46 -05:00
Lance Edgar ff0af6732a bump: version 0.4.1 → 0.4.2 2024-07-01 14:11:57 -05:00
Lance Edgar 6257362534 fix: remove legacy command definitions 2024-07-01 12:20:48 -05:00
Lance Edgar be4d6bfe4d bump: version 0.4.0 → 0.4.1 2024-06-14 17:37:04 -05:00
Lance Edgar f4682c9070 fix: fallback to importlib_metadata on older python 2024-06-14 17:35:23 -05:00
Lance Edgar eb8962003c bump: version 0.3.0 → 0.4.0 2024-06-10 21:23:00 -05:00
Lance Edgar 6b4280a6aa feat: switch from setup.cfg to pyproject.toml + hatchling 2024-06-10 21:21:19 -05:00
Lance Edgar 784f75ac3f Fix default dist filename for release task
not sure why this fix was needed, did setuptools behavior change?
2024-05-30 11:15:07 -05:00
Lance Edgar f01faaf2f9 Update changelog 2024-05-30 11:14:16 -05:00
Lance Edgar 3fde33ac84 Fix pidfile args in typer commands 2024-05-29 07:27:22 -05:00
Lance Edgar 3f46ee6a30 Fix typo for purge command 2024-05-17 09:38:55 -05:00
Lance Edgar 9a39db4546 Add typer equivalents for rattail commands 2024-05-16 20:55:42 -05:00
Lance Edgar d8b865da71 Update changelog 2023-11-30 22:24:15 -06:00
Lance Edgar 7a11ee7ad7 Update subcommand entry point group names, per wuttjamaican 2023-11-22 18:05:58 -06:00
Lance Edgar e82e714417 Update changelog 2023-05-16 17:40:36 -05:00
Lance Edgar a16f2ba718 Replace setup.py contents with setup.cfg 2023-05-16 13:21:26 -05:00
Lance Edgar b887875f80 Update changelog 2023-02-12 11:22:06 -06:00
Lance Edgar 995e0dde0a Refactor Query.get() => Session.get() per SQLAlchemy 1.4 2023-02-11 22:24:56 -06:00
Lance Edgar f7f60eff85 Avoid error when re-running release task 2023-02-10 21:12:57 -06:00
Lance Edgar acfc7f7d80 Update changelog 2023-02-10 21:09:51 -06:00
Lance Edgar 304cec9dd5 Address a warning from SQLAlchemy for declarative_base
as of 1.4 that has moved
2023-02-08 10:57:04 -06:00
Lance Edgar fea643145a Officially drop support for python2 2023-02-08 10:53:27 -06:00
Lance Edgar a45a0b44d5 Use build module instead of invoking setup.py for release 2022-08-06 21:10:57 -05:00
Lance Edgar 1efdd9debd Update changelog 2022-08-06 21:10:11 -05:00
Lance Edgar 1ddeb8a030 Register email profiles provided by this pkg 2022-08-06 21:09:27 -05:00
Lance Edgar 28ecdda0e6 Update changelog 2020-09-22 19:56:10 -05:00
Lance Edgar 4eebd454d5 Declare sort order for Appliance.probes relationship 2020-04-04 19:30:37 -05:00
Lance Edgar 1b03841c7f Remove config for deprecated 'tempmon_critical_temp' email
we now have critical_high and critical_low as separate emails
2019-06-13 12:06:49 -05:00
Lance Edgar 873cd3def9 Update changelog 2019-04-23 22:22:07 -05:00
Lance Edgar 353abcc172 Make sure we use zero as fallback/default timeout values 2019-04-09 12:43:00 -05:00
Lance Edgar 8187c9532f Update changelog 2019-01-28 15:48:57 -06:00
Lance Edgar cf27af81d4 Modify tempmon server logic to take "unfair" time windows into account
when a client or probe first are (re-)enabled, we can't expect to have readings
within the time window we'd normally be checking.  previously we'd get false
alarms about "probe error status" etc. when this happened; hopefully no longer!
2019-01-25 19:49:46 -06:00
Lance Edgar f31a0c4c22 Convert enabled for Client, Probe to use datetime instead of boolean
value is null if disabled, else timestamp of when it was last enabled
2019-01-25 19:33:49 -06:00
Lance Edgar c45baaed5e Add more template context for email previews
just to keep things from breaking outright..although this hasn't been given
much attention still, numbers may not make total sense
2018-10-31 17:20:35 -05:00
Lance Edgar 3b14a0b288 Update changelog 2018-10-25 09:01:19 -05:00
Lance Edgar 7212b07504 Fix bug when sending certain emails while checking probe readings
use common method to add more context for email template
2018-10-25 09:00:17 -05:00
Lance Edgar ad3e647160 Update changelog 2018-10-24 19:20:44 -05:00
Lance Edgar 8220082359 Add try/catch for client's "read temp" logic
this can isolate an error for a certain probe, so that other probes can go
ahead and take their readings during each client run.  that way only the bad
one is marked as "error" status by the server
2018-10-23 17:39:18 -05:00
Lance Edgar b644818eef Don't mark client as online unless it's also enabled 2018-10-23 17:38:38 -05:00
Lance Edgar 30f0fe0a84 Add "default" probe timeout logic for server readings check
this way we don't have to set those timeouts on every single probe
2018-10-23 10:25:08 -05:00
Lance Edgar 1f8507508a Make dummy probe use tighter pattern for random readings
to make for a more reasonable-looking graph
2018-10-20 04:17:42 -05:00
Lance Edgar 44d012b3fd Update release task to use twine for upload 2018-10-19 20:31:49 -05:00
Lance Edgar 157873dc16 Update changelog 2018-10-19 20:30:07 -05:00
Lance Edgar 0ff20eb753 Add image fields for Appliance table
raw, normalized, thumbnail
2018-10-19 19:16:06 -05:00
Lance Edgar 6be6467f59 Add appliance table, and probe "location" in that context 2018-10-19 17:51:25 -05:00
Lance Edgar 871a9154da Update some timeout field docstrings, per latest refactor 2018-10-19 16:47:20 -05:00
Lance Edgar 19553edda6 Add per-status timeouts and tracking for probe status
i.e. this lets us keep track of when a probe becomes "high temp" and then later
if it becomes "critical high temp" we can still know how long it's been high
2018-10-19 14:58:30 -05:00
Lance Edgar 8be64c0580 Update changelog 2018-10-17 19:31:37 -05:00
Lance Edgar 5830c7bd15 Fix logic bug when checking readings for client
yikes! first probe whose readings were checked okay, was causing other probes
to just be skipped
2018-10-17 19:26:48 -05:00
Lance Edgar 3aa4185de9 Leverage common "now" value when sending emails from server
i.e. don't generate new "now" when sending an email, just use the "now" we
established when starting to check the readings
2018-10-17 19:18:34 -05:00
Lance Edgar 71db57b2e0 Add probe URL to email template context 2018-10-17 18:08:54 -05:00
Lance Edgar d6ab9a60f1 Update changelog 2018-10-09 16:36:07 -05:00
Lance Edgar 5bf69a0c21 Add "recent readings" to email template context 2018-10-08 00:52:16 -05:00
Lance Edgar b4c52319c6 Make client more tolerant of database restart
note that a retry is *not* attempted within a given "take readings" run.
rather, client will consider that full readings take to have failed, if any
part of it fails.

but then we keep track of type/amount of some (database connection) failures,
and will suppress logging the full error for first 3 attempts.  in practice
this lets us recover from simple database restarts, and if database becomes
truly unavailable we'll hear about it shortly.

any other type of error is immediately logged on first failure.
2018-10-07 18:47:02 -05:00
Lance Edgar 2f7fa3430a Make server more tolerant of database restart
note that a retry is *not* attempted within a given "check readings" run.
rather, server will consider that full readings check to have failed, if any
part of it fails.

but then we keep track of type/amount of some (database connection) failures,
and will suppress logging the full error for first 3 attempts.  in practice
this lets us recover from simple database restarts, and if database becomes
truly unavailable we'll hear about it shortly.

any other type of error is immediately logged on first failure.
2018-10-07 18:16:18 -05:00
Lance Edgar b4fa6a17c5 Add "status since" to template context for email alerts 2018-10-06 20:17:46 -05:00
Lance Edgar 76e40063ee Tweak server logic for checking client readings
do not check readings for "archived" clients.  do not consider the client
"offline" unless it has *no* current probe readings.  previously we were
assuming offline if any probe readings were missing, even if some were found.
2018-10-06 18:09:02 -05:00
Lance Edgar 440abf2b56 Improve docstrings for some model attributes 2018-10-06 17:39:54 -05:00
Lance Edgar 85e8ed9832 Log error when client probe takes a 185.0 reading
this indicates some power issue with the probe(s) and does not really mean
that's the temperature.  so we don't want the server to send out "high temp"
email etc. but rather just a technical error email

https://www.controlbyweb.com/support/faq/temp-sensor-reading-error.html
2018-10-05 20:53:09 -05:00
Lance Edgar 74de10e74c Log our supposed hostname on startup
just to help with troubleshooting
2018-10-05 19:30:01 -05:00
Lance Edgar 63aa29f7d7 Update changelog 2018-10-04 19:59:14 -05:00
Lance Edgar 18b224a3e0 Add Client.disk_type to track SD card vs. USB
i.e. assuming raspberry pi device for the client.  kind of a specific use case
behind this but it seemed like it could be useful generally
2018-09-28 19:14:55 -05:00
Lance Edgar 30a0f98e1d Add notes field to client and probe tables 2018-09-28 12:26:49 -05:00
Lance Edgar 152ea26c02 Add Client.archived flag, ignore archived for "disabled probes" check
this lets us keep old client config around without deleting it, but it should
not interfere with other logic etc.
2018-09-28 12:21:21 -05:00
Lance Edgar 018a9dcb08 Don't let server mark client as offline until readings fail 3 times in a row
previously we were only letting this fail once, if that
2018-09-28 11:57:27 -05:00
Lance Edgar ab38143039 Include client key in disabled probe list email 2018-09-11 18:42:00 -05:00
Lance Edgar aa98257448 Use invoke instead of fabric to release 2018-02-24 17:20:51 -06:00
Lance Edgar b20c63d023 Update changelog 2018-02-07 17:53:11 -06:00
Lance Edgar 5df3379995 Send email alert when tempmon server marks a client as offline
courtesy of Cole Chaney <cole@mamajeansmarket.com>
2018-02-07 17:48:07 -06:00
Lance Edgar 8a1551e0f5 Send first alert "immediately" if critical temp status
i.e. only wait for "first email" delay if *not* critical

courtesy of Cole Chaney <cole@mamajeansmarket.com>
2018-02-07 17:47:48 -06:00
Lance Edgar f8f29a8551 Update changelog 2017-11-19 17:46:46 -06:00
Lance Edgar fd7cd5cadd Add problem report for disabled clients/probes 2017-11-18 22:12:40 -06:00
Lance Edgar 840c146969 Update changelog 2017-08-08 18:47:29 -05:00
Lance Edgar ef657d0842 Fix alembic script AGAIN
geez man
2017-08-08 18:46:53 -05:00
Lance Edgar 39a2da2b6b Update changelog 2017-08-08 18:44:21 -05:00
Lance Edgar 32eb01fe09 Fix tempmon alembic script per continuum needs
is this really what we have to do now..?
2017-08-08 18:43:10 -05:00
Lance Edgar 5d81ac19a6 Grow the Reading.degrees_f column
sometimes a probe will report a wacky temperature, but we should record
it even so
2017-08-05 12:54:08 -05:00
Lance Edgar ad03e32f77 Don't kill tempmon client if DB session.commit() fails
let the daemon keep trying just like when device.read() fails
2017-08-05 12:49:04 -05:00
Lance Edgar fa09c939f5 Update changelog 2017-08-04 16:08:44 -05:00
Lance Edgar 844a05202e Add Client.readings backref 2017-08-04 16:07:35 -05:00
Lance Edgar aee6f6d341 Auto-delete child objects when deleting Client or Probe object
although if there are a lot of readings, this will still suck...
2017-08-04 15:14:48 -05:00
Lance Edgar 7c7dc56f8d Update changelog 2017-07-07 09:24:48 -05:00
Lance Edgar 90e431c617 Switch license to GPL v3 (no longer Affero)
refs #2
2017-07-06 23:38:50 -05:00
Lance Edgar 6ff84b7f54 Update changelog 2017-07-06 21:33:42 -05:00
Lance Edgar ea921cf58b Replace occurrence of execfile() 2017-07-06 16:24:14 -05:00
Lance Edgar 2acc49be40 Tweak import placement to fix startup
i.e. per Continuum needs
2017-07-04 01:18:28 -05:00
Lance Edgar 9f21244ede Add rattail purge-tempmon command
for getting rid of all those old temperature readings, since
they're mostly just useful in real-time..
2017-07-03 18:10:52 -05:00
Lance Edgar 450f63434b Update changelog 2017-06-01 17:38:57 -05:00
Lance Edgar 396b3739f3 Fix bug when marking client as offline from server loop
..i think?
2017-06-01 17:37:27 -05:00
Lance Edgar 5f680ff672 Update changelog 2017-06-01 17:18:30 -05:00
Lance Edgar f04b4105c7 Tweak mail templates a bit, to reference config values
probably more needs to be done, this is at least a step..
2017-06-01 17:17:11 -05:00
Lance Edgar c4b371cedd Refactor main server loop a bit, to add basic retry w/ error logging
hopefully this lets us get past a simple Postgres restart..
2017-06-01 17:16:31 -05:00
Lance Edgar 27adc5ed70 Update changelog 2017-06-01 16:24:40 -05:00
Lance Edgar 20e3b83525 Add error logging in case committing session fails..
probably need to add a retry somewhere but still not getting the
traceback we need to figure that out...
2017-06-01 16:21:48 -05:00
Lance Edgar 5ee411ba23 Add rattail export-hotcooler command, for initial hotcooler support
..we'll see where this goes..
2017-04-01 18:30:31 -05:00
Lance Edgar 7f85483b73 Fix typo 2017-04-01 16:37:00 -05:00
Lance Edgar 6fb68089a1 Update changelog 2017-02-09 19:05:00 -06:00
Lance Edgar 4e11748b45 Add configurable delay per client; improve try/catch 2017-02-09 17:59:06 -06:00
43 changed files with 2181 additions and 522 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
*~
*.pyc
dist/
rattail_tempmon.egg-info/

53
CHANGELOG.md Normal file
View file

@ -0,0 +1,53 @@
# Changelog
All notable changes to rattail-tempmon will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.4.6 (2024-08-19)
### Fix
- avoid deprecated base class for config extension
## v0.4.5 (2024-07-02)
### Fix
- fix signature for calls to `get_engines()`
## v0.4.4 (2024-07-02)
### Fix
- avoid deprecated function for engine config
## v0.4.3 (2024-07-01)
### Fix
- remove references, dependency for `six` package
## v0.4.2 (2024-07-01)
### Fix
- remove legacy command definitions
## v0.4.1 (2024-06-14)
### Fix
- fallback to `importlib_metadata` on older python
## v0.4.0 (2024-06-10)
### Feat
- switch from setup.cfg to pyproject.toml + hatchling
## Older Releases
Please see `docs/OLDCHANGES.rst` for older release notes.

View file

@ -1,42 +0,0 @@
CHANGELOG
=========
0.1.5 (2016-12-12)
------------------
* Add config for "good temp" email
0.1.4 (2016-12-11)
------------------
* Hopefully fix alert logic when status becomes good
0.1.3 (2016-12-10)
------------------
* Add email config for tempmon-server alerts
* Add mail templates to project manifest
0.1.2 (2016-12-10)
------------------
* Add support for dummy probes (random temp data)
* Add mail templates, plus initial status alert delay for probes
0.1.1 (2016-12-05)
------------------
* Fix import bug in server daemon
0.1.0 (2016-12-05)
------------------
* Initial release.

View file

@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@ -7,15 +7,17 @@
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
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,
our General Public Licenses are intended to guarantee your freedom to
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.
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
@ -24,34 +26,44 @@ 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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
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.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
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.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
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.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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.
@ -60,7 +72,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"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.
@ -537,45 +549,35 @@ 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 General Public License into a single
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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
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 Affero 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
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 Affero General
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 Affero General Public License, you may choose any version ever published
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 Affero General Public License can be used, that proxy's
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.
@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
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 Affero General Public License for more details.
GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# rattail-tempmon
Rattail is a retail software framework, released under the GNU General Public
License.
This is the ``rattail-tempmon`` package, which provides a database schema, and
client/server daemons for recording and processing temperature data.
Please see Rattail's [home page](https://rattailproject.org/) for more
information.

View file

@ -1,13 +0,0 @@
rattail-tempmon
===============
Rattail is a retail software framework, released under the GNU Affero General
Public License.
This is the ``rattail-tempmon`` package, which provides a database schema, and
client/server daemons for recording and processing temperature data.
Please see Rattail's `home page`_ for more information.
.. _home page: https://rattailproject.org/

256
docs/OLDCHANGES.rst Normal file
View file

@ -0,0 +1,256 @@
CHANGELOG
=========
NB. this file contains "old" release notes only. for newer releases
see the `CHANGELOG.md` file in the source root folder.
0.3.0 (2024-05-30)
------------------
* Migrate all commands to use ``typer``.
0.2.10 (2023-11-30)
-------------------
* Update subcommand entry point group names, per wuttjamaican.
0.2.9 (2023-05-16)
------------------
* Replace ``setup.py`` contents with ``setup.cfg``.
0.2.8 (2023-02-12)
------------------
* Refactor ``Query.get()`` => ``Session.get()`` per SQLAlchemy 1.4.
0.2.7 (2023-02-10)
------------------
* Officially drop support for python2.
* Address a warning from SQLAlchemy for ``declarative_base``.
0.2.6 (2022-08-06)
------------------
* Register email profiles provided by this pkg.
0.2.5 (2020-09-22)
------------------
* Remove config for deprecated 'tempmon_critical_temp' email.
* Declare sort order for ``Appliance.probes`` relationship.
0.2.4 (2019-04-23)
------------------
* Make sure we use zero as fallback/default timeout values.
0.2.3 (2019-01-28)
------------------
* Add more template context for email previews.
* Convert ``enabled`` for Client, Probe to use datetime instead of boolean.
* Modify tempmon server logic to take "unfair" time windows into account.
0.2.2 (2018-10-25)
------------------
* Fix bug when sending certain emails while checking probe readings.
0.2.1 (2018-10-24)
------------------
* Make dummy probe use tighter pattern for random readings.
* Add "default" probe timeout logic for server readings check.
* Don't mark client as online unless it's also enabled.
* Add try/catch for client's "read temp" logic.
0.2.0 (2018-10-19)
------------------
* Add per-status timeouts and tracking for probe status.
* Add appliance table, and probe "location" in that context.
* Add image fields for Appliance table.
0.1.19 (2018-10-17)
-------------------
* Add probe URL to email template context.
* Leverage common "now" value when sending emails from server.
* Fix logic bug when checking readings for client.
0.1.18 (2018-10-09)
-------------------
* Log our supposed hostname on client startup.
* Log error on client, when probe takes a 185.0 reading.
* Improve docstrings for some model attributes (for Tailbone).
* Make server more tolerant of database restart.
* Make client more tolerant of database restart.
* Add "status since" to template context for email alerts.
* Add "recent readings" to email template context.
0.1.17 (2018-10-04)
-------------------
* Include client key in disabled probe list email.
* Don't let server mark client as offline until readings fail 3 times in a row.
* Add ``Client.archived`` flag, ignore archived for "disabled probes" check.
* Add notes field to client and probe tables.
* Add ``Client.disk_type`` to track SD card vs. USB.
0.1.16 (2018-02-07)
-------------------
* Send first alert "immediately" if critical temp status.
* Send email alert when tempmon server marks a client as offline.
0.1.15 (2017-11-19)
-------------------
* Add problem report for disabled clients/probes.
0.1.14 (2017-08-08)
-------------------
* Fix alembic script AGAIN
0.1.13 (2017-08-08)
-------------------
* Don't kill tempmon client if DB session.commit() fails
* Grow the ``Reading.degrees_f`` column
* Fix tempmon alembic script per continuum needs
0.1.12 (2017-08-04)
-------------------
* Auto-delete child objects when deleting Client or Probe object
0.1.11 (2017-07-07)
-------------------
* Switch license to GPL v3 (no longer Affero)
0.1.10 (2017-07-06)
-------------------
* Add ``rattail purge-tempmon`` command
* Tweak import placement to fix startup
0.1.9 (2017-06-01)
------------------
* Fix bug when marking client as offline from server loop
0.1.8 (2017-06-01)
------------------
* Refactor main server loop a bit, to add basic retry w/ error logging
* Tweak mail templates a bit, to reference config values
0.1.7 (2017-06-01)
------------------
* Add ``rattail export-hotcooler`` command, for initial hotcooler support
* Add client error logging in case committing session fails..
0.1.6 (2017-02-09)
------------------
* Add configurable delay per client; improve client try/catch
0.1.5 (2016-12-12)
------------------
* Add config for "good temp" email
0.1.4 (2016-12-11)
------------------
* Hopefully fix alert logic when status becomes good
0.1.3 (2016-12-10)
------------------
* Add email config for tempmon-server alerts
* Add mail templates to project manifest
0.1.2 (2016-12-10)
------------------
* Add support for dummy probes (random temp data)
* Add mail templates, plus initial status alert delay for probes
0.1.1 (2016-12-05)
------------------
* Fix import bug in server daemon
0.1.0 (2016-12-05)
------------------
* Initial release.

40
fabfile.py vendored
View file

@ -1,40 +0,0 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Fabric script for 'rattail-tempmon' package
"""
from __future__ import unicode_literals, absolute_import
import shutil
from fabric.api import task, local
@task
def release():
"""
Release a new version of `rattail-tempmon`
"""
shutil.rmtree('rattail_tempmon.egg-info')
local('python setup.py sdist --formats=gztar upload')

52
pyproject.toml Normal file
View file

@ -0,0 +1,52 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "rattail-tempmon"
version = "0.4.6"
description = "Retail Software Framework - Temperature monitoring add-on"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
license = {text = "GNU GPL v3+"}
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Topic :: Office/Business",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"rattail[db]",
"sqlsoup",
]
[project.urls]
Homepage = "https://rattailproject.org"
Repository = "https://forgejo.wuttaproject.org/rattail/rattail-tempmon"
Changelog = "https://forgejo.wuttaproject.org/rattail/rattail-tempmon/src/branch/master/CHANGELOG.md"
[project.entry-points."rattail.config.extensions"]
tempmon = "rattail_tempmon.config:TempmonConfigExtension"
[project.entry-points."rattail.typer_imports"]
rattail_tempmon = "rattail_tempmon.commands"
[project.entry-points."rattail.emails"]
rattail_tempmon = "rattail_tempmon.emails"
[tool.commitizen]
version_provider = "pep621"
tag_format = "v$version"
update_changelog_on_bump = true

View file

@ -2,22 +2,22 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""

View file

@ -1,3 +1,9 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
__version__ = u'0.1.5'
try:
from importlib.metadata import version
except ImportError:
from importlib_metadata import version
__version__ = version('rattail-tempmon')

View file

@ -1,37 +1,36 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
TempMon client daemon
"""
from __future__ import unicode_literals, absolute_import
import time
import datetime
import random
import socket
import logging
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm.exc import NoResultFound
from rattail.daemon import Daemon
@ -57,6 +56,7 @@ class TempmonClient(Daemon):
# figure out which client we are
hostname = self.config.get('tempmon.client', 'hostname', default=socket.gethostname())
log.info("i think my hostname is: %s", hostname)
session = Session()
try:
client = session.query(tempmon.Client)\
@ -66,49 +66,96 @@ class TempmonClient(Daemon):
session.close()
raise ConfigurationError("No tempmon client configured for hostname: {}".format(hostname))
client_uuid = client.uuid
self.delay = client.delay or 60
session.close()
# main loop: take readings, pause, repeat
self.failed_checks = 0
while True:
session = Session()
client = session.query(tempmon.Client).get(client_uuid)
self.take_readings(client_uuid)
time.sleep(self.delay)
def take_readings(self, client_uuid):
"""
Take new readings for all enabled probes on this client.
"""
# log.debug("taking readings")
session = Session()
try:
client = session.get(tempmon.Client, client_uuid)
self.delay = client.delay or 60
if client.enabled:
for probe in client.enabled_probes():
self.take_reading(session, probe)
session.flush()
try:
for probe in client.enabled_probes():
self.take_reading(session, probe)
# one more thing, make sure our client appears "online"
if not client.online:
client.online = True
except:
log.exception("Failed to read/record temperature data")
session.rollback()
raise
except Exception as error:
log_error = True
self.failed_checks += 1
session.rollback()
else:
# make sure we show as being online
if not client.online:
client.online = True
session.commit()
# our goal here is to suppress logging when we see connection
# errors which are due to a simple postgres restart. but if they
# keep coming then we'll go ahead and log them (sending email)
if isinstance(error, OperationalError):
finally:
session.close()
# this first test works upon first DB restart, as well as the
# first time after DB stop. but in the case of DB stop,
# subsequent errors will instead match the second test
if error.connection_invalidated or (
'could not connect to server: Connection refused' in str(error)):
else:
session.close()
# only suppress logging for 3 failures, after that we let them go
# TODO: should make the max attempts configurable
if self.failed_checks < 4:
log_error = False
log.debug("database connection failure #%s: %s",
self.failed_checks,
str(error))
# TODO: make this configurable
time.sleep(60)
# send error email unless we're suppressing it for now
if log_error:
log.exception("Failed to read/record temperature data (but will keep trying)")
else: # taking readings was successful
self.failed_checks = 0
session.commit()
finally:
session.close()
def take_reading(self, session, probe):
"""
Take a single reading and add to Rattail database.
"""
reading = tempmon.Reading()
reading.client = probe.client
reading.probe = probe
reading.degrees_f = self.read_temp(probe)
reading.taken = datetime.datetime.utcnow()
session.add(reading)
return reading
try:
reading.degrees_f = self.read_temp(probe)
except:
log.exception("Failed to read temperature (but will keep trying) for probe: %s", probe)
else:
# a reading of 185.0 °F indicates some sort of power issue. when this
# happens we log an error (which sends basic email) but do not record
# the temperature. that way the server doesn't see the 185.0 reading
# and send out a "false alarm" about the temperature being too high.
# https://www.controlbyweb.com/support/faq/temp-sensor-reading-error.html
if reading.degrees_f == 185.0:
log.error("got reading of 185.0 from probe: %s", probe.description)
else: # we have a good reading
reading.client = probe.client
reading.probe = probe
reading.taken = datetime.datetime.utcnow()
session.add(reading)
return reading
def read_temp(self, probe):
"""
@ -123,7 +170,9 @@ class TempmonClient(Daemon):
equals_pos = lines[1].find('t=')
if equals_pos != -1:
temp_string = lines[1][equals_pos+2:]
# temperature data comes in as celsius
temp_c = float(temp_string) / 1000.0
# convert celsius to fahrenheit
temp_f = temp_c * 9.0 / 5.0 + 32.0
return round(temp_f,4)
@ -135,7 +184,18 @@ class TempmonClient(Daemon):
return therm_file.readlines()
def random_temp(self, probe):
temp = random.uniform(probe.critical_temp_min - 5, probe.critical_temp_max + 5)
last_reading = probe.last_reading()
if last_reading:
volatility = 2
# try to keep somewhat of a tight pattern, so graphs look reasonable
last_degrees = float(last_reading.degrees_f)
temp = random.uniform(last_degrees - volatility * 3, last_degrees + volatility * 3)
if temp > (probe.critical_temp_max + volatility * 2):
temp -= volatility
elif temp < (probe.critical_temp_min - volatility * 2):
temp += volatility
else:
temp = random.uniform(probe.critical_temp_min - 5, probe.critical_temp_max + 5)
return round(temp, 4)

View file

@ -1,89 +1,180 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tempmon commands
"""
from __future__ import unicode_literals, absolute_import
import datetime
import logging
from enum import Enum
from pathlib import Path
from rattail.commands import Subcommand
import typer
from typing_extensions import Annotated
from rattail.commands import rattail_typer
from rattail.commands.typer import importer_command, typer_get_runas_user
from rattail.commands.importing import ImportCommandHandler
class TempmonClient(Subcommand):
log = logging.getLogger(__name__)
class ServiceAction(str, Enum):
start = 'start'
stop = 'stop'
@rattail_typer.command()
@importer_command
def export_hotcooler(
ctx: typer.Context,
**kwargs
):
"""
Export data from Rattail-Tempmon to HotCooler
"""
config = ctx.parent.rattail_config
progress = ctx.parent.rattail_progress
handler = ImportCommandHandler(
config,
import_handler_spec='rattail_tempmon.hotcooler.importing.tempmon:FromTempmonToHotCooler')
kwargs['user'] = typer_get_runas_user(ctx)
handler.run(kwargs, progress=progress)
@rattail_typer.command()
def purge_tempmon(
ctx: typer.Context,
keep_days: Annotated[
int,
typer.Option('--keep',
help="Number of days for which data should be kept.")] = ...,
dry_run: Annotated[
bool,
typer.Option('--dry-run',
help="Go through the full motions and allow logging etc. to "
"occur, but rollback (abort) the transaction at the end.")] = False,
):
"""
Purge stale data from Tempmon database
"""
config = ctx.parent.rattail_config
progress = ctx.parent.rattail_progress
do_purge(config, keep_days, dry_run=dry_run, progress=progress)
@rattail_typer.command()
def tempmon_client(
ctx: typer.Context,
action: Annotated[
ServiceAction,
typer.Argument(help="Action to perform for the service.")] = ...,
pidfile: Annotated[
Path,
typer.Option('--pidfile', '-p',
help="Path to PID file.")] = None,
# TODO: deprecate / remove this
daemonize: Annotated[
bool,
typer.Option('--daemonize',
help="Daemonize when starting.")] = False,
):
"""
Manage the tempmon-client daemon
"""
name = 'tempmon-client'
description = __doc__.strip()
from rattail_tempmon.client import make_daemon
def add_parser_args(self, parser):
subparsers = parser.add_subparsers(title='subcommands')
start = subparsers.add_parser('start', help="Start daemon")
start.set_defaults(subcommand='start')
stop = subparsers.add_parser('stop', help="Stop daemon")
stop.set_defaults(subcommand='stop')
parser.add_argument('-p', '--pidfile',
help="Path to PID file.", metavar='PATH')
parser.add_argument('-D', '--daemonize', action='store_true',
help="Daemonize when starting.")
def run(self, args):
from rattail_tempmon.client import make_daemon
daemon = make_daemon(self.config, args.pidfile)
if args.subcommand == 'start':
daemon.start(args.daemonize)
elif args.subcommand == 'stop':
daemon.stop()
config = ctx.parent.rattail_config
daemon = make_daemon(config, pidfile)
if action == 'start':
daemon.start(daemonize)
elif action == 'stop':
daemon.stop()
class TempmonServer(Subcommand):
@rattail_typer.command()
def tempmon_problems(
ctx: typer.Context,
):
"""
Email report(s) of various Tempmon data problems
"""
from rattail_tempmon import problems
config = ctx.parent.rattail_config
progress = ctx.parent.rattail_progress
problems.disabled_probes(config, progress=progress)
@rattail_typer.command()
def tempmon_server(
ctx: typer.Context,
action: Annotated[
ServiceAction,
typer.Argument(help="Action to perform for the service.")] = ...,
pidfile: Annotated[
Path,
typer.Option('--pidfile', '-p',
help="Path to PID file.")] = None,
# TODO: deprecate / remove this
daemonize: Annotated[
bool,
typer.Option('--daemonize',
help="Daemonize when starting.")] = False,
):
"""
Manage the tempmon-server daemon
"""
name = 'tempmon-server'
description = __doc__.strip()
from rattail_tempmon.server import make_daemon
def add_parser_args(self, parser):
subparsers = parser.add_subparsers(title='subcommands')
config = ctx.parent.rattail_config
daemon = make_daemon(config, pidfile)
if action == 'start':
daemon.start(daemonize)
elif action == 'stop':
daemon.stop()
start = subparsers.add_parser('start', help="Start daemon")
start.set_defaults(subcommand='start')
stop = subparsers.add_parser('stop', help="Stop daemon")
stop.set_defaults(subcommand='stop')
parser.add_argument('-p', '--pidfile',
help="Path to PID file.", metavar='PATH')
parser.add_argument('-D', '--daemonize', action='store_true',
help="Daemonize when starting.")
def do_purge(config, keep_days, dry_run=False, progress=None):
from rattail_tempmon.db import Session, model
from rattail.db.util import finalize_session
def run(self, args):
from rattail_tempmon.server import make_daemon
app = config.get_app()
cutoff = app.today() - datetime.timedelta(days=keep_days)
cutoff = app.localtime(datetime.datetime.combine(cutoff, datetime.time(0)))
session = Session()
daemon = make_daemon(self.config, args.pidfile)
if args.subcommand == 'start':
daemon.start(args.daemonize)
elif args.subcommand == 'stop':
daemon.stop()
readings = session.query(model.Reading)\
.filter(model.Reading.taken < app.make_utc(cutoff))\
.all()
def purge(reading, i):
session.delete(reading)
if i % 200 == 0:
session.flush()
app.progress_loop(purge, readings, progress,
message="Purging stale readings")
log.info("deleted %s stale readings", len(readings))
finalize_session(session, dry_run=dry_run)

View file

@ -1,37 +1,36 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tempmon config extension
"""
from __future__ import unicode_literals, absolute_import
from wuttjamaican.db import get_engines
from wuttjamaican.conf import WuttaConfigExtension
from rattail.config import ConfigExtension
from rattail.db.config import get_engines
from rattail_tempmon.db import Session
class TempmonConfigExtension(ConfigExtension):
class TempmonConfigExtension(WuttaConfigExtension):
"""
Config extension for tempmon; adds tempmon DB engine/Session etc. Expects
something like this in your config:
@ -51,6 +50,12 @@ class TempmonConfigExtension(ConfigExtension):
key = 'tempmon'
def configure(self, config):
config.tempmon_engines = get_engines(config, section='rattail_tempmon.db')
# tempmon
config.tempmon_engines = get_engines(config, 'rattail_tempmon.db')
config.tempmon_engine = config.tempmon_engines.get('default')
Session.configure(bind=config.tempmon_engine)
# hotcooler
config.hotcooler_engines = get_engines(config, 'hotcooler.db')
config.hotcooler_engine = config.hotcooler_engines.get('default')

View file

@ -2,22 +2,22 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""

View file

@ -6,9 +6,10 @@ Alembic Environment Script
from __future__ import unicode_literals, absolute_import
from alembic import context
from sqlalchemy import orm
from rattail.config import make_config
from rattail_tempmon.db import model as tempmon
from rattail.db.config import configure_versioning
# this is the Alembic Config object, which provides
@ -16,7 +17,12 @@ from rattail_tempmon.db import model as tempmon
alembic_config = context.config
# Use same config file for Rattail, as we are for Alembic.
rattail_config = make_config(alembic_config.config_file_name)
rattail_config = make_config(alembic_config.config_file_name, usedb=False)
# configure Continuum...this is trickier than I'd like
configure_versioning(rattail_config)
from rattail_tempmon.db import model as tempmon
orm.configure_mappers()
# add your model's MetaData object here
# for 'autogenerate' support

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8; mode: python -*-
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
"""${message}
Revision ID: ${up_revision}

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8; -*-
"""add client, probe notes
Revision ID: 34041e1032a2
Revises: e9eb7fc0a451
Create Date: 2018-09-28 12:24:11.348627
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = '34041e1032a2'
down_revision = u'e9eb7fc0a451'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# client
op.add_column('client', sa.Column('notes', sa.Text(), nullable=True))
# probe
op.add_column('probe', sa.Column('notes', sa.Text(), nullable=True))
def downgrade():
# probe
op.drop_column('probe', 'notes')
# client
op.drop_column('client', 'notes')

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8; -*-
"""add client.disk_type
Revision ID: 5f2b87474433
Revises: 34041e1032a2
Create Date: 2018-09-28 19:08:02.743343
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = '5f2b87474433'
down_revision = u'34041e1032a2'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# client
op.add_column('client', sa.Column('disk_type', sa.Integer(), nullable=True))
def downgrade():
# client
op.drop_column('client', 'disk_type')

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8; -*-
"""grow degrees
Revision ID: 75c09e11543c
Revises: 76d52bb064b8
Create Date: 2017-08-05 12:50:50.093173
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = '75c09e11543c'
down_revision = u'76d52bb064b8'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# reading
op.alter_column('reading', 'degrees_f', type_=sa.Numeric(precision=8, scale=4))
def downgrade():
# reading
op.alter_column('reading', 'degrees_f', type_=sa.Numeric(precision=7, scale=4))

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""add client delay
Revision ID: 76d52bb064b8
Revises: 7c7d205787b0
Create Date: 2017-02-07 14:46:11.268920
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = '76d52bb064b8'
down_revision = u'7c7d205787b0'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# client
op.add_column('client', sa.Column('delay', sa.Integer(), nullable=True))
def downgrade():
# client
op.drop_column('client', 'delay')

View file

@ -0,0 +1,49 @@
# -*- coding: utf-8; -*-
"""add appliance
Revision ID: 796084026e5b
Revises: b02c531caca5
Create Date: 2018-10-19 17:28:34.146307
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = '796084026e5b'
down_revision = u'b02c531caca5'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# appliance
op.create_table('appliance',
sa.Column('uuid', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('location', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('uuid'),
sa.UniqueConstraint('name', name=u'appliance_uq_name')
)
# probe
op.add_column(u'probe', sa.Column('appliance_uuid', sa.String(length=32), nullable=True))
op.add_column(u'probe', sa.Column('location', sa.String(length=255), nullable=True))
op.create_foreign_key(u'probe_fk_appliance', 'probe', 'appliance', ['appliance_uuid'], ['uuid'])
def downgrade():
# probe
op.drop_constraint(u'probe_fk_appliance', 'probe', type_='foreignkey')
op.drop_column(u'probe', 'location')
op.drop_column(u'probe', 'appliance_uuid')
# appliance
op.drop_table('appliance')

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8; -*-
"""add appliance images
Revision ID: a2676d3dfc1e
Revises: 796084026e5b
Create Date: 2018-10-19 18:27:01.700943
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = 'a2676d3dfc1e'
down_revision = u'796084026e5b'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# appliance
op.add_column('appliance', sa.Column('appliance_type', sa.Integer(), nullable=True))
op.add_column('appliance', sa.Column('image_normal', sa.LargeBinary(), nullable=True))
op.add_column('appliance', sa.Column('image_raw', sa.LargeBinary(), nullable=True))
op.add_column('appliance', sa.Column('image_thumbnail', sa.LargeBinary(), nullable=True))
def downgrade():
# appliance
op.drop_column('appliance', 'image_thumbnail')
op.drop_column('appliance', 'image_raw')
op.drop_column('appliance', 'image_normal')
op.drop_column('appliance', 'appliance_type')

View file

@ -0,0 +1,51 @@
# -*- coding: utf-8; -*-
"""add more timeouts
Revision ID: b02c531caca5
Revises: 5f2b87474433
Create Date: 2018-10-19 13:51:54.422490
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = 'b02c531caca5'
down_revision = u'5f2b87474433'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# probe
op.add_column('probe', sa.Column('critical_max_started', sa.DateTime(), nullable=True))
op.add_column('probe', sa.Column('critical_max_timeout', sa.Integer(), nullable=True))
op.add_column('probe', sa.Column('critical_min_started', sa.DateTime(), nullable=True))
op.add_column('probe', sa.Column('critical_min_timeout', sa.Integer(), nullable=True))
op.add_column('probe', sa.Column('error_started', sa.DateTime(), nullable=True))
op.add_column('probe', sa.Column('error_timeout', sa.Integer(), nullable=True))
op.add_column('probe', sa.Column('good_max_started', sa.DateTime(), nullable=True))
op.add_column('probe', sa.Column('good_max_timeout', sa.Integer(), nullable=True))
op.add_column('probe', sa.Column('good_min_started', sa.DateTime(), nullable=True))
op.add_column('probe', sa.Column('good_min_timeout', sa.Integer(), nullable=True))
def downgrade():
# probe
op.drop_column('probe', 'good_min_timeout')
op.drop_column('probe', 'good_min_started')
op.drop_column('probe', 'good_max_timeout')
op.drop_column('probe', 'good_max_started')
op.drop_column('probe', 'error_timeout')
op.drop_column('probe', 'error_started')
op.drop_column('probe', 'critical_min_timeout')
op.drop_column('probe', 'critical_min_started')
op.drop_column('probe', 'critical_max_timeout')
op.drop_column('probe', 'critical_max_started')

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8; -*-
"""add client.archived
Revision ID: e9eb7fc0a451
Revises: 75c09e11543c
Create Date: 2018-09-28 12:12:19.813042
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = 'e9eb7fc0a451'
down_revision = u'75c09e11543c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# client
op.add_column('client', sa.Column('archived', sa.Boolean(), nullable=True))
client = sa.sql.table('client', sa.sql.column('archived'))
op.execute(client.update().values({'archived': False}))
op.alter_column('client', 'archived', nullable=False)
def downgrade():
# client
op.drop_column('client', 'archived')

View file

@ -0,0 +1,80 @@
# -*- coding: utf-8; -*-
"""make enabled datetime
Revision ID: fd1df160539a
Revises: a2676d3dfc1e
Create Date: 2019-01-25 18:41:01.652823
"""
from __future__ import unicode_literals, absolute_import
# revision identifiers, used by Alembic.
revision = 'fd1df160539a'
down_revision = 'a2676d3dfc1e'
branch_labels = None
depends_on = None
import datetime
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
now = datetime.datetime.utcnow()
# client
op.add_column('client', sa.Column('new_enabled', sa.DateTime(), nullable=True))
client = sa.sql.table('client',
sa.sql.column('enabled'),
sa.sql.column('new_enabled'))
op.execute(client.update()\
.where(client.c.enabled == True)\
.values({'new_enabled': now}))
op.drop_column('client', 'enabled')
op.alter_column('client', 'new_enabled', new_column_name='enabled')
# probe
op.add_column('probe', sa.Column('new_enabled', sa.DateTime(), nullable=True))
probe = sa.sql.table('probe',
sa.sql.column('enabled'),
sa.sql.column('new_enabled'))
op.execute(probe.update()\
.where(probe.c.enabled == True)\
.values({'new_enabled': now}))
op.drop_column('probe', 'enabled')
op.alter_column('probe', 'new_enabled', new_column_name='enabled')
def downgrade():
# probe
op.add_column('probe', sa.Column('old_enabled', sa.Boolean(), nullable=True))
probe = sa.sql.table('probe',
sa.sql.column('enabled'),
sa.sql.column('old_enabled'))
op.execute(probe.update()\
.where(probe.c.enabled != None)\
.values({'old_enabled': True}))
op.execute(probe.update()\
.where(probe.c.enabled == None)\
.values({'old_enabled': False}))
op.drop_column('probe', 'enabled')
op.alter_column('probe', 'old_enabled', new_column_name='enabled', nullable=False)
# client
op.add_column('client', sa.Column('old_enabled', sa.Boolean(), nullable=True))
client = sa.sql.table('client',
sa.sql.column('enabled'),
sa.sql.column('old_enabled'))
op.execute(client.update()\
.where(client.c.enabled != None)\
.values({'old_enabled': True}))
op.execute(client.update()\
.where(client.c.enabled == None)\
.values({'old_enabled': False}))
op.drop_column('client', 'enabled')
op.alter_column('client', 'old_enabled', new_column_name='enabled', nullable=False)

View file

@ -1,37 +1,39 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Data models for tempmon
"""
from __future__ import unicode_literals, absolute_import
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base
try:
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base
from rattail import enum
from rattail.db.model import uuid_column
from rattail.db.model.core import ModelBase
@ -39,6 +41,45 @@ from rattail.db.model.core import ModelBase
Base = declarative_base(cls=ModelBase)
class Appliance(Base):
"""
Represents an appliance which is monitored by tempmon.
"""
__tablename__ = 'appliance'
__table_args__ = (
sa.UniqueConstraint('name', name='appliance_uq_name'),
)
uuid = uuid_column()
name = sa.Column(sa.String(length=255), nullable=False, doc="""
Human-friendly (and unique) name for the appliance.
""")
appliance_type = sa.Column(sa.Integer(), nullable=True, doc="""
Code indicating which "type" of appliance this is.
""")
location = sa.Column(sa.String(length=255), nullable=True, doc="""
Description of the appliance's physical location.
""")
image_raw = sa.Column(sa.LargeBinary(), nullable=True, doc="""
Byte sequence of the raw image, as uploaded.
""")
image_normal = sa.Column(sa.LargeBinary(), nullable=True, doc="""
Byte sequence of the normalized image, i.e. "reasonable" size.
""")
image_thumbnail = sa.Column(sa.LargeBinary(), nullable=True, doc="""
Byte sequence of the thumbnail image.
""")
def __str__(self):
return self.name
class Client(Base):
"""
Represents a tempmon client.
@ -52,10 +93,44 @@ class Client(Base):
config_key = sa.Column(sa.String(length=50), nullable=False)
hostname = sa.Column(sa.String(length=255), nullable=False)
location = sa.Column(sa.String(length=255), nullable=True)
enabled = sa.Column(sa.Boolean(), nullable=False, default=False)
online = sa.Column(sa.Boolean(), nullable=False, default=False)
def __unicode__(self):
disk_type = sa.Column(sa.Integer(), nullable=True, doc="""
Integer code representing the type of hard disk used, if known. The
original motivation for this was to keep track of whether each client
(aka. Raspberry Pi) was using a SD card or USB drive for the root disk.
""")
delay = sa.Column(sa.Integer(), nullable=True, doc="""
Number of seconds to delay between reading / recording temperatures. If
not set, a default of 60 seconds will be assumed.
""")
notes = sa.Column(sa.Text(), nullable=True, doc="""
Any arbitrary notes for the client.
""")
enabled = sa.Column(sa.DateTime(), nullable=True, doc="""
This will either be the date/time when the client was most recently
enabled, or null if it is not currently enabled. If set, the client will
be expected to take readings (but only for "enabled" probes) and the server
will monitor them to ensure they are within the expected range etc.
""")
online = sa.Column(sa.Boolean(), nullable=False, default=False, doc="""
Whether the client is known to be online currently. When a client takes
readings, it will mark itself as online. If the server notices that the
readings have stopped, it will mark the client as *not* online.
""")
archived = sa.Column(sa.Boolean(), nullable=False, default=False, doc="""
Flag indicating this client is "archived". This typically means that the
client itself no longer exists but that the configuration for it should be
retained, to be used as a reference later etc. Note that "archiving" a
client is different from "disabling" it; i.e. disabling is temporary and
archiving is more for the long term.
""")
def __str__(self):
return '{} ({})'.format(self.config_key, self.hostname)
def enabled_probes(self):
@ -69,6 +144,7 @@ class Probe(Base):
__tablename__ = 'probe'
__table_args__ = (
sa.ForeignKeyConstraint(['client_uuid'], ['client.uuid'], name='probe_fk_client'),
sa.ForeignKeyConstraint(['appliance_uuid'], ['appliance.uuid'], name='probe_fk_appliance'),
sa.UniqueConstraint('config_key', name='probe_uq_config_key'),
)
@ -82,34 +158,247 @@ class Probe(Base):
""",
backref=orm.backref(
'probes',
cascade='all, delete-orphan',
doc="""
List of probes connected to this client.
"""))
config_key = sa.Column(sa.String(length=50), nullable=False)
appliance_type = sa.Column(sa.Integer(), nullable=False)
description = sa.Column(sa.String(length=255), nullable=False)
device_path = sa.Column(sa.String(length=255), nullable=True)
enabled = sa.Column(sa.Boolean(), nullable=False, default=True)
good_temp_min = sa.Column(sa.Integer(), nullable=False)
good_temp_max = sa.Column(sa.Integer(), nullable=False)
critical_temp_min = sa.Column(sa.Integer(), nullable=False)
critical_temp_max = sa.Column(sa.Integer(), nullable=False)
therm_status_timeout = sa.Column(sa.Integer(), nullable=False)
status_alert_timeout = sa.Column(sa.Integer(), nullable=False)
appliance_type = sa.Column(sa.Integer(), nullable=False)
appliance_uuid = sa.Column(sa.String(length=32), nullable=True)
appliance = orm.relationship(
Appliance,
doc="""
Reference to the appliance which this probe monitors.
""",
backref=orm.backref(
'probes',
order_by='Probe.description',
doc="""
List of probes which monitor this appliance.
"""))
description = sa.Column(sa.String(length=255), nullable=False, doc="""
General human-friendly description for the probe.
""")
location = sa.Column(sa.String(length=255), nullable=True, doc="""
Description of the probe's physical location, relative to the appliance.
""")
device_path = sa.Column(sa.String(length=255), nullable=True)
enabled = sa.Column(sa.DateTime(), nullable=True, doc="""
This will either be the date/time when the probe was most recently enabled,
or null if it is not currently enabled. If set, the client will be
expected to take readings for this probe, and the server will monitor them
to ensure they are within the expected range etc.
""")
critical_temp_max = sa.Column(sa.Integer(), nullable=False, doc="""
Maximum high temperature; when a reading is greater than or equal to this
value, the probe's status becomes "critical high temp".
""")
critical_max_started = sa.Column(sa.DateTime(), nullable=True, doc="""
Timestamp when the probe readings started to indicate "critical high temp"
status. This should be null unless the probe currently has that status.
""")
critical_max_timeout = sa.Column(sa.Integer(), nullable=True, doc="""
Number of minutes the probe is allowed to have "critical high temp" status,
before the first email alert is sent for that. If empty, there will be no
delay and the first email will go out as soon as that status is reached.
If set, should probably be a *low* number.
""")
good_temp_max = sa.Column(sa.Integer(), nullable=False, doc="""
Maximum good temperature; when a reading is greater than or equal to this
value, the probe's status becomes "high temp" (unless the reading also
breaches the :attr:`critical_temp_max` threshold).
""")
good_max_timeout = sa.Column(sa.Integer(), nullable=True, doc="""
Number of minutes the probe is allowed to have "high temp" status, before
the first email alert is sent for that. This is typically meant to account
for the length of the defrost cycle, so may be a rather large number.
""")
good_max_started = sa.Column(sa.DateTime(), nullable=True, doc="""
Timestamp when the probe readings started to indicate "high temp" status.
This should be null unless the probe currently has either "high temp" or
"critical high temp" status.
""")
good_temp_min = sa.Column(sa.Integer(), nullable=False, doc="""
Minimum good temperature; when a reading is less than or equal to this
value, the probe's status becomes "low temp" (unless the reading also
breaches the :attr:`critical_temp_min` threshold).
""")
good_min_timeout = sa.Column(sa.Integer(), nullable=True, doc="""
Number of minutes the probe is allowed to have "low temp" status, before
the first email alert is sent for that.
""")
good_min_started = sa.Column(sa.DateTime(), nullable=True, doc="""
Timestamp when the probe readings started to indicate "low temp" status.
This should be null unless the probe currently has either "low temp" or
"critical low temp" status.
""")
critical_temp_min = sa.Column(sa.Integer(), nullable=False, doc="""
Minimum low temperature; when a reading is less than or equal to this
value, the probe's status becomes "critical low temp". If empty, there
will be no delay and the first email will go out as soon as that status is
reached.
""")
critical_min_started = sa.Column(sa.DateTime(), nullable=True, doc="""
Timestamp when the probe readings started to indicate "critical low temp"
status. This should be null unless the probe currently has that status.
""")
critical_min_timeout = sa.Column(sa.Integer(), nullable=True, doc="""
Number of minutes the probe is allowed to have "critical low temp" status,
before the first email alert is sent for that. If empty, there will be no
delay and the first email will go out as soon as that status is reached.
""")
error_started = sa.Column(sa.DateTime(), nullable=True, doc="""
Timestamp when the probe readings started to indicate "error" status. This
should be null unless the probe currently has that status.
""")
error_timeout = sa.Column(sa.Integer(), nullable=True, doc="""
Number of minutes the probe is allowed to have "error" status, before the
first email alert is sent for that. If empty, there will be no delay and
the first email will go out as soon as that status is reached.
""")
# TODO: deprecate / remove this
therm_status_timeout = sa.Column(sa.Integer(), nullable=False, doc="""
NOTE: This field is deprecated; please set the value for High Timeout instead.
""")
status_alert_timeout = sa.Column(sa.Integer(), nullable=False, doc="""
Number of minutes between successive status alert emails. These alerts
will continue to be sent until the status changes. Note that the *first*
alert for a given status will be delayed according to the timeout for that
status.
""")
notes = sa.Column(sa.Text(), nullable=True, doc="""
Any arbitrary notes for the probe.
""")
status = sa.Column(sa.Integer(), nullable=True)
status_changed = sa.Column(sa.DateTime(), nullable=True)
status_alert_sent = sa.Column(sa.DateTime(), nullable=True)
def __unicode__(self):
return unicode(self.description or '')
def __str__(self):
return self.description
def last_reading(self):
"""
Returns the reading which was taken most recently for this probe.
"""
session = orm.object_session(self)
return session.query(Reading)\
.filter(Reading.probe == self)\
.order_by(Reading.taken.desc())\
.first()
def start_status(self, status, time):
"""
Update the "started" timestamp field for the given status. This is
used to track e.g. when we cross the "high temp" threshold, as a
separate event from when the "critical high temp" threshold is reached.
Note that in addition to setting the appropriate timestamp field, this
also will clear out other timestamp fields, according to the specific
(new) status.
"""
if status in (enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP,
enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP):
self.critical_max_started = time
# note, we don't clear out "high temp" time
self.good_min_started = None
self.critical_min_started = None
self.error_started = None
elif status == enum.TEMPMON_PROBE_STATUS_HIGH_TEMP:
self.critical_max_started = None
self.good_max_started = time
self.good_min_started = None
self.critical_min_started = None
self.error_started = None
elif status == enum.TEMPMON_PROBE_STATUS_LOW_TEMP:
self.critical_max_started = None
self.good_max_started = None
self.good_min_started = time
self.critical_min_started = None
self.error_started = None
elif status == enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP:
self.critical_max_started = None
self.good_max_started = None
# note, we don't clear out "low temp" time
self.critical_min_started = time
self.error_started = None
elif status == enum.TEMPMON_PROBE_STATUS_ERROR:
# note, we don't clear out any other status times
self.error_started = time
def status_started(self, status):
"""
Return the timestamp indicating when the given status started.
"""
if status in (enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP,
enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP):
return self.critical_max_started
elif status == enum.TEMPMON_PROBE_STATUS_HIGH_TEMP:
return self.good_max_started
elif status == enum.TEMPMON_PROBE_STATUS_LOW_TEMP:
return self.good_min_started
elif status == enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP:
return self.critical_min_started
elif status == enum.TEMPMON_PROBE_STATUS_ERROR:
return self.error_started
def timeout_for_status(self, status):
"""
Returns the timeout value for the given status. This is be the number
of minutes by which we should delay the initial email for the status.
"""
if status in (enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP,
enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP):
return self.critical_max_timeout
elif status == enum.TEMPMON_PROBE_STATUS_HIGH_TEMP:
return self.good_max_timeout or self.therm_status_timeout
elif status == enum.TEMPMON_PROBE_STATUS_LOW_TEMP:
return self.good_min_timeout or self.therm_status_timeout
elif status == enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP:
return self.critical_min_timeout
elif status == enum.TEMPMON_PROBE_STATUS_ERROR:
return self.error_timeout
class Reading(Base):
"""
Represents a single tempurate reading from a tempmon probe.
Represents a single temperature reading from a tempmon probe.
"""
__tablename__ = 'reading'
__table_args__ = (
@ -124,17 +413,24 @@ class Reading(Base):
Client,
doc="""
Reference to the tempmon client which took this reading.
""")
""",
backref=orm.backref(
'readings',
cascade='all, delete-orphan'))
probe_uuid = sa.Column(sa.String(length=32), nullable=False)
probe = orm.relationship(
Probe,
doc="""
Reference to the tempmon probe which took this reading.
""")
""",
backref=orm.backref(
'readings',
order_by='Reading.taken',
cascade='all, delete-orphan'))
taken = sa.Column(sa.DateTime(), nullable=False, default=datetime.datetime.utcnow)
degrees_f = sa.Column(sa.Numeric(precision=7, scale=4), nullable=False)
degrees_f = sa.Column(sa.Numeric(precision=8, scale=4), nullable=False)
def __unicode__(self):
return unicode(self.degrees_f)
def __str__(self):
return str(self.degrees_f)

View file

@ -1,23 +1,23 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
@ -30,40 +30,60 @@ from rattail.db import model
from rattail.mail import Email
from rattail.time import localtime
from rattail_tempmon.db import model as tempmon
class tempmon(object):
class TempmonBase(object):
"""
Generic base class for all tempmon-related emails; adds common sample data.
"""
def sample_data(self, request):
now = localtime(self.config)
client = model.TempmonClient(config_key='testclient', hostname='testclient')
probe = model.TempmonProbe(config_key='testprobe', description="Test Probe")
client = tempmon.Client(config_key='testclient', hostname='testclient')
probe = tempmon.Probe(config_key='testprobe', description="Test Probe",
good_max_timeout=45)
client.probes.append(probe)
return {
'client': client,
'probe': probe,
'probe_url': '#',
'status': self.enum.TEMPMON_PROBE_STATUS[self.enum.TEMPMON_PROBE_STATUS_ERROR],
'reading': model.TempmonReading(),
'reading': tempmon.Reading(),
'taken': now,
'now': now,
'status_since': now.strftime('%I:%M %p'),
'status_since_delta': 'now',
'recent_minutes': 90,
'recent_readings': [],
}
class tempmon_critical_temp(tempmon, Email):
class tempmon_critical_high_temp(TempmonBase, Email):
"""
Sent when a tempmon probe takes a reading which is "critical" in either the
high or low sense.
Sent when a tempmon probe takes a "critical high" temperature reading.
"""
default_subject = "Critical temperature detected"
default_subject = "CRITICAL HIGH Temperature"
def sample_data(self, request):
data = super(tempmon_critical_temp, self).sample_data(request)
data['status'] = self.enum.TEMPMON_PROBE_STATUS[self.enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP]
data = super(tempmon_critical_high_temp, self).sample_data(request)
data['status'] = self.enum.TEMPMON_PROBE_STATUS[self.enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP]
return data
class tempmon_error(tempmon, Email):
class tempmon_critical_low_temp(TempmonBase, Email):
"""
Sent when a tempmon probe takes a "critical low" temperature reading.
"""
default_subject = "CRITICAL LOW Temperature"
def sample_data(self, request):
data = super(tempmon_critical_low_temp, self).sample_data(request)
data['status'] = self.enum.TEMPMON_PROBE_STATUS[self.enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP]
return data
class tempmon_error(TempmonBase, Email):
"""
Sent when a tempmon probe is noticed to have some error, i.e. no current readings.
"""
@ -76,12 +96,19 @@ class tempmon_error(tempmon, Email):
return data
class tempmon_good_temp(tempmon, Email):
class tempmon_client_offline(TempmonBase, Email):
"""
Sent when a tempmon client has been marked offline.
"""
default_subject = "Client Offline"
class tempmon_good_temp(TempmonBase, Email):
"""
Sent whenever a tempmon probe first takes a "good temp" reading, after
having previously had some bad reading(s).
"""
default_subject = "Good temperature detected"
default_subject = "OK Temperature"
def sample_data(self, request):
data = super(tempmon_good_temp, self).sample_data(request)
@ -89,12 +116,12 @@ class tempmon_good_temp(tempmon, Email):
return data
class tempmon_high_temp(tempmon, Email):
class tempmon_high_temp(TempmonBase, Email):
"""
Sent when a tempmon probe takes a reading which is above the "maximum good
temp" range, but still below the "critically high temp" threshold.
"""
default_subject = "High temperature detected"
default_subject = "HIGH Temperature"
def sample_data(self, request):
data = super(tempmon_high_temp, self).sample_data(request)
@ -102,14 +129,32 @@ class tempmon_high_temp(tempmon, Email):
return data
class tempmon_low_temp(tempmon, Email):
class tempmon_low_temp(TempmonBase, Email):
"""
Sent when a tempmon probe takes a reading which is below the "minimum good
temp" range, but still above the "critically low temp" threshold.
"""
default_subject = "Low temperature detected"
default_subject = "LOW Temperature"
def sample_data(self, request):
data = super(tempmon_low_temp, self).sample_data(request)
data['status'] = self.enum.TEMPMON_PROBE_STATUS[self.enum.TEMPMON_PROBE_STATUS_LOW_TEMP]
return data
class tempmon_disabled_probes(Email):
"""
Notifies of any Tempmon client devices or probes which are disabled.
"""
default_subject = "Disabled probes"
def sample_data(self, request):
return {
'disabled_clients': [
tempmon.Client(config_key='foo', hostname='foo.example.com'),
],
'disabled_probes': [
tempmon.Probe(description="north wall of walk-in cooler",
client=tempmon.Client(config_key='bar')),
],
}

View file

View file

@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""
HotCooler -> Tempmon importers
"""
from __future__ import unicode_literals, absolute_import
import datetime
from uuid import UUID
import sqlalchemy as sa
from sqlalchemy import orm
import sqlsoup
from rattail import importing
from rattail.util import OrderedDict
from rattail.time import make_utc
from rattail_tempmon.db import Session as TempmonSession, model as tempmon
class FromTempmonToHotCooler(importing.FromSQLAlchemyHandler, importing.ToSQLAlchemyHandler):
"""
Handler for Tempmon -> HotCooler data import
"""
host_title = "Tempmon"
local_title = "HotCooler"
def make_host_session(self):
return TempmonSession()
def make_session(self):
self.soup = sqlsoup.SQLSoup(self.config.hotcooler_engine)
return self.soup.session
def get_importers(self):
importers = OrderedDict()
importers['Client'] = ClientImporter
importers['Probe'] = ProbeImporter
importers['Reading'] = ReadingImporter
return importers
def get_importer_kwargs(self, key, **kwargs):
kwargs = super(FromTempmonToHotCooler, self).get_importer_kwargs(key, **kwargs)
kwargs['soup'] = self.soup
return kwargs
class FromTempmon(importing.FromSQLAlchemy):
"""
Base class for importers where Tempmon is host
"""
def normalize_host_object(self, obj):
data = dict([(field, getattr(obj, field, None)) for field in self.fields])
data['uuid'] = str(UUID(data['uuid']))
for field in self.fields:
if isinstance(data[field], datetime.datetime):
data[field] = make_utc(data[field], tzinfo=True)
return data
class ToHotCooler(importing.ToSQLAlchemy):
"""
Base class for importers where HotCooler is local/target
"""
key = 'uuid'
@property
def model_mapper(self):
soup_model = self.get_soup_model(self.soup)
return orm.class_mapper(soup_model)
def cache_local_data(self, host_data=None):
soup_model = self.get_soup_model(self.soup)
return self.cache_model(soup_model, key=self.get_cache_key,
query=self.cache_query(),
normalizer=self.normalize_cache_object)
def cache_query(self):
soup_model = self.get_soup_model(self.soup)
return self.session.query(soup_model)
def normalize_local_object(self, obj):
data = super(ToHotCooler, self).normalize_local_object(obj)
for field in self.fields:
if isinstance(data[field], datetime.datetime):
data[field] = make_utc(data[field], tzinfo=True)
return data
def make_object(self):
soup_model = self.get_soup_model(self.soup)
return soup_model()
def create_object(self, key, host_data):
# TODO: this seems hacky..?
return super(importing.ToSQLAlchemy, self).create_object(key, host_data)
class ClientImporter(FromTempmon, ToHotCooler):
"""
Tempmon -> HotCooler importer for Client data
"""
host_model_class = tempmon.Client
supported_fields = [
'uuid',
'config_key',
'hostname',
'location',
'delay',
'enabled',
'online',
]
def get_soup_model(self, soup):
return soup.client
class ProbeImporter(FromTempmon, ToHotCooler):
"""
Tempmon -> HotCooler importer for Probe data
"""
host_model_class = tempmon.Probe
supported_fields = [
'uuid',
'client_id',
'config_key',
'appliance_type',
'description',
'device_path',
'enabled',
'good_temp_min',
'good_temp_max',
'critical_temp_min',
'critical_temp_max',
'therm_status_timeout',
'status_alert_timeout',
]
def get_soup_model(self, soup):
return soup.probe
def setup(self):
self.clients = self.cache_model(self.soup.client, key='uuid')
def normalize_host_object(self, probe):
data = super(ProbeImporter, self).normalize_host_object(probe)
uuid = str(UUID(probe.client_uuid))
client = self.clients.get(uuid)
data['client_id'] = client.id
return data
class ReadingImporter(FromTempmon, ToHotCooler):
"""
Tempmon -> HotCooler importer for Reading data
"""
host_model_class = tempmon.Reading
supported_fields = [
'uuid',
'client_id',
'probe_id',
'taken',
'degrees_f',
]
def get_soup_model(self, soup):
return soup.reading
def setup(self):
self.clients = self.cache_model(self.soup.client, key='uuid')
self.probes = self.cache_model(self.soup.probe, key='uuid')
def normalize_host_object(self, reading):
data = super(ReadingImporter, self).normalize_host_object(reading)
uuid = str(UUID(reading.client_uuid))
client = self.clients.get(uuid)
data['client_id'] = client.id
uuid = str(UUID(reading.probe_uuid))
probe = self.probes.get(uuid)
data['probe_id'] = probe.id
return data

View file

@ -0,0 +1,53 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail 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.
#
# Rattail 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.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tempmon data problems
"""
from __future__ import unicode_literals, absolute_import
from rattail.mail import send_email
from rattail_tempmon.db import Session as TempmonSession, model as tempmon
def disabled_probes(config, progress=None):
"""
Notifies if any (non-archived) Tempmon client devices or probes are disabled.
"""
tempmon_session = TempmonSession()
clients = tempmon_session.query(tempmon.Client)\
.filter(tempmon.Client.archived == False)\
.filter(tempmon.Client.enabled == None)\
.all()
probes = tempmon_session.query(tempmon.Probe)\
.join(tempmon.Client)\
.filter(tempmon.Client.archived == False)\
.filter(tempmon.Client.enabled != None)\
.filter(tempmon.Probe.enabled == None)\
.all()
if clients or probes:
send_email(config, 'tempmon_disabled_probes', {
'disabled_clients': clients,
'disabled_probes': probes,
})
tempmon_session.close()

View file

@ -1,35 +1,37 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
# 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.
#
# Rattail 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 Affero General Public License for
# more details.
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tempmon server daemon
"""
from __future__ import unicode_literals, absolute_import
import time
import datetime
import logging
import humanize
from sqlalchemy import orm
from sqlalchemy.exc import OperationalError
from rattail.db import Session, api
from rattail_tempmon.db import Session as TempmonSession, model as tempmon
from rattail.daemon import Daemon
@ -51,60 +53,148 @@ class TempmonServerDaemon(Daemon):
Keeps an eye on tempmon readings and sends alerts as needed.
"""
self.extra_emails = self.config.getlist('rattail.tempmon', 'extra_emails', default=[])
delay = self.config.getint('rattail.tempmon', 'server.delay', default=60)
self.failed_checks = 0
while True:
self.check_readings()
# TODO: make this configurable
time.sleep(60)
time.sleep(delay)
def check_readings(self):
# log.debug("checking readings")
self.now = make_utc()
session = TempmonSession()
probes = session.query(tempmon.Probe)\
.join(tempmon.Client)\
.filter(tempmon.Client.enabled == True)\
.filter(tempmon.Probe.enabled == True)\
.all()
if probes:
cutoff = self.now - datetime.timedelta(seconds=120)
uuids = [probe.uuid for probe in probes]
readings = session.query(tempmon.Reading)\
.filter(tempmon.Reading.probe_uuid.in_(uuids))\
.filter(tempmon.Reading.taken >= cutoff)\
.all()
self.process_readings(probes, readings)
try:
clients = session.query(tempmon.Client)\
.filter(tempmon.Client.enabled != None)\
.filter(tempmon.Client.archived == False)
for client in clients:
self.check_readings_for_client(session, client)
session.flush()
else:
log.warning("found no enabled probes!")
except Exception as error:
log_error = True
self.failed_checks += 1
session.rollback()
session.commit()
session.close()
# our goal here is to suppress logging when we see connection
# errors which are due to a simple postgres restart. but if they
# keep coming then we'll go ahead and log them (sending email)
if isinstance(error, OperationalError):
# TODO: not sure this is really necessary?
self.set_last_checked()
# this first test works upon first DB restart, as well as the
# first time after DB stop. but in the case of DB stop,
# subsequent errors will instead match the second test
if error.connection_invalidated or (
'could not connect to server: Connection refused' in str(error)):
def process_readings(self, probes, readings):
for probe in probes:
probe_readings = [r for r in readings if r.probe is probe]
if probe_readings:
reading = sorted(probe_readings, key=lambda r: r.taken)[-1]
# only suppress logging for 3 failures, after that we let them go
# TODO: should make the max attempts configurable
if self.failed_checks < 4:
log_error = False
log.debug("database connection failure #%s: %s",
self.failed_checks,
str(error))
if (reading.degrees_f <= probe.critical_temp_min or
reading.degrees_f >= probe.critical_temp_max):
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP, reading)
# send error email unless we're suppressing it for now
if log_error:
log.exception("Failed to check client probe readings (but will keep trying)")
elif reading.degrees_f < probe.good_temp_min:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_LOW_TEMP, reading)
else: # checks were successful
self.failed_checks = 0
session.commit()
elif reading.degrees_f > probe.good_temp_max:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_HIGH_TEMP, reading)
finally:
session.close()
else: # temp is good
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_GOOD_TEMP, reading)
else: # no readings for probe
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_ERROR)
def check_readings_for_client(self, session, client):
"""
Check readings for all (enabled) probes for the given client.
"""
# cutoff is calculated as the client delay (i.e. how often it takes
# readings) plus one minute. we "should" have a reading for each probe
# within that time window. if no readings are found we will consider
# the client to be (possibly) offline.
delay = client.delay or 60
cutoff = self.now - datetime.timedelta(seconds=delay + 60)
# but if client was "just now" enabled, cutoff may not be quite fair.
# in this case we'll just skip checks until cutoff does seem fair.
if cutoff < client.enabled:
return
# we make similar checks for each probe; if cutoff "is not fair" for
# any of them, we'll skip that probe check, and avoid marking client
# offline for this round, just to be safe
online = False
cutoff_unfair = False
for probe in client.enabled_probes():
if cutoff < probe.enabled:
cutoff_unfair = True
elif self.check_readings_for_probe(session, probe, cutoff):
online = True
if cutoff_unfair:
return
# if client was previously marked online, but we have no "new"
# readings, then let's look closer to see if it's been long enough to
# mark it offline
if client.online and not online:
# we consider client offline if it has failed to take readings for
# 3 times in a row. allow a one minute buffer for good measure.
cutoff = self.now - datetime.timedelta(seconds=(delay * 3) + 60)
reading = session.query(tempmon.Reading)\
.filter(tempmon.Reading.client == client)\
.filter(tempmon.Reading.taken >= cutoff)\
.first()
if not reading:
log.info("marking client as OFFLINE: {}".format(client))
client.online = False
send_email(self.config, 'tempmon_client_offline', {
'client': client,
'now': localtime(self.config, self.now, from_utc=True),
})
def check_readings_for_probe(self, session, probe, cutoff):
"""
Check readings for the given probe, within the time window defined by
the given cutoff.
"""
# we really only care about the latest reading
reading = session.query(tempmon.Reading)\
.filter(tempmon.Reading.probe == probe)\
.filter(tempmon.Reading.taken >= cutoff)\
.order_by(tempmon.Reading.taken.desc())\
.first()
if reading:
# is reading above critical max?
if reading.degrees_f >= probe.critical_temp_max:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP, reading)
# is reading above good max?
elif reading.degrees_f >= probe.good_temp_max:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_HIGH_TEMP, reading)
# is reading below good min?
elif reading.degrees_f <= probe.good_temp_min:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_LOW_TEMP, reading)
# is reading below critical min?
elif reading.degrees_f <= probe.critical_temp_min:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP, reading)
else: # temp is good
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_GOOD_TEMP, reading)
return True
else: # no current readings for probe
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_ERROR)
return False
def update_status(self, probe, status, reading=None):
data = {
@ -112,20 +202,32 @@ class TempmonServerDaemon(Daemon):
'status': self.enum.TEMPMON_PROBE_STATUS[status],
'reading': reading,
'taken': localtime(self.config, reading.taken, from_utc=True) if reading else None,
'now': localtime(self.config),
'now': localtime(self.config, self.now, from_utc=True),
}
prev_status = probe.status
prev_alert_sent = probe.status_alert_sent
if probe.status != status:
probe.status = status
probe.start_status(status, self.now)
probe.status_changed = self.now
probe.status_alert_sent = None
# send email when things go back to normal, after being bad
if status == self.enum.TEMPMON_PROBE_STATUS_GOOD_TEMP and prev_alert_sent:
send_email(self.config, 'tempmon_good_temp', data)
# send "high temp" email if previous status was critical, even if
# we haven't been high for that long overall
if (status == self.enum.TEMPMON_PROBE_STATUS_HIGH_TEMP
and prev_status in (self.enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP,
self.enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP)
and prev_alert_sent):
self.send_email(status, 'tempmon_high_temp', data)
probe.status_alert_sent = self.now
return
# send email when things go back to normal (i.e. from any other status)
if status == self.enum.TEMPMON_PROBE_STATUS_GOOD_TEMP and prev_alert_sent:
self.send_email(status, 'tempmon_good_temp', data)
probe.status_alert_sent = self.now
return
# no (more) email if status is good
if status == self.enum.TEMPMON_PROBE_STATUS_GOOD_TEMP:
@ -138,30 +240,69 @@ class TempmonServerDaemon(Daemon):
return
# delay even the first email, until configured threshold is reached
timeout = datetime.timedelta(minutes=probe.therm_status_timeout)
if (self.now - probe.status_changed) <= timeout:
timeout = probe.timeout_for_status(status)
if timeout is None:
if status == self.enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP:
timeout = self.config.getint('rattail_tempmon', 'probe.default.critical_max_timeout',
default=0)
elif status == self.enum.TEMPMON_PROBE_STATUS_HIGH_TEMP:
timeout = self.config.getint('rattail_tempmon', 'probe.default.good_max_timeout',
default=0)
elif status == self.enum.TEMPMON_PROBE_STATUS_LOW_TEMP:
timeout = self.config.getint('rattail_tempmon', 'probe.default.good_min_timeout',
default=0)
elif status == self.enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP:
timeout = self.config.getint('rattail_tempmon', 'probe.default.critical_min_timeout',
default=0)
elif status == self.enum.TEMPMON_PROBE_STATUS_ERROR:
timeout = self.config.getint('rattail_tempmon', 'probe.default.error_timeout',
default=0)
timeout = datetime.timedelta(minutes=timeout or 0)
started = probe.status_started(status) or probe.status_changed
if (self.now - started) <= timeout:
return
msgtypes = {
self.enum.TEMPMON_PROBE_STATUS_LOW_TEMP : 'tempmon_low_temp',
self.enum.TEMPMON_PROBE_STATUS_CRITICAL_HIGH_TEMP : 'tempmon_critical_high_temp',
self.enum.TEMPMON_PROBE_STATUS_HIGH_TEMP : 'tempmon_high_temp',
self.enum.TEMPMON_PROBE_STATUS_CRITICAL_TEMP : 'tempmon_critical_temp',
self.enum.TEMPMON_PROBE_STATUS_LOW_TEMP : 'tempmon_low_temp',
self.enum.TEMPMON_PROBE_STATUS_CRITICAL_LOW_TEMP : 'tempmon_critical_low_temp',
self.enum.TEMPMON_PROBE_STATUS_ERROR : 'tempmon_error',
}
send_email(self.config, msgtypes[status], data)
self.send_email(status, msgtypes[status], data)
# maybe send more emails if config said so
for msgtype in self.extra_emails:
send_email(self.config, msgtype, data)
self.send_email(status, msgtype, data)
probe.status_alert_sent = self.now
def set_last_checked(self):
session = Session()
api.save_setting(session, 'tempmon.server.last_checked', self.now.strftime(self.timefmt))
session.commit()
session.close()
def send_email(self, status, template, data):
probe = data['probe']
started = probe.status_started(status) or probe.status_changed
# determine URL for probe, if possible
url = self.config.get('tailbone', 'url.tempmon.probe', default='#')
data['probe_url'] = url.format(uuid=probe.uuid)
since = localtime(self.config, started, from_utc=True)
data['status_since'] = since.strftime('%I:%M %p')
data['status_since_delta'] = humanize.naturaltime(self.now - started)
# fetch last 90 minutes of readings
session = orm.object_session(probe)
recent_minutes = 90 # TODO: make configurable
cutoff = self.now - datetime.timedelta(seconds=(60 * recent_minutes))
readings = session.query(tempmon.Reading)\
.filter(tempmon.Reading.probe == probe)\
.filter(tempmon.Reading.taken >= cutoff)\
.order_by(tempmon.Reading.taken.desc())
data['recent_minutes'] = recent_minutes
data['recent_readings'] = readings
data['pretty_time'] = lambda dt: localtime(self.config, dt, from_utc=True).strftime('%Y-%m-%d %I:%M %p')
send_email(self.config, template, data)
def make_daemon(config, pidfile=None):

View file

@ -0,0 +1,88 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail 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.
#
# Rattail 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.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Rattail Tempmon Settings
"""
from __future__ import unicode_literals, absolute_import
from rattail.settings import Setting
##############################
# TempMon
##############################
class rattail_tempmon_probe_default_critical_max_timeout(Setting):
"""
Default value to be used as Critical High Timeout value, for any probe
which does not have this timeout defined.
"""
group = "TempMon"
namespace = 'rattail_tempmon'
name = 'probe.default.critical_max_timeout'
data_type = int
class rattail_tempmon_probe_default_critical_min_timeout(Setting):
"""
Default value to be used as Critical Low Timeout value, for any probe which
does not have this timeout defined.
"""
group = "TempMon"
namespace = 'rattail_tempmon'
name = 'probe.default.critical_min_timeout'
data_type = int
class rattail_tempmon_probe_default_error_timeout(Setting):
"""
Default value to be used as Error Timeout value, for any probe which does
not have this timeout defined.
"""
group = "TempMon"
namespace = 'rattail_tempmon'
name = 'probe.default.error_timeout'
data_type = int
class rattail_tempmon_probe_default_good_max_timeout(Setting):
"""
Default value to be used as High Timeout value, for any probe which does
not have this timeout defined.
"""
group = "TempMon"
namespace = 'rattail_tempmon'
name = 'probe.default.good_max_timeout'
data_type = int
class rattail_tempmon_probe_default_good_min_timeout(Setting):
"""
Default value to be used as Low Timeout value, for any probe which does not
have this timeout defined.
"""
group = "TempMon"
namespace = 'rattail_tempmon'
name = 'probe.default.good_min_timeout'
data_type = int

View file

@ -0,0 +1,8 @@
## -*- coding: utf-8 -*-
<html>
<body>
<p>At ${now.strftime("%Y-%m-%d %I:%M %p")} the Tempmon server failed to locate readings for ${client}.<br>
This client is now marked as offline. Investigate asap.
</p>
</body>
</html>

View file

@ -17,7 +17,7 @@
Check out <a href="http://www.fsis.usda.gov/wps/portal/fsis/topics/food-safety-education/get-answers/food-safety-fact-sheets/safe-food-handling/freezing-and-food-safety/CT_Index/!ut/p/a1/jZFRT8IwEIB_DY9dbw7J8G1ZYtiUTYJK2Qsp7NYt2dqlrU759RZ8UQJK-9LefV-ud6UFZbSQ_L0R3DZK8vZwLyYbWMDEn8aQ5lP_HpLsdZE_xDGEy1sHrP8AsuBK_8KK4D8_vaLAjZ7Hc0GLntuaNLJSlAm0hEszoDaUVUqVxPAK7Sep-M4SUyNalzjEyDFbc1m2jRQO1oh7d3J6SX6YlMXPm0SW-EFXtPj9KvDdTrJgOZ6lWQD5-BQ4M7Zv4PJcXOOiVdvjH60juQ1C16HGCjVq7027cG1tb-5GMIJhGDyhlGjR26nunFArYyk74fruhe0foxk0T90qNNEXiOIqAA!!/#16">this USDA link</a> for useful information
</p>
<p>
This email will repeat every 15 minutes until the issue<br>
This email will repeat every ${probe.status_alert_timeout} minutes until the issue<br>
has been resolved.
</p>
<p>

View file

@ -0,0 +1,25 @@
## -*- coding: utf-8 -*-
<html>
<body>
<p>
<b>This is an alert from ${probe}!</b><br>
The status of ${probe} is: ${status}.<br>
The current temperature is: ${reading.degrees_f}.<br>
The temperature should never be this high.
Investigate Immediately!<br>
</p>
<p>
Notes: <br>
Frozen food that is above 40 degrees needs to be thrown away<br>
if it remains at that temperature for two hours or more.<br>
</p>
<p>
Check out <a href="http://www.fsis.usda.gov/wps/portal/fsis/topics/food-safety-education/get-answers/food-safety-fact-sheets/safe-food-handling/freezing-and-food-safety/CT_Index/!ut/p/a1/jZFRT8IwEIB_DY9dbw7J8G1ZYtiUTYJK2Qsp7NYt2dqlrU759RZ8UQJK-9LefV-ud6UFZbSQ_L0R3DZK8vZwLyYbWMDEn8aQ5lP_HpLsdZE_xDGEy1sHrP8AsuBK_8KK4D8_vaLAjZ7Hc0GLntuaNLJSlAm0hEszoDaUVUqVxPAK7Sep-M4SUyNalzjEyDFbc1m2jRQO1oh7d3J6SX6YlMXPm0SW-EFXtPj9KvDdTrJgOZ6lWQD5-BQ4M7Zv4PJcXOOiVdvjH60juQ1C16HGCjVq7027cG1tb-5GMIJhGDyhlGjR26nunFArYyk74fruhe0foxk0T90qNNEXiOIqAA!!/#16">this USDA link</a> for useful information
</p>
<p>
This email will repeat every ${probe.status_alert_timeout} minutes until the issue<br>
has been resolved.
</p>
<p>
</body>
</html>

View file

@ -0,0 +1,31 @@
## -*- coding: utf-8; -*-
<html>
<body>
<h1>Disabled Tempmon Probes</h1>
<p>
We checked if there were any offline temperature probes at your location.&nbsp;
Someone wanted these deactivated at some point, but you should make sure it is
okay that they still are.&nbsp; Here are the results:
</p>
% if disabled_probes:
<h3>Disabled Probes</h3>
<ul>
% for probe in disabled_probes:
<li>${probe} (for client: ${probe.client.config_key})</li>
% endfor
</ul>
% endif
% if disabled_clients:
<h3>Disabled Clients</h3>
<ul>
% for client in disabled_clients:
<li>${client}</li>
% endfor
</ul>
% endif
<p>Please contact the Tech Department with any questions.</p>
</body>
</html>

View file

@ -4,7 +4,7 @@
<p>At ${taken or now}, ${probe} reported that its status was: ${status}.<br>
Something went wrong. Please investigate as soon as possible.
</p>
<p>This email will repeat every 15 minutes until the issue is resolved.
<p>This email will repeat every ${probe.status_alert_timeout} minutes until the issue is resolved.
</p>
</body>
</html>

View file

@ -10,7 +10,7 @@
</p>
<p>
Notes:<br>
This alert will happen every 15 minutes until<br>
This alert will happen every ${probe.status_alert_timeout} minutes until<br>
the temperature reaches an acceptable level.<br>
</p>
</body>

View file

@ -10,7 +10,7 @@
</p>
<p>
Notes:<br>
This alert will happen every 15 minutes until<br>
This alert will happen every ${probe.status_alert_timeout} minutes until<br>
the temperature reaches an acceptable level.<br>
</p>
</body>

106
setup.py
View file

@ -1,106 +0,0 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
setup script for rattail-tempmon
"""
from __future__ import unicode_literals, absolute_import
import os
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
execfile(os.path.join(here, 'rattail_tempmon', '_version.py'))
README = open(os.path.join(here, 'README.rst')).read()
requires = [
#
# Version numbers within comments below have specific meanings.
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
# In other words:
#
# If either a 'low' or 'high' value exists, the primary point to be
# made about the value is that it represents the most current (stable)
# version available for the package (assuming typical public access
# methods) whenever this project was started and/or documented.
# Therefore:
#
# If a 'low' version is present, you should know that attempts to use
# versions of the package significantly older than the 'low' version
# may not yield happy results. (A "hard" high limit may or may not be
# indicated by a true version requirement.)
#
# Similarly, if a 'high' version is present, and especially if this
# project has laid dormant for a while, you may need to refactor a bit
# when attempting to support a more recent version of the package. (A
# "hard" low limit should be indicated by a true version requirement
# when a 'high' version is present.)
#
# In any case, developers and other users are encouraged to play
# outside the lines with regard to these soft limits. If bugs are
# encountered then they should be filed as such.
#
# package # low high
'rattail[db]', # 0.7.46
]
setup(
name = "rattail-tempmon",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "https://rattailproject.org/",
license = "GNU Affero GPL v3",
description = "Retail Software Framework - Temperature monitoring add-on",
long_description = README,
classifiers = [
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU Affero General Public License v3',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.7',
'Topic :: Office/Business',
'Topic :: Software Development :: Libraries :: Python Modules',
],
install_requires = requires,
packages = find_packages(),
include_package_data = True,
entry_points = {
'rattail.commands': [
'tempmon-client = rattail_tempmon.commands:TempmonClient',
'tempmon-server = rattail_tempmon.commands:TempmonServer',
],
'rattail.config.extensions': [
'tempmon = rattail_tempmon.config:TempmonConfigExtension',
],
},
)

43
tasks.py Normal file
View file

@ -0,0 +1,43 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail 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.
#
# Rattail 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.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tasks for 'rattail-tempmon' package
"""
import os
import shutil
from invoke import task
@task
def release(c):
"""
Release a new version of `rattail-tempmon`
"""
if os.path.exists('dist'):
shutil.rmtree('dist')
if os.path.exists('rattail_tempmon.egg-info'):
shutil.rmtree('rattail_tempmon.egg-info')
c.run('python -m build --sdist')
c.run('twine upload dist/*')