diff --git a/README.md b/README.md
index 05f6f960..d4474729 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ The table below identifies the services this tool supports and some example serv
| [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/
| [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port
| [Kumulos](https://github.com/caronc/apprise/wiki/Notify_kumulos) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey
+| [LaMetric](https://github.com/caronc/apprise/wiki/Notify_lametric) | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr
lametric://apikey@hostname:port
lametric://client_id@client_secret
| [Mailgun](https://github.com/caronc/apprise/wiki/Notify_mailgun) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey
mailgun://user@hostname/apikey/email
mailgun://user@hostname/apikey/email1/email2/emailN
mailgun://user@hostname/apikey/?name="From%20User"
| [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
matrix://user:token@hostname/?webhook=slack&format=markdown
| [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
diff --git a/apprise/common.py b/apprise/common.py
index 90c65744..b78c0c14 100644
--- a/apprise/common.py
+++ b/apprise/common.py
@@ -31,15 +31,15 @@ class NotifyType(object):
"""
INFO = 'info'
SUCCESS = 'success'
- FAILURE = 'failure'
WARNING = 'warning'
+ FAILURE = 'failure'
NOTIFY_TYPES = (
NotifyType.INFO,
NotifyType.SUCCESS,
- NotifyType.FAILURE,
NotifyType.WARNING,
+ NotifyType.FAILURE,
)
diff --git a/apprise/plugins/NotifyLametric.py b/apprise/plugins/NotifyLametric.py
new file mode 100644
index 00000000..fd271f32
--- /dev/null
+++ b/apprise/plugins/NotifyLametric.py
@@ -0,0 +1,839 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2020 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+# For LaMetric to work, you need to first setup a custom application on their
+# website. it can be done as follows:
+
+# Cloud Mode:
+# 1. Sign Up and login to the developer webpage https://developer.lametric.com
+# 2. Create a **Notification App** if you haven't already done so from:
+# https://developer.lametric.com/applications/sources
+# 3. Provide it an app name, a description and privacy URL (which can point to
+# anywhere; I set mine to `http://localhost`). No permissions are
+# required.
+# 4. Access your newly created app so that you can acquire both the
+# **Client ID** and the **Client Secret** here:
+# https://developer.lametric.com/applications/sources
+
+# Device Mode:
+# - Sign Up and login to the developer webpage https://developer.lametric.com
+# - Locate your Device API Key; you can find it here:
+# https://developer.lametric.com/user/devices
+# - From here you can get your your API Key for the device you plan to notify.
+# - Your devices IP Address can be found in LaMetric Time app at:
+# Settings -> Wi-Fi -> IP Address
+
+# A great source for API examples (Device Mode):
+# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\
+# /device-notifications.html
+
+# A great source for API examples (Cloud Mode):
+# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\
+# /lametric-cloud-reference.html
+
+# A great source for the icon reference:
+# - https://developer.lametric.com/icons
+import six
+import requests
+from json import dumps
+from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
+from ..common import NotifyType
+from ..utils import validate_regex
+from ..AppriseLocale import gettext_lazy as _
+from ..utils import is_hostname
+from ..utils import is_ipaddr
+
+
+class LametricMode(object):
+ """
+ Define Lametric Notification Modes
+ """
+ # App posts upstream to the developer API on Lametric's website
+ CLOUD = "cloud"
+
+ # Device mode posts directly to the device that you identify
+ DEVICE = "device"
+
+
+LAMETRIC_MODES = (
+ LametricMode.CLOUD,
+ LametricMode.DEVICE,
+)
+
+
+class LametricPriority(object):
+ """
+ Priority of the message
+ """
+
+ # info: this priority means that notification will be displayed on the
+ # same “level” as all other notifications on the device that come
+ # from apps (for example facebook app). This notification will not
+ # be shown when screensaver is active. By default message is sent
+ # with "info" priority. This level of notification should be used
+ # for notifications like news, weather, temperature, etc.
+ INFO = 'info'
+
+ # warning: notifications with this priority will interrupt ones sent with
+ # lower priority (“info”). Should be used to notify the user
+ # about something important but not critical. For example,
+ # events like “someone is coming home” should use this priority
+ # when sending notifications from smart home.
+ WARNING = 'warning'
+
+ # critical: the most important notifications. Interrupts notification
+ # with priority info or warning and is displayed even if
+ # screensaver is active. Use with care as these notifications
+ # can pop in the middle of the night. Must be used only for
+ # really important notifications like notifications from smoke
+ # detectors, water leak sensors, etc. Use it for events that
+ # require human interaction immediately.
+ CRITICAL = 'critical'
+
+
+LAMETRIC_PRIORITIES = (
+ LametricPriority.INFO,
+ LametricPriority.WARNING,
+ LametricPriority.CRITICAL,
+)
+
+
+class LametricIconType(object):
+ """
+ Represents the nature of notification.
+ """
+
+ # info - "i" icon will be displayed prior to the notification. Means that
+ # notification contains information, no need to take actions on it.
+ INFO = 'info'
+
+ # alert: "!!!" icon will be displayed prior to the notification. Use it
+ # when you want the user to pay attention to that notification as
+ # it indicates that something bad happened and user must take
+ # immediate action.
+ ALERT = 'alert'
+
+ # none: no notification icon will be shown.
+ NONE = 'none'
+
+
+LAMETRIC_ICON_TYPES = (
+ LametricIconType.INFO,
+ LametricIconType.ALERT,
+ LametricIconType.NONE,
+)
+
+
+class LametricSoundCategory(object):
+ """
+ Define Sound Categories
+ """
+ NOTIFICATIONS = "notifications"
+ ALARMS = "alarms"
+
+
+class LametricSound(object):
+ """
+ There are 2 categories of sounds, to make things simple we just lump them
+ all togther in one class object.
+
+ Syntax is (Category, (AlarmID, Alias1, Alias2, ...))
+ """
+
+ # Alarm Category Sounds
+ ALARM01 = (LametricSoundCategory.ALARMS, ('alarm1', 'a1', 'a01'))
+ ALARM02 = (LametricSoundCategory.ALARMS, ('alarm2', 'a2', 'a02'))
+ ALARM03 = (LametricSoundCategory.ALARMS, ('alarm3', 'a3', 'a03'))
+ ALARM04 = (LametricSoundCategory.ALARMS, ('alarm4', 'a4', 'a04'))
+ ALARM05 = (LametricSoundCategory.ALARMS, ('alarm5', 'a5', 'a05'))
+ ALARM06 = (LametricSoundCategory.ALARMS, ('alarm6', 'a6', 'a06'))
+ ALARM07 = (LametricSoundCategory.ALARMS, ('alarm7', 'a7', 'a07'))
+ ALARM08 = (LametricSoundCategory.ALARMS, ('alarm8', 'a8', 'a08'))
+ ALARM09 = (LametricSoundCategory.ALARMS, ('alarm9', 'a9', 'a09'))
+ ALARM10 = (LametricSoundCategory.ALARMS, ('alarm10', 'a10'))
+ ALARM11 = (LametricSoundCategory.ALARMS, ('alarm11', 'a11'))
+ ALARM12 = (LametricSoundCategory.ALARMS, ('alarm12', 'a12'))
+ ALARM13 = (LametricSoundCategory.ALARMS, ('alarm13', 'a13'))
+
+ # Notification Category Sounds
+ BICYCLE = (LametricSoundCategory.NOTIFICATIONS, ('bicycle', 'bike'))
+ CAR = (LametricSoundCategory.NOTIFICATIONS, ('car', ))
+ CASH = (LametricSoundCategory.NOTIFICATIONS, ('cash', ))
+ CAT = (LametricSoundCategory.NOTIFICATIONS, ('cat', ))
+ DOG01 = (LametricSoundCategory.NOTIFICATIONS, ('dog', 'dog1', 'dog01'))
+ DOG02 = (LametricSoundCategory.NOTIFICATIONS, ('dog2', 'dog02'))
+ ENERGY = (LametricSoundCategory.NOTIFICATIONS, ('energy', ))
+ KNOCK = (LametricSoundCategory.NOTIFICATIONS, ('knock-knock', 'knock'))
+ EMAIL = (LametricSoundCategory.NOTIFICATIONS, (
+ 'letter_email', 'letter', 'email'))
+ LOSE01 = (LametricSoundCategory.NOTIFICATIONS, ('lose1', 'lose01', 'lose'))
+ LOSE02 = (LametricSoundCategory.NOTIFICATIONS, ('lose2', 'lose02'))
+ NEGATIVE01 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'negative1', 'negative01', 'neg01', 'neg1', '-'))
+ NEGATIVE02 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'negative2', 'negative02', 'neg02', 'neg2', '--'))
+ NEGATIVE03 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'negative3', 'negative03', 'neg03', 'neg3', '---'))
+ NEGATIVE04 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'negative4', 'negative04', 'neg04', 'neg4', '----'))
+ NEGATIVE05 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'negative5', 'negative05', 'neg05', 'neg5', '-----'))
+ NOTIFICATION01 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'notification', 'notification1', 'notification01', 'not01', 'not1'))
+ NOTIFICATION02 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'notification2', 'notification02', 'not02', 'not2'))
+ NOTIFICATION03 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'notification3', 'notification03', 'not03', 'not3'))
+ NOTIFICATION04 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'notification4', 'notification04', 'not04', 'not4'))
+ OPEN_DOOR = (LametricSoundCategory.NOTIFICATIONS, (
+ 'open_door', 'open', 'door'))
+ POSITIVE01 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'positive1', 'positive01', 'pos01', 'p1', '+'))
+ POSITIVE02 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'positive2', 'positive02', 'pos02', 'p2', '++'))
+ POSITIVE03 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'positive3', 'positive03', 'pos03', 'p3', '+++'))
+ POSITIVE04 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'positive4', 'positive04', 'pos04', 'p4', '++++'))
+ POSITIVE05 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'positive5', 'positive05', 'pos05', 'p5', '+++++'))
+ POSITIVE06 = (LametricSoundCategory.NOTIFICATIONS, (
+ 'positive6', 'positive06', 'pos06', 'p6', '++++++'))
+ STATISTIC = (LametricSoundCategory.NOTIFICATIONS, ('statistic', 'stat'))
+ THUNDER = (LametricSoundCategory.NOTIFICATIONS, ('thunder'))
+ WATER01 = (LametricSoundCategory.NOTIFICATIONS, ('water1', 'water01'))
+ WATER02 = (LametricSoundCategory.NOTIFICATIONS, ('water2', 'water02'))
+ WIN01 = (LametricSoundCategory.NOTIFICATIONS, ('win', 'win01', 'win1'))
+ WIN02 = (LametricSoundCategory.NOTIFICATIONS, ('win2', 'win02'))
+ WIND = (LametricSoundCategory.NOTIFICATIONS, ('wind', ))
+ WIND_SHORT = (LametricSoundCategory.NOTIFICATIONS, ('wind_short', ))
+
+
+# A listing of all the sounds; the order DOES matter, content is read from
+# top down and then right to left (over aliases). Longer similar sounding
+# elements should be placed higher in the list over others. for example
+# ALARM10 should come before ALARM01 (because ALARM01 can match on 'alarm1'
+# which is very close to 'alarm10'
+LAMETRIC_SOUNDS = (
+ # Alarm Category Entries
+ LametricSound.ALARM13, LametricSound.ALARM12, LametricSound.ALARM11,
+ LametricSound.ALARM10, LametricSound.ALARM09, LametricSound.ALARM08,
+ LametricSound.ALARM07, LametricSound.ALARM06, LametricSound.ALARM05,
+ LametricSound.ALARM04, LametricSound.ALARM03, LametricSound.ALARM02,
+ LametricSound.ALARM01,
+
+ # Notification Category Entries
+ LametricSound.BICYCLE, LametricSound.CAR, LametricSound.CASH,
+ LametricSound.CAT, LametricSound.DOG02, LametricSound.DOG01,
+ LametricSound.ENERGY, LametricSound.KNOCK, LametricSound.EMAIL,
+ LametricSound.LOSE02, LametricSound.LOSE01, LametricSound.NEGATIVE01,
+ LametricSound.NEGATIVE02, LametricSound.NEGATIVE03,
+ LametricSound.NEGATIVE04, LametricSound.NEGATIVE05,
+ LametricSound.NOTIFICATION04, LametricSound.NOTIFICATION03,
+ LametricSound.NOTIFICATION02, LametricSound.NOTIFICATION01,
+ LametricSound.OPEN_DOOR, LametricSound.POSITIVE01,
+ LametricSound.POSITIVE02, LametricSound.POSITIVE03,
+ LametricSound.POSITIVE04, LametricSound.POSITIVE05,
+ LametricSound.POSITIVE01, LametricSound.STATISTIC, LametricSound.THUNDER,
+ LametricSound.WATER02, LametricSound.WATER01, LametricSound.WIND,
+ LametricSound.WIND_SHORT, LametricSound.WIN01, LametricSound.WIN02,
+)
+
+
+class NotifyLametric(NotifyBase):
+ """
+ A wrapper for LaMetric Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'LaMetric'
+
+ # The services URL
+ service_url = 'https://lametric.com'
+
+ # The default protocol
+ protocol = 'lametric'
+
+ # The default secure protocol
+ secure_protocol = 'lametrics'
+
+ # Allow 300 requests per minute.
+ # 60/300 = 0.2
+ request_rate_per_sec = 0.20
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_lametric'
+
+ # Lametric does have titles when creating a message
+ title_maxlen = 0
+
+ # URL used for notifying Lametric App's created in the Dev Portal
+ cloud_notify_url = 'https://developer.lametric.com/api/v1' \
+ '/dev/widget/update/com.lametric.{client_id}'
+
+ # URL used for local notifications directly to the device
+ device_notify_url = '{schema}://{host}{port}/api/v2/device/notifications'
+
+ # LaMetric Default port
+ default_device_port = 8080
+
+ # The Device User ID
+ default_device_user = 'dev'
+
+ # Track all icon mappings back to Apprise Icon NotifyType's
+ # See: https://developer.lametric.com/icons
+ # Icon ID looks like XXX, where is:
+ # - "i" (for static icon)
+ # - "a" (for animation)
+ # - XXX - is the number of the icon and can be found at:
+ # https://developer.lametric.com/icons
+ lametric_icon_id_mapping = {
+ # 620/Info
+ NotifyType.INFO: 'i620',
+ # 9182/info_good
+ NotifyType.SUCCESS: 'i9182',
+ # 9183/info_caution
+ NotifyType.WARNING: 'i9183',
+ # 9184/info_error
+ NotifyType.FAILURE: 'i9184',
+ }
+
+ # Define object templates
+ templates = (
+ # App Mode
+ '{schema}://{client_id}@{secret}',
+
+ # Device Mode
+ '{schema}://{apikey}@{host}',
+ '{schema}://{apikey}@{host}:{port}',
+ '{schema}://{user}:{apikey}@{host}:{port}',
+ )
+
+ # Define our template tokens
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ 'apikey': {
+ 'name': _('Device API Key'),
+ 'type': 'string',
+ 'private': True,
+ },
+ 'host': {
+ 'name': _('Hostname'),
+ 'type': 'string',
+ 'required': True,
+ },
+ 'port': {
+ 'name': _('Port'),
+ 'type': 'int',
+ 'min': 1,
+ 'max': 65535,
+ },
+ 'user': {
+ 'name': _('Username'),
+ 'type': 'string',
+ },
+ 'client_id': {
+ 'name': _('Client ID'),
+ 'type': 'string',
+ 'private': True,
+ 'regex': (r'^[a-z0-9-]+$', 'i'),
+ },
+ 'secret': {
+ 'name': _('Client Secret'),
+ 'type': 'string',
+ 'private': True,
+ },
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'oauth_id': {
+ 'alias_of': 'client_id',
+ },
+ 'oauth_secret': {
+ 'alias_of': 'secret',
+ },
+ 'apikey': {
+ 'alias_of': 'apikey',
+ },
+ 'priority': {
+ 'name': _('Priority'),
+ 'type': 'choice:string',
+ 'values': LAMETRIC_PRIORITIES,
+ 'default': LametricPriority.INFO,
+ },
+ 'icon_type': {
+ 'name': _('Icon Type'),
+ 'type': 'choice:string',
+ 'values': LAMETRIC_ICON_TYPES,
+ 'default': LametricIconType.NONE,
+ },
+ 'mode': {
+ 'name': _('Mode'),
+ 'type': 'choice:string',
+ 'values': LAMETRIC_MODES,
+ 'default': LametricMode.DEVICE,
+ },
+ 'sound': {
+ 'name': _('Sound'),
+ 'type': 'string',
+ },
+ # Lifetime is in seconds
+ 'cycles': {
+ 'name': _('Cycles'),
+ 'type': 'int',
+ 'min': 0,
+ 'default': 1,
+ },
+ })
+
+ def __init__(self, apikey=None, client_id=None, secret=None, priority=None,
+ icon_type=None, sound=None, mode=None, cycles=None, **kwargs):
+ """
+ Initialize LaMetric Object
+ """
+ super(NotifyLametric, self).__init__(**kwargs)
+
+ self.mode = mode.strip().lower() \
+ if isinstance(mode, six.string_types) \
+ else self.template_args['mode']['default']
+
+ if self.mode not in LAMETRIC_MODES:
+ msg = 'An invalid LaMetric Mode ({}) was specified.'.format(mode)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # Default Cloud Arguments
+ self.secret = None
+ self.client_id = None
+
+ # Default Device Arguments
+ self.apikey = None
+
+ if self.mode == LametricMode.CLOUD:
+ # Client ID
+ self.client_id = validate_regex(
+ client_id, *self.template_tokens['client_id']['regex'])
+ if not self.client_id:
+ msg = 'An invalid LaMetric Client OAuth2 ID ' \
+ '({}) was specified.'.format(client_id)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # Client Secret
+ self.secret = validate_regex(secret)
+ if not self.secret:
+ msg = 'An invalid LaMetric Client OAuth2 Secret ' \
+ '({}) was specified.'.format(secret)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ else: # LametricMode.DEVICE
+
+ # API Key
+ self.apikey = validate_regex(apikey)
+ if not self.apikey:
+ msg = 'An invalid LaMetric Device API Key ' \
+ '({}) was specified.'.format(apikey)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ if priority not in LAMETRIC_PRIORITIES:
+ self.priority = self.template_args['priority']['default']
+
+ else:
+ self.priority = priority
+
+ if icon_type not in LAMETRIC_ICON_TYPES:
+ self.icon_type = self.template_args['icon_type']['default']
+
+ else:
+ self.icon_type = icon_type
+
+ # The number of times the message should be displayed
+ self.cycles = self.template_args['cycles']['default'] \
+ if not (isinstance(cycles, int) and
+ cycles > self.template_args['cycles']['min']) else cycles
+
+ self.sound = None
+ if isinstance(sound, six.string_types):
+ # If sound is set, get it's match
+ self.sound = self.sound_lookup(sound.strip().lower())
+ if self.sound is None:
+ self.logger.warning(
+ 'An invalid LaMetric sound ({}) was specified.'.format(
+ sound))
+ return
+
+ @staticmethod
+ def sound_lookup(lookup):
+ """
+ A simple match function that takes string and returns the
+ LametricSound object it was found in.
+
+ """
+
+ for x in LAMETRIC_SOUNDS:
+ match = next((f for f in x[1] if f.startswith(lookup)), None)
+ if match:
+ # We're done
+ return x
+
+ # No match was found
+ return None
+
+ def _cloud_notification_payload(self, body, notify_type, headers):
+ """
+ Return URL and payload for cloud directed requests
+ """
+
+ # Update header entries
+ headers.update({
+ 'X-Access-Token': self.secret,
+ 'Cache-Control': 'no-cache',
+ })
+
+ if self.sound:
+ self.logger.warning(
+ 'LaMetric sound setting is unavailable in Cloud mode')
+
+ if self.priority != self.template_args['priority']['default']:
+ self.logger.warning(
+ 'LaMetric priority setting is unavailable in Cloud mode')
+
+ if self.icon_type != self.template_args['icon_type']['default']:
+ self.logger.warning(
+ 'LaMetric icon_type setting is unavailable in Cloud mode')
+
+ if self.cycles != self.template_args['cycles']['default']:
+ self.logger.warning(
+ 'LaMetric cycle settings is unavailable in Cloud mode')
+
+ # Cloud Notifications don't have as much functionality
+ # You can not set priority and/or sound
+ payload = {
+ "frames": [
+ {
+ "icon": self.lametric_icon_id_mapping[notify_type],
+ "text": body,
+ }
+ ]
+ }
+
+ # Prepare our Cloud Notify URL
+ notify_url = self.cloud_notify_url.format(client_id=self.client_id)
+
+ # Return request parameters
+ return (notify_url, None, payload)
+
+ def _device_notification_payload(self, body, notify_type, headers):
+ """
+ Return URL and Payload for Device directed requests
+ """
+
+ # Our Payload
+ payload = {
+ # Priority of the message
+ "priority": self.priority,
+
+ # Icon Type: Represents the nature of notification
+ "icon_type": self.icon_type,
+
+ # The time notification lives in queue to be displayed in
+ # milliseconds (ms). The default lifetime is 2 minutes (120000ms).
+ # If notification stayed in queue for longer than lifetime
+ # milliseconds - it will not be displayed.
+ "lifetime": 120000,
+
+ "model": {
+ # cycles - the number of times message should be displayed. If
+ # cycles is set to 0, notification will stay on the screen
+ # until user dismisses it manually. By default it is set to 1.
+ "cycles": self.cycles,
+ "frames": [
+ {
+ "icon": self.lametric_icon_id_mapping[notify_type],
+ "text": body,
+ }
+ ]
+ }
+ }
+
+ if self.sound:
+ # Sound was set, so add it to the payload
+ payload["model"]["sound"] = {
+ # The sound category
+ "category": self.sound[0],
+
+ # The first element of our tuple is always the id
+ "id": self.sound[1][0],
+
+ # repeat - defines the number of times sound must be played.
+ # If set to 0 sound will be played until notification is
+ # dismissed. By default the value is set to 1.
+ "repeat": 1,
+ }
+
+ if not self.user:
+ # Use default user if there wasn't one otherwise specified
+ self.user = self.default_device_user
+
+ # Prepare our authentication
+ auth = (self.user, self.password)
+
+ # Prepare our Direct Access Notify URL
+ notify_url = self.device_notify_url.format(
+ schema="https" if self.secure else "http",
+ host=self.host,
+ port=':{}'.format(
+ self.port if self.port else self.default_device_port))
+
+ # Return request parameters
+ return (notify_url, auth, payload)
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Perform LaMetric Notification
+ """
+
+ # Prepare our headers:
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ }
+
+ # Depending on the mode, the payload is gathered by
+ # - _device_notification_payload()
+ # - _cloud_notification_payload()
+ (notify_url, auth, payload) = getattr(
+ self, '_{}_notification_payload'.format(self.mode))(
+ body=body, notify_type=notify_type, headers=headers)
+
+ self.logger.debug('LaMetric POST URL: %s (cert_verify=%r)' % (
+ notify_url, self.verify_certificate,
+ ))
+ self.logger.debug('LaMetric Payload: %s' % str(payload))
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ try:
+ r = requests.post(
+ notify_url,
+ data=dumps(payload),
+ headers=headers,
+ auth=auth,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+ # An ideal response would be:
+ # {
+ # "success": {
+ # "id": ""
+ # }
+ # }
+
+ if r.status_code not in (
+ requests.codes.created, requests.codes.ok):
+ # We had a problem
+ status_str = \
+ NotifyLametric.http_response_code_lookup(r.status_code)
+
+ self.logger.warning(
+ 'Failed to send LaMetric notification: '
+ '{}{}error={}.'.format(
+ status_str,
+ ', ' if status_str else '',
+ r.status_code))
+
+ self.logger.debug('Response Details:\r\n{}'.format(r.content))
+
+ # Return; we're done
+ return False
+
+ else:
+ self.logger.info('Sent LaMetric notification.')
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occurred sending LaMetric '
+ 'notification to %s.' % self.host)
+ self.logger.debug('Socket Exception: %s' % str(e))
+
+ # Return; we're done
+ return False
+
+ return True
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any URL parameters
+ params = {
+ 'mode': self.mode,
+ }
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+
+ if self.mode == LametricMode.CLOUD:
+ # Upstream/LaMetric App Return
+ return '{schema}://{client_id}@{secret}/?{params}'.format(
+ schema=self.protocol,
+ client_id=self.pprint(self.client_id, privacy, safe=''),
+ secret=self.pprint(
+ self.secret, privacy, mode=PrivacyMode.Secret, safe=''),
+ params=NotifyLametric.urlencode(params))
+
+ #
+ # If we reach here then we're dealing with LametricMode.DEVICE
+ #
+ if self.priority != self.template_args['priority']['default']:
+ params['priority'] = self.priority
+
+ if self.icon_type != self.template_args['icon_type']['default']:
+ params['icon_type'] = self.icon_type
+
+ if self.cycles != self.template_args['cycles']['default']:
+ params['cycles'] = self.cycles
+
+ if self.sound:
+ # Store our sound entry
+ # The first element of our tuple is always the id
+ params['sound'] = self.sound[1][0]
+
+ auth = ''
+ if self.user and self.password:
+ auth = '{user}:{apikey}@'.format(
+ user=NotifyLametric.quote(self.user, safe=''),
+ apikey=self.pprint(self.apikey, privacy, safe=''),
+ )
+ else: # self.apikey is set
+ auth = '{apikey}@'.format(
+ apikey=self.pprint(self.apikey, privacy, safe=''),
+ )
+
+ # Local Return
+ return '{schema}://{auth}{hostname}{port}/?{params}'.format(
+ schema=self.secure_protocol if self.secure else self.protocol,
+ auth=auth,
+ # never encode hostname since we're expecting it to be a valid one
+ hostname=self.host,
+ port='' if self.port is None
+ or self.port == self.default_device_port
+ else ':{}'.format(self.port),
+ params=NotifyLametric.urlencode(params),
+ )
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to substantiate this object.
+
+ """
+
+ results = NotifyBase.parse_url(url, verify_host=False)
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ if results.get('user') and not results.get('password'):
+ # Handle URL like:
+ # schema://user@host
+
+ # This becomes the password
+ results['password'] = results['user']
+ results['user'] = None
+
+ # Priority Handling
+ if 'priority' in results['qsd'] and len(results['qsd']['priority']):
+ results['priority'] = results['qsd']['priority'].strip().lower()
+
+ # Icon Type
+ if 'icon_type' in results['qsd'] and len(results['qsd']['icon_type']):
+ results['icon_type'] = results['qsd']['icon_type'].strip().lower()
+
+ # Sound
+ if 'sound' in results['qsd'] and len(results['qsd']['sound']):
+ results['sound'] = results['qsd']['sound'].strip().lower()
+
+ # We can detect the mode based on the validity of the hostname
+ results['mode'] = LametricMode.DEVICE \
+ if (is_hostname(results['host']) or
+ is_ipaddr(results['host'])) else LametricMode.CLOUD
+
+ # Mode override
+ if 'mode' in results['qsd'] and len(results['qsd']['mode']):
+ results['mode'] = NotifyLametric.unquote(results['qsd']['mode'])
+
+ # API Key (Device Mode)
+ if results['mode'] == LametricMode.DEVICE:
+ if 'apikey' in results['qsd'] and len(results['qsd']['apikey']):
+ # Extract API Key from an argument
+ results['apikey'] = \
+ NotifyLametric.unquote(results['qsd']['apikey'])
+
+ else:
+ results['apikey'] = \
+ NotifyLametric.unquote(results['password'])
+
+ elif results['mode'] == LametricMode.CLOUD:
+ # OAuth2 ID (Cloud Mode)
+ if 'oauth_id' in results['qsd'] \
+ and len(results['qsd']['oauth_id']):
+
+ # Extract the OAuth2 Key from an argument
+ results['client_id'] = \
+ NotifyLametric.unquote(results['qsd']['oauth_id'])
+
+ else:
+ results['client_id'] = \
+ NotifyLametric.unquote(results['password'])
+
+ # OAuth2 Secret (Cloud Mode)
+ if 'oauth_secret' in results['qsd'] and \
+ len(results['qsd']['oauth_secret']):
+ # Extract the API Secret from an argument
+ results['secret'] = \
+ NotifyLametric.unquote(results['qsd']['oauth_secret'])
+
+ else:
+ results['secret'] = \
+ NotifyLametric.unquote(results['host'])
+
+ # Set cycles
+ try:
+ results['cycles'] = abs(int(results['qsd'].get('cycles')))
+
+ except (TypeError, ValueError):
+ # Not a valid integer; ignore entry
+ pass
+
+ return results
diff --git a/apprise/utils.py b/apprise/utils.py
index 79f3cee5..d1518a0f 100644
--- a/apprise/utils.py
+++ b/apprise/utils.py
@@ -141,6 +141,40 @@ def is_hostname(hostname):
return all(allowed.match(x) for x in hostname.split("."))
+def is_ipaddr(addr):
+ """
+ Validates against IPV4 and IPV6 IP Addresses
+ """
+
+ # Based on https://stackoverflow.com/questions/5284147/\
+ # validating-ipv4-addresses-with-regexp
+ re_ipv4 = re.compile(
+ r'^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}'
+ r'(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$'
+ )
+
+ # Based on https://stackoverflow.com/questions/53497/\
+ # regular-expression-that-matches-valid-ipv6-addresses
+ re_ipv6 = re.compile(
+ r'(([0-9a-f]{1,4}:){7,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,7}:'
+ r'|([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,5}'
+ r'(:[0-9a-f]{1,4}){1,2}|([0-9a-f]{1,4}:){1,4}'
+ r'(:[0-9a-f]{1,4}){1,3}|([0-9a-f]{1,4}:){1,3}'
+ r'(:[0-9a-f]{1,4}){1,4}|([0-9a-f]{1,4}:){1,2}'
+ r'(:[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:'
+ r'((:[0-9a-f]{1,4}){1,6})|:((:[0-9a-f]{1,4}){1,7}|:)|'
+ r'fe80:(:[0-9a-f]{0,4}){0,4}%[0-9a-z]{1,}|::'
+ r'(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]'
+ r'|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|'
+ r'1{0,1}[0-9]){0,1}[0-9])|([0-9a-f]{1,4}:){1,4}:((25[0-5]|'
+ r'(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|'
+ r'1{0,1}[0-9]){0,1}[0-9]))', re.I,
+ )
+
+ # Returns true if we match an IP and/or
+ return (re_ipv4.match(addr) is not None or re_ipv6.match(addr) is not None)
+
+
def is_email(address):
"""Determine if the specified entry is an email address
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index c317505a..c86a5b07 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -48,9 +48,9 @@ notification services that are out there. Apprise opens the door and makes
it easy to access:
Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl,
-IFTTT, Join, Kavenegar, KODI, Kumulos, MacOSX, Mailgun, MatterMost, Matrix,
-Microsoft Windows, Microsoft Teams, MessageBird, MSG91, MyAndroid, Nexmo,
-Nextcloud, Notica, Notifico, Office365, PopcornNotify, Prowl, Pushalot,
+IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, MacOSX, Mailgun, MatterMost,
+Matrix, Microsoft Windows, Microsoft Teams, MessageBird, MSG91, MyAndroid,
+Nexmo, Nextcloud, Notica, Notifico, Office365, PopcornNotify, Prowl, Pushalot,
PushBullet, Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush,
Sinch, Slack, Spontit, Super Toasty, Stride, Syslog, Techulus Push, Telegram,
Twilio, Twitter, Twist, XBMC, XMPP, Webex Teams}
diff --git a/setup.py b/setup.py
index bbe9b439..4d75c69f 100755
--- a/setup.py
+++ b/setup.py
@@ -71,12 +71,12 @@ setup(
url='https://github.com/caronc/apprise',
keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend '
'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join '
- 'Kavenegar KODI Kumulos MacOS Mailgun Matrix Mattermost MessageBird '
- 'MSG91 Nexmo Nextcloud Notica Notifico Office365 PopcornNotify Prowl '
- 'PushBullet Pushjet Pushed Pushover PushSafer Rocket.Chat Ryver '
- 'SendGrid SimplePush Sinch Slack Spontit Stride Syslog Techulus Push '
- 'Telegram Twilio Twist Twitter XBMC Microsoft MSTeams Windows Webex '
- 'CLI API',
+ 'Kavenegar KODI Kumulos LaMetric MacOS Mailgun Matrix Mattermost '
+ 'MessageBird MSG91 Nexmo Nextcloud Notica Notifico Office365 '
+ 'PopcornNotify Prowl PushBullet Pushjet Pushed Pushover PushSafer '
+ 'Rocket.Chat Ryver SendGrid SimplePush Sinch Slack Spontit Stride '
+ 'Syslog Techulus Push Telegram Twilio Twist Twitter XBMC Microsoft '
+ 'MSTeams Windows Webex CLI API',
author='Chris Caron',
author_email='lead2gold@gmail.com',
packages=find_packages(),
diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py
index 52433a80..dc7063f4 100644
--- a/test/test_rest_plugins.py
+++ b/test/test_rest_plugins.py
@@ -23,6 +23,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
+import re
import os
import six
import pytest
@@ -1247,6 +1248,164 @@ TEST_URLS = (
'test_requests_exceptions': True,
}),
+ ##################################
+ # NotifyLametric
+ ##################################
+ ('lametric://', {
+ # No APIKey or Client ID/Secret specified
+ 'instance': TypeError,
+ }),
+ ('lametric://:@/', {
+ # No APIKey or Client ID/Secret specified
+ 'instance': TypeError,
+ }),
+ ('lametric://{}/'.format(UUID4), {
+ # No APIKey or Client ID specified
+ 'instance': TypeError,
+ }),
+ ('lametric://root:{}@192.168.0.5:8080/'.format(UUID4), {
+ # Everything is okay; this would be picked up in Device Mode
+ # We're using a default port and enforcing a special user
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://root:8...2@192.168.0.5/',
+ }),
+ ('lametric://{}@192.168.0.4:8000/'.format(UUID4), {
+ # Everything is okay; this would be picked up in Device Mode
+ # Port is enforced
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://8...2@192.168.0.4:8000/',
+ }),
+ ('lametric://{}@192.168.0.5/'.format(UUID4), {
+ # Everything is okay; this would be picked up in Device Mode
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://8...2@192.168.0.5/',
+ }),
+ ('lametrics://{}@192.168.0.6/?mode=device'.format(UUID4), {
+ # Everything is okay; Device mode forced
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametrics://8...2@192.168.0.6/',
+ }),
+ ('lametric://192.168.2.8/?mode=device&apikey=abc123', {
+ # Everything is okay; Device mode forced
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://a...3@192.168.2.8/',
+ }),
+ ('lametrics://{}@abcd==/?mode=cloud'.format(UUID4), {
+ # Everything is okay; Cloud mode forced
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://8...2@****/',
+ }),
+ ('lametric://_/?mode=cloud&oauth_id=abcd&oauth_secret=1234&cycles=3', {
+ # Everything is okay; Cloud mode forced
+ # arguments used on URL path
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://a...d@****/',
+ }),
+ ('lametrics://{}@abcd==/?mode=cloud&sound=knock&icon_type=info'
+ '&priority=critical'.format(UUID4), {
+ # Cloud mode forced, sound, icon_type, and priority not supported
+ # with cloud mode so warnings are created
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://8...2@****/',
+ }),
+ ('lametrics://{}@192.168.0.7/?mode=invalid'.format(UUID4), {
+ # Invalid Mode
+ 'instance': TypeError,
+ }),
+ ('lametrics://{}@192.168.0.6/?sound=alarm1'.format(UUID4), {
+ # Device mode with sound set to alarm1
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametrics://{}@192.168.0.7/?sound=bike'.format(UUID4), {
+ # Device mode with sound set to bicycle using alias
+ 'instance': plugins.NotifyLametric,
+ # Bike is an alias,
+ 'url_matches': r'sound=bicycle',
+ }),
+ ('lametrics://{}@192.168.0.8/?sound=invalid!'.format(UUID4), {
+ # Invalid sounds just produce warnings... object still loads
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametrics://{}@192.168.0.9/?icon_type=alert'.format(UUID4), {
+ # Icon Type Changed
+ 'instance': plugins.NotifyLametric,
+ # icon=alert exists somewhere on our generated URL
+ 'url_matches': r'icon_type=alert',
+ }),
+ ('lametrics://{}@192.168.0.10/?icon_type=invalid'.format(UUID4), {
+ # Invalid icon types just produce warnings... object still loads
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametric://{}@192.168.1.1/?priority=warning'.format(UUID4), {
+ # Priority changed
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametrics://{}@192.168.1.2/?priority=invalid'.format(UUID4), {
+ # Invalid priority just produce warnings... object still loads
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametric://{}@192.168.1.3/?cycles=2'.format(UUID4), {
+ # Cycles changed
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametric://{}@192.168.1.4/?cycles=-1'.format(UUID4), {
+ # Cycles changed (out of range)
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametrics://{}@192.168.1.5/?cycles=invalid'.format(UUID4), {
+ # Invalid priority just produce warnings... object still loads
+ 'instance': plugins.NotifyLametric,
+ }),
+ ('lametric://{}@{}/'.format(
+ UUID4, 'YWosnkdnoYREsdogfoSDff734kjsfbweo7r434597FYODIoicosdonnreiuhvd'
+ 'ciuhouerhohcd8sds89fdRw=='), {
+ # Everything is okay; this would be picked up in Cloud Mode
+ 'instance': plugins.NotifyLametric,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://8...2@****/',
+ }),
+ ('lametric://{}@example.com/'.format(UUID4), {
+ 'instance': plugins.NotifyLametric,
+ # force a failure
+ 'response': False,
+ 'requests_response_code': requests.codes.internal_server_error,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametric://8...2@example.com/',
+ }),
+ ('lametrics://{}@example.ca/'.format(UUID4), {
+ 'instance': plugins.NotifyLametric,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'lametrics://8...2@example.ca/',
+ }),
+ ('lametrics://{}@example.net/'.format(UUID4), {
+ 'instance': plugins.NotifyLametric,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+
##################################
# NotifyMailgun
##################################
@@ -4447,6 +4606,9 @@ def test_rest_plugins(mock_post, mock_get):
# Don't set this if don't need to check it's value
privacy_url = meta.get('privacy_url')
+ # Our regular expression
+ url_matches = meta.get('url_matches')
+
# Test attachments
# Don't set this if don't need to check it's value
check_attachments = meta.get('check_attachments', True)
@@ -4544,6 +4706,10 @@ def test_rest_plugins(mock_post, mock_get):
# Assess that our privacy url is as expected
assert obj.url(privacy=True).startswith(privacy_url)
+ if url_matches:
+ # Assess that our URL matches a set regex
+ assert re.search(url_matches, obj.url())
+
# Instantiate the exact same object again using the URL from
# the one that was already created properly
obj_cmp = Apprise.instantiate(obj.url())
@@ -5061,6 +5227,20 @@ def test_notify_gotify_plugin():
plugins.NotifyGotify(token=" ")
+def test_notify_lametric_plugin():
+ """
+ API: NotifyLametric() Extra Checks
+
+ """
+ # Initializes the plugin with an invalid API Key
+ with pytest.raises(TypeError):
+ plugins.NotifyLametric(apikey=None, mode="device")
+
+ # Initializes the plugin with an invalid Client Secret
+ with pytest.raises(TypeError):
+ plugins.NotifyLametric(client_id='valid', secret=None, mode="cloud")
+
+
@mock.patch('requests.post')
def test_notify_msg91_plugin(mock_post):
"""
diff --git a/test/test_utils.py b/test/test_utils.py
index 7f610f73..c34f9f97 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -487,6 +487,29 @@ def test_is_hostname():
assert utils.is_hostname('') is False
+def test_is_ipaddr():
+ """
+ API: is_ipaddr() function
+
+ """
+ # Valid IPv4 Addresses
+ assert utils.is_ipaddr('127.0.0.1') is True
+ assert utils.is_ipaddr('0.0.0.0') is True
+ assert utils.is_ipaddr('255.255.255.255') is True
+
+ # Invalid IPv4 Addresses
+ assert utils.is_ipaddr('1.2.3') is False
+ assert utils.is_ipaddr('256.256.256.256') is False
+ assert utils.is_ipaddr('999.0.0.0') is False
+ assert utils.is_ipaddr('1.2.3.4.5') is False
+ assert utils.is_ipaddr(' 127.0.0.1 ') is False
+ assert utils.is_ipaddr(' ') is False
+ assert utils.is_ipaddr('') is False
+
+ # Valid IPv6 Addresses
+ assert utils.is_ipaddr('2001:0db8:85a3:0000:0000:8a2e:0370:7334') is True
+
+
def test_is_email():
"""
API: is_email() function