Compare commits

...

83 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
29 changed files with 1687 additions and 437 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,100 +0,0 @@
CHANGELOG
=========
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.

View file

@ -1,6 +1,5 @@
rattail-tempmon
===============
# rattail-tempmon
Rattail is a retail software framework, released under the GNU General Public
License.
@ -8,6 +7,5 @@ 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/
Please see Rattail's [home page](https://rattailproject.org/) for more
information.

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.

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

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

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,14 +24,13 @@
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)\
@ -70,47 +70,92 @@ class TempmonClient(Daemon):
session.close()
# main loop: take readings, pause, repeat
self.failed_checks = 0
while True:
self.take_readings(client_uuid)
time.sleep(self.delay)
session = Session()
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.query(tempmon.Client).get(client_uuid)
self.delay = client.delay or 60
if client.enabled:
for probe in client.enabled_probes():
self.take_reading(session, probe)
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()
except:
log.exception("Failed to read/record temperature data (but will keep trying)")
session.rollback()
else:
# make sure we show as being online
# one more thing, make sure our client appears "online"
if not client.online:
client.online = True
try:
session.commit()
except:
log.exception("Failed to read/record temperature data (but will keep trying)")
session.rollback()
finally:
session.close()
except Exception as error:
log_error = True
self.failed_checks += 1
session.rollback()
time.sleep(self.delay)
# 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):
# 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)):
# 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))
# 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):
"""
@ -125,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)
@ -137,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

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,124 +24,157 @@
Tempmon commands
"""
from __future__ import unicode_literals, absolute_import
import datetime
import logging
from enum import Enum
from pathlib import Path
from rattail import commands
from rattail.time import localtime, make_utc
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
log = logging.getLogger(__name__)
class ExportHotCooler(commands.ImportSubcommand):
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
"""
name = 'export-hotcooler'
description = __doc__.strip()
handler_spec = 'rattail_tempmon.hotcooler.importing.tempmon:FromTempmonToHotCooler'
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)
class PurgeTempmon(commands.Subcommand):
@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
"""
name = 'purge-tempmon'
description = __doc__.strip()
def add_parser_args(self, parser):
parser.add_argument('--keep', metavar='DAYS', required=True, type=int,
help="Number of days for which data should be kept.")
parser.add_argument('--dry-run', action='store_true',
help="Go through the full motions and allow logging etc. to "
"occur, but rollback (abort) the transaction at the end.")
def run(self, args):
from rattail_tempmon.db import Session as TempmonSession, model as tempmon
cutoff = localtime(self.config).date() - datetime.timedelta(days=args.keep)
cutoff = localtime(self.config, datetime.datetime.combine(cutoff, datetime.time(0)))
session = TempmonSession()
readings = session.query(tempmon.Reading)\
.filter(tempmon.Reading.taken < make_utc(cutoff))
count = readings.count()
def purge(reading, i):
session.delete(reading)
if i % 200 == 0:
session.flush()
self.progress_loop(purge, readings, count=count, message="Purging stale readings")
log.info("deleted {} stale readings".format(count))
if args.dry_run:
session.rollback()
log.info("dry run, so transaction was rolled back")
else:
session.commit()
log.info("transaction was committed")
session.close()
config = ctx.parent.rattail_config
progress = ctx.parent.rattail_progress
do_purge(config, keep_days, dry_run=dry_run, progress=progress)
class TempmonClient(commands.Subcommand):
@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(commands.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,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,14 +24,13 @@
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:
@ -53,10 +52,10 @@ class TempmonConfigExtension(ConfigExtension):
def configure(self, config):
# tempmon
config.tempmon_engines = get_engines(config, section='rattail_tempmon.db')
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, section='hotcooler.db')
config.hotcooler_engines = get_engines(config, 'hotcooler.db')
config.hotcooler_engine = config.hotcooler_engines.get('default')

View file

@ -6,6 +6,7 @@ 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.db.config import configure_versioning

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,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

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,16 @@
Data models for tempmon
"""
from __future__ import unicode_literals, absolute_import
import datetime
import six
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
@ -40,7 +41,45 @@ from rattail.db.model.core import ModelBase
Base = declarative_base(cls=ModelBase)
@six.python_2_unicode_compatible
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.
@ -55,13 +94,41 @@ class Client(Base):
hostname = sa.Column(sa.String(length=255), nullable=False)
location = sa.Column(sa.String(length=255), nullable=True)
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.
""")
enabled = sa.Column(sa.Boolean(), nullable=False, default=False)
online = sa.Column(sa.Boolean(), nullable=False, default=False)
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)
@ -70,7 +137,6 @@ class Client(Base):
return [probe for probe in self.probes if probe.enabled]
@six.python_2_unicode_compatible
class Probe(Base):
"""
Represents a probe connected to a tempmon client.
@ -78,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'),
)
@ -97,17 +164,135 @@ class Probe(Base):
"""))
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)
@ -116,8 +301,101 @@ class Probe(Base):
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
@six.python_2_unicode_compatible
class Reading(Base):
"""
Represents a single temperature reading from a tempmon probe.
@ -148,6 +426,7 @@ class Reading(Base):
""",
backref=orm.backref(
'readings',
order_by='Reading.taken',
cascade='all, delete-orphan'))
taken = sa.Column(sa.DateTime(), nullable=False, default=datetime.datetime.utcnow)

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
@ -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

@ -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,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,12 +24,14 @@
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,65 +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()
try:
clients = session.query(tempmon.Client)\
.filter(tempmon.Client.enabled == True)
.filter(tempmon.Client.enabled != None)\
.filter(tempmon.Client.archived == False)
for client in clients:
self.check_readings_for_client(session, client)
except:
log.exception("Failed to check client probe readings (but will keep trying)")
session.flush()
except Exception as error:
log_error = True
self.failed_checks += 1
session.rollback()
else:
# 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):
# 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)):
# 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))
# 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)")
else: # checks were successful
self.failed_checks = 0
session.commit()
finally:
session.close()
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)
online = True
# 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():
online = online and bool(self.check_readings_for_probe(session, probe, cutoff))
if not online and client.online:
log.info("marking client as OFFLINE: {}".format(client))
client.online = False
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):
readings = session.query(tempmon.Reading)\
.filter(tempmon.Reading.probe == probe)\
.filter(tempmon.Reading.taken >= cutoff)\
.all()
if readings:
# we really only care about the latest reading
reading = sorted(readings, key=lambda r: r.taken)[-1]
"""
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:
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)
# 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)
elif reading.degrees_f < probe.good_temp_min:
# 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)
elif reading.degrees_f > probe.good_temp_max:
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_HIGH_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)
else: # no readings for probe
self.update_status(probe, self.enum.TEMPMON_PROBE_STATUS_ERROR)
return True
return readings
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 = {
@ -117,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:
@ -143,25 +240,70 @@ 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 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

@ -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>

110
setup.py
View file

@ -1,110 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# 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 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/>.
#
################################################################################
"""
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__))
exec(open(os.path.join(here, 'rattail_tempmon', '_version.py')).read())
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
'six', # 1.10.0
'sqlsoup', # 0.9.1
]
setup(
name = "rattail-tempmon",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "https://rattailproject.org/",
license = "GNU 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 General Public License v3 or later (GPLv3+)',
'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': [
'export-hotcooler = rattail_tempmon.commands:ExportHotCooler',
'purge-tempmon = rattail_tempmon.commands:PurgeTempmon',
'tempmon-client = rattail_tempmon.commands:TempmonClient',
'tempmon-server = rattail_tempmon.commands:TempmonServer',
],
'rattail.config.extensions': [
'tempmon = rattail_tempmon.config:TempmonConfigExtension',
],
},
)

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -21,20 +21,23 @@
#
################################################################################
"""
Fabric script for 'rattail-tempmon' package
Tasks for 'rattail-tempmon' package
"""
from __future__ import unicode_literals, absolute_import
import os
import shutil
from fabric.api import task, local
from invoke import task
@task
def release():
def release(c):
"""
Release a new version of `rattail-tempmon`
"""
shutil.rmtree('rattail_tempmon.egg-info')
local('python setup.py sdist --formats=gztar upload')
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/*')