Mopiqtt 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mopiqtt/__init__.py +39 -0
- mopiqtt/ext.conf +7 -0
- mopiqtt/frontend.py +415 -0
- mopiqtt/mqtt.py +119 -0
- mopiqtt/utils.py +74 -0
- mopiqtt-2.0.0.dist-info/METADATA +189 -0
- mopiqtt-2.0.0.dist-info/RECORD +11 -0
- mopiqtt-2.0.0.dist-info/WHEEL +5 -0
- mopiqtt-2.0.0.dist-info/entry_points.txt +2 -0
- mopiqtt-2.0.0.dist-info/licenses/LICENSE +201 -0
- mopiqtt-2.0.0.dist-info/top_level.txt +1 -0
mopiqtt/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import unicode_literals
|
|
2
|
+
|
|
3
|
+
from __future__ import absolute_import
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from mopidy import config, ext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__version__ = "2.0.0"
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Extension(ext.Extension):
|
|
15
|
+
|
|
16
|
+
dist_name = "Mopiqtt"
|
|
17
|
+
ext_name = "mopiqtt"
|
|
18
|
+
version = __version__
|
|
19
|
+
|
|
20
|
+
def get_default_config(self):
|
|
21
|
+
conf_file = os.path.join(os.path.dirname(__file__), "ext.conf")
|
|
22
|
+
return config.read(conf_file)
|
|
23
|
+
|
|
24
|
+
def get_config_schema(self):
|
|
25
|
+
schema = super(Extension, self).get_config_schema()
|
|
26
|
+
|
|
27
|
+
schema["host"] = config.Hostname()
|
|
28
|
+
schema["port"] = config.Port(optional=True)
|
|
29
|
+
schema["topic"] = config.String()
|
|
30
|
+
|
|
31
|
+
schema["username"] = config.String(optional=True)
|
|
32
|
+
schema["password"] = config.Secret(optional=True)
|
|
33
|
+
|
|
34
|
+
return schema
|
|
35
|
+
|
|
36
|
+
def setup(self, registry):
|
|
37
|
+
from mopiqtt.frontend import MopiqttFrontend
|
|
38
|
+
|
|
39
|
+
registry.add("frontend", MopiqttFrontend)
|
mopiqtt/ext.conf
ADDED
mopiqtt/frontend.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
from builtins import str
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import pykka
|
|
7
|
+
from mopidy.core import CoreListener
|
|
8
|
+
from mopidy.models import SearchResult
|
|
9
|
+
|
|
10
|
+
from .mqtt import Comms
|
|
11
|
+
from .utils import describe_track, describe_stream, get_track_artwork
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_playback_state():
|
|
15
|
+
for module_name in ("mopidy.types", "mopidy.audio"):
|
|
16
|
+
try:
|
|
17
|
+
module = import_module(module_name)
|
|
18
|
+
return getattr(module, "PlaybackState")
|
|
19
|
+
except (AttributeError, ImportError):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
raise ImportError("Cannot import PlaybackState from Mopidy")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PlaybackState = _load_playback_state()
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
VOLUME_MAX = 100
|
|
31
|
+
VOLUME_MIN = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MopiqttFrontend(pykka.ThreadingActor, CoreListener):
|
|
35
|
+
def __init__(self, config, core):
|
|
36
|
+
"""
|
|
37
|
+
config (dict): The entire Mopidy configuration.
|
|
38
|
+
core (ActorProxy): Core actor for Mopidy Core API.
|
|
39
|
+
"""
|
|
40
|
+
super(MopiqttFrontend, self).__init__()
|
|
41
|
+
self.core = core
|
|
42
|
+
self.mqtt = Comms(frontend=self, **config["mopiqtt"])
|
|
43
|
+
self.defaultImage = (
|
|
44
|
+
"https://upload.wikimedia.org/wikipedia/commons/1/14/No_Image_Available.jpg"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def on_start(self):
|
|
48
|
+
"""
|
|
49
|
+
Hook for doing any setup that should be done *after* the actor is
|
|
50
|
+
started, but *before* it starts processing messages.
|
|
51
|
+
"""
|
|
52
|
+
log.debug("Starting MQTT frontend: %s", self)
|
|
53
|
+
self.mqtt.start()
|
|
54
|
+
|
|
55
|
+
def on_stop(self):
|
|
56
|
+
"""
|
|
57
|
+
Hook for doing any cleanup that should be done *after* the actor has
|
|
58
|
+
processed the last message, and *before* the actor stops.
|
|
59
|
+
"""
|
|
60
|
+
log.debug("Stopping MQTT frontend: %s", self)
|
|
61
|
+
self.mqtt.stop()
|
|
62
|
+
|
|
63
|
+
def on_failure(self, exception_type, exception_value, traceback):
|
|
64
|
+
"""
|
|
65
|
+
Hook for doing any cleanup *after* an unhandled exception is raised,
|
|
66
|
+
and *before* the actor stops.
|
|
67
|
+
"""
|
|
68
|
+
log.error("MQTT frontend failed: %s", exception_value)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def volume(self):
|
|
72
|
+
return self.core.mixer.get_volume().get()
|
|
73
|
+
|
|
74
|
+
@volume.setter
|
|
75
|
+
def volume(self, value):
|
|
76
|
+
# Normalize.
|
|
77
|
+
value = min(value, VOLUME_MAX)
|
|
78
|
+
value = max(value, VOLUME_MIN)
|
|
79
|
+
self.core.mixer.set_volume(value).get()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def current_state(self):
|
|
83
|
+
return self.core.playback.get_state().get()
|
|
84
|
+
|
|
85
|
+
def tracklist_changed(self):
|
|
86
|
+
# reports any change to the current tracklist
|
|
87
|
+
# and triggers trklist
|
|
88
|
+
log.debug("MQTT tracklist changed")
|
|
89
|
+
#
|
|
90
|
+
# get list of all tracks in the queue
|
|
91
|
+
#
|
|
92
|
+
tk_list = self.core.tracklist.get_tracks().get()
|
|
93
|
+
tracks = []
|
|
94
|
+
item = {}
|
|
95
|
+
for a in tk_list:
|
|
96
|
+
name = a.name or ""
|
|
97
|
+
if a.artists:
|
|
98
|
+
artist = next(iter(a.artists)).name or ""
|
|
99
|
+
item_name = "{} - {}".format(artist, name) if artist else name
|
|
100
|
+
else:
|
|
101
|
+
item_name = name
|
|
102
|
+
item = {"name": item_name, "uri": a.uri}
|
|
103
|
+
tracks.append(item)
|
|
104
|
+
self.mqtt.publish("trklist", json.dumps(tracks))
|
|
105
|
+
log.debug("Generated tracklist list")
|
|
106
|
+
|
|
107
|
+
def playback_state_changed(self, old_state, new_state):
|
|
108
|
+
"""
|
|
109
|
+
old_state (mopidy.core.PlaybackState) - the state before the change.
|
|
110
|
+
new_state (mopidy.core.PlaybackState) - the state after the change.
|
|
111
|
+
"""
|
|
112
|
+
log.debug("MQTT playback state changed: %s", new_state)
|
|
113
|
+
self.mqtt.publish("plstate", new_state)
|
|
114
|
+
|
|
115
|
+
def track_playback_started(self, tl_track):
|
|
116
|
+
"""
|
|
117
|
+
tl_track (mopidy.models.TlTrack) - the track that just started playing.
|
|
118
|
+
"""
|
|
119
|
+
log.debug("MQTT track started: %s", tl_track.track)
|
|
120
|
+
self.mqtt.publish("trk", describe_track(tl_track.track))
|
|
121
|
+
|
|
122
|
+
# get track's uri
|
|
123
|
+
self.mqtt.publish("trk_uri", tl_track.track.uri)
|
|
124
|
+
|
|
125
|
+
# get track's artwork (if any)
|
|
126
|
+
self.mqtt.publish("artw", get_track_artwork(self, tl_track.track))
|
|
127
|
+
|
|
128
|
+
# get track playing indexes
|
|
129
|
+
curr = self.core.tracklist.index().get()
|
|
130
|
+
last = self.core.tracklist.get_length().get()
|
|
131
|
+
pl_index = {}
|
|
132
|
+
pl_index["current"] = curr + 1 if curr is not None else None
|
|
133
|
+
pl_index["last"] = last
|
|
134
|
+
pl_index = json.dumps(pl_index)
|
|
135
|
+
self.mqtt.publish("trk-index", pl_index)
|
|
136
|
+
|
|
137
|
+
def track_playback_ended(self, tl_track, time_position):
|
|
138
|
+
"""
|
|
139
|
+
tl_track (mopidy.models.TlTrack) - the track that was played before
|
|
140
|
+
playback stopped.
|
|
141
|
+
time_position (int) - the time position in milliseconds.
|
|
142
|
+
"""
|
|
143
|
+
log.debug("MQTT track ended: %s", tl_track.track.name)
|
|
144
|
+
self.mqtt.publish("trk", "")
|
|
145
|
+
|
|
146
|
+
def volume_changed(self, volume):
|
|
147
|
+
"""
|
|
148
|
+
volume (int) - the new volume in the range [0..100].
|
|
149
|
+
"""
|
|
150
|
+
log.debug("MQTT volume changed: %s", volume)
|
|
151
|
+
self.mqtt.publish("vol", str(volume))
|
|
152
|
+
|
|
153
|
+
def stream_title_changed(self, title):
|
|
154
|
+
"""
|
|
155
|
+
title (string) - the new stream title.
|
|
156
|
+
"""
|
|
157
|
+
log.debug("MQTT title changed: %s", title)
|
|
158
|
+
self.mqtt.publish("trk", describe_stream(title))
|
|
159
|
+
|
|
160
|
+
def playlists_loaded(self):
|
|
161
|
+
log.debug("Playlists loaded event")
|
|
162
|
+
self.mqtt.publish("refreshed", "")
|
|
163
|
+
|
|
164
|
+
def on_action_plb(self, value):
|
|
165
|
+
"""Playback control."""
|
|
166
|
+
if value == "play":
|
|
167
|
+
return self.core.playback.play().get()
|
|
168
|
+
if value == "stop":
|
|
169
|
+
return self.core.playback.stop().get()
|
|
170
|
+
if value == "pause":
|
|
171
|
+
return self.core.playback.pause().get()
|
|
172
|
+
if value == "resume":
|
|
173
|
+
return self.core.playback.resume().get()
|
|
174
|
+
|
|
175
|
+
if value == "toggle":
|
|
176
|
+
if self.current_state == PlaybackState.PLAYING:
|
|
177
|
+
return self.core.playback.pause().get()
|
|
178
|
+
if self.current_state == PlaybackState.PAUSED:
|
|
179
|
+
return self.core.playback.resume().get()
|
|
180
|
+
if self.current_state == PlaybackState.STOPPED:
|
|
181
|
+
return self.core.playback.play().get()
|
|
182
|
+
|
|
183
|
+
if value == "prev":
|
|
184
|
+
return self.core.playback.previous().get()
|
|
185
|
+
if value == "next":
|
|
186
|
+
return self.core.playback.next().get()
|
|
187
|
+
|
|
188
|
+
log.warning("Unknown playback control action: %s", value)
|
|
189
|
+
|
|
190
|
+
def on_action_vol(self, value):
|
|
191
|
+
"""Volume control."""
|
|
192
|
+
if not value or len(value) < 2:
|
|
193
|
+
return log.warning("Invalid volume control parameter: %s", value)
|
|
194
|
+
|
|
195
|
+
operator = value[0]
|
|
196
|
+
try:
|
|
197
|
+
amount = int(value[1:])
|
|
198
|
+
except ValueError:
|
|
199
|
+
return log.warning("Invalid volume setting value: %s", value[1:])
|
|
200
|
+
|
|
201
|
+
# Exact volume.
|
|
202
|
+
if operator == "=":
|
|
203
|
+
self.volume = amount
|
|
204
|
+
return
|
|
205
|
+
# Volume down.
|
|
206
|
+
if operator == "-":
|
|
207
|
+
self.volume -= amount
|
|
208
|
+
return
|
|
209
|
+
# Volume up.
|
|
210
|
+
if operator == "+":
|
|
211
|
+
self.volume += amount
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
log.warning("Unknown volume control operator: %s", operator)
|
|
215
|
+
|
|
216
|
+
def on_action_add(self, value):
|
|
217
|
+
"""Append URI to queue (tracklist)."""
|
|
218
|
+
if not value:
|
|
219
|
+
return log.warning("Cannot add empty track to queue")
|
|
220
|
+
|
|
221
|
+
track = []
|
|
222
|
+
track.append(value)
|
|
223
|
+
self.core.tracklist.add(uris=track).get()
|
|
224
|
+
log.debug("Added track: %s", value)
|
|
225
|
+
|
|
226
|
+
def _resolve_tracks(self, uris):
|
|
227
|
+
if not uris:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# Mopidy 3 and 4 implement tracklist.add(uris=...) using this same
|
|
231
|
+
# lookup and flattening each URI in order. Iterating the original URI
|
|
232
|
+
# list here also preserves duplicate playlist entries.
|
|
233
|
+
lookup = self.core.library.lookup(uris=uris).get()
|
|
234
|
+
if not lookup:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
tracks = []
|
|
238
|
+
for uri in uris:
|
|
239
|
+
uri_tracks = lookup.get(uri)
|
|
240
|
+
if not uri_tracks:
|
|
241
|
+
return None
|
|
242
|
+
tracks.extend(uri_tracks)
|
|
243
|
+
|
|
244
|
+
return tracks
|
|
245
|
+
|
|
246
|
+
def _restore_tracklist(self, tracks):
|
|
247
|
+
try:
|
|
248
|
+
self.core.tracklist.clear().get()
|
|
249
|
+
if tracks:
|
|
250
|
+
self.core.tracklist.add(tracks=tracks).get()
|
|
251
|
+
except Exception:
|
|
252
|
+
log.exception("Failed to restore previous tracklist")
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
def _replace_tracklist(self, tracks, shuffle=False):
|
|
258
|
+
try:
|
|
259
|
+
previous_version = self.core.tracklist.get_version().get()
|
|
260
|
+
previous_tracks = self.core.tracklist.get_tracks().get()
|
|
261
|
+
if self.core.tracklist.get_version().get() != previous_version:
|
|
262
|
+
log.warning("Tracklist changed while preparing replacement")
|
|
263
|
+
return False
|
|
264
|
+
except Exception:
|
|
265
|
+
log.exception("Cannot snapshot the current tracklist")
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
self.core.tracklist.clear().get()
|
|
270
|
+
self.core.tracklist.add(tracks=tracks).get()
|
|
271
|
+
if shuffle:
|
|
272
|
+
self.core.tracklist.shuffle().get()
|
|
273
|
+
self.core.playback.play().get()
|
|
274
|
+
except Exception:
|
|
275
|
+
log.exception("Failed to replace tracklist; restoring previous queue")
|
|
276
|
+
self._restore_tracklist(previous_tracks)
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
def on_action_pstream(self, value):
|
|
282
|
+
"""Load and start a radio stream or a single track (tracklist)."""
|
|
283
|
+
if not value:
|
|
284
|
+
return log.warning("Cannot load empty track to queue")
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
tracks = self._resolve_tracks([value])
|
|
288
|
+
except Exception:
|
|
289
|
+
log.exception("Failed to validate stream: %s", value)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if not tracks:
|
|
293
|
+
log.info("Invalid stream: %s", value)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
if self._replace_tracklist(tracks):
|
|
297
|
+
log.debug("Started track: %s", value)
|
|
298
|
+
|
|
299
|
+
def _get_playlist_tracks(self, uri):
|
|
300
|
+
items = self.core.playlists.get_items(uri).get()
|
|
301
|
+
if not items:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
uris = []
|
|
305
|
+
for item in items:
|
|
306
|
+
item_uri = getattr(item, "uri", None)
|
|
307
|
+
if not item_uri:
|
|
308
|
+
return None
|
|
309
|
+
uris.append(item_uri)
|
|
310
|
+
|
|
311
|
+
return self._resolve_tracks(uris)
|
|
312
|
+
|
|
313
|
+
def on_action_pload(self, value):
|
|
314
|
+
"""Replace current queue with playlist from URI."""
|
|
315
|
+
if not value:
|
|
316
|
+
return log.warning("Cannot load unnamed playlist")
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
tracks = self._get_playlist_tracks(value)
|
|
320
|
+
except Exception:
|
|
321
|
+
log.exception("Failed to validate playlist: %s", value)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
if not tracks:
|
|
325
|
+
log.info("Invalid playlist: %s", value)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
if self._replace_tracklist(tracks):
|
|
329
|
+
log.debug("Started Playlist: %s", value)
|
|
330
|
+
|
|
331
|
+
def on_action_ploadshfl(self, value):
|
|
332
|
+
# Replace current queue with shuffled playlist from URI.
|
|
333
|
+
if not value:
|
|
334
|
+
return log.warning("Cannot load unnamed playlist")
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
tracks = self._get_playlist_tracks(value)
|
|
338
|
+
except Exception:
|
|
339
|
+
log.exception("Failed to validate playlist: %s", value)
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
if not tracks:
|
|
343
|
+
log.info("Invalid playlist: %s", value)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if self._replace_tracklist(tracks, shuffle=True):
|
|
347
|
+
log.debug("Started shuffled Playlist: %s", value)
|
|
348
|
+
|
|
349
|
+
def on_action_clr(self, value):
|
|
350
|
+
"""Clear the queue (tracklist)."""
|
|
351
|
+
return self.core.tracklist.clear().get()
|
|
352
|
+
|
|
353
|
+
def on_action_plist(self, value):
|
|
354
|
+
# Request a list of all playlist
|
|
355
|
+
plist = self.core.playlists.as_list()
|
|
356
|
+
playlists = []
|
|
357
|
+
item = {}
|
|
358
|
+
for a in plist.get():
|
|
359
|
+
item = {"name": a.name, "uri": a.uri}
|
|
360
|
+
playlists.append(item)
|
|
361
|
+
self.mqtt.publish("plists", json.dumps(playlists))
|
|
362
|
+
log.debug("Generated playlist list")
|
|
363
|
+
|
|
364
|
+
def on_action_plrefresh(self, value):
|
|
365
|
+
# refresh a single playlist or all
|
|
366
|
+
# value = uri_scheme, if value=None, all playlists are refreshed
|
|
367
|
+
if value:
|
|
368
|
+
self.core.playlists.refresh(uri_scheme=value).get()
|
|
369
|
+
log.debug("Refreshed playlists with uri_scheme: %s", value)
|
|
370
|
+
else:
|
|
371
|
+
self.core.playlists.refresh().get()
|
|
372
|
+
log.debug("Refreshed all playlists")
|
|
373
|
+
|
|
374
|
+
def on_action_chgtrk(self, value):
|
|
375
|
+
# change current playing track in the queue
|
|
376
|
+
if not value:
|
|
377
|
+
return log.info("chgtrk: Cannot change track to empty uri")
|
|
378
|
+
|
|
379
|
+
flt = self.core.tracklist.filter(criteria={"uri": [value]}).get()
|
|
380
|
+
if not flt:
|
|
381
|
+
return log.info("chgtrk: Invalid track")
|
|
382
|
+
tl_track = flt[0]
|
|
383
|
+
self.core.playback.play(tlid=tl_track.tlid).get()
|
|
384
|
+
log.debug("Changed track to tlid: %s", tl_track.tlid)
|
|
385
|
+
|
|
386
|
+
def on_action_queryschemes(self, value):
|
|
387
|
+
# request uri_schemes handled by search
|
|
388
|
+
schemes = self.core.get_uri_schemes().get()
|
|
389
|
+
log.debug("Uri_schemes handled by search: %s", schemes)
|
|
390
|
+
self.mqtt.publish("uri_schemes", json.dumps(schemes))
|
|
391
|
+
|
|
392
|
+
def on_action_search(self, value):
|
|
393
|
+
if not value:
|
|
394
|
+
return log.info("search: Cannot search empty strings")
|
|
395
|
+
value = json.loads(value)
|
|
396
|
+
lookup_str = value["search"]
|
|
397
|
+
lookup_uris = value["uri_schemes"]
|
|
398
|
+
query = {"any": lookup_str}
|
|
399
|
+
results = self.core.library.search(query=query, uris=lookup_uris).get()
|
|
400
|
+
if isinstance(results, SearchResult):
|
|
401
|
+
search_result = results
|
|
402
|
+
else:
|
|
403
|
+
search_result = next(iter(results or ()), None)
|
|
404
|
+
|
|
405
|
+
tracks = getattr(search_result, "tracks", ()) or ()
|
|
406
|
+
found = len(tracks)
|
|
407
|
+
item = {}
|
|
408
|
+
final_list = []
|
|
409
|
+
for k in tracks:
|
|
410
|
+
item = {"name": k.name, "uri": k.uri}
|
|
411
|
+
final_list.append(item)
|
|
412
|
+
log.debug(
|
|
413
|
+
"Search for %s in %s ended. Found %d tracks", lookup_str, lookup_uris, found
|
|
414
|
+
)
|
|
415
|
+
self.mqtt.publish("search_results", json.dumps(final_list))
|
mopiqtt/mqtt.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from paho.mqtt import client as mqtt
|
|
5
|
+
from paho.mqtt.enums import CallbackAPIVersion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
HANDLER_PREFIX = "on_action_"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Comms:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
frontend,
|
|
18
|
+
host="localhost",
|
|
19
|
+
port=1883,
|
|
20
|
+
topic="mopidy",
|
|
21
|
+
username=None,
|
|
22
|
+
password=None,
|
|
23
|
+
**kwargs
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Configure MQTT communication client.
|
|
27
|
+
frontend (MopiqttFrontend): Instance of extension's frontend.
|
|
28
|
+
"""
|
|
29
|
+
self.frontend = frontend
|
|
30
|
+
self.host = host
|
|
31
|
+
self.port = port
|
|
32
|
+
self.topic = topic
|
|
33
|
+
self.user = username
|
|
34
|
+
self.password = password
|
|
35
|
+
|
|
36
|
+
self.client = mqtt.Client(
|
|
37
|
+
callback_api_version=CallbackAPIVersion.VERSION2,
|
|
38
|
+
client_id="mopidy-{}".format(random.randint(1000, 9999)),
|
|
39
|
+
)
|
|
40
|
+
self.client.on_connect = self._on_connect
|
|
41
|
+
self.client.on_message = self._on_message
|
|
42
|
+
|
|
43
|
+
def start(self):
|
|
44
|
+
"""
|
|
45
|
+
Attempt connection to MQTT broker and initialise network loop.
|
|
46
|
+
"""
|
|
47
|
+
if self.user and self.password:
|
|
48
|
+
self.client.username_pw_set(username=self.user, password=self.password)
|
|
49
|
+
|
|
50
|
+
self.client.connect_async(host=self.host, port=self.port)
|
|
51
|
+
log.debug("Connecting to MQTT broker at %s:%s", self.host, self.port)
|
|
52
|
+
self.client.loop_start()
|
|
53
|
+
log.debug("Started MQTT communication loop.")
|
|
54
|
+
|
|
55
|
+
def stop(self):
|
|
56
|
+
"""
|
|
57
|
+
Clean up and disconnect from MQTT broker.
|
|
58
|
+
"""
|
|
59
|
+
self.client.disconnect()
|
|
60
|
+
log.debug("Disconnected from MQTT broker")
|
|
61
|
+
|
|
62
|
+
def _on_connect(self, client, userdata, flags, rc, properties):
|
|
63
|
+
if rc != 0:
|
|
64
|
+
log.error("Failed to connect to MQTT broker, result: %s", rc)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
log.info("Successfully connected to MQTT broker, result :%s", rc)
|
|
68
|
+
|
|
69
|
+
for name in dir(self.frontend):
|
|
70
|
+
if not name.startswith(HANDLER_PREFIX):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
suffix = name[len(HANDLER_PREFIX) :]
|
|
74
|
+
full_topic = "{}/cmnd/{}".format(self.topic, suffix)
|
|
75
|
+
result, _ = self.client.subscribe(full_topic)
|
|
76
|
+
|
|
77
|
+
if result == mqtt.MQTT_ERR_SUCCESS:
|
|
78
|
+
log.debug("Subscribed to MQTT topic: %s", full_topic)
|
|
79
|
+
else:
|
|
80
|
+
log.warning(
|
|
81
|
+
"Failed to subscribe to MQTT topic: %s, result: %s",
|
|
82
|
+
full_topic,
|
|
83
|
+
result,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _on_message(self, client, userdata, message):
|
|
87
|
+
topic = "<unknown>"
|
|
88
|
+
payload = None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
topic = message.topic
|
|
92
|
+
action = topic.split("/")[-1]
|
|
93
|
+
|
|
94
|
+
handler = getattr(self.frontend, HANDLER_PREFIX + action, None)
|
|
95
|
+
if not handler:
|
|
96
|
+
log.warning("Cannot handle MQTT messages on topic: %s", topic)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
payload = message.payload.decode("utf8")
|
|
100
|
+
log.debug(
|
|
101
|
+
"Passing payload: %s to MQTT handler: %s",
|
|
102
|
+
message.payload,
|
|
103
|
+
handler.__name__,
|
|
104
|
+
)
|
|
105
|
+
handler(value=payload)
|
|
106
|
+
except Exception:
|
|
107
|
+
if payload is None:
|
|
108
|
+
payload = getattr(message, "payload", None)
|
|
109
|
+
log.exception(
|
|
110
|
+
"Failed to process MQTT message: topic=%s payload=%r",
|
|
111
|
+
topic,
|
|
112
|
+
payload,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def publish(self, subtopic, value):
|
|
116
|
+
full_topic = "{}/stat/{}".format(self.topic, subtopic)
|
|
117
|
+
|
|
118
|
+
log.debug("Publishing: %s to MQTT topic: %s", value, full_topic)
|
|
119
|
+
return self.client.publish(topic=full_topic, payload=value)
|
mopiqtt/utils.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
log = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
UNKNOWN = ""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def describe_track(track):
|
|
11
|
+
"""
|
|
12
|
+
Prepare a short human-readable Track description.
|
|
13
|
+
|
|
14
|
+
track (mopidy.models.Track): Track to source song data from.
|
|
15
|
+
"""
|
|
16
|
+
title = track.name or UNKNOWN
|
|
17
|
+
|
|
18
|
+
# Simple/regular case: normal song (e.g. from Spotify).
|
|
19
|
+
if track.artists:
|
|
20
|
+
artist = next(iter(track.artists)).name
|
|
21
|
+
elif track.album and track.album.artists: # Album-only artist case.
|
|
22
|
+
artist = next(iter(track.album.artists)).name
|
|
23
|
+
else:
|
|
24
|
+
artist = UNKNOWN
|
|
25
|
+
|
|
26
|
+
if track.album and track.album.name:
|
|
27
|
+
album = track.album.name
|
|
28
|
+
else:
|
|
29
|
+
album = UNKNOWN
|
|
30
|
+
|
|
31
|
+
return ";".join([title, artist, album])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def describe_stream(raw_title):
|
|
35
|
+
"""
|
|
36
|
+
Attempt to parse given stream title in very rudimentary way.
|
|
37
|
+
"""
|
|
38
|
+
title = UNKNOWN
|
|
39
|
+
artist = UNKNOWN
|
|
40
|
+
album = UNKNOWN
|
|
41
|
+
|
|
42
|
+
# Very common separator.
|
|
43
|
+
if "-" in raw_title:
|
|
44
|
+
parts = raw_title.split("-")
|
|
45
|
+
artist = parts[0].strip()
|
|
46
|
+
title = parts[1].strip()
|
|
47
|
+
else:
|
|
48
|
+
# Just assume we only have track title.
|
|
49
|
+
title = raw_title
|
|
50
|
+
|
|
51
|
+
return ";".join([title, artist, album])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_track_artwork(self, track):
|
|
55
|
+
track_uri = getattr(track, "uri", None)
|
|
56
|
+
if not track_uri:
|
|
57
|
+
return self.defaultImage
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
artwork = self.core.library.get_images([track_uri]).get()
|
|
61
|
+
images = (artwork or {}).get(track_uri, ())
|
|
62
|
+
if not images:
|
|
63
|
+
return self.defaultImage
|
|
64
|
+
|
|
65
|
+
image_uri = getattr(images[0], "uri", None)
|
|
66
|
+
if not image_uri:
|
|
67
|
+
return self.defaultImage
|
|
68
|
+
if image_uri == "/local" or image_uri.startswith("/local/"):
|
|
69
|
+
return self.defaultImage
|
|
70
|
+
|
|
71
|
+
return image_uri
|
|
72
|
+
except Exception:
|
|
73
|
+
log.exception("Failed to get artwork for track URI: %s", track_uri)
|
|
74
|
+
return self.defaultImage
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: Mopiqtt
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Control mopidy music server through MQTT broker
|
|
5
|
+
Home-page: https://github.com/fmarzocca/mopiqtt
|
|
6
|
+
Author: Fabio Marzocca
|
|
7
|
+
Author-email: marzoccafabio@gmail.com
|
|
8
|
+
License: Apache License, Version 2.0
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
19
|
+
Classifier: Environment :: No Input/Output (Daemon)
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: Mopidy<5,>=3.4
|
|
24
|
+
Requires-Dist: paho-mqtt>=2.0
|
|
25
|
+
Requires-Dist: Pykka>=2.0
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: author-email
|
|
28
|
+
Dynamic: classifier
|
|
29
|
+
Dynamic: description
|
|
30
|
+
Dynamic: description-content-type
|
|
31
|
+
Dynamic: home-page
|
|
32
|
+
Dynamic: license
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
Dynamic: requires-dist
|
|
35
|
+
Dynamic: requires-python
|
|
36
|
+
Dynamic: summary
|
|
37
|
+
|
|
38
|
+
Based on [mopidy-mqtt](https://github.com/odiroot/mopidy-mqtt)
|
|
39
|
+
|
|
40
|
+
# Mopiqtt
|
|
41
|
+
[](https://github.com/fmarzocca/Mopiqtt/actions/workflows/ci.yml)
|
|
42
|
+
|
|
43
|
+
MQTT interface for Mopidy music server. Allows easy integration with Node Red or any MQTT client.
|
|
44
|
+
This package is mainly useful to Node Red users, who can embed in their flows a full control over Mopidy by simple mqtt-in or mqtt-out nodes. See [Node Red examples](https://github.com/fmarzocca/Mopiqtt/tree/Development/NodeRed%20examples). Of course, it can be used by any other MQTT client too.
|
|
45
|
+
|
|
46
|
+
# Requirements
|
|
47
|
+
|
|
48
|
+
* Python 3.13 or later
|
|
49
|
+
* Mopidy 3.4.x or Mopidy 4.0.x
|
|
50
|
+
* An MQTT broker
|
|
51
|
+
|
|
52
|
+
Mopidy 4 is recommended for new installations. Mopidy 3.4 compatibility is
|
|
53
|
+
maintained throughout the Mopiqtt 2.x series.
|
|
54
|
+
|
|
55
|
+
# Installation
|
|
56
|
+
|
|
57
|
+
Using pip:
|
|
58
|
+
```
|
|
59
|
+
[sudo] python3 -m pip install Mopiqtt
|
|
60
|
+
```
|
|
61
|
+
(If you are running Mopidy as a service, use `sudo`)
|
|
62
|
+
|
|
63
|
+
# Configuration
|
|
64
|
+
|
|
65
|
+
Add the following section to your mopidy's configuration file: `/etc/mopidy/mopidy.conf`
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[mopiqtt]
|
|
70
|
+
host = <mqtt broker address>
|
|
71
|
+
port = 1883
|
|
72
|
+
topic = mopidy
|
|
73
|
+
username =
|
|
74
|
+
password =
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
*Note*: Remember to also supply `username` and `password` options if your
|
|
78
|
+
MQTT broker requires authentication. If not, just leave blank the two values.
|
|
79
|
+
|
|
80
|
+
*Note*: Restart Mopidy with `sudo service mopidy restart`
|
|
81
|
+
|
|
82
|
+
To check Mopidy log run `sudo tail -f /var/log/mopidy/mopidy.log`
|
|
83
|
+
|
|
84
|
+
### Mopidy 3 compatibility
|
|
85
|
+
|
|
86
|
+
Mopidy 3 imports `pkg_resources` at runtime. If you use Mopidy 3, keep
|
|
87
|
+
`setuptools` below version 82, since version 82 removed `pkg_resources`.
|
|
88
|
+
Mopidy 4 does not require this constraint.
|
|
89
|
+
|
|
90
|
+
Mopidy 3 compatibility is maintained in the 2.x series and may be removed in a
|
|
91
|
+
future major release. New installations should use Mopidy 4.
|
|
92
|
+
|
|
93
|
+
# Features
|
|
94
|
+
|
|
95
|
+
* Sends information about Mopidy state on any change
|
|
96
|
+
- Playback status
|
|
97
|
+
- Volume
|
|
98
|
+
- Track description
|
|
99
|
+
- Playlists list
|
|
100
|
+
- Artwork
|
|
101
|
+
- Track index
|
|
102
|
+
* Reacts to control commands
|
|
103
|
+
- Playback control
|
|
104
|
+
- Tracklist control
|
|
105
|
+
- Volume control
|
|
106
|
+
- Load & play a playlist (straight or shuffle)
|
|
107
|
+
- Request playlists list
|
|
108
|
+
- Refresh playlists
|
|
109
|
+
- Get tracklist
|
|
110
|
+
- Search for artist/album/track name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# MQTT protocol
|
|
114
|
+
|
|
115
|
+
## Topics
|
|
116
|
+
|
|
117
|
+
Default top level topic: `mopidy`.
|
|
118
|
+
|
|
119
|
+
Control topic: `mopidy/cmnd`.
|
|
120
|
+
|
|
121
|
+
Information topic `mopidy/stat`.
|
|
122
|
+
|
|
123
|
+
## Messages to subscribe to (mopidy/stat/`<msg>`)
|
|
124
|
+
|
|
125
|
+
| | Subtopic | Values |
|
|
126
|
+
|:-------------:|:---------:|:-----------------------------------------:|
|
|
127
|
+
| Playback State| `/plstate` | `paused` / `stopped` / `playing` |
|
|
128
|
+
| Volume | `/vol` | `<level(int)>` |
|
|
129
|
+
| Current track | `/trk` | `<title(str)>;<artist(str)>;<album(str)>` or ` ` |
|
|
130
|
+
| List of playlists | `/plists` | `<array of playlists name:uri>` |
|
|
131
|
+
| Track Artwork (*)| `/artw` | `<url of image to download>` |
|
|
132
|
+
| Track uri (*)| `/trk_uri` | `<track uri (string)>` |
|
|
133
|
+
| Playing track index (*)| `/trk-index` | ` {current: x, last: y}` |
|
|
134
|
+
| Playlists have been refreshed | `/refreshed` | ` ` |
|
|
135
|
+
| List of tracks in the queue(**) | `/trklist` | `<array of tracks name:uri>` |
|
|
136
|
+
| List of URI schemes Mopidy can handle in search(***) | `/uri_schemes` | `<array of schemes (str)>` |
|
|
137
|
+
| Search results | `/search_results` | `<array of objects {name: .. , uri:...}>` |
|
|
138
|
+
|
|
139
|
+
`(*)` Published after any track started playback. Local artwork is NOT supported
|
|
140
|
+
`(**)` Published after any tracklist change
|
|
141
|
+
`(***)`Published after a request `queryschemes`
|
|
142
|
+
|
|
143
|
+
## Messages to publish to (mopidy/cmnd/`<msg>`)
|
|
144
|
+
|
|
145
|
+
| | Subtopic | Parameters |
|
|
146
|
+
|:----------------:|:--------:|:-----------------------------------------------------------------:|
|
|
147
|
+
| Playback control | `/plb` | `play` / `stop` / `pause` / `resume` / `toggle` / `prev` / `next` |
|
|
148
|
+
| Volume control | `/vol` | `=<int>` or `-<int>` or `+<int>` |
|
|
149
|
+
| Clear queue | `/clr` | ` ` |
|
|
150
|
+
| Add to queue | `/add` | `<uri(str)>` |
|
|
151
|
+
| Load and play playlist (straight) | `/pload` | `<uri(str)>` |
|
|
152
|
+
| Load and play playlist (shuffle) | `/ploadshfl` | `<uri(str)>` |
|
|
153
|
+
| Request list of playlists| `/plist` | ` ` |
|
|
154
|
+
| Load and play a radio stream (or a single track) | `/pstream`| `<uri(str)>` |
|
|
155
|
+
| Refresh one or all playlists(*)| `/plrefresh` | `<uri_scheme>` or `None` |
|
|
156
|
+
| Change current playing track(**)| `/chgtrk` | `<uri(str)>` |
|
|
157
|
+
| Query URI schemes Mopidy can handle in search | `/queryschemes` | ` ` |
|
|
158
|
+
| Search for any string (artist, track, album) | `/search` | `<JSON {"search": [list of strings], "uri_schemes": [list of schemes]}>` |
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
`(*)` If `uri_scheme` is None, all backends are asked to refresh. If `uri_scheme` is an URI scheme handled by a backend, only that backend is asked to refresh.
|
|
162
|
+
`(**)` Note that the track must already be in the tracklist.
|
|
163
|
+
|
|
164
|
+
Playlist and stream load commands validate every URI before changing the queue. If
|
|
165
|
+
loading fails after the replacement starts, Mopiqtt attempts to restore the previous
|
|
166
|
+
queue using Mopidy's public tracklist operations. This rollback is best-effort and
|
|
167
|
+
only covers queue contents; it does not restore the current track, TLID, playback
|
|
168
|
+
position, or playback state.
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Contribute
|
|
172
|
+
|
|
173
|
+
You can contribute to Mopiqtt by:
|
|
174
|
+
|
|
175
|
+
[](https://www.paypal.com/donate/?hosted_button_id=NQHVVDCNK3UDL)
|
|
176
|
+
|
|
177
|
+
# Credits
|
|
178
|
+
- Current maintainer: [fmarzocca](https://github.com/fmarzocca)
|
|
179
|
+
|
|
180
|
+
Based on previous works of:
|
|
181
|
+
- [odiroot](https://github.com/odiroot)
|
|
182
|
+
- [magcode](https://github.com/magcode>)
|
|
183
|
+
|
|
184
|
+
# Project resources
|
|
185
|
+
[](https://pepy.tech/project/mopiqtt)
|
|
186
|
+
|
|
187
|
+
- [Source code](<https://github.com/fmarzocca/mopiqtt>)
|
|
188
|
+
- [Issue tracker](<https://github.com/fmarzocca/mopiqtt/issues>)
|
|
189
|
+
- [Changelog](<https://github.com/fmarzocca/mopiqtt/blob/main/CHANGELOG.md>)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mopiqtt/__init__.py,sha256=1nU0N2oz04WA6BJa_-iIUC8t84MiT9PFRDILEsJKZ9I,963
|
|
2
|
+
mopiqtt/ext.conf,sha256=sX7ejcS6-Whl5RSpoRprEZstRQC7Q5vcPExUfFSo87c,98
|
|
3
|
+
mopiqtt/frontend.py,sha256=0mPcDB4gIJ6SO0GGaeMmovSXoEp_OWE7mSo1jRzpjaI,14002
|
|
4
|
+
mopiqtt/mqtt.py,sha256=nhx_BedXpez6p70bgQrXWWrillOHL_MIAKSO-iCllGQ,3671
|
|
5
|
+
mopiqtt/utils.py,sha256=I4VDMKH_ddmrrG3aR-0Bl8cYapnj0aibBrWqv_TOV6o,1880
|
|
6
|
+
mopiqtt-2.0.0.dist-info/licenses/LICENSE,sha256=UOZ1F5fFDe3XXvG4oNnkL1-Ecun7zpHzRxjp-XsMeAo,11324
|
|
7
|
+
mopiqtt-2.0.0.dist-info/METADATA,sha256=R2TutOYng9sKD5zU9I0Q-SyrGKcyNW1R4VZa2kfzUpE,7641
|
|
8
|
+
mopiqtt-2.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
mopiqtt-2.0.0.dist-info/entry_points.txt,sha256=-BJFFSMBNL7I7NYM4-TWX3_PGuzfJE3AVR_xAv-E1-Q,41
|
|
10
|
+
mopiqtt-2.0.0.dist-info/top_level.txt,sha256=JOydIglHbWPv_XZnlDM8eKwqPDRaKKqg6A9-OxYVeH8,8
|
|
11
|
+
mopiqtt-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mopiqtt
|