hamcws 0.0.4__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.
- LICENSE +21 -0
- hamcws/__init__.py +1 -0
- hamcws/hamcws.py +329 -0
- hamcws-0.0.4.dist-info/LICENSE +21 -0
- hamcws-0.0.4.dist-info/METADATA +22 -0
- hamcws-0.0.4.dist-info/RECORD +7 -0
- hamcws-0.0.4.dist-info/WHEEL +4 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Matt Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hamcws/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .hamcws import get_mcws_connection, MediaServer, Zone, CannotConnectError, InvalidAuthError
|
hamcws/hamcws.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Implementation of a MCWS inteface."""
|
|
2
|
+
from typing import Tuple, List
|
|
3
|
+
from xml.etree import ElementTree as et
|
|
4
|
+
|
|
5
|
+
from aiohttp import ClientSession, ClientResponseError, BasicAuth
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Zone:
|
|
9
|
+
def __init__(self, content: dict, zone_index: int, active_zone_id: int):
|
|
10
|
+
self.index = zone_index
|
|
11
|
+
self.id = int(content[f"ZoneID{self.index}"])
|
|
12
|
+
self.name = content[f"ZoneName{self.index}"]
|
|
13
|
+
self.guid = content[f"ZoneGUID{self.index}"]
|
|
14
|
+
self.is_dlna = True if (content[f"ZoneDLNA{self.index}"] == "1") else False
|
|
15
|
+
self.active = self.id == active_zone_id
|
|
16
|
+
|
|
17
|
+
def __identifier(self):
|
|
18
|
+
if self.id is not None:
|
|
19
|
+
return self.id
|
|
20
|
+
if self.name is not None:
|
|
21
|
+
return self.name
|
|
22
|
+
if self.index is not None:
|
|
23
|
+
return self.index
|
|
24
|
+
|
|
25
|
+
def __identifier_type(self):
|
|
26
|
+
if self.id is not None:
|
|
27
|
+
return "ID"
|
|
28
|
+
if self.name is not None:
|
|
29
|
+
return "Name"
|
|
30
|
+
if self.index is not None:
|
|
31
|
+
return "Index"
|
|
32
|
+
|
|
33
|
+
def as_query_params(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
'Zone': self.__identifier(),
|
|
36
|
+
'ZoneType': self.__identifier_type()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def __str__(self):
|
|
40
|
+
return self.name
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_mcws_connection(host: str, port: int, username: str | None = None, password: str | None = None,
|
|
44
|
+
ssl: bool = False, timeout: int = 5, session: ClientSession = None):
|
|
45
|
+
"""Returns a MCWS connection."""
|
|
46
|
+
return MediaServerConnection(host, port, username, password, ssl, timeout, session)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MediaServerConnection:
|
|
50
|
+
"""A connection to MCWS."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, host: str, port: int, username: str | None, password: str | None, ssl: bool, timeout: int,
|
|
53
|
+
session: ClientSession | None):
|
|
54
|
+
self._session = session
|
|
55
|
+
self._close_session_on_exit = False
|
|
56
|
+
if self._session is None:
|
|
57
|
+
self._session = ClientSession()
|
|
58
|
+
self._close_session_on_exit = True
|
|
59
|
+
|
|
60
|
+
self._timeout = timeout
|
|
61
|
+
self._auth = BasicAuth(username, password) if username is not None else None
|
|
62
|
+
self._base_url = f"http{'s' if ssl else ''}://{host}:{port}/MCWS/v1"
|
|
63
|
+
|
|
64
|
+
async def get(self, path: str, params: dict | None = None) -> Tuple[bool, dict]:
|
|
65
|
+
async with self._session.get(f'{self._base_url}/{path}', params=params, timeout=self._timeout,
|
|
66
|
+
auth=self._auth) as resp:
|
|
67
|
+
try:
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
return self.__to_dict(await resp.text())
|
|
70
|
+
except ClientResponseError as e:
|
|
71
|
+
if e.status == 401:
|
|
72
|
+
raise InvalidAuthError from e
|
|
73
|
+
else:
|
|
74
|
+
raise CannotConnectError from e
|
|
75
|
+
|
|
76
|
+
async def close(self):
|
|
77
|
+
"""Close the connection if necessary."""
|
|
78
|
+
if self._close_session_on_exit and self._session is not None:
|
|
79
|
+
await self._session.close()
|
|
80
|
+
self._session = None
|
|
81
|
+
self._close_session_on_exit = False
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def __to_dict(content: str, try_int_cast: bool = False) -> Tuple[bool, dict]:
|
|
85
|
+
"""
|
|
86
|
+
Converts the relatively unstructured XML responses from MCWS into a dictionary with a flag to indicate
|
|
87
|
+
if the response was "OK".
|
|
88
|
+
"""
|
|
89
|
+
result: dict = {}
|
|
90
|
+
root = et.fromstring(content)
|
|
91
|
+
for child in root:
|
|
92
|
+
result[child.attrib["Name"]] = child.text
|
|
93
|
+
if try_int_cast:
|
|
94
|
+
for key in result:
|
|
95
|
+
if key:
|
|
96
|
+
try:
|
|
97
|
+
result[key] = int(result[key])
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass # passing is ok: If untranslatable, leave alone
|
|
100
|
+
return root.attrib['Status'] == 'OK', result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MediaServer:
|
|
104
|
+
"""A high level interface for MCWS."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, connection: MediaServerConnection):
|
|
107
|
+
self._conn = connection
|
|
108
|
+
|
|
109
|
+
async def close(self):
|
|
110
|
+
await self._conn.close()
|
|
111
|
+
|
|
112
|
+
async def can_connect(self) -> bool:
|
|
113
|
+
""" returns true if server allows the connection. """
|
|
114
|
+
ok, resp = await self._conn.get('Authenticate')
|
|
115
|
+
return 'Token' in resp.keys()
|
|
116
|
+
|
|
117
|
+
async def get_zones(self) -> List[Zone]:
|
|
118
|
+
""" all known zones """
|
|
119
|
+
ok, resp = await self._conn.get("Playback/Zones")
|
|
120
|
+
num_zones = int(resp["NumberZones"])
|
|
121
|
+
active_zone_id = resp['CurrentZoneID']
|
|
122
|
+
return [Zone(resp, i, active_zone_id) for i in range(num_zones)]
|
|
123
|
+
|
|
124
|
+
async def get_playback_info(self, zone: Zone | None = None, extra_fields: List[str] | None = None) -> dict:
|
|
125
|
+
params = self.__zone_params(zone)
|
|
126
|
+
if extra_fields:
|
|
127
|
+
params['Fields'] = ';'.join(extra_fields)
|
|
128
|
+
ok, resp = await self._conn.get("Playback/Info")
|
|
129
|
+
if 'Playback Info' in extra_fields:
|
|
130
|
+
pass # parse into a nested dict
|
|
131
|
+
return resp
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def __zone_params(zone: Zone | None = None) -> dict:
|
|
135
|
+
return zone.as_query_params() if zone else {}
|
|
136
|
+
|
|
137
|
+
async def volume_up(self, step: float = 0.1, zone: Zone | None = None) -> None:
|
|
138
|
+
"""Send volume up command."""
|
|
139
|
+
await self._conn.get('Playback/Volume', params={'Level': step, 'Relative': 1, **self.__zone_params(zone)})
|
|
140
|
+
|
|
141
|
+
async def volume_down(self, step: float = 0.1, zone: Zone | None = None) -> None:
|
|
142
|
+
"""Send volume down command."""
|
|
143
|
+
await self._conn.get('Playback/Volume', params={'Level': f'{"-" if step > 0 else ""}{step}', 'Relative': 1,
|
|
144
|
+
**self.__zone_params(zone)})
|
|
145
|
+
|
|
146
|
+
async def set_volume_level(self, volume: float, zone: Zone | None = None) -> None:
|
|
147
|
+
"""Set volume level, range 0-100."""
|
|
148
|
+
if volume < 0:
|
|
149
|
+
raise ValueError(f'{volume} not in range 0-100')
|
|
150
|
+
if volume > 100:
|
|
151
|
+
raise ValueError(f'{volume} not in range 0-100')
|
|
152
|
+
volume = volume / 100
|
|
153
|
+
await self._conn.get('Playback/Volume', params={'Level': volume, **self.__zone_params(zone)})
|
|
154
|
+
|
|
155
|
+
async def mute(self, mute: bool, zone: Zone | None = None) -> None:
|
|
156
|
+
"""Send (un)mute command."""
|
|
157
|
+
await self._conn.get('Playback/Mute', params={'Set': '1' if mute else '0', **self.__zone_params(zone)})
|
|
158
|
+
|
|
159
|
+
async def play_pause(self, zone: Zone | None = None) -> None:
|
|
160
|
+
"""Send play/pause command."""
|
|
161
|
+
await self._conn.get('Playback/PlayPause', params=self.__zone_params(zone))
|
|
162
|
+
|
|
163
|
+
async def play(self, zone: Zone | None = None) -> None:
|
|
164
|
+
"""Send play command."""
|
|
165
|
+
await self._conn.get('Playback/Play', params=self.__zone_params(zone))
|
|
166
|
+
|
|
167
|
+
async def pause(self, zone: Zone | None = None) -> None:
|
|
168
|
+
"""Send pause command."""
|
|
169
|
+
await self._conn.get('Playback/Pause', params=self.__zone_params(zone))
|
|
170
|
+
|
|
171
|
+
async def stop(self, zone: Zone | None = None) -> None:
|
|
172
|
+
"""Send stop command."""
|
|
173
|
+
await self._conn.get('Playback/Stop', params=self.__zone_params(zone))
|
|
174
|
+
|
|
175
|
+
async def next_track(self, zone: Zone | None = None) -> None:
|
|
176
|
+
"""Send next track command."""
|
|
177
|
+
await self._conn.get('Playback/Next', params=self.__zone_params(zone))
|
|
178
|
+
|
|
179
|
+
async def previous_track(self, zone: Zone | None = None) -> None:
|
|
180
|
+
"""Send previous track command."""
|
|
181
|
+
# TODO does it go to the start of the current track?
|
|
182
|
+
await self._conn.get('Playback/Previous', params=self.__zone_params(zone))
|
|
183
|
+
|
|
184
|
+
async def media_seek(self, position: float, zone: Zone | None = None) -> None:
|
|
185
|
+
"""seek to a specified position in ms."""
|
|
186
|
+
await self._conn.get('Playback/Position', params={'Position': position, **self.__zone_params(zone)})
|
|
187
|
+
|
|
188
|
+
async def play_item(self, item: str, zone: Zone | None = None) -> None:
|
|
189
|
+
await self._conn.get('Playback/PlayByKey', params={'Key': item, **self.__zone_params(zone)})
|
|
190
|
+
|
|
191
|
+
async def play_playlist(self, playlist_id: str, zone: Zone | None = None) -> None:
|
|
192
|
+
"""Play the given playlist."""
|
|
193
|
+
await self._conn.get('Playback/PlayPlaylist', params={'Playlist': playlist_id, **self.__zone_params(zone)})
|
|
194
|
+
|
|
195
|
+
async def play_file(self, file: str, zone: Zone | None = None) -> None:
|
|
196
|
+
"""Play the given file."""
|
|
197
|
+
await self._conn.get('Playback/PlayByFilename', params={'Filenames': file, **self.__zone_params(zone)})
|
|
198
|
+
|
|
199
|
+
async def set_shuffle(self, shuffle: bool, zone: Zone | None = None) -> None:
|
|
200
|
+
"""Set shuffle mode, for the first player."""
|
|
201
|
+
await self._conn.get('Control/MCC', params={'Command': '10005', 'Parameter': '4' if shuffle else '3',
|
|
202
|
+
**self.__zone_params(zone)})
|
|
203
|
+
|
|
204
|
+
async def _add_item_to_playlist(self, item):
|
|
205
|
+
# await self._server.Playlist.Add(**{"playlistid": 0, "item": item})
|
|
206
|
+
raise NotImplementedError
|
|
207
|
+
|
|
208
|
+
async def add_song_to_playlist(self, song_id):
|
|
209
|
+
"""Add song to default playlist (i.e. playlistid=0)."""
|
|
210
|
+
# await self._add_item_to_playlist({"songid": song_id})
|
|
211
|
+
raise NotImplementedError
|
|
212
|
+
|
|
213
|
+
async def add_album_to_playlist(self, album_id):
|
|
214
|
+
"""Add album to default playlist (i.e. playlistid=0)."""
|
|
215
|
+
# await self._add_item_to_playlist({"albumid": album_id})
|
|
216
|
+
raise NotImplementedError
|
|
217
|
+
|
|
218
|
+
async def add_artist_to_playlist(self, artist_id):
|
|
219
|
+
"""Add album to default playlist (i.e. playlistid=0)."""
|
|
220
|
+
# await self._add_item_to_playlist({"artistid": artist_id})
|
|
221
|
+
raise NotImplementedError
|
|
222
|
+
|
|
223
|
+
async def clear_playlist(self, zone: Zone | None = None):
|
|
224
|
+
"""Clear default playlist."""
|
|
225
|
+
await self._conn.get('Playback/ClearPlaylist', params=self.__zone_params(zone))
|
|
226
|
+
|
|
227
|
+
async def get_artists(self, properties=None):
|
|
228
|
+
"""Get artists list."""
|
|
229
|
+
# return await self._server.AudioLibrary.GetArtists(
|
|
230
|
+
# **_build_query(properties=properties)
|
|
231
|
+
# )
|
|
232
|
+
raise NotImplementedError
|
|
233
|
+
|
|
234
|
+
async def get_artist_details(self, artist_id=None, properties=None):
|
|
235
|
+
"""Get artist details."""
|
|
236
|
+
# return await self._server.AudioLibrary.GetArtistDetails(
|
|
237
|
+
# **_build_query(artistid=artist_id, properties=properties)
|
|
238
|
+
# )
|
|
239
|
+
raise NotImplementedError
|
|
240
|
+
|
|
241
|
+
async def get_albums(self, artist_id=None, album_id=None, properties=None):
|
|
242
|
+
"""Get albums list."""
|
|
243
|
+
# filter = {}
|
|
244
|
+
# if artist_id:
|
|
245
|
+
# filter["artistid"] = artist_id
|
|
246
|
+
# if album_id:
|
|
247
|
+
# filter["albumid"] = album_id
|
|
248
|
+
#
|
|
249
|
+
# return await self._server.AudioLibrary.GetAlbums(
|
|
250
|
+
# **_build_query(filter=filter, properties=properties)
|
|
251
|
+
# )
|
|
252
|
+
raise NotImplementedError
|
|
253
|
+
|
|
254
|
+
async def get_album_details(self, album_id, properties=None):
|
|
255
|
+
"""Get album details."""
|
|
256
|
+
# return await self._server.AudioLibrary.GetAlbumDetails(
|
|
257
|
+
# **_build_query(albumid=album_id, properties=properties)
|
|
258
|
+
# )
|
|
259
|
+
raise NotImplementedError
|
|
260
|
+
|
|
261
|
+
async def get_songs(self, artist_id=None, album_id=None, properties=None):
|
|
262
|
+
"""Get songs list."""
|
|
263
|
+
# filter = {}
|
|
264
|
+
# if artist_id:
|
|
265
|
+
# filter["artistid"] = artist_id
|
|
266
|
+
# if album_id:
|
|
267
|
+
# filter["albumid"] = album_id
|
|
268
|
+
#
|
|
269
|
+
# return await self._server.AudioLibrary.GetSongs(
|
|
270
|
+
# **_build_query(filter=filter, properties=properties)
|
|
271
|
+
# )
|
|
272
|
+
raise NotImplementedError
|
|
273
|
+
|
|
274
|
+
async def get_movies(self, properties=None):
|
|
275
|
+
"""Get movies list."""
|
|
276
|
+
# return await self._server.VideoLibrary.GetMovies(
|
|
277
|
+
# **_build_query(properties=properties)
|
|
278
|
+
# )
|
|
279
|
+
raise NotImplementedError
|
|
280
|
+
|
|
281
|
+
async def get_movie_details(self, movie_id, properties=None):
|
|
282
|
+
"""Get movie details."""
|
|
283
|
+
# return await self._server.VideoLibrary.GetMovieDetails(
|
|
284
|
+
# **_build_query(movieid=movie_id, properties=properties)
|
|
285
|
+
# )
|
|
286
|
+
raise NotImplementedError
|
|
287
|
+
|
|
288
|
+
async def get_seasons(self, tv_show_id, properties=None):
|
|
289
|
+
"""Get seasons list."""
|
|
290
|
+
# return await self._server.VideoLibrary.GetSeasons(
|
|
291
|
+
# **_build_query(tvshowid=tv_show_id, properties=properties)
|
|
292
|
+
# )
|
|
293
|
+
raise NotImplementedError
|
|
294
|
+
|
|
295
|
+
async def get_season_details(self, season_id, properties=None):
|
|
296
|
+
"""Get songs list."""
|
|
297
|
+
# return await self._server.VideoLibrary.GetSeasonDetails(
|
|
298
|
+
# **_build_query(seasonid=season_id, properties=properties)
|
|
299
|
+
# )
|
|
300
|
+
raise NotImplementedError
|
|
301
|
+
|
|
302
|
+
async def get_episodes(self, tv_show_id, season_id, properties=None):
|
|
303
|
+
"""Get episodes list."""
|
|
304
|
+
# return await self._server.VideoLibrary.GetEpisodes(
|
|
305
|
+
# **_build_query(tvshowid=tv_show_id, season=season_id, properties=properties)
|
|
306
|
+
# )
|
|
307
|
+
raise NotImplementedError
|
|
308
|
+
|
|
309
|
+
async def get_tv_shows(self, properties=None):
|
|
310
|
+
"""Get tv shows list."""
|
|
311
|
+
# return await self._server.VideoLibrary.GetTVShows(
|
|
312
|
+
# **_build_query(properties=properties)
|
|
313
|
+
# )
|
|
314
|
+
raise NotImplementedError
|
|
315
|
+
|
|
316
|
+
async def get_tv_show_details(self, tv_show_id=None, properties=None):
|
|
317
|
+
"""Get songs list."""
|
|
318
|
+
# return await self._server.VideoLibrary.GetTVShowDetails(
|
|
319
|
+
# **_build_query(tvshowid=tv_show_id, properties=properties)
|
|
320
|
+
# )
|
|
321
|
+
raise NotImplementedError
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class CannotConnectError(Exception):
|
|
325
|
+
"""Exception to indicate an error in connection."""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class InvalidAuthError(Exception):
|
|
329
|
+
"""Exception to indicate an error in authentication."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Matt Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: hamcws
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: homeassistant friendly wrapper for J River MCWS
|
|
5
|
+
Home-page: http://github.com/3ll3d00d/hamcws
|
|
6
|
+
License: MIT
|
|
7
|
+
Author: 3ll3d00d
|
|
8
|
+
Author-email: mattkhan+hamcws@gmail.com
|
|
9
|
+
Requires-Python: >=3.11,<3.12
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Requires-Dist: aiohttp (>=3.9.1,<4.0.0)
|
|
14
|
+
Project-URL: Repository, http://github.com/3ll3d00d/hamcws
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# hamcws
|
|
18
|
+
|
|
19
|
+
A python based async wrapper around J River [MCWS](https://wiki.jriver.com/index.php/Web_Service_Interface) designed for use
|
|
20
|
+
by a [Home Assistant](https://www.home-assistant.io/) integration.
|
|
21
|
+
|
|
22
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
LICENSE,sha256=PO9x0sYfytq6yQuSu7Cd3k_Wnm-lKAcF8vqI4v4DaQ8,1066
|
|
2
|
+
hamcws/__init__.py,sha256=LEyNkNPQIN2j4osoa4KEPj67bR0kSvK-y9pU8ASAuuE,97
|
|
3
|
+
hamcws/hamcws.py,sha256=LBzFmO1NhqEGfAeJSwDp94l5zzs4a748K5J1A3BVwkg,13438
|
|
4
|
+
hamcws-0.0.4.dist-info/LICENSE,sha256=PO9x0sYfytq6yQuSu7Cd3k_Wnm-lKAcF8vqI4v4DaQ8,1066
|
|
5
|
+
hamcws-0.0.4.dist-info/METADATA,sha256=2vStL5jPmJ01fdWIaH8GH9XUQFlI9jHhHna8xDb4fgw,746
|
|
6
|
+
hamcws-0.0.4.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
7
|
+
hamcws-0.0.4.dist-info/RECORD,,
|