1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
4# All rights reserved.
5#
6# This code is licensed under the MIT License.
7#
8# Permission is hereby granted, free of charge, to any person obtaining a copy
9# of this software and associated documentation files(the "Software"), to deal
10# in the Software without restriction, including without limitation the rights
11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12# copies of the Software, and to permit persons to whom the Software is
13# furnished to do so, subject to the following conditions :
14#
15# The above copyright notice and this permission notice shall be included in
16# all copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24# THE SOFTWARE.
25
26# For LaMetric to work, you need to first setup a custom application on their
27# website. it can be done as follows:
28
29# Cloud Mode:
30# - Sign Up and login to the developer webpage https://developer.lametric.com
31#
32# - Create a **Indicator App** if you haven't already done so from here:
33#     https://developer.lametric.com/applications/sources
34#
35#   There is a great official tutorial on how to do this here:
36#     https://lametric-documentation.readthedocs.io/en/latest/\
37#           guides/first-steps/first-lametric-indicator-app.html
38#
39# - Make sure to set the **Communication Type** to **PUSH**.
40#
41# - You will be able to **Publish** your app once you've finished setting it
42#   up.  This will allow it to be accessible from the internet using the
43#   `cloud` mode of this Apprise Plugin. The **Publish** button shows up
44#   from within the settings of your Lametric App upon clicking on the
45#   **Draft Vx** folder (where `x` is the version - usually a 1)
46#
47# When you've completed, the site would have provided you a **PUSH URL** that
48# looks like this:
49#    https://developer.lametric.com/api/v1/dev/widget/update/\
50#             com.lametric.{app_id}/{app_ver}
51#
52# You will need to record the `{app_id}` and `{app_ver}` to use the `cloud`
53# mode.
54#
55# The same page should also provide you with an **Access Token**.  It's
56# approximately 86 characters with two equal (`=`) characters at the end of it.
57# This becomes your `{app_token}`. Here is an example of what one might
58# look like:
59#    K2MxWI0NzU0ZmI2NjJlZYTgViMDgDRiN8YjlmZjRmNTc4NDVhJzk0RiNjNh0EyKWW==`
60#
61# The syntax for the cloud mode is:
62# * `lametric://{app_token}@{app_id}/{app_ver}?mode=cloud`
63
64# Device Mode:
65# - Sign Up and login to the developer webpage https://developer.lametric.com
66# - Locate your Device API Key; you can find it here:
67#      https://developer.lametric.com/user/devices
68# - From here you can get your your API Key for the device you plan to notify.
69# - Your devices IP Address can be found in LaMetric Time app at:
70#       Settings -> Wi-Fi -> IP Address
71#
72# The syntax for the device mode is:
73#  * `lametric://{apikey}@{host}`
74
75# A great source for API examples (Device Mode):
76# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\
77#       /device-notifications.html
78#
79# A great source for API examples (Cloud Mode):
80# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\
81#       /lametric-cloud-reference.html
82
83# A great source for the icon reference:
84# - https://developer.lametric.com/icons
85
86
87import re
88import six
89import requests
90from json import dumps
91from .NotifyBase import NotifyBase
92from ..common import NotifyType
93from ..utils import validate_regex
94from ..AppriseLocale import gettext_lazy as _
95from ..utils import is_hostname
96from ..utils import is_ipaddr
97
98# A URL Parser to detect App ID
99LAMETRIC_APP_ID_DETECTOR_RE = re.compile(
100    r'(com\.lametric\.)?(?P<app_id>[0-9a-z.-]{1,64})'
101    r'(/(?P<app_ver>[1-9][0-9]*))?', re.I)
102
103# Tokens are huge
104LAMETRIC_IS_APP_TOKEN = re.compile(r'^[a-z0-9]{80,}==$', re.I)
105
106
107class LametricMode(object):
108    """
109    Define Lametric Notification Modes
110    """
111    # App posts upstream to the developer API on Lametric's website
112    CLOUD = "cloud"
113
114    # Device mode posts directly to the device that you identify
115    DEVICE = "device"
116
117
118LAMETRIC_MODES = (
119    LametricMode.CLOUD,
120    LametricMode.DEVICE,
121)
122
123
124class LametricPriority(object):
125    """
126    Priority of the message
127    """
128
129    # info: this priority means that notification will be displayed on the
130    #        same “level” as all other notifications on the device that come
131    #        from apps (for example facebook app). This notification will not
132    #        be shown when screensaver is active. By default message is sent
133    #        with "info" priority. This level of notification should be used
134    #        for notifications like news, weather, temperature, etc.
135    INFO = 'info'
136
137    # warning: notifications with this priority will interrupt ones sent with
138    #           lower priority (“info”). Should be used to notify the user
139    #           about something important but not critical. For example,
140    #           events like “someone is coming home” should use this priority
141    #           when sending notifications from smart home.
142    WARNING = 'warning'
143
144    # critical: the most important notifications. Interrupts notification
145    #            with priority info or warning and is displayed even if
146    #            screensaver is active. Use with care as these notifications
147    #            can pop in the middle of the night. Must be used only for
148    #            really important notifications like notifications from smoke
149    #            detectors, water leak sensors, etc. Use it for events that
150    #            require human interaction immediately.
151    CRITICAL = 'critical'
152
153
154LAMETRIC_PRIORITIES = (
155    LametricPriority.INFO,
156    LametricPriority.WARNING,
157    LametricPriority.CRITICAL,
158)
159
160
161class LametricIconType(object):
162    """
163    Represents the nature of notification.
164    """
165
166    # info  - "i" icon will be displayed prior to the notification. Means that
167    #         notification contains information, no need to take actions on it.
168    INFO = 'info'
169
170    # alert: "!!!" icon will be displayed prior to the notification. Use it
171    #         when you want the user to pay attention to that notification as
172    #         it indicates that something bad happened and user must take
173    #         immediate action.
174    ALERT = 'alert'
175
176    # none: no notification icon will be shown.
177    NONE = 'none'
178
179
180LAMETRIC_ICON_TYPES = (
181    LametricIconType.INFO,
182    LametricIconType.ALERT,
183    LametricIconType.NONE,
184)
185
186
187class LametricSoundCategory(object):
188    """
189    Define Sound Categories
190    """
191    NOTIFICATIONS = "notifications"
192    ALARMS = "alarms"
193
194
195class LametricSound(object):
196    """
197    There are 2 categories of sounds, to make things simple we just lump them
198    all togther in one class object.
199
200    Syntax is (Category, (AlarmID, Alias1, Alias2, ...))
201    """
202
203    # Alarm Category Sounds
204    ALARM01 = (LametricSoundCategory.ALARMS, ('alarm1', 'a1', 'a01'))
205    ALARM02 = (LametricSoundCategory.ALARMS, ('alarm2', 'a2', 'a02'))
206    ALARM03 = (LametricSoundCategory.ALARMS, ('alarm3', 'a3', 'a03'))
207    ALARM04 = (LametricSoundCategory.ALARMS, ('alarm4', 'a4', 'a04'))
208    ALARM05 = (LametricSoundCategory.ALARMS, ('alarm5', 'a5', 'a05'))
209    ALARM06 = (LametricSoundCategory.ALARMS, ('alarm6', 'a6', 'a06'))
210    ALARM07 = (LametricSoundCategory.ALARMS, ('alarm7', 'a7', 'a07'))
211    ALARM08 = (LametricSoundCategory.ALARMS, ('alarm8', 'a8', 'a08'))
212    ALARM09 = (LametricSoundCategory.ALARMS, ('alarm9', 'a9', 'a09'))
213    ALARM10 = (LametricSoundCategory.ALARMS, ('alarm10', 'a10'))
214    ALARM11 = (LametricSoundCategory.ALARMS, ('alarm11', 'a11'))
215    ALARM12 = (LametricSoundCategory.ALARMS, ('alarm12', 'a12'))
216    ALARM13 = (LametricSoundCategory.ALARMS, ('alarm13', 'a13'))
217
218    # Notification Category Sounds
219    BICYCLE = (LametricSoundCategory.NOTIFICATIONS, ('bicycle', 'bike'))
220    CAR = (LametricSoundCategory.NOTIFICATIONS, ('car', ))
221    CASH = (LametricSoundCategory.NOTIFICATIONS, ('cash', ))
222    CAT = (LametricSoundCategory.NOTIFICATIONS, ('cat', ))
223    DOG01 = (LametricSoundCategory.NOTIFICATIONS, ('dog', 'dog1', 'dog01'))
224    DOG02 = (LametricSoundCategory.NOTIFICATIONS, ('dog2', 'dog02'))
225    ENERGY = (LametricSoundCategory.NOTIFICATIONS, ('energy', ))
226    KNOCK = (LametricSoundCategory.NOTIFICATIONS, ('knock-knock', 'knock'))
227    EMAIL = (LametricSoundCategory.NOTIFICATIONS, (
228        'letter_email', 'letter', 'email'))
229    LOSE01 = (LametricSoundCategory.NOTIFICATIONS, ('lose1', 'lose01', 'lose'))
230    LOSE02 = (LametricSoundCategory.NOTIFICATIONS, ('lose2', 'lose02'))
231    NEGATIVE01 = (LametricSoundCategory.NOTIFICATIONS, (
232        'negative1', 'negative01', 'neg01', 'neg1', '-'))
233    NEGATIVE02 = (LametricSoundCategory.NOTIFICATIONS, (
234        'negative2', 'negative02', 'neg02', 'neg2', '--'))
235    NEGATIVE03 = (LametricSoundCategory.NOTIFICATIONS, (
236        'negative3', 'negative03', 'neg03', 'neg3', '---'))
237    NEGATIVE04 = (LametricSoundCategory.NOTIFICATIONS, (
238        'negative4', 'negative04', 'neg04', 'neg4', '----'))
239    NEGATIVE05 = (LametricSoundCategory.NOTIFICATIONS, (
240        'negative5', 'negative05', 'neg05', 'neg5', '-----'))
241    NOTIFICATION01 = (LametricSoundCategory.NOTIFICATIONS, (
242        'notification', 'notification1', 'notification01', 'not01', 'not1'))
243    NOTIFICATION02 = (LametricSoundCategory.NOTIFICATIONS, (
244        'notification2', 'notification02', 'not02', 'not2'))
245    NOTIFICATION03 = (LametricSoundCategory.NOTIFICATIONS, (
246        'notification3', 'notification03', 'not03', 'not3'))
247    NOTIFICATION04 = (LametricSoundCategory.NOTIFICATIONS, (
248        'notification4', 'notification04', 'not04', 'not4'))
249    OPEN_DOOR = (LametricSoundCategory.NOTIFICATIONS, (
250        'open_door', 'open', 'door'))
251    POSITIVE01 = (LametricSoundCategory.NOTIFICATIONS, (
252        'positive1', 'positive01', 'pos01', 'p1', '+'))
253    POSITIVE02 = (LametricSoundCategory.NOTIFICATIONS, (
254        'positive2', 'positive02', 'pos02', 'p2', '++'))
255    POSITIVE03 = (LametricSoundCategory.NOTIFICATIONS, (
256        'positive3', 'positive03', 'pos03', 'p3', '+++'))
257    POSITIVE04 = (LametricSoundCategory.NOTIFICATIONS, (
258        'positive4', 'positive04', 'pos04', 'p4', '++++'))
259    POSITIVE05 = (LametricSoundCategory.NOTIFICATIONS, (
260        'positive5', 'positive05', 'pos05', 'p5', '+++++'))
261    POSITIVE06 = (LametricSoundCategory.NOTIFICATIONS, (
262        'positive6', 'positive06', 'pos06', 'p6', '++++++'))
263    STATISTIC = (LametricSoundCategory.NOTIFICATIONS, ('statistic', 'stat'))
264    THUNDER = (LametricSoundCategory.NOTIFICATIONS, ('thunder'))
265    WATER01 = (LametricSoundCategory.NOTIFICATIONS, ('water1', 'water01'))
266    WATER02 = (LametricSoundCategory.NOTIFICATIONS, ('water2', 'water02'))
267    WIN01 = (LametricSoundCategory.NOTIFICATIONS, ('win', 'win01', 'win1'))
268    WIN02 = (LametricSoundCategory.NOTIFICATIONS, ('win2', 'win02'))
269    WIND = (LametricSoundCategory.NOTIFICATIONS, ('wind', ))
270    WIND_SHORT = (LametricSoundCategory.NOTIFICATIONS, ('wind_short', ))
271
272
273# A listing of all the sounds; the order DOES matter, content is read from
274# top down and then right to left (over aliases). Longer similar sounding
275# elements should be placed higher in the list over others. for example
276# ALARM10 should come before ALARM01 (because ALARM01 can match on 'alarm1'
277# which is very close to 'alarm10'
278LAMETRIC_SOUNDS = (
279    # Alarm Category Entries
280    LametricSound.ALARM13, LametricSound.ALARM12, LametricSound.ALARM11,
281    LametricSound.ALARM10, LametricSound.ALARM09, LametricSound.ALARM08,
282    LametricSound.ALARM07, LametricSound.ALARM06, LametricSound.ALARM05,
283    LametricSound.ALARM04, LametricSound.ALARM03, LametricSound.ALARM02,
284    LametricSound.ALARM01,
285
286    # Notification Category Entries
287    LametricSound.BICYCLE, LametricSound.CAR, LametricSound.CASH,
288    LametricSound.CAT, LametricSound.DOG02, LametricSound.DOG01,
289    LametricSound.ENERGY, LametricSound.KNOCK, LametricSound.EMAIL,
290    LametricSound.LOSE02, LametricSound.LOSE01, LametricSound.NEGATIVE01,
291    LametricSound.NEGATIVE02, LametricSound.NEGATIVE03,
292    LametricSound.NEGATIVE04, LametricSound.NEGATIVE05,
293    LametricSound.NOTIFICATION04, LametricSound.NOTIFICATION03,
294    LametricSound.NOTIFICATION02, LametricSound.NOTIFICATION01,
295    LametricSound.OPEN_DOOR, LametricSound.POSITIVE01,
296    LametricSound.POSITIVE02, LametricSound.POSITIVE03,
297    LametricSound.POSITIVE04, LametricSound.POSITIVE05,
298    LametricSound.POSITIVE01, LametricSound.STATISTIC, LametricSound.THUNDER,
299    LametricSound.WATER02, LametricSound.WATER01, LametricSound.WIND,
300    LametricSound.WIND_SHORT, LametricSound.WIN01, LametricSound.WIN02,
301)
302
303
304class NotifyLametric(NotifyBase):
305    """
306    A wrapper for LaMetric Notifications
307    """
308
309    # The default descriptive name associated with the Notification
310    service_name = 'LaMetric'
311
312    # The services URL
313    service_url = 'https://lametric.com'
314
315    # The default protocol
316    protocol = 'lametric'
317
318    # The default secure protocol
319    secure_protocol = 'lametrics'
320
321    # Allow 300 requests per minute.
322    # 60/300 = 0.2
323    request_rate_per_sec = 0.20
324
325    # A URL that takes you to the setup/help of the specific protocol
326    setup_url = 'https://github.com/caronc/apprise/wiki/Notify_lametric'
327
328    # Lametric does have titles when creating a message
329    title_maxlen = 0
330
331    # URL used for notifying Lametric App's created in the Dev Portal
332    cloud_notify_url = 'https://developer.lametric.com/api/v1' \
333                       '/dev/widget/update/com.lametric.{app_id}/{app_ver}'
334
335    # URL used for local notifications directly to the device
336    device_notify_url = '{schema}://{host}{port}/api/v2/device/notifications'
337
338    # The Device User ID
339    default_device_user = 'dev'
340
341    # Track all icon mappings back to Apprise Icon NotifyType's
342    # See: https://developer.lametric.com/icons
343    # Icon ID looks like <prefix>XXX, where <prefix> is:
344    #   - "i" (for static icon)
345    #   - "a" (for animation)
346    #   - XXX - is the number of the icon and can be found at:
347    #            https://developer.lametric.com/icons
348    lametric_icon_id_mapping = {
349        # 620/Info
350        NotifyType.INFO: 'i620',
351        # 9182/info_good
352        NotifyType.SUCCESS: 'i9182',
353        # 9183/info_caution
354        NotifyType.WARNING: 'i9183',
355        # 9184/info_error
356        NotifyType.FAILURE: 'i9184',
357    }
358
359    # Define object templates
360    templates = (
361        # Cloud (App) Mode
362        '{schema}://{app_token}@{app_id}',
363        '{schema}://{app_token}@{app_id}/{app_ver}',
364
365        # Device Mode
366        '{schema}://{apikey}@{host}',
367        '{schema}://{apikey}@{host}:{port}',
368        '{schema}://{user}:{apikey}@{host}:{port}',
369    )
370
371    # Define our template tokens
372    template_tokens = dict(NotifyBase.template_tokens, **{
373        # Used for Local Device mode
374        'apikey': {
375            'name': _('Device API Key'),
376            'type': 'string',
377            'private': True,
378        },
379        # Used for Cloud mode
380        'app_id': {
381            'name': _('App ID'),
382            'type': 'string',
383            'private': True,
384        },
385        # Used for Cloud mode
386        'app_ver': {
387            'name': _('App Version'),
388            'type': 'string',
389            'regex': (r'^[1-9][0-9]*$', ''),
390            'default': '1',
391        },
392        # Used for Cloud mode
393        'app_token': {
394            'name': _('App Access Token'),
395            'type': 'string',
396            'regex': (r'^[A-Z0-9]{80,}==$', 'i'),
397        },
398        'host': {
399            'name': _('Hostname'),
400            'type': 'string',
401            'required': True,
402        },
403        'port': {
404            'name': _('Port'),
405            'type': 'int',
406            'min': 1,
407            'max': 65535,
408            'default': 8080,
409        },
410        'user': {
411            'name': _('Username'),
412            'type': 'string',
413        },
414    })
415
416    # Define our template arguments
417    template_args = dict(NotifyBase.template_args, **{
418        'apikey': {
419            'alias_of': 'apikey',
420        },
421        'app_id': {
422            'alias_of': 'app_id',
423        },
424        'app_ver': {
425            'alias_of': 'app_ver',
426        },
427        'app_token': {
428            'alias_of': 'app_token',
429        },
430        'priority': {
431            'name': _('Priority'),
432            'type': 'choice:string',
433            'values': LAMETRIC_PRIORITIES,
434            'default': LametricPriority.INFO,
435        },
436        'icon': {
437            'name': _('Custom Icon'),
438            'type': 'string',
439        },
440        'icon_type': {
441            'name': _('Icon Type'),
442            'type': 'choice:string',
443            'values': LAMETRIC_ICON_TYPES,
444            'default': LametricIconType.NONE,
445        },
446        'mode': {
447            'name': _('Mode'),
448            'type': 'choice:string',
449            'values': LAMETRIC_MODES,
450            'default': LametricMode.DEVICE,
451        },
452        'sound': {
453            'name': _('Sound'),
454            'type': 'string',
455        },
456        # Lifetime is in seconds
457        'cycles': {
458            'name': _('Cycles'),
459            'type': 'int',
460            'min': 0,
461            'default': 1,
462        },
463    })
464
465    def __init__(self, apikey=None, app_token=None, app_id=None,
466                 app_ver=None, priority=None, icon=None, icon_type=None,
467                 sound=None, mode=None, cycles=None, **kwargs):
468        """
469        Initialize LaMetric Object
470        """
471        super(NotifyLametric, self).__init__(**kwargs)
472
473        self.mode = mode.strip().lower() \
474            if isinstance(mode, six.string_types) \
475            else self.template_args['mode']['default']
476
477        # Default Cloud Argument
478        self.lametric_app_id = None
479        self.lametric_app_ver = None
480        self.lametric_app_access_token = None
481
482        # Default Device/Cloud Argument
483        self.lametric_apikey = None
484
485        if self.mode not in LAMETRIC_MODES:
486            msg = 'An invalid LaMetric Mode ({}) was specified.'.format(mode)
487            self.logger.warning(msg)
488            raise TypeError(msg)
489
490        if self.mode == LametricMode.CLOUD:
491            try:
492                results = LAMETRIC_APP_ID_DETECTOR_RE.match(app_id)
493            except TypeError:
494                msg = 'An invalid LaMetric Application ID ' \
495                      '({}) was specified.'.format(app_id)
496                self.logger.warning(msg)
497                raise TypeError(msg)
498
499            # Detect our Access Token
500            self.lametric_app_access_token = validate_regex(
501                app_token,
502                *self.template_tokens['app_token']['regex'])
503            if not self.lametric_app_access_token:
504                msg = 'An invalid LaMetric Application Access Token ' \
505                      '({}) was specified.'.format(app_token)
506                self.logger.warning(msg)
507                raise TypeError(msg)
508
509            # If app_ver is specified, it over-rides all
510            if app_ver:
511                self.lametric_app_ver = validate_regex(
512                    app_ver, *self.template_tokens['app_ver']['regex'])
513                if not self.lametric_app_ver:
514                    msg = 'An invalid LaMetric Application Version ' \
515                          '({}) was specified.'.format(app_ver)
516                    self.logger.warning(msg)
517                    raise TypeError(msg)
518
519            else:
520                # If app_ver wasn't specified, we parse it from the
521                # Application ID
522                self.lametric_app_ver = results.group('app_ver') \
523                    if results.group('app_ver') else \
524                    self.template_tokens['app_ver']['default']
525
526            # Store our Application ID
527            self.lametric_app_id = results.group('app_id')
528
529        if self.mode == LametricMode.DEVICE:
530            self.lametric_apikey = validate_regex(apikey)
531            if not self.lametric_apikey:
532                msg = 'An invalid LaMetric Device API Key ' \
533                      '({}) was specified.'.format(apikey)
534                self.logger.warning(msg)
535                raise TypeError(msg)
536
537        if priority not in LAMETRIC_PRIORITIES:
538            self.priority = self.template_args['priority']['default']
539
540        else:
541            self.priority = priority
542
543        # assign our icon (if it was defined); we also eliminate
544        # any hashtag (#) entries that might be present
545        self.icon = re.search(r'[#\s]*(?P<value>.+?)\s*$', icon) \
546            .group('value') if isinstance(icon, six.string_types) else None
547
548        if icon_type not in LAMETRIC_ICON_TYPES:
549            self.icon_type = self.template_args['icon_type']['default']
550
551        else:
552            self.icon_type = icon_type
553
554        # The number of times the message should be displayed
555        self.cycles = self.template_args['cycles']['default'] \
556            if not (isinstance(cycles, int) and
557                    cycles > self.template_args['cycles']['min']) else cycles
558
559        self.sound = None
560        if isinstance(sound, six.string_types):
561            # If sound is set, get it's match
562            self.sound = self.sound_lookup(sound.strip().lower())
563            if self.sound is None:
564                self.logger.warning(
565                    'An invalid LaMetric sound ({}) was specified.'.format(
566                        sound))
567        return
568
569    @staticmethod
570    def sound_lookup(lookup):
571        """
572        A simple match function that takes string and returns the
573        LametricSound object it was found in.
574
575        """
576
577        for x in LAMETRIC_SOUNDS:
578            match = next((f for f in x[1] if f.startswith(lookup)), None)
579            if match:
580                # We're done
581                return x
582
583        # No match was found
584        return None
585
586    def _cloud_notification_payload(self, body, notify_type, headers):
587        """
588        Return URL and payload for cloud directed requests
589        """
590
591        # Update header entries
592        headers.update({
593            'X-Access-Token': self.lametric_apikey,
594        })
595
596        if self.sound:
597            self.logger.warning(
598                'LaMetric sound setting is unavailable in Cloud mode')
599
600        if self.priority != self.template_args['priority']['default']:
601            self.logger.warning(
602                'LaMetric priority setting is unavailable in Cloud mode')
603
604        if self.icon_type != self.template_args['icon_type']['default']:
605            self.logger.warning(
606                'LaMetric icon_type setting is unavailable in Cloud mode')
607
608        if self.cycles != self.template_args['cycles']['default']:
609            self.logger.warning(
610                'LaMetric cycle settings is unavailable in Cloud mode')
611
612        # Assign our icon if the user specified a custom one, otherwise
613        # choose from our pre-set list (based on notify_type)
614        icon = self.icon if self.icon \
615            else self.lametric_icon_id_mapping[notify_type]
616
617        # Our Payload
618        # Cloud Notifications don't have as much functionality
619        # You can not set priority and/or sound
620        payload = {
621            "frames": [
622                {
623                    "icon": icon,
624                    "text": body,
625                    "index": 0,
626                }
627            ]
628        }
629
630        # Prepare our Cloud Notify URL
631        notify_url = self.cloud_notify_url.format(
632            app_id=self.lametric_app_id, app_ver=self.lametric_app_ver)
633
634        # Return request parameters
635        return (notify_url, None, payload)
636
637    def _device_notification_payload(self, body, notify_type, headers):
638        """
639        Return URL and Payload for Device directed requests
640        """
641
642        # Assign our icon if the user specified a custom one, otherwise
643        # choose from our pre-set list (based on notify_type)
644        icon = self.icon if self.icon \
645            else self.lametric_icon_id_mapping[notify_type]
646
647        # Our Payload
648        payload = {
649            # Priority of the message
650            "priority": self.priority,
651
652            # Icon Type: Represents the nature of notification
653            "icon_type": self.icon_type,
654
655            # The time notification lives in queue to be displayed in
656            # milliseconds (ms). The default lifetime is 2 minutes (120000ms).
657            # If notification stayed in queue for longer than lifetime
658            # milliseconds - it will not be displayed.
659            "lifetime": 120000,
660
661            "model": {
662                # cycles - the number of times message should be displayed. If
663                # cycles is set to 0, notification will stay on the screen
664                # until user dismisses it manually. By default it is set to 1.
665                "cycles": self.cycles,
666                "frames": [
667                    {
668                        "icon": icon,
669                        "text": body,
670                    }
671                ]
672            }
673        }
674
675        if self.sound:
676            # Sound was set, so add it to the payload
677            payload["model"]["sound"] = {
678                # The sound category
679                "category": self.sound[0],
680
681                # The first element of our tuple is always the id
682                "id": self.sound[1][0],
683
684                # repeat - defines the number of times sound must be played.
685                # If set to 0 sound will be played until notification is
686                # dismissed. By default the value is set to 1.
687                "repeat": 1,
688            }
689
690        if not self.user:
691            # Use default user if there wasn't one otherwise specified
692            self.user = self.default_device_user
693
694        # Prepare our authentication
695        auth = (self.user, self.password)
696
697        # Prepare our Direct Access Notify URL
698        notify_url = self.device_notify_url.format(
699            schema="https" if self.secure else "http",
700            host=self.host,
701            port=':{}'.format(
702                self.port if self.port
703                else self.template_tokens['port']['default']))
704
705        # Return request parameters
706        return (notify_url, auth, payload)
707
708    def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
709        """
710        Perform LaMetric Notification
711        """
712
713        # Prepare our headers:
714        headers = {
715            'User-Agent': self.app_id,
716            'Content-Type': 'application/json',
717            'Accept': 'application/json',
718            'Cache-Control': 'no-cache',
719        }
720
721        # Depending on the mode, the payload is gathered by
722        # - _device_notification_payload()
723        # - _cloud_notification_payload()
724        (notify_url, auth, payload) = getattr(
725            self, '_{}_notification_payload'.format(self.mode))(
726                body=body, notify_type=notify_type, headers=headers)
727
728        self.logger.debug('LaMetric POST URL: %s (cert_verify=%r)' % (
729            notify_url, self.verify_certificate,
730        ))
731        self.logger.debug('LaMetric Payload: %s' % str(payload))
732
733        # Always call throttle before any remote server i/o is made
734        self.throttle()
735
736        try:
737            r = requests.post(
738                notify_url,
739                data=dumps(payload),
740                headers=headers,
741                auth=auth,
742                verify=self.verify_certificate,
743                timeout=self.request_timeout,
744            )
745            # An ideal response would be:
746            # {
747            #   "success": {
748            #     "id": "<notification id>"
749            #   }
750            # }
751
752            if r.status_code not in (
753                    requests.codes.created, requests.codes.ok):
754                # We had a problem
755                status_str = \
756                    NotifyLametric.http_response_code_lookup(r.status_code)
757
758                self.logger.warning(
759                    'Failed to send LaMetric notification: '
760                    '{}{}error={}.'.format(
761                        status_str,
762                        ', ' if status_str else '',
763                        r.status_code))
764
765                self.logger.debug('Response Details:\r\n{}'.format(r.content))
766
767                # Return; we're done
768                return False
769
770            else:
771                self.logger.info('Sent LaMetric notification.')
772
773        except requests.RequestException as e:
774            self.logger.warning(
775                'A Connection error occurred sending LaMetric '
776                'notification to %s.' % self.host)
777            self.logger.debug('Socket Exception: %s' % str(e))
778
779            # Return; we're done
780            return False
781
782        return True
783
784    def url(self, privacy=False, *args, **kwargs):
785        """
786        Returns the URL built dynamically based on specified arguments.
787        """
788
789        # Define any URL parameters
790        params = {
791            'mode': self.mode,
792        }
793
794        # Extend our parameters
795        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
796
797        if self.icon:
798            # Assign our icon IF one was specified
799            params['icon'] = self.icon
800
801        if self.mode == LametricMode.CLOUD:
802            # Upstream/LaMetric App Return
803            return '{schema}://{token}@{app_id}/{app_ver}/?{params}'.format(
804                schema=self.protocol,
805                token=self.pprint(
806                    self.lametric_app_access_token, privacy, safe=''),
807                app_id=self.pprint(self.lametric_app_id, privacy, safe=''),
808                app_ver=NotifyLametric.quote(self.lametric_app_ver, safe=''),
809                params=NotifyLametric.urlencode(params))
810
811        #
812        # If we reach here then we're dealing with LametricMode.DEVICE
813        #
814        if self.priority != self.template_args['priority']['default']:
815            params['priority'] = self.priority
816
817        if self.icon_type != self.template_args['icon_type']['default']:
818            params['icon_type'] = self.icon_type
819
820        if self.cycles != self.template_args['cycles']['default']:
821            params['cycles'] = self.cycles
822
823        if self.sound:
824            # Store our sound entry
825            # The first element of our tuple is always the id
826            params['sound'] = self.sound[1][0]
827
828        auth = ''
829        if self.user and self.password:
830            auth = '{user}:{apikey}@'.format(
831                user=NotifyLametric.quote(self.user, safe=''),
832                apikey=self.pprint(self.lametric_apikey, privacy, safe=''),
833            )
834        else:  # self.apikey is set
835            auth = '{apikey}@'.format(
836                apikey=self.pprint(self.lametric_apikey, privacy, safe=''),
837            )
838
839        # Local Return
840        return '{schema}://{auth}{hostname}{port}/?{params}'.format(
841            schema=self.secure_protocol if self.secure else self.protocol,
842            auth=auth,
843            # never encode hostname since we're expecting it to be a valid one
844            hostname=self.host,
845            port='' if self.port is None
846                 or self.port == self.template_tokens['port']['default']
847                 else ':{}'.format(self.port),
848            params=NotifyLametric.urlencode(params),
849        )
850
851    @staticmethod
852    def parse_url(url):
853        """
854        Parses the URL and returns enough arguments that can allow
855        us to re-instantiate this object.
856
857        """
858
859        results = NotifyBase.parse_url(url, verify_host=False)
860        if not results:
861            # We're done early as we couldn't load the results
862            return results
863
864        if results.get('user') and not results.get('password'):
865            # Handle URL like:
866            # schema://user@host
867
868            # This becomes the password
869            results['password'] = results['user']
870            results['user'] = None
871
872        # Priority Handling
873        if 'priority' in results['qsd'] and results['qsd']['priority']:
874            results['priority'] = NotifyLametric.unquote(
875                results['qsd']['priority'].strip().lower())
876
877        # Icon Type
878        if 'icon' in results['qsd'] and results['qsd']['icon']:
879            results['icon'] = NotifyLametric.unquote(
880                results['qsd']['icon'].strip().lower())
881
882        # Icon Type
883        if 'icon_type' in results['qsd'] and results['qsd']['icon_type']:
884            results['icon_type'] = NotifyLametric.unquote(
885                results['qsd']['icon_type'].strip().lower())
886
887        # Sound
888        if 'sound' in results['qsd'] and results['qsd']['sound']:
889            results['sound'] = NotifyLametric.unquote(
890                results['qsd']['sound'].strip().lower())
891
892        # API Key (Device Mode)
893        if 'apikey' in results['qsd'] and results['qsd']['apikey']:
894            # Extract API Key from an argument
895            results['apikey'] = \
896                NotifyLametric.unquote(results['qsd']['apikey'])
897
898        # App ID
899        if 'app' in results['qsd'] \
900                and results['qsd']['app']:
901
902            # Extract the App ID from an argument
903            results['app_id'] = \
904                NotifyLametric.unquote(results['qsd']['app'])
905
906        # App Version
907        if 'app_ver' in results['qsd'] \
908                and results['qsd']['app_ver']:
909
910            # Extract the App ID from an argument
911            results['app_ver'] = \
912                NotifyLametric.unquote(results['qsd']['app_ver'])
913
914        if 'token' in results['qsd'] and results['qsd']['token']:
915            # Extract Application Access Token from an argument
916            results['app_token'] = \
917                NotifyLametric.unquote(results['qsd']['token'])
918
919        # Mode override
920        if 'mode' in results['qsd'] and results['qsd']['mode']:
921            results['mode'] = NotifyLametric.unquote(
922                results['qsd']['mode'].strip().lower())
923        else:
924            # We can try to detect the mode based on the validity of the
925            # hostname. We can also scan the validity of the Application
926            # Access token
927            #
928            # This isn't a surfire way to do things though; it's best to
929            # specify the mode= flag
930            results['mode'] = LametricMode.DEVICE \
931                if ((is_hostname(results['host']) or
932                    is_ipaddr(results['host'])) and
933
934                    # make sure password is not an Access Token
935                    (results['password'] and not
936                        LAMETRIC_IS_APP_TOKEN.match(results['password'])) and
937
938                    # Scan for app_ flags
939                    next((f for f in results.keys() \
940                          if f.startswith('app_')), None) is None) \
941                else LametricMode.CLOUD
942
943        # Handle defaults if not set
944        if results['mode'] == LametricMode.DEVICE:
945            # Device Mode Defaults
946            if 'apikey' not in results:
947                results['apikey'] = \
948                    NotifyLametric.unquote(results['password'])
949
950        else:
951            # CLOUD Mode Defaults
952            if 'app_id' not in results:
953                results['app_id'] = \
954                    NotifyLametric.unquote(results['host'])
955            if 'app_token' not in results:
956                results['app_token'] = \
957                    NotifyLametric.unquote(results['password'])
958
959        # Set cycles
960        try:
961            results['cycles'] = abs(int(results['qsd'].get('cycles')))
962
963        except (TypeError, ValueError):
964            # Not a valid integer; ignore entry
965            pass
966
967        return results
968
969    @staticmethod
970    def parse_native_url(url):
971        """
972        Support
973           https://developer.lametric.com/api/v1/dev/\
974                   widget/update/com.lametric.{APP_ID}/1
975
976           https://developer.lametric.com/api/v1/dev/\
977                   widget/update/com.lametric.{APP_ID}/{APP_VER}
978        """
979
980        # If users do provide the Native URL they wll also want to add
981        # ?token={APP_ACCESS_TOKEN} to the parameters at the end or the
982        # URL will fail to load in later stages.
983        result = re.match(
984            r'^http(?P<secure>s)?://(?P<host>[^/]+)'
985            r'/api/(?P<api_ver>v[1-9]*[0-9]+)'
986            r'/dev/widget/update/'
987            r'com\.lametric\.(?P<app_id>[0-9a-z.-]{1,64})'
988            r'(/(?P<app_ver>[1-9][0-9]*))?/?'
989            r'(?P<params>\?.+)?$', url, re.I)
990
991        if result:
992            return NotifyLametric.parse_url(
993                '{schema}://{app_id}{app_ver}/{params}'.format(
994                    schema=NotifyLametric.secure_protocol
995                    if result.group('secure') else NotifyLametric.protocol,
996                    app_id=result.group('app_id'),
997                    app_ver='/{}'.format(result.group('app_ver'))
998                    if result.group('app_ver') else '',
999                    params='' if not result.group('params')
1000                    else result.group('params')))
1001
1002        return None
1003