diff --git a/Changes b/Changes new file mode 100644 index 0000000..1f768fd --- /dev/null +++ b/Changes @@ -0,0 +1,268 @@ +Changes in control.py: + + In class ControlInterface: + + In general, the idea with this update is to add (almost) all of the + calls as described in the docs (plus a few available ones not yet + documented). A the same time, the interface to the existing calls + were made more consistent, and a couple of bugs were fixed in the + process. In more detail: + +* Added several missing functions for getting and setting info in the + device: + - check_status + - delete_movies + - delete_playlist + - get_led_config + - get_led_effects_current + - get_led_effects + - get_led_layout + - get_led_movie_config + - get_movies + - get_movies_current (missing in xled-docs) + - get_mqtt_config + - get_playlist + - get_playlist_current + - get_saturation (missing in xled-docs) + - set_led_effects_current (missing in xled-docs) + - set_led_layout + - set_movies_current (missing in xled-docs) + - set_movies_full + - set_movies_new + - set_mqtt_config + - set_playlist + - set_playlist_current (missing in xled-docs) + - set_rt_frame_rest + - set_saturation (missing in xled-docs) + +* Added support for sending rt frames over a socket, which is wastly + quicker than using the rest protocol (thanks goes to rec and + magicus). The first time this is used, an UDP client is created to + take care of the actual socket communication (and is reused in all + subsequent calls). One new function: + - set_rt_frame_socket + +* Made asserting the return values and returning the response + consistent for all calls, affecting these previous functions: + - firmware_version + - get_device_info + - led_reset + - network_scan + - network_scan_results + - set_device_name + - set_led_movie_config + - set_led_movie_full + - set_mode + - set_network_mode_ap + - set_network_mode_station + - set_timer + +* The brightness value can either be set Absolute (type A) or Relative + (type R). (This option is missing in xled-doc.) An ptional parameter + is added in the call. Also, while the call may not crash with + parameters up to 255, it completely ignores anything above 100, so + it seems meaningless to allow. + +* Saturation can be set similar to the brightness. This call is + completely missing in xled-doc. Whether it is very useful is + uncertain, but I added it anyway since it is available. The + corresponding calls are already mentioned above under new + functions. + +* While setting the mode to Access-Point, a new password can be + provided for the device (instead of the default one). An optional + password argument is added to set_network_mode_ap to enable this + functionality. + +* When setting the mode to Station, an ssid and password was + required. However, if these have already been provided in a previos + call, it is possible to switch to Station mode without providing + them. The ssi and password arguments to set_network_mode_station are + made optional to enable this use. + +* Fixed a copy-paste typo in the doc string for + set_network_mode_station (was the same as set_network_mode_ap). + +* Attempt to make firmware_update work for generation II in accordance to + docs, allowing a single stage image (untested though, since I have no + firmware image to try) + +* Fixed a bug in get_timer: In early firmware (eg 2.2.1) there is no "code" + in the response. + +* Added "effect", "rt", and "playlist" mode among allowed modes, to + reflect the various added functionality above. + + + In class HighControlInterface: + + The general vision behind these changes is to provide a high level + interface with these properties: + - Easier and more intuitive to use than the low-level interface + (without the need to understand the underlying technicalities, or + in which order calls have to be made, or the format to use for + movies, etc). + - A uniform interface with respect to different families and + versions - the same high-level calls should work across all + versions (as far as possible - a functionality which is not there + in the device will still not work). + - A small set of generic but powerful primitives on top of which any + conceivable functionality (allowed by the device) should be + possible to build. + The goals may be ambitious, but I think I have got a fair bit + towards it with these suggested changes. In more detail: + +* Provide a number of high level entrypoints along the principle "just + tell the leds what to show next and they will do it": + - show_pattern: shows a static, one-frame movie, given as argument + - show_movie: takes either the movie to show and its fps, or the id + of a previously uploaded movie + - show_rt_frame: shows a one-frame movie in realtime mode + - show_playlist: takes either a movie id to start from in an + existing playlist, or a list of movie ids to install as a playlist + and start showing + - show_effect: takes a preset effect id to show + - show_demo: in recent firmware cycles through preset effects, in + earlier behaves similar to show_effect + The user may of course call set_mode directly, but often it will + not be needed, since the above will make sure the device is in + the required mode. + +* In addition to the above entrypoints, two functions are needed to + handle movie lists / playlists: + - upload_movie: Upload the movie without showing it yet. Returns a + movie id to use in the above calls. + - clear_movies: Removes all uploaded movies (and any corresponding + playlist). + +* Make the HighControlInterface remember characteristics of the + device, such as number of leds, led profile, family, firmware + version, and string configuration, which can be used to make + operation more device independent. + +* Make the interface remember the current mode, and last mode before + turn_off, so that turn_on() can return to the last mode. Also, (in + case last_mode is not known yet) make turn_on avoid "movie" mode if + there is no uploaded movie (otherwise the device refuses to turn + on). + +* To use the above entrypoints, a number of primitives for creating + and manipulating movies and patterns are needed. (A first impulse + was to put them in a separate class. However, to uphold the + abstraction levels, and avoid having the interface expose device + specific parameters and the internal representations used, this set + of primitives are included in the interface. That is, the low-level + representation and operations depends heavily on the led-profile, + the string configuration, the physical layout, and other parameters, + and with this solution no other package needs to worry about those + parameters.) These primitives are added to create and manipulate + "patterns" (ie single frames for movies): + - make_solid_pattern: Produce a single-colored pattern + - make_func_pattern: Produce a pattern by calling a user function + for the color of each led + - make_layout_pattern: Produce a pattern by calling a user function + for the color at each leds physical position + - copy_pattern: Copy a pattern (since modify_pattern below makes + destructive changes, for efficiency) + - modify_pattern: Set the color of a specific led in a pattern + - shift_pattern: Move a pattern along the string of leds + - rotate_pattern: Move a pattern along the string of leds, shifting + in at one end what is shifted out at the other + - permute pattern: Perform a given permutation of led colors in the + pattern + Then the following primitives are provided to combine "patterns" + into "movies" (essentially prioviding three different ways of + achieving the same thing, dependent on preferences): + - make_empty_movie: Make an initially empty movie to start from + - add_to_movie: Add a pattern last to a previously created movie + - make_func_movie: Make a movie by calling a user function for each + frame pattern. + - to_movie: Convert a single pattern or a list of patterns into a + movie object + +* Make the code handle both RGB and RGBW seamlessly (for increased + device independence): by making the above functions all use the + function make_pixel, the same color will be provided on both RGB and + RGBW devices (for now by always setting the white component to zero, + but more advanced solutions can be conceived of). (AWW is less of a + problem, although the colors will not become the same of course.) + +* Added functions save_movie and load_movie to save a created movie to + a file and read it back (possibly on another device). The format is + different from the raw dump of the bytestream though - its in + hexadecimal with one frame per row and with a header providing + parameters, to make it more device independent - but I'm happy to + discuss different options here. + +* Fixed bug in get_formatted_timer: reported the same time for off as + on. + +* Added corresponding method set_formatted_timer. + +* Attempt to make firmware_update work for generation II in accordance to + docs, allowing a single stage image (untested as mentioned above). + Also cleaned up the code a little. (Has this function ever been used? + From where can firmware images of different versions be fetched?) + +* Removed the obsoleted function write_static_movie. I also strongly + suggest removing the function set_static_color, since it can be + trivially expressed as a combination of show_pattern and + make_solid_pattern and it violates the separation between creating + and showing patterns. However, that requires follow up changes, so + for now it is still there. + + +Changes in util.py: + +* Fixed a bug - get_formatted_timer reported the current time plus the + device time, when just current time is wanted. + +* Added new utility function seconds_after_midnight_from_string, needed + in control.py + + +Changes in security.py: + +* Fixed bug causing set_network_mode_station not to work with python3: + Make sure all relevant strings are of type bytes. + + +New file ledcolor.py: + +* Implements a color model based on HSL, so that colors can be + specified as hue, saturation, and lightness, instead of raw r, g, + and b values. Main entrypoint is hsl_color. + +* The color model also takes care of white balancing, and conversion + between led-colors and computer screen colors. The latter needs + gamma correction whereas the leds do not, so the same rgb-values can + not be used. The functions image_to_led_rgb and led_to_image_rgb + converts between them. + +* You can choose from a few color circles (same colors but at + different distances) and two lighess policies. + + +New file pattern.py: + +* Contains a number of utility functions useful when creating + patterns, as well as several examples of functions using them for + creating various patterns and a few movies. + + +New file colorsphere.py: + +* Implements an interactive 3-dimensional color picker from which the + led lights can be rudimentarily controlled (a single color can be + set for now, but the plan is to extend the interface to be able to + select more complex effects). Requires matplotlib and numpy. + + +New file windowmgr.py: + +* Just a helper file for the colorsphere, to help arrange several + interactive widgets in a figure. + + + / Anders Holst + diff --git a/tests/cassettes/TestControlInterface.test_brightness_saturation.yaml b/tests/cassettes/TestControlInterface.test_brightness_saturation.yaml new file mode 100644 index 0000000..71536b5 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_brightness_saturation.yaml @@ -0,0 +1,183 @@ +interactions: +- request: + body: '{"challenge": "kDnin8N58+P0XbuqACz3L14KWm55B3vYYUhUI8JXnPg="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"j5+BZVR2vKw=","authentication_token_expires_in":14400,"challenge-response":"3d9e7bc910f8a0b1403afdd924f24feb21559e1c","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "3d9e7bc910f8a0b1403afdd924f24feb21559e1c"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"value": 50, "type": "A", "mode": "enabled"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['45'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/led/out/brightness + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"value": -20, "type": "R", "mode": "enabled"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['46'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/led/out/brightness + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"value": 100, "type": "A", "mode": "disabled"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['47'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/led/out/brightness + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: GET + uri: http://192.168.10.100/xled/v1/led/out/brightness + response: + body: {string: '{"value":100,"mode":"disabled","code":1000}'} + headers: + Connection: [close] + Content-Length: ['43'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"value": 90, "type": "A", "mode": "enabled"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['45'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/led/out/saturation + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"value": -30, "type": "R", "mode": "enabled"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['46'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/led/out/saturation + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"value": 80, "type": "R", "mode": "disabled"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['46'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: POST + uri: http://192.168.10.100/xled/v1/led/out/saturation + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [j5+BZVR2vKw=] + method: GET + uri: http://192.168.10.100/xled/v1/led/out/saturation + response: + body: {string: '{"value":100,"mode":"disabled","code":1000}'} + headers: + Connection: [close] + Content-Length: ['43'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_effect.yaml b/tests/cassettes/TestControlInterface.test_effect.yaml new file mode 100644 index 0000000..93fe4dd --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_effect.yaml @@ -0,0 +1,124 @@ +interactions: +- request: + body: '{"challenge": "7bxBSNZkm9KBZ0MjtG/AYIyMmHYRsl0B6eni4rOpQ7M="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"LlyI7TdT9+g=","authentication_token_expires_in":14400,"challenge-response":"e2947ac39e115964d8413930f585c619f3eb254d","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "e2947ac39e115964d8413930f585c619f3eb254d"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [LlyI7TdT9+g=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": "effect"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['18'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [LlyI7TdT9+g=] + method: POST + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [LlyI7TdT9+g=] + method: GET + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"mode":"effect","shop_mode":0,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['43'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [LlyI7TdT9+g=] + method: GET + uri: http://192.168.10.100/xled/v1/led/effects + response: + body: {string: '{"code": 1000,"effects_number":15,"unique_ids":["00000000-0000-0000-0000-000000000001","00000000-0000-0000-0000-000000000002","00000000-0000-0000-0000-000000000003","00000000-0000-0000-0000-000000000004","00000000-0000-0000-0000-000000000005","00000000-0000-0000-0000-000000000006","00000000-0000-0000-0000-000000000007","00000000-0000-0000-0000-000000000008","00000000-0000-0000-0000-000000000009","00000000-0000-0000-0000-00000000000A","00000000-0000-0000-0000-00000000000B","00000000-0000-0000-0000-00000000000C","00000000-0000-0000-0000-00000000000D","00000000-0000-0000-0000-00000000000E","00000000-0000-0000-0000-00000000000F"]}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: '{"effect_id": 2}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['16'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [LlyI7TdT9+g=] + method: POST + uri: http://192.168.10.100/xled/v1/led/effects/current + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [LlyI7TdT9+g=] + method: GET + uri: http://192.168.10.100/xled/v1/led/effects/current + response: + body: {string: '{"preset_id":2,"unique_id":"00000000-0000-0000-0000-000000000003","code":1000}'} + headers: + Connection: [close] + Content-Length: ['78'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_layout.yaml b/tests/cassettes/TestControlInterface.test_layout.yaml new file mode 100644 index 0000000..7b2d10a --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_layout.yaml @@ -0,0 +1,197 @@ +interactions: +- request: + body: '{"challenge": "p4+NRVmgjS5weh+4dFyAm33J1EJpMdrmNAR9/yOcPgc="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"7oM16neow5E=","authentication_token_expires_in":14400,"challenge-response":"fe3c2c66d64e831fe12951899279cdcdb8fc7e5a","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "fe3c2c66d64e831fe12951899279cdcdb8fc7e5a"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [7oM16neow5E=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [7oM16neow5E=] + method: GET + uri: http://192.168.10.100/xled/v1/led/layout/full + response: + body: {string: '{"source":"2d","synthesized":false,"uuid":"00000000-0000-0000-0000-000000000000","coordinates":[{"x":-0.902832,"y":0.724670,"z":1.000000},{"x":-0.898071,"y":0.499268,"z":1.000000},{"x":-0.777832,"y":0.690613,"z":1.000000},{"x":-0.763428,"y":0.483459,"z":1.000000},{"x":-0.735840,"y":0.699890,"z":1.000000},{"x":-0.700317,"y":0.484619,"z":1.000000},{"x":-0.663208,"y":0.699890,"z":1.000000},{"x":-0.621338,"y":0.488403,"z":1.000000},{"x":-0.583740,"y":0.699890,"z":1.000000},{"x":-0.544922,"y":0.480957,"z":1.000000},{"x":-0.489014,"y":0.695312,"z":1.000000},{"x":-0.461182,"y":0.488403,"z":1.000000},{"x":-0.417236,"y":0.706421,"z":1.000000},{"x":-0.407471,"y":0.493164,"z":1.000000},{"x":-0.354248,"y":0.692932,"z":1.000000},{"x":-0.328857,"y":0.479858,"z":1.000000},{"x":-0.264282,"y":0.691833,"z":1.000000},{"x":-0.226685,"y":0.473755,"z":1.000000},{"x":-0.155273,"y":0.700317,"z":1.000000},{"x":-0.099487,"y":0.472412,"z":1.000000},{"x":-0.065430,"y":0.689270,"z":1.000000},{"x":-0.003540,"y":0.461609,"z":1.000000},{"x":0.017334,"y":0.692932,"z":1.000000},{"x":0.063232,"y":0.487061,"z":1.000000},{"x":0.137451,"y":0.690613,"z":1.000000},{"x":0.142334,"y":0.480957,"z":1.000000},{"x":0.195557,"y":0.686829,"z":1.000000},{"x":0.221191,"y":0.456604,"z":1.000000},{"x":0.253784,"y":0.684509,"z":1.000000},{"x":0.340210,"y":0.470154,"z":1.000000},{"x":0.340210,"y":0.682068,"z":1.000000},{"x":0.408203,"y":0.488403,"z":1.000000},{"x":0.427368,"y":0.690613,"z":1.000000},{"x":0.496582,"y":0.474854,"z":1.000000},{"x":0.533203,"y":0.700317,"z":1.000000},{"x":0.575684,"y":0.480957,"z":1.000000},{"x":0.574219,"y":0.706421,"z":1.000000},{"x":0.670044,"y":0.474854,"z":1.000000},{"x":0.646973,"y":0.705078,"z":1.000000},{"x":0.770874,"y":0.483459,"z":1.000000},{"x":0.729614,"y":0.736877,"z":1.000000},{"x":0.894897,"y":0.723328,"z":1.000000},{"x":0.700073,"y":0.609985,"z":1.000000},{"x":0.755005,"y":0.825684,"z":1.000000},{"x":0.700073,"y":0.609985,"z":1.000000},{"x":0.633911,"y":0.863525,"z":1.000000},{"x":0.585327,"y":0.611267,"z":1.000000},{"x":0.518433,"y":0.836731,"z":1.000000},{"x":0.527100,"y":0.619812,"z":1.000000},{"x":0.455200,"y":0.825684,"z":1.000000},{"x":0.489502,"y":0.628418,"z":1.000000},{"x":0.392334,"y":0.829468,"z":1.000000},{"x":0.411865,"y":0.616272,"z":1.000000},{"x":0.322998,"y":0.827026,"z":1.000000},{"x":0.315918,"y":0.613708,"z":1.000000},{"x":0.270752,"y":0.804993,"z":1.000000},{"x":0.266113,"y":0.594360,"z":1.000000},{"x":0.226318,"y":0.819946,"z":1.000000},{"x":0.182129,"y":0.612610,"z":1.000000},{"x":0.141113,"y":0.828125,"z":1.000000},{"x":0.070435,"y":0.605225,"z":1.000000},{"x":0.044067,"y":0.818481,"z":1.000000},{"x":-0.015503,"y":0.601562,"z":1.000000},{"x":-0.049438,"y":0.813477,"z":1.000000},{"x":-0.114014,"y":0.593018,"z":1.000000},{"x":-0.169800,"y":0.825684,"z":1.000000},{"x":-0.203857,"y":0.591919,"z":1.000000},{"x":-0.254517,"y":0.811035,"z":1.000000},{"x":-0.310425,"y":0.601562,"z":1.000000},{"x":-0.337036,"y":0.845276,"z":1.000000},{"x":-0.392944,"y":0.611267,"z":1.000000},{"x":-0.412598,"y":0.840271,"z":1.000000},{"x":-0.441528,"y":0.611267,"z":1.000000},{"x":-0.498779,"y":0.814819,"z":1.000000},{"x":-0.538208,"y":0.609985,"z":1.000000},{"x":-0.575195,"y":0.818481,"z":1.000000},{"x":-0.611450,"y":0.617371,"z":1.000000},{"x":-0.669922,"y":0.795227,"z":1.000000},{"x":-0.666260,"y":0.602905,"z":1.000000},{"x":-0.704468,"y":0.799988,"z":1.000000},{"x":-0.742798,"y":0.577209,"z":1.000000},{"x":-0.740112,"y":0.808716,"z":1.000000},{"x":-0.878540,"y":0.624817,"z":1.000000},{"x":-0.668701,"y":0.752563,"z":1.000000},{"x":-0.895630,"y":0.802673,"z":1.000000},{"x":-0.712158,"y":0.660034,"z":1.000000},{"x":-0.805664,"y":0.912292,"z":1.000000},{"x":-0.734009,"y":0.721069,"z":1.000000},{"x":-0.669922,"y":0.994995,"z":1.000000},{"x":-0.603149,"y":0.764771,"z":1.000000},{"x":-0.750000,"y":0.914734,"z":1.000000},{"x":-0.552124,"y":0.937744,"z":1.000000},{"x":-0.562866,"y":0.753784,"z":1.000000},{"x":-0.491455,"y":0.931641,"z":1.000000},{"x":-0.445068,"y":0.741760,"z":1.000000},{"x":-0.451416,"y":0.986572,"z":1.000000},{"x":-0.358887,"y":0.756226,"z":1.000000},{"x":-0.361328,"y":0.968323,"z":1.000000},{"x":-0.299438,"y":0.761230,"z":1.000000},{"x":-0.195068,"y":0.931641,"z":1.000000},{"x":-0.223022,"y":0.735535,"z":1.000000},{"x":-0.118652,"y":0.941528,"z":1.000000},{"x":-0.082397,"y":0.728271,"z":1.000000},{"x":-0.041138,"y":0.941528,"z":1.000000},{"x":-0.017822,"y":0.733215,"z":1.000000},{"x":0.024414,"y":0.960938,"z":1.000000},{"x":0.038940,"y":0.733215,"z":1.000000},{"x":0.082886,"y":0.936584,"z":1.000000},{"x":0.092529,"y":0.730774,"z":1.000000},{"x":0.167603,"y":0.939087,"z":1.000000},{"x":0.224854,"y":0.728271,"z":1.000000},{"x":0.269775,"y":0.937744,"z":1.000000},{"x":0.312256,"y":0.728271,"z":1.000000},{"x":0.344849,"y":0.932983,"z":1.000000},{"x":0.385132,"y":0.740417,"z":1.000000},{"x":0.416626,"y":0.943848,"z":1.000000},{"x":0.433594,"y":0.737976,"z":1.000000},{"x":0.484619,"y":0.949890,"z":1.000000},{"x":0.549927,"y":0.744080,"z":1.000000},{"x":0.600952,"y":0.982788,"z":1.000000},{"x":0.670044,"y":0.768433,"z":1.000000},{"x":0.739502,"y":0.994995,"z":1.000000},{"x":0.781738,"y":0.781921,"z":1.000000},{"x":0.857300,"y":1.000000,"z":1.000000},{"x":0.843750,"y":0.762329,"z":1.000000},{"x":-0.901733,"y":0.036438,"z":1.000000},{"x":-0.929688,"y":0.297058,"z":1.000000},{"x":-0.848145,"y":0.085083,"z":1.000000},{"x":-0.810425,"y":0.295959,"z":1.000000},{"x":-0.805664,"y":0.088745,"z":1.000000},{"x":-0.719482,"y":0.302063,"z":1.000000},{"x":-0.674561,"y":0.062073,"z":1.000000},{"x":-0.633301,"y":0.292297,"z":1.000000},{"x":-0.607788,"y":0.066833,"z":1.000000},{"x":-0.549561,"y":0.295959,"z":1.000000},{"x":-0.526733,"y":0.065735,"z":1.000000},{"x":-0.440552,"y":0.295959,"z":1.000000},{"x":-0.414795,"y":0.066833,"z":1.000000},{"x":-0.381958,"y":0.341003,"z":1.000000},{"x":-0.347046,"y":0.074280,"z":1.000000},{"x":-0.306763,"y":0.302063,"z":1.000000},{"x":-0.277832,"y":0.093750,"z":1.000000},{"x":-0.242676,"y":0.281250,"z":1.000000},{"x":-0.198730,"y":0.059631,"z":1.000000},{"x":-0.116455,"y":0.288635,"z":1.000000},{"x":-0.121338,"y":0.075500,"z":1.000000},{"x":0.002808,"y":0.277710,"z":1.000000},{"x":0.030640,"y":0.033936,"z":1.000000},{"x":0.094727,"y":0.280090,"z":1.000000},{"x":0.177246,"y":0.057190,"z":1.000000},{"x":0.171265,"y":0.281250,"z":1.000000},{"x":0.236816,"y":0.000000,"z":1.000000},{"x":0.280762,"y":0.280090,"z":1.000000},{"x":0.314697,"y":0.053589,"z":1.000000},{"x":0.359619,"y":0.256897,"z":1.000000},{"x":0.433594,"y":0.021790,"z":1.000000},{"x":0.416626,"y":0.275146,"z":1.000000},{"x":0.507446,"y":0.037598,"z":1.000000},{"x":0.549927,"y":0.274048,"z":1.000000},{"x":0.604492,"y":0.018188,"z":1.000000},{"x":0.627808,"y":0.269043,"z":1.000000},{"x":0.701660,"y":0.009583,"z":1.000000},{"x":0.711548,"y":0.258057,"z":1.000000},{"x":0.818359,"y":0.004822,"z":1.000000},{"x":0.762573,"y":0.247253,"z":1.000000},{"x":0.997803,"y":0.153442,"z":1.000000},{"x":0.762573,"y":0.197144,"z":1.000000},{"x":0.951660,"y":0.333557,"z":1.000000},{"x":0.784424,"y":0.148438,"z":1.000000},{"x":0.836670,"y":0.395813,"z":1.000000},{"x":0.739502,"y":0.197144,"z":1.000000},{"x":0.745605,"y":0.412903,"z":1.000000},{"x":0.710449,"y":0.188660,"z":1.000000},{"x":0.616943,"y":0.390808,"z":1.000000},{"x":0.588989,"y":0.164307,"z":1.000000},{"x":0.556030,"y":0.387207,"z":1.000000},{"x":0.494141,"y":0.165649,"z":1.000000},{"x":0.478516,"y":0.399353,"z":1.000000},{"x":0.410400,"y":0.163147,"z":1.000000},{"x":0.371460,"y":0.399353,"z":1.000000},{"x":0.355713,"y":0.155884,"z":1.000000},{"x":0.288940,"y":0.392151,"z":1.000000},{"x":0.225830,"y":0.164307,"z":1.000000},{"x":0.197021,"y":0.386108,"z":1.000000},{"x":0.119019,"y":0.144897,"z":1.000000},{"x":0.119019,"y":0.410461,"z":1.000000},{"x":0.019775,"y":0.160645,"z":1.000000},{"x":0.024414,"y":0.400696,"z":1.000000},{"x":-0.090698,"y":0.182556,"z":1.000000},{"x":-0.065430,"y":0.416565,"z":1.000000},{"x":-0.139282,"y":0.199646,"z":1.000000},{"x":-0.195068,"y":0.418945,"z":1.000000},{"x":-0.282471,"y":0.200806,"z":1.000000},{"x":-0.287598,"y":0.445618,"z":1.000000},{"x":-0.348022,"y":0.215454,"z":1.000000},{"x":-0.349487,"y":0.442017,"z":1.000000},{"x":-0.408936,"y":0.223938,"z":1.000000},{"x":-0.427124,"y":0.456604,"z":1.000000},{"x":-0.476685,"y":0.200806,"z":1.000000},{"x":-0.510620,"y":0.448059,"z":1.000000},{"x":-0.560425,"y":0.238708,"z":1.000000},{"x":-0.617676,"y":0.445618,"z":1.000000},{"x":-0.639282,"y":0.204590,"z":1.000000},{"x":-0.717285,"y":0.426208,"z":1.000000},{"x":-0.734009,"y":0.194702,"z":1.000000},{"x":-0.780396,"y":0.433594,"z":1.000000},{"x":-0.842285,"y":0.193542,"z":1.000000},{"x":-0.839844,"y":0.451843,"z":1.000000},{"x":-0.959717,"y":0.223938,"z":1.000000},{"x":-0.767944,"y":0.400696,"z":1.000000},{"x":-1.000000,"y":0.467712,"z":1.000000},{"x":-0.828979,"y":0.361694,"z":1.000000},{"x":-0.804688,"y":0.591919,"z":1.000000},{"x":-0.773071,"y":0.351868,"z":1.000000},{"x":-0.690063,"y":0.574890,"z":1.000000},{"x":-0.722046,"y":0.344604,"z":1.000000},{"x":-0.670044,"y":0.574890,"z":1.000000},{"x":-0.649292,"y":0.359314,"z":1.000000},{"x":-0.579956,"y":0.576111,"z":1.000000},{"x":-0.590820,"y":0.365356,"z":1.000000},{"x":-0.492676,"y":0.583313,"z":1.000000},{"x":-0.524292,"y":0.350647,"z":1.000000},{"x":-0.425903,"y":0.562561,"z":1.000000},{"x":-0.445068,"y":0.358093,"z":1.000000},{"x":-0.347046,"y":0.549316,"z":1.000000},{"x":-0.323730,"y":0.343445,"z":1.000000},{"x":-0.258179,"y":0.547974,"z":1.000000},{"x":-0.196533,"y":0.278748,"z":1.000000},{"x":-0.186890,"y":0.559021,"z":1.000000},{"x":-0.144409,"y":0.328857,"z":1.000000},{"x":-0.055664,"y":0.551758,"z":1.000000},{"x":-0.053101,"y":0.332458,"z":1.000000},{"x":0.023438,"y":0.541870,"z":1.000000},{"x":0.090210,"y":0.343445,"z":1.000000},{"x":0.107178,"y":0.555420,"z":1.000000},{"x":0.149414,"y":0.343445,"z":1.000000},{"x":0.228516,"y":0.538208,"z":1.000000},{"x":0.216187,"y":0.333557,"z":1.000000},{"x":0.291626,"y":0.540771,"z":1.000000},{"x":0.335327,"y":0.326355,"z":1.000000},{"x":0.371460,"y":0.546875,"z":1.000000},{"x":0.426392,"y":0.329956,"z":1.000000},{"x":0.472290,"y":0.544312,"z":1.000000},{"x":0.581787,"y":0.323853,"z":1.000000},{"x":0.636108,"y":0.562561,"z":1.000000},{"x":0.645874,"y":0.341003,"z":1.000000},{"x":0.733154,"y":0.549316,"z":1.000000},{"x":0.790527,"y":0.329956,"z":1.000000},{"x":0.863281,"y":0.560120,"z":1.000000},{"x":0.880371,"y":0.289856,"z":1.000000}],"code":1000}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: '{"source": "2d", "coordinates": [{"x": -0.902832, "y": 0.72467, "z": 1.0}, + {"x": -0.898071, "y": 0.499268, "z": 1.0}, {"x": -0.777832, "y": 0.690613, "z": + 1.0}, {"x": -0.763428, "y": 0.483459, "z": 1.0}, {"x": -0.73584, "y": 0.69989, + "z": 1.0}, {"x": -0.700317, "y": 0.484619, "z": 1.0}, {"x": -0.663208, "y": + 0.69989, "z": 1.0}, {"x": -0.621338, "y": 0.488403, "z": 1.0}, {"x": -0.58374, + "y": 0.69989, "z": 1.0}, {"x": -0.544922, "y": 0.480957, "z": 1.0}, {"x": -0.489014, + "y": 0.695312, "z": 1.0}, {"x": -0.461182, "y": 0.488403, "z": 1.0}, {"x": -0.417236, + "y": 0.706421, "z": 1.0}, {"x": -0.407471, "y": 0.493164, "z": 1.0}, {"x": -0.354248, + "y": 0.692932, "z": 1.0}, {"x": -0.328857, "y": 0.479858, "z": 1.0}, {"x": -0.264282, + "y": 0.691833, "z": 1.0}, {"x": -0.226685, "y": 0.473755, "z": 1.0}, {"x": -0.155273, + "y": 0.700317, "z": 1.0}, {"x": -0.099487, "y": 0.472412, "z": 1.0}, {"x": -0.06543, + "y": 0.68927, "z": 1.0}, {"x": -0.00354, "y": 0.461609, "z": 1.0}, {"x": 0.017334, + "y": 0.692932, "z": 1.0}, {"x": 0.063232, "y": 0.487061, "z": 1.0}, {"x": 0.137451, + "y": 0.690613, "z": 1.0}, {"x": 0.142334, "y": 0.480957, "z": 1.0}, {"x": 0.195557, + "y": 0.686829, "z": 1.0}, {"x": 0.221191, "y": 0.456604, "z": 1.0}, {"x": 0.253784, + "y": 0.684509, "z": 1.0}, {"x": 0.34021, "y": 0.470154, "z": 1.0}, {"x": 0.34021, + "y": 0.682068, "z": 1.0}, {"x": 0.408203, "y": 0.488403, "z": 1.0}, {"x": 0.427368, + "y": 0.690613, "z": 1.0}, {"x": 0.496582, "y": 0.474854, "z": 1.0}, {"x": 0.533203, + "y": 0.700317, "z": 1.0}, {"x": 0.575684, "y": 0.480957, "z": 1.0}, {"x": 0.574219, + "y": 0.706421, "z": 1.0}, {"x": 0.670044, "y": 0.474854, "z": 1.0}, {"x": 0.646973, + "y": 0.705078, "z": 1.0}, {"x": 0.770874, "y": 0.483459, "z": 1.0}, {"x": 0.729614, + "y": 0.736877, "z": 1.0}, {"x": 0.894897, "y": 0.723328, "z": 1.0}, {"x": 0.700073, + "y": 0.609985, "z": 1.0}, {"x": 0.755005, "y": 0.825684, "z": 1.0}, {"x": 0.700073, + "y": 0.609985, "z": 1.0}, {"x": 0.633911, "y": 0.863525, "z": 1.0}, {"x": 0.585327, + "y": 0.611267, "z": 1.0}, {"x": 0.518433, "y": 0.836731, "z": 1.0}, {"x": 0.5271, + "y": 0.619812, "z": 1.0}, {"x": 0.4552, "y": 0.825684, "z": 1.0}, {"x": 0.489502, + "y": 0.628418, "z": 1.0}, {"x": 0.392334, "y": 0.829468, "z": 1.0}, {"x": 0.411865, + "y": 0.616272, "z": 1.0}, {"x": 0.322998, "y": 0.827026, "z": 1.0}, {"x": 0.315918, + "y": 0.613708, "z": 1.0}, {"x": 0.270752, "y": 0.804993, "z": 1.0}, {"x": 0.266113, + "y": 0.59436, "z": 1.0}, {"x": 0.226318, "y": 0.819946, "z": 1.0}, {"x": 0.182129, + "y": 0.61261, "z": 1.0}, {"x": 0.141113, "y": 0.828125, "z": 1.0}, {"x": 0.070435, + "y": 0.605225, "z": 1.0}, {"x": 0.044067, "y": 0.818481, "z": 1.0}, {"x": -0.015503, + "y": 0.601562, "z": 1.0}, {"x": -0.049438, "y": 0.813477, "z": 1.0}, {"x": -0.114014, + "y": 0.593018, "z": 1.0}, {"x": -0.1698, "y": 0.825684, "z": 1.0}, {"x": -0.203857, + "y": 0.591919, "z": 1.0}, {"x": -0.254517, "y": 0.811035, "z": 1.0}, {"x": -0.310425, + "y": 0.601562, "z": 1.0}, {"x": -0.337036, "y": 0.845276, "z": 1.0}, {"x": -0.392944, + "y": 0.611267, "z": 1.0}, {"x": -0.412598, "y": 0.840271, "z": 1.0}, {"x": -0.441528, + "y": 0.611267, "z": 1.0}, {"x": -0.498779, "y": 0.814819, "z": 1.0}, {"x": -0.538208, + "y": 0.609985, "z": 1.0}, {"x": -0.575195, "y": 0.818481, "z": 1.0}, {"x": -0.61145, + "y": 0.617371, "z": 1.0}, {"x": -0.669922, "y": 0.795227, "z": 1.0}, {"x": -0.66626, + "y": 0.602905, "z": 1.0}, {"x": -0.704468, "y": 0.799988, "z": 1.0}, {"x": -0.742798, + "y": 0.577209, "z": 1.0}, {"x": -0.740112, "y": 0.808716, "z": 1.0}, {"x": -0.87854, + "y": 0.624817, "z": 1.0}, {"x": -0.668701, "y": 0.752563, "z": 1.0}, {"x": -0.89563, + "y": 0.802673, "z": 1.0}, {"x": -0.712158, "y": 0.660034, "z": 1.0}, {"x": -0.805664, + "y": 0.912292, "z": 1.0}, {"x": -0.734009, "y": 0.721069, "z": 1.0}, {"x": -0.669922, + "y": 0.994995, "z": 1.0}, {"x": -0.603149, "y": 0.764771, "z": 1.0}, {"x": -0.75, + "y": 0.914734, "z": 1.0}, {"x": -0.552124, "y": 0.937744, "z": 1.0}, {"x": -0.562866, + "y": 0.753784, "z": 1.0}, {"x": -0.491455, "y": 0.931641, "z": 1.0}, {"x": -0.445068, + "y": 0.74176, "z": 1.0}, {"x": -0.451416, "y": 0.986572, "z": 1.0}, {"x": -0.358887, + "y": 0.756226, "z": 1.0}, {"x": -0.361328, "y": 0.968323, "z": 1.0}, {"x": -0.299438, + "y": 0.76123, "z": 1.0}, {"x": -0.195068, "y": 0.931641, "z": 1.0}, {"x": -0.223022, + "y": 0.735535, "z": 1.0}, {"x": -0.118652, "y": 0.941528, "z": 1.0}, {"x": -0.082397, + "y": 0.728271, "z": 1.0}, {"x": -0.041138, "y": 0.941528, "z": 1.0}, {"x": -0.017822, + "y": 0.733215, "z": 1.0}, {"x": 0.024414, "y": 0.960938, "z": 1.0}, {"x": 0.03894, + "y": 0.733215, "z": 1.0}, {"x": 0.082886, "y": 0.936584, "z": 1.0}, {"x": 0.092529, + "y": 0.730774, "z": 1.0}, {"x": 0.167603, "y": 0.939087, "z": 1.0}, {"x": 0.224854, + "y": 0.728271, "z": 1.0}, {"x": 0.269775, "y": 0.937744, "z": 1.0}, {"x": 0.312256, + "y": 0.728271, "z": 1.0}, {"x": 0.344849, "y": 0.932983, "z": 1.0}, {"x": 0.385132, + "y": 0.740417, "z": 1.0}, {"x": 0.416626, "y": 0.943848, "z": 1.0}, {"x": 0.433594, + "y": 0.737976, "z": 1.0}, {"x": 0.484619, "y": 0.94989, "z": 1.0}, {"x": 0.549927, + "y": 0.74408, "z": 1.0}, {"x": 0.600952, "y": 0.982788, "z": 1.0}, {"x": 0.670044, + "y": 0.768433, "z": 1.0}, {"x": 0.739502, "y": 0.994995, "z": 1.0}, {"x": 0.781738, + "y": 0.781921, "z": 1.0}, {"x": 0.8573, "y": 1.0, "z": 1.0}, {"x": 0.84375, + "y": 0.762329, "z": 1.0}, {"x": -0.901733, "y": 0.036438, "z": 1.0}, {"x": -0.929688, + "y": 0.297058, "z": 1.0}, {"x": -0.848145, "y": 0.085083, "z": 1.0}, {"x": -0.810425, + "y": 0.295959, "z": 1.0}, {"x": -0.805664, "y": 0.088745, "z": 1.0}, {"x": -0.719482, + "y": 0.302063, "z": 1.0}, {"x": -0.674561, "y": 0.062073, "z": 1.0}, {"x": -0.633301, + "y": 0.292297, "z": 1.0}, {"x": -0.607788, "y": 0.066833, "z": 1.0}, {"x": -0.549561, + "y": 0.295959, "z": 1.0}, {"x": -0.526733, "y": 0.065735, "z": 1.0}, {"x": -0.440552, + "y": 0.295959, "z": 1.0}, {"x": -0.414795, "y": 0.066833, "z": 1.0}, {"x": -0.381958, + "y": 0.341003, "z": 1.0}, {"x": -0.347046, "y": 0.07428, "z": 1.0}, {"x": -0.306763, + "y": 0.302063, "z": 1.0}, {"x": -0.277832, "y": 0.09375, "z": 1.0}, {"x": -0.242676, + "y": 0.28125, "z": 1.0}, {"x": -0.19873, "y": 0.059631, "z": 1.0}, {"x": -0.116455, + "y": 0.288635, "z": 1.0}, {"x": -0.121338, "y": 0.0755, "z": 1.0}, {"x": 0.002808, + "y": 0.27771, "z": 1.0}, {"x": 0.03064, "y": 0.033936, "z": 1.0}, {"x": 0.094727, + "y": 0.28009, "z": 1.0}, {"x": 0.177246, "y": 0.05719, "z": 1.0}, {"x": 0.171265, + "y": 0.28125, "z": 1.0}, {"x": 0.236816, "y": 0.0, "z": 1.0}, {"x": 0.280762, + "y": 0.28009, "z": 1.0}, {"x": 0.314697, "y": 0.053589, "z": 1.0}, {"x": 0.359619, + "y": 0.256897, "z": 1.0}, {"x": 0.433594, "y": 0.02179, "z": 1.0}, {"x": 0.416626, + "y": 0.275146, "z": 1.0}, {"x": 0.507446, "y": 0.037598, "z": 1.0}, {"x": 0.549927, + "y": 0.274048, "z": 1.0}, {"x": 0.604492, "y": 0.018188, "z": 1.0}, {"x": 0.627808, + "y": 0.269043, "z": 1.0}, {"x": 0.70166, "y": 0.009583, "z": 1.0}, {"x": 0.711548, + "y": 0.258057, "z": 1.0}, {"x": 0.818359, "y": 0.004822, "z": 1.0}, {"x": 0.762573, + "y": 0.247253, "z": 1.0}, {"x": 0.997803, "y": 0.153442, "z": 1.0}, {"x": 0.762573, + "y": 0.197144, "z": 1.0}, {"x": 0.95166, "y": 0.333557, "z": 1.0}, {"x": 0.784424, + "y": 0.148438, "z": 1.0}, {"x": 0.83667, "y": 0.395813, "z": 1.0}, {"x": 0.739502, + "y": 0.197144, "z": 1.0}, {"x": 0.745605, "y": 0.412903, "z": 1.0}, {"x": 0.710449, + "y": 0.18866, "z": 1.0}, {"x": 0.616943, "y": 0.390808, "z": 1.0}, {"x": 0.588989, + "y": 0.164307, "z": 1.0}, {"x": 0.55603, "y": 0.387207, "z": 1.0}, {"x": 0.494141, + "y": 0.165649, "z": 1.0}, {"x": 0.478516, "y": 0.399353, "z": 1.0}, {"x": 0.4104, + "y": 0.163147, "z": 1.0}, {"x": 0.37146, "y": 0.399353, "z": 1.0}, {"x": 0.355713, + "y": 0.155884, "z": 1.0}, {"x": 0.28894, "y": 0.392151, "z": 1.0}, {"x": 0.22583, + "y": 0.164307, "z": 1.0}, {"x": 0.197021, "y": 0.386108, "z": 1.0}, {"x": 0.119019, + "y": 0.144897, "z": 1.0}, {"x": 0.119019, "y": 0.410461, "z": 1.0}, {"x": 0.019775, + "y": 0.160645, "z": 1.0}, {"x": 0.024414, "y": 0.400696, "z": 1.0}, {"x": -0.090698, + "y": 0.182556, "z": 1.0}, {"x": -0.06543, "y": 0.416565, "z": 1.0}, {"x": -0.139282, + "y": 0.199646, "z": 1.0}, {"x": -0.195068, "y": 0.418945, "z": 1.0}, {"x": -0.282471, + "y": 0.200806, "z": 1.0}, {"x": -0.287598, "y": 0.445618, "z": 1.0}, {"x": -0.348022, + "y": 0.215454, "z": 1.0}, {"x": -0.349487, "y": 0.442017, "z": 1.0}, {"x": -0.408936, + "y": 0.223938, "z": 1.0}, {"x": -0.427124, "y": 0.456604, "z": 1.0}, {"x": -0.476685, + "y": 0.200806, "z": 1.0}, {"x": -0.51062, "y": 0.448059, "z": 1.0}, {"x": -0.560425, + "y": 0.238708, "z": 1.0}, {"x": -0.617676, "y": 0.445618, "z": 1.0}, {"x": -0.639282, + "y": 0.20459, "z": 1.0}, {"x": -0.717285, "y": 0.426208, "z": 1.0}, {"x": -0.734009, + "y": 0.194702, "z": 1.0}, {"x": -0.780396, "y": 0.433594, "z": 1.0}, {"x": -0.842285, + "y": 0.193542, "z": 1.0}, {"x": -0.839844, "y": 0.451843, "z": 1.0}, {"x": -0.959717, + "y": 0.223938, "z": 1.0}, {"x": -0.767944, "y": 0.400696, "z": 1.0}, {"x": -1.0, + "y": 0.467712, "z": 1.0}, {"x": -0.828979, "y": 0.361694, "z": 1.0}, {"x": -0.804688, + "y": 0.591919, "z": 1.0}, {"x": -0.773071, "y": 0.351868, "z": 1.0}, {"x": -0.690063, + "y": 0.57489, "z": 1.0}, {"x": -0.722046, "y": 0.344604, "z": 1.0}, {"x": -0.670044, + "y": 0.57489, "z": 1.0}, {"x": -0.649292, "y": 0.359314, "z": 1.0}, {"x": -0.579956, + "y": 0.576111, "z": 1.0}, {"x": -0.59082, "y": 0.365356, "z": 1.0}, {"x": -0.492676, + "y": 0.583313, "z": 1.0}, {"x": -0.524292, "y": 0.350647, "z": 1.0}, {"x": -0.425903, + "y": 0.562561, "z": 1.0}, {"x": -0.445068, "y": 0.358093, "z": 1.0}, {"x": -0.347046, + "y": 0.549316, "z": 1.0}, {"x": -0.32373, "y": 0.343445, "z": 1.0}, {"x": -0.258179, + "y": 0.547974, "z": 1.0}, {"x": -0.196533, "y": 0.278748, "z": 1.0}, {"x": -0.18689, + "y": 0.559021, "z": 1.0}, {"x": -0.144409, "y": 0.328857, "z": 1.0}, {"x": -0.055664, + "y": 0.551758, "z": 1.0}, {"x": -0.053101, "y": 0.332458, "z": 1.0}, {"x": 0.023438, + "y": 0.54187, "z": 1.0}, {"x": 0.09021, "y": 0.343445, "z": 1.0}, {"x": 0.107178, + "y": 0.55542, "z": 1.0}, {"x": 0.149414, "y": 0.343445, "z": 1.0}, {"x": 0.228516, + "y": 0.538208, "z": 1.0}, {"x": 0.216187, "y": 0.333557, "z": 1.0}, {"x": 0.291626, + "y": 0.540771, "z": 1.0}, {"x": 0.335327, "y": 0.326355, "z": 1.0}, {"x": 0.37146, + "y": 0.546875, "z": 1.0}, {"x": 0.426392, "y": 0.329956, "z": 1.0}, {"x": 0.47229, + "y": 0.544312, "z": 1.0}, {"x": 0.581787, "y": 0.323853, "z": 1.0}, {"x": 0.636108, + "y": 0.562561, "z": 1.0}, {"x": 0.645874, "y": 0.341003, "z": 1.0}, {"x": 0.733154, + "y": 0.549316, "z": 1.0}, {"x": 0.790527, "y": 0.329956, "z": 1.0}, {"x": 0.863281, + "y": 0.56012, "z": 1.0}, {"x": 0.880371, "y": 0.289856, "z": 1.0}], "synthesized": + false}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['10598'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [7oM16neow5E=] + method: POST + uri: http://192.168.10.100/xled/v1/led/layout/full + response: + body: {string: '{"parsed_coordinates":250,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['38'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_misc_info.yaml b/tests/cassettes/TestControlInterface.test_misc_info.yaml new file mode 100644 index 0000000..a2cf135 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_misc_info.yaml @@ -0,0 +1,157 @@ +interactions: +- request: + body: '{"challenge": "tjs3lCjtW5QyTLZWnzSVeM9xqTapi/Lb2qGHBfA37M4="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"wZoGUrp7tJU=","authentication_token_expires_in":14400,"challenge-response":"6b0c7ef829bedbe0d3095c8edf13ea742d4dbcc3","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "6b0c7ef829bedbe0d3095c8edf13ea742d4dbcc3"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [wZoGUrp7tJU=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: GET + uri: http://192.168.10.100/xled/v1/status + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: GET + uri: http://192.168.10.100/xled/v1/led/reset + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: GET + uri: http://192.168.10.100/xled/v1/fw/version + response: + body: {string: '{"version":"2.7.1","code":1000}'} + headers: + Connection: [close] + Content-Length: ['31'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: GET + uri: http://192.168.10.100/xled/v1/gestalt + response: + body: {string: '{"product_name":"Twinkly","hardware_version":"100","bytes_per_led":3,"hw_id":"d64f58","flash_size":64,"led_type":14,"product_code":"TWS250STP-B","fw_family":"F","device_name":"Twinkly_D64F59","uptime":"1058345","mac":"84:0d:8e:d6:4f:59","uuid":"330467ED-C463-4F4E-9FBB-AA0ECE825ADD","max_supported_led":500,"number_of_led":250,"led_profile":"RGB","frame_rate":20,"measured_frame_rate":23.81,"movie_capacity":992,"copyright":"LEDWORKS + 2021","code":1000}'} + headers: + Connection: [close] + Content-Length: ['452'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: GET + uri: http://192.168.10.100/xled/v1/device_name + response: + body: {string: '{"name":"Twinkly_D64F59","code":1000}'} + headers: + Connection: [close] + Content-Length: ['37'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"name": "Twinkly_D64F59"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['26'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: POST + uri: http://192.168.10.100/xled/v1/device_name + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wZoGUrp7tJU=] + method: GET + uri: http://192.168.10.100/xled/v1/led/config + response: + body: {string: '{"strings":[{"first_led_id":0,"length":125},{"first_led_id":125,"length":125}],"code":1000}'} + headers: + Connection: [close] + Content-Length: ['91'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_movie_newif.yaml b/tests/cassettes/TestControlInterface.test_movie_newif.yaml new file mode 100644 index 0000000..7689cb9 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_movie_newif.yaml @@ -0,0 +1,240 @@ +interactions: +- request: + body: '{"challenge": "lNIdUa2C8zfk0vbkMbwEPq3GCte0NmM03JldQKBavns="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"ykS8MjVghrY=","authentication_token_expires_in":14400,"challenge-response":"b553a64c7f8e7aa5af629ca1d1e80273306f02df","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "b553a64c7f8e7aa5af629ca1d1e80273306f02df"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": "off"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['15'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['0'] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: DELETE + uri: http://192.168.10.100/xled/v1/movies + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: GET + uri: http://192.168.10.100/xled/v1/movies + response: + body: {string: '{"movies":[],"available_frames":992,"max_capacity":992,"code":1000}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: '{"name": "green", "unique_id": "00000000-0000-0000-000A-000000000001", + "descriptor_type": "rgb_raw", "leds_per_frame": 250, "frames_number": 1, "fps": + 1}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['153'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/movies/new + response: + body: {string: '{"entry_point":0,"id":0,"handle":15,"unique_id":"00000000-0000-0000-000A-000000000001","code":1000}'} + headers: + Connection: [close] + Content-Length: ['99'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: !!python/object/new:_io.BytesIO + state: !!python/tuple + - !!binary | + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8A + - 0 + - null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['750'] + Content-Type: [application/octet-stream] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/movies/full + response: + body: {string: '{"frames_number":1,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['31'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"name": "lime", "unique_id": "00000000-0000-0000-000A-000000000002", "descriptor_type": + "rgb_raw", "leds_per_frame": 250, "frames_number": 1, "fps": 1}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['152'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/movies/new + response: + body: {string: '{"entry_point":2,"id":1,"handle":16,"unique_id":"00000000-0000-0000-000A-000000000002","code":1000}'} + headers: + Connection: [close] + Content-Length: ['99'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: !!python/object/new:_io.BytesIO + state: !!python/tuple + - !!binary | + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8AZP8A + ZP8AZP8AZP8A + - 0 + - null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['750'] + Content-Type: [application/octet-stream] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/movies/full + response: + body: {string: '{"frames_number":1,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['31'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: GET + uri: http://192.168.10.100/xled/v1/movies/current + response: + body: {string: '{"id":1,"unique_id":"00000000-0000-0000-000A-000000000002","name":"lime","code":1000}'} + headers: + Connection: [close] + Content-Length: ['85'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"id": 0}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['9'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [ykS8MjVghrY=] + method: POST + uri: http://192.168.10.100/xled/v1/movies/current + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_movie_oldif.yaml b/tests/cassettes/TestControlInterface.test_movie_oldif.yaml new file mode 100644 index 0000000..7a4dc97 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_movie_oldif.yaml @@ -0,0 +1,144 @@ +interactions: +- request: + body: '{"challenge": "zzW/C0YISNlqoekphGbglQxlcs/WWxzn6FGlw7baztg="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"V+rfI4OYkTM=","authentication_token_expires_in":14400,"challenge-response":"1d97808f827eeffaef3213665b47fdb03b4b1b32","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "1d97808f827eeffaef3213665b47fdb03b4b1b32"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [V+rfI4OYkTM=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [V+rfI4OYkTM=] + method: GET + uri: http://192.168.10.100/xled/v1/led/movie/config + response: + body: {string: '{"frame_delay":50,"leds_number":250,"loop_type":0,"frames_number":120,"sync":{"mode":"none","compat_mode":0},"code":1000}'} + headers: + Connection: [close] + Content-Length: ['121'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: !!python/object/new:_io.BytesIO + state: !!python/tuple + - !!binary | + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g5v+g + 5v+g5v+g5v+g + - 0 + - null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['750'] + Content-Type: [application/octet-stream] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [V+rfI4OYkTM=] + method: POST + uri: http://192.168.10.100/xled/v1/led/movie/full + response: + body: {string: '{"frames_number":1,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['31'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"frame_delay": 1000, "frames_number": 1, "leds_number": 250}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [V+rfI4OYkTM=] + method: POST + uri: http://192.168.10.100/xled/v1/led/movie/config + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": "movie"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['17'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [V+rfI4OYkTM=] + method: POST + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [V+rfI4OYkTM=] + method: GET + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"mode":"movie","shop_mode":0,"id":0,"unique_id":"A151A936-2836-4BB2-926C-BE592052D60E","name":"","code":1000}'} + headers: + Connection: [close] + Content-Length: ['110'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_mqtt.yaml b/tests/cassettes/TestControlInterface.test_mqtt.yaml new file mode 100644 index 0000000..d2eb57e --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_mqtt.yaml @@ -0,0 +1,72 @@ +interactions: +- request: + body: '{"challenge": "ApB5jjOdi7kOm0DhjkcJHHLtOS9mmHJmWxYPQX5YqyA="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"5KFsIjJsQcg=","authentication_token_expires_in":14400,"challenge-response":"684d5d61c7fc13afdf9012de0c949332570dfda7","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "684d5d61c7fc13afdf9012de0c949332570dfda7"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [5KFsIjJsQcg=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [5KFsIjJsQcg=] + method: GET + uri: http://192.168.10.100/xled/v1/mqtt/config + response: + body: {string: '{"broker_host":"127.0.0.1","broker_port":8883,"client_id":"840D8ED64F59","user":"twinkly32","keep_alive_interval":7200,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['131'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"broker_host": "127.0.0.1", "broker_port": 8883, "client_id": "840D8ED64F59", + "keep_alive_interval": 7200, "user": "twinkly32"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['128'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [5KFsIjJsQcg=] + method: POST + uri: http://192.168.10.100/xled/v1/mqtt/config + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_network_mode_ap.yaml b/tests/cassettes/TestControlInterface.test_network_mode_ap.yaml new file mode 100644 index 0000000..bbb247f --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_network_mode_ap.yaml @@ -0,0 +1,71 @@ +interactions: +- request: + body: '{"challenge": "lFjNrZsOUNs8F7BZaqsIahQMVHJTa5uEom1NuGq+Noc="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"wMI+uPoq8o0=","authentication_token_expires_in":14400,"challenge-response":"eb508c9f1af11d222578b9dff2ec6df08602701a","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "eb508c9f1af11d222578b9dff2ec6df08602701a"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [wMI+uPoq8o0=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wMI+uPoq8o0=] + method: GET + uri: http://192.168.10.100/xled/v1/network/status + response: + body: {string: '{"mode":1,"station":{"ssid":"TN_24GHz_D21CEF","ip":"192.168.10.100","gw":"192.168.10.1","mask":"255.255.255.0","rssi":-60},"ap":{"ssid":"Twinkly_D64F59","channel":1,"ip":"192.168.4.1","enc":4,"ssid_hidden":0,"max_connections":4,"password_changed":1},"code":1000}'} + headers: + Connection: [close] + Content-Length: ['262'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": 2}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['11'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [wMI+uPoq8o0=] + method: POST + uri: http://192.168.10.100/xled/v1/network/status + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_network_mode_station.yaml b/tests/cassettes/TestControlInterface.test_network_mode_station.yaml new file mode 100644 index 0000000..ea3a49f --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_network_mode_station.yaml @@ -0,0 +1,71 @@ +interactions: +- request: + body: '{"challenge": "qMh1/9lPi7RWyEneD6NG7EuNDYqqgQP4Bx/iEjf23zw="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.4.1/xled/v1/login + response: + body: {string: '{"authentication_token":"21R3mG4Ttjc=","authentication_token_expires_in":14400,"challenge-response":"00ca2180a41b309e825233f93d468a7a10a922fc","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "00ca2180a41b309e825233f93d468a7a10a922fc"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [21R3mG4Ttjc=] + method: POST + uri: http://192.168.4.1/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [21R3mG4Ttjc=] + method: GET + uri: http://192.168.4.1/xled/v1/network/status + response: + body: {string: '{"mode":2,"station":{"ssid":"","ip":"0.0.0.0","gw":"0.0.0.0","mask":"0.0.0.0"},"ap":{"ssid":"Twinkly_D64F59","channel":6,"ip":"192.168.4.1","enc":3,"ssid_hidden":0,"max_connections":4,"password_changed":1},"code":1000}'} + headers: + Connection: [close] + Content-Length: ['218'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": 1}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['11'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [21R3mG4Ttjc=] + method: POST + uri: http://192.168.4.1/xled/v1/network/status + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_network_scan.yaml b/tests/cassettes/TestControlInterface.test_network_scan.yaml new file mode 100644 index 0000000..dcaf7a6 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_network_scan.yaml @@ -0,0 +1,69 @@ +interactions: +- request: + body: '{"challenge": "faEr54IjEfX8to6j+8KbZIyH+Qi4BTPBkTc+r9kjdSI="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"twRz1V60otw=","authentication_token_expires_in":14400,"challenge-response":"ed7371b55de52c65042ab5585d7b26fda936a697","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "ed7371b55de52c65042ab5585d7b26fda936a697"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [twRz1V60otw=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [twRz1V60otw=] + method: GET + uri: http://192.168.10.100/xled/v1/network/scan + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [twRz1V60otw=] + method: GET + uri: http://192.168.10.100/xled/v1/network/scan_results + response: + body: {string: '{"code": 1000,"networks":[{"ssid":"TN_24GHz_D21CEF","mac":"30:91:8f:d2:1c:ef","rssi":203,"channel":11,"enc":4}]}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_off.yaml b/tests/cassettes/TestControlInterface.test_off.yaml new file mode 100644 index 0000000..af5c109 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_off.yaml @@ -0,0 +1,71 @@ +interactions: +- request: + body: '{"challenge": "bGw3siXO0Ez4V5wu1SAJhjhzfS/KQZrSBto+CE13dZM="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"2HdRDgkVNQk=","authentication_token_expires_in":14400,"challenge-response":"76c3009c029df51ad5a5d145dd7589cc9bb738a9","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "76c3009c029df51ad5a5d145dd7589cc9bb738a9"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [2HdRDgkVNQk=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": "off"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['15'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [2HdRDgkVNQk=] + method: POST + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [2HdRDgkVNQk=] + method: GET + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"mode":"off","shop_mode":0,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['40'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_playlist.yaml b/tests/cassettes/TestControlInterface.test_playlist.yaml new file mode 100644 index 0000000..053d6ed --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_playlist.yaml @@ -0,0 +1,179 @@ +interactions: +- request: + body: '{"challenge": "ASXYKh6ierdN76fcQuUONIy5fi8bPmhMrfWFXKy3l4k="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"HImNJTdacco=","authentication_token_expires_in":14400,"challenge-response":"562e93778d7d87bba5f56169417be7c92398179f","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "562e93778d7d87bba5f56169417be7c92398179f"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [HImNJTdacco=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: GET + uri: http://192.168.10.100/xled/v1/movies + response: + body: {string: '{"movies":[{"id":0,"name":"green","unique_id":"00000000-0000-0000-000A-000000000001","descriptor_type":"rgb_raw","leds_per_frame":250,"frames_number":1,"fps":1},{"id":1,"name":"lime","unique_id":"00000000-0000-0000-000A-000000000002","descriptor_type":"rgb_raw","leds_per_frame":250,"frames_number":1,"fps":1}],"available_frames":987,"max_capacity":992,"code":1000}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: '{"entries": [{"unique_id": "00000000-0000-0000-000A-000000000001", "duration": + 5}, {"unique_id": "00000000-0000-0000-000A-000000000002", "duration": 5}]}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['153'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: POST + uri: http://192.168.10.100/xled/v1/playlist + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: GET + uri: http://192.168.10.100/xled/v1/playlist + response: + body: {string: '{"unique_id":"00000000-0000-0000-0000-000000000000","name":"","entries":[{"id":0,"handle":15,"name":"green","unique_id":"00000000-0000-0000-000A-000000000001","duration":5},{"id":1,"handle":16,"name":"lime","unique_id":"00000000-0000-0000-000A-000000000002","duration":5}],"code":1000}'} + headers: + Connection: [close] + Content-Type: [application/json] + Transfer-Encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: '{"mode": "playlist"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['20'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: POST + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: GET + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"mode":"playlist","shop_mode":0,"movie":{"id":0,"unique_id":"00000000-0000-0000-000A-000000000001","name":"green","duration":5},"name":"","unique_id":"00000000-0000-0000-0000-000000000000","code":1000}'} + headers: + Connection: [close] + Content-Length: ['202'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: GET + uri: http://192.168.10.100/xled/v1/playlist/current + response: + body: {string: '{"duration":5,"id":0,"unique_id":"00000000-0000-0000-000A-000000000001","name":"green","code":1000}'} + headers: + Connection: [close] + Content-Length: ['99'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"id": 1}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['9'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: POST + uri: http://192.168.10.100/xled/v1/playlist/current + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['0'] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [HImNJTdacco=] + method: DELETE + uri: http://192.168.10.100/xled/v1/playlist + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_realtime.yaml b/tests/cassettes/TestControlInterface.test_realtime.yaml new file mode 100644 index 0000000..9e350bb --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_realtime.yaml @@ -0,0 +1,91 @@ +interactions: +- request: + body: '{"challenge": "Hmcr5ua4jJV8xpRNh9sSA6faUsoYeRRcyNFbPwvmE98="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"MCIGBF1qJlg=","authentication_token_expires_in":14400,"challenge-response":"4c9873e7297758e935ff5be0783b67608567cb22","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "4c9873e7297758e935ff5be0783b67608567cb22"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [MCIGBF1qJlg=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"mode": "rt"}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['14'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [MCIGBF1qJlg=] + method: POST + uri: http://192.168.10.100/xled/v1/led/mode + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: !!python/object/new:_io.BytesIO + state: !!python/tuple + - !!binary | + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8AAP8A + AP8AAP8AAP8A + - 0 + - null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['750'] + Content-Type: [application/octet-stream] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [MCIGBF1qJlg=] + method: POST + uri: http://192.168.10.100/xled/v1/led/rt/frame + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/cassettes/TestControlInterface.test_timer.yaml b/tests/cassettes/TestControlInterface.test_timer.yaml new file mode 100644 index 0000000..03acc27 --- /dev/null +++ b/tests/cassettes/TestControlInterface.test_timer.yaml @@ -0,0 +1,90 @@ +interactions: +- request: + body: '{"challenge": "4C0OELYfRx/cXo4kdbObmp+YKiHuir2fsRq7fF54lIE="}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['61'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + method: POST + uri: http://192.168.10.100/xled/v1/login + response: + body: {string: '{"authentication_token":"Ds9b2QCAlIs=","authentication_token_expires_in":14400,"challenge-response":"cf6cace67b2bff484bcbc697969152c00add1107","code":1000}'} + headers: + Connection: [close] + Content-Length: ['155'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"challenge_response": "cf6cace67b2bff484bcbc697969152c00add1107"}' + headers: + Content-Length: ['66'] + Content-Type: [application/json] + X-Auth-Token: [Ds9b2QCAlIs=] + method: POST + uri: http://192.168.10.100/xled/v1/verify + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"time_on": 3600, "time_off": 7200, "time_now": 68181}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['54'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [Ds9b2QCAlIs=] + method: POST + uri: http://192.168.10.100/xled/v1/timer + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [Ds9b2QCAlIs=] + method: GET + uri: http://192.168.10.100/xled/v1/timer + response: + body: {string: '{"time_now":68181,"time_on":3600,"time_off":7200,"code":1000}'} + headers: + Connection: [close] + Content-Length: ['61'] + Content-Type: [application/json] + status: {code: 200, message: OK} +- request: + body: '{"time_on": -1, "time_off": -1, "time_now": 68181}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['50'] + Content-Type: [application/json] + User-Agent: [python-requests/2.25.1] + X-Auth-Token: [Ds9b2QCAlIs=] + method: POST + uri: http://192.168.10.100/xled/v1/timer + response: + body: {string: '{"code":1000}'} + headers: + Connection: [close] + Content-Length: ['13'] + Content-Type: [application/json] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/test_control_low.py b/tests/test_control_low.py new file mode 100644 index 0000000..1f4d40c --- /dev/null +++ b/tests/test_control_low.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Tests for `xled` package.""" + +from __future__ import absolute_import + +import unittest +import warnings +import io +import struct +import time + +from xled.compat import is_py2 +from xled.control import ControlInterface + + +with warnings.catch_warnings(): + if is_py2: + warnings.simplefilter("ignore", category=DeprecationWarning) + import vcr + +def make_solid_movie(num, r, g, b): + pat = [struct.pack(">BBB", r, g, b)] * num + movie = io.BytesIO() + movie.write(b''.join(pat)) + movie.seek(0) + return movie + +def rotate90(coords): + return [{'x': 1.0-2*ele['y'], 'y': (ele['x']+1.0)/2, 'z': ele['z']} + for ele in coords] + +class TestControlInterface(unittest.TestCase): + """ + Tests for all the methods of ControlInterface in the `xled.control` module, + except the realtime protocols which are tested separately. + """ + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_misc_info.yaml") + def test_misc_info(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.check_status()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.led_reset()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.firmware_version()._data + self.assertEqual(res, {'version': '2.7.1', 'code': 1000}) + #print(res) + + res = ctr.get_device_info()._data + self.assertEqual(res, {'product_name': 'Twinkly', 'hardware_version': '100', 'bytes_per_led': 3, 'hw_id': 'd64f58', 'flash_size': 64, 'led_type': 14, 'product_code': 'TWS250STP-B', 'fw_family': 'F', 'device_name': 'Twinkly_D64F59', 'uptime': '1058345', 'mac': '84:0d:8e:d6:4f:59', 'uuid': '330467ED-C463-4F4E-9FBB-AA0ECE825ADD', 'max_supported_led': 500, 'number_of_led': 250, 'led_profile': 'RGB', 'frame_rate': 20, 'measured_frame_rate': 23.81, 'movie_capacity': 992, 'copyright': 'LEDWORKS 2021', 'code': 1000}) + #print(res) + + res = ctr.get_device_name()._data + self.assertEqual(res, {'name': 'Twinkly_D64F59', 'code': 1000}) + #print(res) + + res = ctr.set_device_name(res['name'])._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_led_config()._data + self.assertEqual(res, {'strings': [{'first_led_id': 0, 'length': 125}, {'first_led_id': 125, 'length': 125}], 'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_timer.yaml") + def test_timer(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.set_timer(3600, 7200)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_timer()._data + self.assertEqual(res, {'time_now': 68181, 'time_on': 3600, 'time_off': 7200, 'code': 1000}) + #print(res) + + res = ctr.set_timer(-1, -1)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + # Available from fw version 2.4.2 + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_mqtt.yaml") + def test_mqtt(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.get_mqtt_config()._data + self.assertEqual(res, {'broker_host': '127.0.0.1', 'broker_port': 8883, 'client_id': '840D8ED64F59', 'user': 'twinkly32', 'keep_alive_interval': 7200, 'code': 1000}) + #print(res) + + res = ctr.set_mqtt_config(res['broker_host'], res['broker_port'], res['client_id'], res['user'], res['keep_alive_interval'])._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_effect.yaml") + def test_effect(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.set_mode('effect')._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_mode()._data + self.assertEqual(res, {'mode': 'effect', 'shop_mode': 0, 'code': 1000}) + #print(res) + + res = ctr.get_led_effects()._data + self.assertEqual(res, {'code': 1000, 'effects_number': 15, 'unique_ids': ['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000006', '00000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000008', '00000000-0000-0000-0000-000000000009', '00000000-0000-0000-0000-00000000000A', '00000000-0000-0000-0000-00000000000B', '00000000-0000-0000-0000-00000000000C', '00000000-0000-0000-0000-00000000000D', '00000000-0000-0000-0000-00000000000E', '00000000-0000-0000-0000-00000000000F']}) + #print(res) + + res = ctr.set_led_effects_current(2)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_led_effects_current()._data + self.assertEqual(res, {'preset_id': 2, 'unique_id': '00000000-0000-0000-0000-000000000003', 'code': 1000}) + #print(res) + + # Available from fw version 2.4.2 + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_brightness_saturation.yaml") + def test_brightness_saturation(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.set_brightness(50)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.set_brightness(-20, relative=True)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.set_brightness(100, enabled=False)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_brightness()._data + self.assertEqual(res, {'value': 100, 'mode': 'disabled', 'code': 1000}) + #print(res) + + res = ctr.set_saturation(90)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.set_saturation(-30, relative=True)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.set_saturation(+80, enabled=False, relative=True)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_saturation()._data + self.assertEqual(res, {'value': 100, 'mode': 'disabled', 'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_movie_oldif.yaml") + def test_movie_oldif(self): + ctr = ControlInterface("192.168.10.100") + numleds = 250 + m_white = make_solid_movie(numleds, 230, 255, 160) + + res = ctr.get_led_movie_config()._data + self.assertEqual(res, {'frame_delay': 50, 'leds_number': 250, 'loop_type': 0, 'frames_number': 120, 'sync': {'mode': 'none', 'compat_mode': 0}, 'code': 1000}) + #print(res) + + res = ctr.set_led_movie_full(m_white)._data + self.assertEqual(res, {'frames_number': 1, 'code': 1000}) + #print(res) + + res = ctr.set_led_movie_config(1000, 1, numleds)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.set_mode('movie')._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_mode()._data + self.assertEqual(res, {'mode': 'movie', 'shop_mode': 0, 'id': 0, 'unique_id': 'A151A936-2836-4BB2-926C-BE592052D60E', 'name': '', 'code': 1000}) + #print(res) + + # Avalable from fw version 2.5.6 + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_movie_newif.yaml") + def test_movie_newif(self): + ctr = ControlInterface("192.168.10.100") + numleds = 250 + m_green = make_solid_movie(numleds, 0, 255, 0) + m_lime = make_solid_movie(numleds, 100, 255, 0) + + ctr.set_mode('off') # Needed during recording, should not be in movie mode + + res = ctr.delete_movies()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_movies()._data + self.assertEqual(res, {'movies': [], 'available_frames': 992, 'max_capacity': 992, 'code': 1000}) + #print(res) + + res = ctr.set_movies_new("green", "00000000-0000-0000-000A-000000000001", "rgb_raw", numleds, 1, 1)._data + self.assertEqual(res, {'entry_point': 0, 'id': 0, 'handle': 15, 'unique_id': '00000000-0000-0000-000A-000000000001', 'code': 1000}) + #print(res) + + res = ctr.set_movies_full(m_green)._data + self.assertEqual(res, {'frames_number': 1, 'code': 1000}) + #print(res) + + res = ctr.set_movies_new("lime", "00000000-0000-0000-000A-000000000002", "rgb_raw", numleds, 1, 1)._data + self.assertEqual(res, {'entry_point': 2, 'id': 1, 'handle': 16, 'unique_id': '00000000-0000-0000-000A-000000000002', 'code': 1000}) + #print(res) + + res = ctr.set_movies_full(m_lime)._data + self.assertEqual(res, {'frames_number': 1, 'code': 1000}) + #print(res) + + res = ctr.get_movies_current()._data + self.assertEqual(res, {'id': 1, 'unique_id': '00000000-0000-0000-000A-000000000002', 'name': 'lime', 'code': 1000}) + #print(res) + + res = ctr.set_movies_current(0)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + # Avalable from fw version 2.5.6 + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_playlist.yaml") + def test_playlist(self): + ctr = ControlInterface("192.168.10.100") + + # Assumes being recorded after test_movies_newif, so there are some movies + lst = ctr.get_movies()['movies'] + pl = [{'unique_id': ele['unique_id'], 'duration': 5} for ele in lst] + + res = ctr.set_playlist(pl)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_playlist()._data + self.assertEqual(res, {'unique_id': '00000000-0000-0000-0000-000000000000', 'name': '', 'entries': [{'id': 0, 'handle': 15, 'name': 'green', 'unique_id': '00000000-0000-0000-000A-000000000001', 'duration': 5}, {'id': 1, 'handle': 16, 'name': 'lime', 'unique_id': '00000000-0000-0000-000A-000000000002', 'duration': 5}], 'code': 1000}) + #print(res) + + res = ctr.set_mode('playlist')._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_mode()._data + self.assertEqual(res, {'mode': 'playlist', 'shop_mode': 0, 'movie': {'id': 0, 'unique_id': '00000000-0000-0000-000A-000000000001', 'name': 'green', 'duration': 5}, 'name': '', 'unique_id': '00000000-0000-0000-0000-000000000000', 'code': 1000}) + #print(res) + + res = ctr.get_playlist_current()._data + self.assertEqual(res, {'duration': 5, 'id': 0, 'unique_id': '00000000-0000-0000-000A-000000000001', 'name': 'green', 'code': 1000}) + #print(res) + + res = ctr.set_playlist_current(1)._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.delete_playlist()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_layout.yaml") + def test_layout(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.get_led_layout()._data + # res['coordinates'] is very long, skip comparison + self.assertEqual(res['source'], '2d') + self.assertEqual(res['synthesized'], False) + self.assertEqual(res['code'], 1000) + #print(res) + + res = ctr.set_led_layout(res['source'], res['coordinates'], res['synthesized'])._data + self.assertEqual(res, {'parsed_coordinates': 250, 'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_network_scan.yaml") + def test_network_scan(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.network_scan()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + #time.sleep(5) # Needed during recording + res = ctr.network_scan_results()._data + self.assertEqual(res, {'code': 1000, 'networks': [{'ssid': 'TN_24GHz_D21CEF', 'mac': '30:91:8f:d2:1c:ef', 'rssi': 203, 'channel': 11, 'enc': 4}]}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_network_mode_ap.yaml") + def test_network_mode_ap(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.get_network_status()._data + self.assertEqual(res, {'mode': 1, 'station': {'ssid': 'TN_24GHz_D21CEF', 'ip': '192.168.10.100', 'gw': '192.168.10.1', 'mask': '255.255.255.0', 'rssi': -60}, 'ap': {'ssid': 'Twinkly_D64F59', 'channel': 1, 'ip': '192.168.4.1', 'enc': 4, 'ssid_hidden': 0, 'max_connections': 4, 'password_changed': 1}, 'code': 1000}) + #print(res) + + res = ctr.set_network_mode_ap()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_network_mode_station.yaml") + def test_network_mode_station(self): + ctr = ControlInterface("192.168.4.1") + + res = ctr.get_network_status()._data + self.assertEqual(res, {'mode': 2, 'station': {'ssid': '', 'ip': '0.0.0.0', 'gw': '0.0.0.0', 'mask': '0.0.0.0'}, 'ap': {'ssid': 'Twinkly_D64F59', 'channel': 6, 'ip': '192.168.4.1', 'enc': 3, 'ssid_hidden': 0, 'max_connections': 4, 'password_changed': 1}, 'code': 1000}) + #print(res) + + res = ctr.set_network_mode_station()._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_off.yaml") + def test_off(self): + ctr = ControlInterface("192.168.10.100") + + res = ctr.set_mode('off')._data + self.assertEqual(res, {'code': 1000}) + #print(res) + + res = ctr.get_mode()._data + self.assertEqual(res, {'mode': 'off', 'shop_mode': 0, 'code': 1000}) + #print(res) + diff --git a/tests/test_control_realtime.py b/tests/test_control_realtime.py new file mode 100644 index 0000000..256e4b1 --- /dev/null +++ b/tests/test_control_realtime.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Tests for `xled` package.""" + +from __future__ import absolute_import + +import unittest +import warnings +import io +import struct + +from xled.compat import is_py2 +from xled.control import ControlInterface + + +with warnings.catch_warnings(): + if is_py2: + warnings.simplefilter("ignore", category=DeprecationWarning) + import vcr + + +def make_solid_movie(num, r, g, b): + pat = [struct.pack(">BBB", r, g, b)] * num + movie = io.BytesIO() + movie.write(b''.join(pat)) + movie.seek(0) + return movie + + +class FakeUDPclient(): + """ + Fake UDP client to replace the real UDP client, to catch the socket traffic. + """ + + def __init__(self): + self.data = False + + def send(self, data): + self.data = data + + def retrieve_data(self): + data = self.data + self.data = False + return data + + +class TestControlInterfaceRealtime(unittest.TestCase): + """ + Tests for the realtime mode of the `xled.control` module. + + This code tests the UDP realtime protocols by replacing the UDPClient of + the ControlInterface with a fake UDP client which instead just records + the transfered data so it can be compared to the expected traffic. + """ + + def redirect_xled_socket_to_fake_client(self, ctr): + self.fakeclient = FakeUDPclient() + ctr._udpclient = self.fakeclient + + @vcr.use_cassette("tests/cassettes/TestControlInterface.test_realtime.yaml") + def test_realtime_protocols(self): + num_leds = 250 + ctr = ControlInterface("192.168.10.100") + ctr.set_mode("rt") + + # Restful realtime protocol + ctr.set_rt_frame_rest(make_solid_movie(num_leds, 0, 255, 0)) + + # Must be here (not in setUp), since it needs ctr + self.redirect_xled_socket_to_fake_client(ctr) + + # Version 1 socket realtime protocol + ctr.set_rt_frame_socket(make_solid_movie(num_leds, 230, 170, 0), 1, min(255, num_leds)) + self.assertEqual(self.fakeclient.retrieve_data(), + b'\x010"\x06\x04]j&X\xfa' + b'\xe6\xaa\x00' * num_leds) + + # Version 2 socket realtime protocol + ctr.set_rt_frame_socket(make_solid_movie(num_leds, 100, 255, 0), 2) + self.assertEqual(self.fakeclient.retrieve_data(), + b'\x020"\x06\x04]j&X\x00' + b'd\xff\x00' * num_leds) + + # Version 3 socket realtime protocol + ctr.set_rt_frame_socket(make_solid_movie(num_leds, 230, 85, 0), 3) + self.assertEqual(self.fakeclient.retrieve_data(), + b'\x030"\x06\x04]j&X\x00\x00\x00' + b'\xe6U\x00' * num_leds) diff --git a/xled/colormeander.py b/xled/colormeander.py new file mode 100644 index 0000000..adc2c4a --- /dev/null +++ b/xled/colormeander.py @@ -0,0 +1,124 @@ +""" +xled.colormeander +~~~~~~~~~~~~~~~~~ + +Author: Anders Holst (anders.holst@ri.se), 2021 + +Performing a random walk through color space + +The idea with this module is to produce a sequence of slowly changing colors, +not just cycling through the spectrum in a deterministic and repetitive way, +but through the entire 3D color space, never repeating exactly the same path. + +To use it, create a ColorMeander object (no mandatory arguments, but optional +ones described below). By default it starts at full white. By calling 'step' +it moves one step in color space. You can then retrieve the current color +coordinates by 'get()' (for rgb coordinates), 'get_hsl()' (for hsl coordinates), +'get_xyz()' (for the raw x,y,z-coordinates if you want to interpret them as a +color in some other way), or 'get_compl()' which gives the rgb coordinates of +the complementary color (opposite hue but same lightness and saturation). + +It can be customized by a number of optional arguments when created: + +'style' can be 'sphere' (the default, moves inside spherical color coordinates), +'cylinder' (to move inside cylindrical coordinates) or 'surface' (just moving on +the surface of the color sphere). + +'speed' is the length to move inside the color space, should be much less than 1 +for best effect, default is 0.01. + +'noise' is the level of the random noise to add to the current direction. Should +also be less than 1 for best effect. Default is 0.15. + +'start' is the starting point in space. Default is (0,0,1) which corresponds +to white. (0,0,-1) means black. (0,0,0) means mid gray. The initial direction +is random. +""" + +from math import asin, atan2, sqrt, pi +import random + +from xled.ledcolor import hsl_color + + +def hyp(*args): + return sqrt(sum(map(lambda x: x * x, args))) + + +class ColorMeander(): + + def __init__(self, style='sphere', speed=0.01, noise=0.15, start=(0.0, 0.0, 1.0)): + self.steplen = speed + self.noiselev = noise + self.xyz = start + self.dir = (random.random() - 0.5, random.random() - 0.5, random.random() - 0.5 - start[2]) + self.style = style + + def normalize(self, vec): + nrm = hyp(*vec) + if nrm == 0.0: + nrm = 1.0 + return tuple(map(lambda v: v / nrm, vec)) + + def xyz_to_hsl(self, x, y, z): + if self.style == 'cylinder': + h = atan2(y, x) / (2 * pi) + 0.5 + s = min(1.0, hyp(x, y)) + l = z + else: + h = atan2(y, x) / (2 * pi) + 0.5 + l = asin(z) * 2.0 / pi + r = sqrt(x * x + y * y) + r0 = sqrt(1 - z * z) + s = min(1.0, r / r0 if r0 > 0.0 else 0.0) + return (h, s, l) + + def xyz_color(self, x, y, z): + return hsl_color(*self.xyz_to_hsl(x, y, z)) + + def step(self): + (nx, ny, nz) = tuple(map(lambda v, d: v + d * self.steplen, self.xyz, self.dir)) + if self.style == 'cylinder': + if abs(nz) > 1.0: + nz = max(-1.0, min(1.0, nz)) + nrm = hyp(nx, ny) + if nrm > 1.0: + nx = nx / nrm + ny = ny / nrm + self.dir = self.normalize((nx - self.xyz[0], ny - self.xyz[1], nz - self.xyz[2])) + ndir = self.normalize(tuple(map(lambda v: v + random.random() * 2 * self.noiselev - self.noiselev, self.dir))) + if abs(nz + ndir[2]) > 1.0: + sgn = 1 if nz + ndir[2] > 0.0 else -1 + delta = sqrt(1.0 - (sgn - nz)**2) + nrm = hyp(ndir[0], ndir[1]) + ndir = (ndir[0] * delta / nrm, ndir[1] * delta / nrm, sgn - nz) + elif self.style == 'surface': + nrm = hyp(nx, ny, nz) + nx = nx / nrm + ny = ny / nrm + nz = nz / nrm + ndir = self.normalize((nx - self.xyz[0], ny - self.xyz[1], nz - self.xyz[2])) + ndir = self.normalize(tuple(map(lambda v: v + random.random() * 2 * self.noiselev - self.noiselev, ndir))) + else: + nrm = hyp(nx, ny, nz) + if nrm > 1.0: + nrm = nrm * nrm # bounce equally much inside + nx = nx / nrm + ny = ny / nrm + nz = nz / nrm + self.dir = self.normalize((nx - self.xyz[0], ny - self.xyz[1], nz - self.xyz[2])) + ndir = self.normalize(tuple(map(lambda v: v + random.random() * 2 * self.noiselev - self.noiselev, self.dir))) + self.xyz = (nx, ny, nz) + self.dir = ndir + + def get(self): + return self.xyz_color(*self.xyz) + + def get_compl(self): + return self.xyz_color(-self.xyz[0], -self.xyz[1], self.xyz[2]) + + def get_xyz(self): + return self.xyz + + def get_hsl(self): + return self.xyz_to_hsl(*self.xyz) diff --git a/xled/colorsphere.py b/xled/colorsphere.py new file mode 100644 index 0000000..a4da450 --- /dev/null +++ b/xled/colorsphere.py @@ -0,0 +1,407 @@ + +""" +xled.colorsphere +~~~~~~~~~~~~~~~~ + +Author: Anders Holst (anders.holst@ri.se), 2021 + +This module implements an interactive 3-dimensional color picker - +to the author's knowledge the first ever 3-dimensional color picker. +It can be connected directly to controlling the led lights by a +suitable callback function. One simple example callback function is +provided. + +The main entrypoint is the class ColorPicker, which takes one callback +function to call whenever a color is clicked, and another callback +function to call when the mouse moves. Both callback functions +takes the hsl-coordinates under the mouse (or False if outside the +sphere) and the click event as arguments. + +There is also an entrypoint launch_colorpicker, which takes a +HighControlInterface object connected to some led lights as argument, +and then shows a window with the color sphere. When the user picks a +color in the sphere, it sets the color of the lights accordingly. + +The color sphere represents the whole color body, where one pole +is black, the other pole is white, and the color circle is around the +equator. If you follow a meridian from the black pole, the color will +gradually increase in strength to its maximum brilliance and then +seamlessly continue to become brighter all the way to white. Less +saturated colors are inside the sphere. The axis through the middle of +the sphere between the poles contains all grays from black to +white. Thus, the hue is represented by the longitude, the lightness by +the latitude, and the saturation by the proportion from the surface to +the center black-white axis of the sphere. You can rotate the sphere +either by dragging the surface, or using the scroll wheel. Shift- +scrolling goes sideways. Control scrolling goes inside the spere. + +The module requires matplotlib and numpy, and a fairly fast computer to +run. +""" + +import matplotlib.pyplot as plt +import matplotlib as mpl +import numpy as np +from math import floor, sqrt, sin, cos, atan2, acos, pi +import random + +from xled.ledcolor import get_color_style, hsl_color +from xled.windowmgr import WindowMgr + + +brightness = [0.25, 0.54, 0.21] +ramp_n = {} +ramp_v = {} +ramp_d = {} +ramp = {} +ramp["3col"] = [[0.0, 0.0, 1.0], [0.0, 0.5, 0.5], [0.0, 1.0, 0.0], [0.5, 0.5, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 0.5], [0.0, 0.0, 1.0]] +ramp["4col"] = [[0.0, 0.0, 1.0], [0.0, 0.5, 0.5], [0.0, 1.0, 0.0], [0.25, 0.75, 0.0], [0.5, 0.5, 0.0], [0.75, 0.25, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 0.5], [0.0, 0.0, 1.0]] +ramp["6col"] = [[0.0, 0.0, 1.0], [0.0, 0.5, 0.5], [0.0, 1.0, 0.0], [0.25, 0.75, 0.0], [0.5, 0.5, 0.0], [0.625, 0.375, 0.0], [0.75, 0.25, 0.0], [0.875, 0.125, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 0.5], [1.0 / 3, 0.0, 2.0 / 3], [1.0 / 6, 0.0, 5.0 / 6], [0.0, 0.0, 1.0]] +ramp["8col"] = [[0.0, 0.0, 1.0], [0.0, 0.5, 0.5], [0.0, 1.0, 0.0], [0.5, 0.5, 0.0], [0.75, 0.25, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 0.5], [0.25, 0.0, 0.75], [0.0, 0.0, 1.0]] +ramp["10col"] = [[0.0, 0.0, 1.0], [0.0, 0.25, 0.75], [0.0, 0.5, 0.5], [0.0, 1.0, 0.0], [0.5, 0.5, 0.0], [4.0 / 6, 2.0 / 6, 0.0], [5.0 / 6, 1.0 / 6, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 0.5], [0.25, 0.0, 0.75], [0.0, 0.0, 1.0]] +for key in ramp: + ramp_n[key] = len(ramp[key]) - 1 + ramp_v[key] = np.array(list(map(lambda x: x + [max(x)], ramp[key]))).transpose() + ramp_d[key] = np.array(list(map(lambda v: np.append(v[1:], v[-1]) - v, ramp_v[key]))) +xxarr = np.array([i for j in range(101) for i in range(101)]) +yyarr = np.array([j for j in range(101) for i in range(101)]) + + +def hsl_color_im(h, s, l): + tmp = h * ramp_n[get_color_style()[0]] + hind = int(floor(tmp)) + hprop = tmp % 1.0 + rgbm = tuple(map(lambda v, d: v[hind] + d[hind] * hprop, + ramp_v[get_color_style()[0]], + ramp_d[get_color_style()[0]])) + rgb = tuple(map(lambda x: x / rgbm[3], rgbm[0:3])) + ll = (l + 1.0) * 0.5 + if get_color_style()[1] == "linear": + if ll < 0.5: + t1 = l + 1.0 + t2 = 0.0 + else: + t1 = 1.0 - l + t2 = l + else: + br = sum(map(lambda c, b: c * b, rgb, brightness)) + p = min(1.0, (1.0 - ll) / (1.0 - br), (1.0 - ll) / (1.0 - brightness[1])) + t1 = ll * p / ((br - 1.0) * p + 1.0) + t2 = max(0.0, ll - t1 * br) + t1 = s * t1 + t2 = s * t2 + ll * (1.0 - s) + return tuple(map(lambda c: pow(max(0.0, min(1.0, c * t1 + t2)), 0.417), rgb)) + + +def rotxmatrix(ang): + sa = sin(ang) + ca = cos(ang) + return np.matrix([[ca, 0, sa], [0, 1, 0], [-sa, 0, ca]]) + + +def rotymatrix(ang): + sa = sin(ang) + ca = cos(ang) + return np.matrix([[1, 0, 0], [0, ca, sa], [0, -sa, ca]]) + + +def origmatrix(): + return np.matrix([[1, 0, 0], [0, 0, -1], [0, 1, 0]]) + + +class ColorSphere(): + + def __init__(self, fig, rect, wdt, hgt, pixpt, callback, useevent=False): + self.callback = callback + self.useevent = useevent + self.mouse_color_callbacks = [] + self.block_draw = False + self.lastbpos = False + self.p1 = False + self.fig = fig + self.rect = rect + self.size = min(wdt * rect[2], hgt * rect[3]) + self.xoff = wdt * (rect[0] + rect[2]/2) - self.size / 2 + self.yoff = hgt * (rect[1] + rect[3]/2) - self.size / 2 + self.pixpt = pixpt + self.diam = 1.0 + cent = (0, 0) + self.ax = fig.add_axes(rect, frame_on=False, xticks=[], yticks=[]) + self.gray1 = hsl_color_im(0.0, 0.0, 0.0) + self.gray2 = hsl_color_im(0.0, 0.0, 0.5) + self.im = self.ax.imshow([[self.gray1]], origin='lower') + diameps = 0.5 * 10 / self.size + self.circ2 = mpl.patches.Ellipse(cent, self.diam, self.diam, + linewidth=0, edgecolor=self.gray2, fill=False) + self.circ1 = mpl.patches.Ellipse(cent, self.diam + diameps, self.diam + diameps, + linewidth=10 * self.pixpt, edgecolor=self.gray1, fill=False) + self.ax.add_artist(self.circ2) + self.ax.add_artist(self.circ1) + self.rad = 1.0 + self.dotsz = self.size / 100.0 + self.eye = origmatrix() + self.draw() + + def resize(self, newwdt, newhgt): + self.size = min(newwdt * self.rect[2], newhgt * self.rect[3]) + self.xoff = newwdt * (self.rect[0] + self.rect[2]/2) - self.size / 2 + self.yoff = newhgt * (self.rect[1] + self.rect[3]/2) - self.size / 2 + diameps = 0.5 * 10 / self.size + self.dotsz = self.size / 100.0 + + def coordinates(self, xx, yy): + x = xx / (self.size * 0.5) - 1.0 + y = yy / (self.size * 0.5) - 1.0 + p2 = x * x + y * y + r2 = self.rad * self.rad + if p2 > r2 + 2.0 * self.rad * self.dotsz / (self.size * 0.5): + return False + z = sqrt(max(0.0, r2 - p2)) + pe = list(map(lambda v: v[0], self.eye * [[x], [y], [z]])) + h = (atan2(pe[0], pe[1]) / (2 * pi)) % 1.0 + if self.rad < 1.0: + q1 = pe[0] * pe[0] + pe[1] * pe[1] + q2 = max(0.0, 1.0 - pe[2] * pe[2]) + s = sqrt(q1 / q2) if q2 > q1 else 1.0 + else: + s = 1.0 + l = 1.0 - 2.0 * acos(max(-1.0, min(1.0, pe[2]))) / pi + return (h, s, l) + + def draw(self, event=None): + if not self.block_draw: + ndiam = self.diam * (0.5 + self.rad / 2.0) + self.circ2.width = ndiam + self.circ2.height = ndiam + self.circ2.set_linewidth((1.0 - ndiam / self.diam) * self.size * self.pixpt) + arr = self.coordinates_color_array(xxarr * self.dotsz, yyarr * self.dotsz) + arr = np.array(arr).reshape((101, 101, 3)) + self.im.set_array(arr) + if event and self.mouse_color_callbacks: + self.block_draw = True + self.color_change_event(event) + self.block_draw = False + if not plt.isinteractive(): + self.fig.canvas.draw() + + def scroll_event(self, event): + changed = False + if event.key == "control": + if event.button == "up": + if self.rad < 1.0: + self.rad = min(1.0, self.rad + 0.01) + changed = True + elif event.button == "down": + if self.rad > 0.01: + self.rad = max(0.01, self.rad - 0.01) + changed = True + elif event.key == "shift": + if event.button == "up": + self.eye = self.eye * rotxmatrix(-5.0 * pi / 180.0) + changed = True + elif event.button == "down": + self.eye = self.eye * rotxmatrix(5.0 * pi / 180.0) + changed = True + else: + if event.button == "up": + self.eye = self.eye * rotymatrix(-5.0 * pi / 180.0) + changed = True + elif event.button == "down": + self.eye = self.eye * rotymatrix(5.0 * pi / 180.0) + changed = True + if changed: + self.draw(event) + + def button_press_event(self, event): + self.lastbpos = (event.x, event.y) + self.starteye = self.eye + x = (event.x - self.xoff) / (self.size * 0.5) - 1.0 + y = (event.y - self.yoff) / (self.size * 0.5) - 1.0 + rr2 = x * x + y * y + r2 = self.rad * self.rad + if rr2 <= r2 + 2.0 * self.rad * self.dotsz / (self.size * 0.5): + self.p1 = np.array([x, y, sqrt(max(0.0, r2 - rr2))]) + else: + self.p1 = False + + def button_release_event(self, event): + if self.lastbpos == (event.x, event.y): + if event.button == 1 and self.callback: + coord = self.coordinates(event.x - self.xoff, event.y - self.yoff) + if coord: + if self.useevent: + self.callback(coord, event) + else: + self.callback(coord) + + def motion_notify_event(self, event): + if self.p1 is not False: + x = (event.x - self.xoff) / (self.size * 0.5) - 1.0 + y = (event.y - self.yoff) / (self.size * 0.5) - 1.0 + p2 = np.array([x, y, sqrt(max(0.0, self.rad * self.rad - x * x - y * y))]) + p1 = self.p1 + q = np.cross(p1, p2) + l = np.vdot(q, q) + if l == 0.0: + self.eye = self.starteye + else: + q = q / sqrt(l) + a = atan2(-q[1], q[2]) + b = atan2(sqrt(q[1] * q[1] + q[2] * q[2]), q[0]) + v = atan2(np.vdot(np.cross(q, p1), p2), np.vdot(p1, p2)) + tt = rotxmatrix(b) * rotymatrix(a) + self.eye = self.starteye * tt.transpose() * rotymatrix(v) * tt + self.draw() + + def color_change_event(self, event): + hsl = self.coordinates(event.x - self.xoff, event.y - self.yoff) + for func in self.mouse_color_callbacks: + func(hsl, event) + if not self.block_draw: + if not plt.isinteractive(): + self.fig.canvas.draw() + + def key_press_event(self, event): + pass + + def coordinates_color_array(self, xxarr, yyarr): + x = xxarr / (self.size * 0.5) - 1.0 + y = yyarr / (self.size * 0.5) - 1.0 + p2 = x * x + y * y + r2 = self.rad * self.rad + z = np.sqrt((r2 - p2 + np.abs(r2 - p2)) / 2.0) + mask = (p2 < r2 + 2.0 * self.rad * self.dotsz / (self.size * 0.5)).astype(int) + x = np.multiply(x, mask) + y = np.multiply(y, mask) + z = np.multiply(z, mask) + pe = self.eye * [x, y, z] + pe = (pe + 1.0 - np.abs(pe - 1.0)) / 2.0 + pe = (pe - 1.0 + np.abs(pe + 1.0)) / 2.0 + h = (np.arctan2(pe[0], pe[1]) / (2 * pi)) % 1.0 + if self.rad < 1.0: + qe = np.multiply(pe, pe) + q1 = qe[0] + qe[1] + q2 = 1.0001 - qe[2] + s = np.sqrt(q1 / q2) + s = (s + 1.0 - np.abs(s - 1.0)) / 2.0 + else: + s = np.multiply(np.ones(pe[2].shape), mask) + l = 1.0 - 2.0 * np.arccos(pe[2]) / pi + tmp = h * ramp_n[get_color_style()[0]] + hind = np.floor(np.array(tmp)[0]).astype(int) + hprop = tmp % 1.0 + v = np.take_along_axis(ramp_v[get_color_style()[0]], np.array([hind] * 4), 1) + d = np.take_along_axis(ramp_d[get_color_style()[0]], np.array([hind] * 4), 1) + rgbm = np.array(v + np.multiply(d, hprop)) + rgb = np.divide(np.matrix(rgbm[0:3]), rgbm[3]) + ll = (l + 1.0) * 0.5 + if get_color_style()[1] == "linear": + t1 = 1.0 - np.abs(l) + t2 = (l + 1.0 - t1) * 0.5 + else: + br = brightness * rgb + lmin = (ll + br - np.abs(ll - br)) / 2.0 + lmin = (lmin + brightness[1] - np.abs(lmin - brightness[1])) / 2.0 + p = (1.0 - ll) / (1.0 - lmin) + t1 = np.multiply(ll, p) / (np.multiply(br - 1.0, p) + 1.0) + t2 = ll - np.multiply(t1, br) + t1 = np.multiply(s, t1) + t2 = np.multiply(s, t2) + np.multiply(ll, 1.0 - s) + rgb = np.add(np.multiply(rgb, t1), t2) + rgb = (rgb + 1.0 - np.abs(rgb - 1.0)) / 2.0 + rgb = (rgb + np.abs(rgb)) / 2.0 + rgb = np.power(rgb, 0.417) + return rgb.transpose() + + +class ColorSample(): + + def __init__(self, fig, rect, bw, initcol): + self.fig = fig + self.ax = fig.add_axes(rect, frame_on=False, xticks=[], yticks=[]) + self.rect = rect + self.sqr = plt.Rectangle((0, 0), 1.0, 1.0, + linewidth=bw, edgecolor=(0, 0, 0), facecolor=initcol) + self.ax.add_artist(self.sqr) + + def set_color(self, hsl, ev=None): + if hsl: + self.sqr.set_facecolor(hsl_color_im(*hsl)) + + +class ColorPicker(): + + def __init__(self, callback_click, callback_move, name="Color Sphere"): + width = 500 + height = 500 + rect = (0.1, 0.1, 0.8, 0.8) + self.win = WindowMgr(name, width, height, 1, 1) + self.win.set_background(hsl_color_im(0.0, 0.0, 0.0)) + self.sphere = ColorSphere(self.win.fig, rect, width, height, self.win.pixpt, callback_click, True) + self.sample = ColorSample(self.win.fig, (0.04, 0.04, 0.16, 0.16), 2*self.win.pixpt, hsl_color_im(0.0, 0.0, 1.0)) + self.win.register_target(rect, self.sphere) + self.win.add_motion_callback(self.sphere.color_change_event) + self.win.add_resize_callback(lambda ev: self.sphere.resize(ev.width, ev.height)) + self.sphere.mouse_color_callbacks.append(self.sample.set_color) + if callback_move: + self.sphere.mouse_color_callbacks.append(callback_move) + self.win.add_close_callback(lambda ev: callback_move(None, ev)) + + +# Below is an example application of the color picker. +# Call launch_colorpicker with the HighControlInterface as argument. +# Hover over the sphere to watch colors. Click on a color to upload +# it as a movie. +# You can provide your own click and move callbacks for other effects. + + +global_cp = False +rtmode = False +outermode = False +printcol = False + + +def make_click_func(ctr): + + def on_click(hsl, event): + global outermode + global printcol + if hsl: + pat = ctr.make_solid_pattern(hsl_color(*hsl)) + id = ctr.upload_movie(ctr.to_movie(pat), 1, force=True) + ctr.set_movies_current(id) + if printcol: + print(hsl_color(*hsl)) + outermode = 'movie' + + return on_click + + +def make_move_func(ctr): + + def on_move(hsl, event): + global rtmode + global outermode + if hsl: + if not rtmode: + outermode = ctr.get_mode()['mode'] + pat = ctr.make_solid_pattern(hsl_color(*hsl)) + ctr.show_rt_frame(ctr.to_movie(pat)) + rtmode = True + else: + if rtmode: + if outermode: + ctr.set_mode(outermode) + rtmode = False + + return on_move + + +def launch_colorpicker(ctr, printcolor=False, fromshell=False): + global global_cp + global printcol + printcol = printcolor + global_cp = ColorPicker(make_click_func(ctr), make_move_func(ctr), name="Xled Color Picker") + if fromshell: + global_cp.win.add_close_callback(lambda *args: global_cp.win.fig.canvas.stop_event_loop()) + global_cp.win.fig.canvas.start_event_loop(0) diff --git a/xled/control.py b/xled/control.py index ab3654b..f332279 100644 --- a/xled/control.py +++ b/xled/control.py @@ -21,21 +21,30 @@ import io import logging import struct +import binascii +import time +import base64 +import uuid +import math as m from operator import xor from requests.compat import urljoin import xled.util +import xled.security +from xled.udp_client import UDPClient from xled.auth import BaseUrlChallengeResponseAuthSession -from xled.compat import xrange from xled.exceptions import HighInterfaceError from xled.response import ApplicationResponse -from xled.security import encrypt_wifi_password log = logging.getLogger(__name__) +#: UDP port to send realtime frames to +REALTIME_UDP_PORT_NUMBER = 7777 + #: Time format as defined by C standard TIME_FORMAT = "%H:%M:%S" +SHORT_TIME_FORMAT = "%H:%M" class ControlInterface(object): @@ -49,6 +58,7 @@ def __init__(self, host, hw_address=None): self.host = host self.hw_address = hw_address self._session = None + self._udpclient = None self._base_url = None @property @@ -74,6 +84,66 @@ def session(self): assert self._session return self._session + @property + def udpclient(self): + """ + Client for sending UDP packets to the realtime port + + :return: the UDP client + :py:class:`~.udp_client.UDPClient()`. + :rtype: udp_client.UDPClient + """ + if not self._udpclient: + self._udpclient = UDPClient(REALTIME_UDP_PORT_NUMBER, self.host) + assert self._udpclient + return self._udpclient + + def check_status(self): + """ + Checks that the device is online and responding + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "status") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def delete_movies(self): + """ + Remove all uploaded movies. + + .. seealso:: :py:meth:`get_movies()` :py:meth:`set_movies_new()` :py:meth:`set_movies_full()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "movies") + response = self.session.delete(url) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def delete_playlist(self): + """ + Clears the playlist + + .. seealso:: :py:meth:`get_playlist()` :py:meth:`set_playlist()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "playlist") + response = self.session.delete(url) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + def firmware_0_update(self, firmware): """ Uploads first stage of the firmware @@ -100,7 +170,7 @@ def firmware_1_update(self, firmware): app_response = ApplicationResponse(response) return app_response - def firmware_update(self, stage0_sha1sum, stage1_sha1sum): + def firmware_update(self, stage0_sha1sum, stage1_sha1sum=None): """ Performs firmware update from previously uploaded images @@ -112,9 +182,10 @@ def firmware_update(self, stage0_sha1sum, stage1_sha1sum): json_payload = { "checksum": { "stage0_sha1sum": stage0_sha1sum, - "stage1_sha1sum": stage1_sha1sum, } } + if stage1_sha1sum is not None: + json_payload["checksum"]["stage1_sha1sum"] = stage1_sha1sum url = urljoin(self.base_url, "fw/update") response = self.session.post(url, json=json_payload) app_response = ApplicationResponse(response) @@ -130,6 +201,8 @@ def firmware_version(self): url = urljoin(self.base_url, "fw/version") response = self.session.get(url) app_response = ApplicationResponse(response) + required_keys = [u"version", u"code"] + assert all(key in app_response.keys() for key in required_keys) return app_response def get_brightness(self): @@ -156,6 +229,8 @@ def get_device_info(self): url = urljoin(self.base_url, "gestalt") response = self.session.get(url) app_response = ApplicationResponse(response) + required_keys = [u"code"] # and several more, dependent on fw version + assert all(key in app_response.keys() for key in required_keys) return app_response def get_device_name(self): @@ -175,16 +250,76 @@ def get_device_name(self): assert all(key in app_response.keys() for key in required_keys) return app_response - def get_network_status(self): + def get_led_config(self): """ - Gets network status + Gets the structural configuration of the leds in term of strings :raises ApplicationError: on application error :rtype: :class:`~xled.response.ApplicationResponse` """ - url = urljoin(self.base_url, "network/status") + url = urljoin(self.base_url, "led/config") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"strings", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_led_effects_current(self): + """ + Gets the current effect index + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/effects/current") response = self.session.get(url) app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_led_effects(self): + """ + Gets the number of effects and their unique_ids + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/effects") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"effects_number", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_led_layout(self): + """ + Gets the physical layout of the leds + + .. seealso:: :py:meth:`set_led_layout()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/layout/full") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"source", u"synthesized", u"coordinates", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_led_movie_config(self): + """ + Gets the parameters for playing the uploaded movie + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/movie/config") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"frame_delay", u"leds_number", u"frames_number", u"code"] + assert all(key in app_response.keys() for key in required_keys) return app_response def get_mode(self): @@ -205,6 +340,114 @@ def get_mode(self): assert all(key in app_response.keys() for key in required_keys) return app_response + def get_movies(self): + """ + Gets list of uploaded movies. + + .. seealso:: :py:meth:`delete_movies()` :py:meth:`set_movies_new()` :py:meth:`set_movies_full()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "movies") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"movies", u"available_frames", u"max_capacity", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_movies_current(self): + """ + Gets the movie id of the currently played movie in the movie list + + .. seealso:: :py:meth:`set_movies_current()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "movies/current") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"id", u"unique_id", u"name", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_mqtt_config(self): + """ + Gets the mqtt configuration parameters + + .. seealso:: :py:meth:`set_mqtt_config()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "mqtt/config") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"code"] # and some more that depends on the family + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_network_status(self): + """ + Gets network status + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "network/status") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"mode", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_playlist(self): + """ + Gets the current playlist + + .. seealso:: :py:meth:`delete_playlist()` :py:meth:`set_playlist()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "playlist") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"entries", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_playlist_current(self): + """ + Gets the movie id of the currently played movie in the playlist + + .. seealso:: :py:meth:`set_playlist_current()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "playlist/current") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"id", u"unique_id", u"name", u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def get_saturation(self): + """ + Gets current saturation level and if desaturation is applied + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/out/saturation") + response = self.session.get(url) + app_response = ApplicationResponse(response) + required_keys = [u"code", u"mode", u"value"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + def get_timer(self): """ Gets current timer @@ -218,7 +461,7 @@ def get_timer(self): url = urljoin(self.base_url, "timer") response = self.session.get(url) app_response = ApplicationResponse(response) - required_keys = [u"time_now", u"time_off", u"time_on", u"code"] + required_keys = [u"time_now", u"time_off", u"time_on"] # Early firmware lack 'code' assert all(key in app_response.keys() for key in required_keys) return app_response @@ -231,19 +474,24 @@ def led_reset(self): """ url = urljoin(self.base_url, "led/reset") response = self.session.get(url) - return ApplicationResponse(response) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response def network_scan(self): """ Initiate WiFi network scan :raises ApplicationError: on application error - :rtype: None + :rtype: :class:`~xled.response.ApplicationResponse` """ url = urljoin(self.base_url, "network/scan") response = self.session.get(url) app_response = ApplicationResponse(response) - assert list(app_response.keys()) == [u"code"] + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response def network_scan_results(self): """ @@ -255,25 +503,32 @@ def network_scan_results(self): url = urljoin(self.base_url, "network/scan_results") response = self.session.get(url) app_response = ApplicationResponse(response) + required_keys = [u"networks", u"code"] + assert all(key in app_response.keys() for key in required_keys) return app_response - def set_brightness(self, brightness=None, enabled=True): + def set_brightness(self, brightness=None, enabled=True, relative=False): """ Sets new brightness or enable/disable brightness dimming - :param brightness: new brightness in range of 0..255 or None if no - change is requested - :param bool enabled: set to False if the dimming should not be applied + :param brightness: new brightness in range of 0..100 or a relative + change in -100..100 or None if no change is requested + :param bool enabled: set to False if no dimming should be applied + :param bool relative: set to True to make a relative change :raises ApplicationError: on application error :rtype: :class:`~xled.response.ApplicationResponse` """ - assert brightness in range(0, 256) or brightness is None + if brightness is not None: + if relative: + assert brightness in range(-100, 101) + json_payload = {"value": brightness, "type": "R"} # Relative + else: + assert brightness in range(0, 101) + json_payload = {"value": brightness, "type": "A"} # Absolute if enabled: - json_payload = {"mode": "enabled", "type": "A"} + json_payload["mode"] = "enabled" else: - json_payload = {"mode": "disabled"} - if brightness is not None: - json_payload["value"] = brightness + json_payload["mode"] = "disabled" url = urljoin(self.base_url, "led/out/brightness") response = self.session.post(url, json=json_payload) app_response = ApplicationResponse(response) @@ -287,7 +542,7 @@ def set_device_name(self, name): :param str name: new device name :raises ApplicationError: on application error - :rtype: None + :rtype: :class:`~xled.response.ApplicationResponse` """ assert len(name) <= 32 json_payload = {"name": name} @@ -296,6 +551,47 @@ def set_device_name(self, name): app_response = ApplicationResponse(response) required_keys = [u"code"] assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_led_effects_current(self, effect_id): + """ + Sets the current effect of effect mode + + :param int effect_id: id of effect + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + json_payload = {"effect_id": effect_id} + url = urljoin(self.base_url, "led/effects/current") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_led_layout(self, source, coordinates, synthesized=False): + """ + Sets the physical layout of the leds + + :param str source: 2d, 3d, or linear + :param list coordinates: list of dictionaries with keys 'x', 'y', and 'z' + :param bool synthesized: presumably whether it is synthetic or real coordinates + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + assert source in ['linear', '2d', '3d'] + assert isinstance(coordinates, list) + json_payload = { + "source": source, + "coordinates": coordinates, + "synthesized": synthesized + } + url = urljoin(self.base_url, "led/layout/full") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response def set_led_movie_config(self, frame_delay, frames_number, leds_number): """ @@ -314,72 +610,317 @@ def set_led_movie_config(self, frame_delay, frames_number, leds_number): } url = urljoin(self.base_url, "led/movie/config") response = self.session.post(url, json=json_payload) - return ApplicationResponse(response) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_led_movie_full(self, movie): + """ + Uploads movie + + :param movie: file-like object that points to movie file. + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/movie/full") + head = {"Content-Type": "application/octet-stream"} + response = self.session.post(url, headers=head, data=movie) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response def set_mode(self, mode): """ Sets new LED operation mode. - :param str mode: Mode to set. One of 'movie', 'demo', 'off'. + :param str mode: Mode to set. One of 'movie', 'playlist', 'rt', 'demo', 'effect' or 'off'. :raises ApplicationError: on application error - :rtype: None + :rtype: :class:`~xled.response.ApplicationResponse` """ - assert mode in ("movie", "demo", "off") + assert mode in ("movie", "playlist", "rt", "demo", "effect", "off") json_payload = {"mode": mode} url = urljoin(self.base_url, "led/mode") response = self.session.post(url, json=json_payload) app_response = ApplicationResponse(response) required_keys = [u"code"] assert all(key in app_response.keys() for key in required_keys) + return app_response - def set_led_movie_full(self, movie): + def set_movies_current(self, movie_id): """ - Uploads movie + Sets which movie in the movie list to play + + .. seealso:: :py:meth:`get_movies_current()` + + :param int movie_id: id of movie to play + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + json_payload = {"id": movie_id} + url = urljoin(self.base_url, "movies/current") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_movies_full(self, movie): + """ + Uploads a movie to the movie list + + Presumes that 'set_movies_new' has been called earlier with the movie params. + + .. seealso:: :py:meth:`get_movies()` :py:meth:`delete_movies()` :py:meth:`set_movies_new()` :param movie: file-like object that points to movie file. :raises ApplicationError: on application error :rtype: :class:`~xled.response.ApplicationResponse` """ - url = urljoin(self.base_url, "led/movie/full") - response = self.session.post( - url, headers={"Content-Type": "application/octet-stream"}, data=movie - ) - return ApplicationResponse(response) + url = urljoin(self.base_url, "movies/full") + head = {"Content-Type": "application/octet-stream"} + response = self.session.post(url, headers=head, data=movie) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_movies_new(self, name, uid, dtype, nleds, nframes, fps): + """ + Prepares the upload of a new movie to the movie list by setting its parameters + + .. seealso:: :py:meth:`get_movies()` :py:meth:`delete_movies()` :py:meth:`set_movies_full()` + + :param str name: name of new movie + :param str uid: unique id of new movie + :param str dtype: descriptor_type, one of rgb_raw, rgbw_raw, or aww_raw + :param int nleds: number of leds + :param int nframes: number of frames + :param int fps: frames per second of the new movie + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + assert len(name) <= 32 + json_payload = { + "name": name, + "unique_id": uid, + "descriptor_type": dtype, + "leds_per_frame": nleds, + "frames_number": nframes, + "fps": fps + } + url = urljoin(self.base_url, "movies/new") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_mqtt_config(self, broker_host=None, broker_port=None, client_id=None, user=None, interval=None): + """ + Sets the mqtt configuration parameters + + .. seealso:: :py:meth:`get_mqtt_config()` - def set_network_mode_ap(self): + :param str broker_host: optional broker host + :param int broker_port: optional broker port + :param str client_id: optional client_id + :param str user: optional user name + :param int interval: optional keep alive interval + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + json_payload = {} + if broker_host: + json_payload["broker_host"] = broker_host + if broker_port: + json_payload["broker_port"] = broker_port + if client_id: + json_payload["client_id"] = client_id + if interval is not None: + json_payload["keep_alive_interval"] = interval + if user: + json_payload["user"] = user + if not json_payload: + msg = "At least some value needs to be set" + raise ValueError(msg) + url = urljoin(self.base_url, "mqtt/config") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_network_mode_ap(self, password=None): """ Sets network mode to Access Point + If password is given, changes the Access Point password + (after which you have to connect again with the new password) + + :param str password: new password to set :raises ApplicationError: on application error - :rtype: None + :rtype: :class:`~xled.response.ApplicationResponse` """ json_payload = {"mode": 2} + if password: + json_payload["ap"] = {"password": password, "enc": 4} url = urljoin(self.base_url, "network/status") response = self.session.post(url, json=json_payload) app_response = ApplicationResponse(response) required_keys = [u"code"] assert all(key in app_response.keys() for key in required_keys) + return app_response - def set_network_mode_station(self, ssid, password): + def set_network_mode_station(self, ssid=None, password=None): """ - Sets network mode to Access Point + Sets network mode to Station + + The first time you need to provide an ssid and password for + the WIFI to connect to. - :param str ssid: SSID if the access point to connect to + :param str ssid: SSID of the access point to connect to :param str password: password to use :raises ApplicationError: on application error - :rtype: None + :rtype: :class:`~xled.response.ApplicationResponse` """ - assert self.hw_address - encpassword = encrypt_wifi_password(password, self.hw_address) - json_payload = { - "mode": 1, - "station": {"dhcp": 1, "ssid": ssid, "encpassword": encpassword}, - } + json_payload = {"mode": 1} + if ssid and password: + assert self.hw_address + encpassword = xled.security.encrypt_wifi_password(password, self.hw_address) + json_payload["station"] = {"dhcp": 1, "ssid": ssid, "encpassword": encpassword} + else: + assert not ssid and not password url = urljoin(self.base_url, "network/status") response = self.session.post(url, json=json_payload) app_response = ApplicationResponse(response) required_keys = [u"code"] assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_playlist(self, entries): + """ + Sets a new playlist + + .. seealso:: :py:meth:`get_playlist()` :py:meth:`delete_playlist()` + + :param list entries: list of playlist entries each with keys "unique_id" and "duration" + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + assert isinstance(entries, list) + json_payload = {"entries": entries} + url = urljoin(self.base_url, "playlist") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_playlist_current(self, movie_id): + """ + Sets which movie in the playlist to play + + .. seealso:: :py:meth:`get_playlist_current()` + + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + json_payload = {"id": movie_id} + url = urljoin(self.base_url, "playlist/current") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_rt_frame_rest(self, frame): + """ + Uploads a frame in rt-mode, using the ordinary restful protocol + + :param frame: file-like object that points to frame file. + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + url = urljoin(self.base_url, "led/rt/frame") + response = self.session.post( + url, headers={"Content-Type": "application/octet-stream"}, data=frame + ) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + + def set_rt_frame_socket(self, frame, version, leds_number=None): + """ + Uploads a frame in rt-mode, over an UDP socket. + This is much faster than the restful protocol. + + :param frame: file-like object representing the frame + :param version: use protocol version 1, 2 or 3 + :param int leds_number: the number of leds (only used in version 1) + :rtype: None + """ + if version == 1: + # Send single frame, generation I + packet = bytearray(b'\x01') + packet.extend(base64.b64decode(self.session.access_token)) + packet.extend(struct.pack(">B", leds_number)) + packet.extend(frame.read()) + self.udpclient.send(packet) + elif version == 2: + # Send single frame, generation II pre 2.4.14 + packet = bytearray(b'\x02') + packet.extend(base64.b64decode(self.session.access_token)) + packet.extend(b'\x00') + packet.extend(frame.read()) + self.udpclient.send(packet) + else: + # Send multi frame, generation II post 2.4.14 + packet_size = 900 + data_packet = frame.read(packet_size) + i = 0 + while data_packet: + packet = bytearray(b'\x03') + packet.extend(base64.b64decode(self.session.access_token)) + packet.extend(b'\x00\x00') + packet.extend(struct.pack(">B", i)) + packet.extend(data_packet) + self.udpclient.send(packet) + data_packet = frame.read(packet_size) + i += 1 + + def set_saturation(self, saturation=None, enabled=True, relative=False): + """ + Sets new saturation or enable/disable desaturation + + :param saturation: new saturation in range of 0..100 or a relative + change in -100..100 or None if no change is requested + :param bool enabled: set to False if no desaturation should be applied + :param bool relative: set to True to make a relative change + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + if saturation is not None: + if relative: + assert saturation in range(-100, 101) + json_payload = {"value": saturation, "type": "R"} # Relative + else: + assert saturation in range(0, 101) + json_payload = {"value": saturation, "type": "A"} # Absolute + else: + json_payload = {} + if enabled: + json_payload["mode"] = "enabled" + else: + json_payload["mode"] = "disabled" + url = urljoin(self.base_url, "led/out/saturation") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response def set_timer(self, time_on, time_off, time_now=None): """ @@ -393,7 +934,7 @@ def set_timer(self, time_on, time_off, time_now=None): automatically if not set. :type time_now: int or None :raises ApplicationError: on application error - :rtype: None + :rtype: :class:`~xled.response.ApplicationResponse` """ assert isinstance(time_on, int) assert time_on >= -1 @@ -403,12 +944,17 @@ def set_timer(self, time_on, time_off, time_now=None): time_now = xled.util.seconds_after_midnight() log.debug("Setting time now to %s", time_now) - json_payload = {"time_on": time_on, "time_off": time_off, "time_now": time_now} + json_payload = { + "time_on": time_on, + "time_off": time_off, + "time_now": time_now + } url = urljoin(self.base_url, "timer") response = self.session.post(url, json=json_payload) app_response = ApplicationResponse(response) required_keys = [u"code"] assert all(key in app_response.keys() for key in required_keys) + return app_response class HighControlInterface(ControlInterface): @@ -416,38 +962,62 @@ class HighControlInterface(ControlInterface): High level interface to control specific device """ - def update_firmware(self, stage0, stage1): + def __init__(self, host, hw_address=None): + super(HighControlInterface, self).__init__(host, hw_address) + info = self.get_device_info() + self.num_leds = info['number_of_led'] + self.family = info['fw_family'] if 'fw_family' in info else "D" + self.led_bytes = info['bytes_per_led'] if 'bytes_per_led' in info else 3 + self.led_profile = info['led_profile'] if 'led_profile' in info else "RGB" + self.version = tuple(map(int, self.firmware_version()['version'].split('.'))) + self.string_config = self.get_led_config()['strings'] + if not self.hw_address: + self.hw_address = info['mac'] + self.layout = False + self.layout_bounds = False + self.last_mode = None + self.last_rt_time = 0 + self.curr_mode = self.get_mode()['mode'] + + def firmware_num_stages(self): + if self.family == "D": + return 2 + else: + return 1 + + def update_firmware(self, stage0, stage1=None): """ Uploads firmware and runs update - :param stage0: file-like object pointing to stage0 of firmware. Must support seek(). - :param stage1: file-like object pointing to stage1 of firmware. Must support seek(). + :param stage0: file-like seekable object pointing to stage0 of firmware. + :param stage1: file-like seekable object pointing to stage1 of firmware, + or None if there is no stage1. :raises ApplicationError: on application error :raises HighInterfaceError: on error during update """ + twostage = self.fimware_num_stages() == 2 + if twostage: + assert stage1 + else: + assert stage1 is None fw_stage_sums = [None, None] - for stage in (0, 1): - # I don't know how to dynamically construct variable name - if stage == 0: - fw_stage_sums[stage] = xled.security.sha1sum(stage0) - elif stage == 1: - fw_stage_sums[stage] = xled.security.sha1sum(stage1) + fw_images = [stage0, stage1] + fw_funcalls = [self.firmware_0_update, self.firmware_1_update] + stages = [0, 1] if twostage else [0] + for stage in stages: + fw_images[stage].seek(0) + fw_stage_sums[stage] = xled.security.sha1sum(fw_images[stage]) log.debug("Firmware stage %d SHA1SUM: %r", stage, fw_stage_sums[stage]) if not fw_stage_sums[stage]: - msg = "Failed to compute SHA1SUM for firmware stage %d." % (stage) + msg = "Failed to compute SHA1SUM for firmware stage {}.".format(stage) raise HighInterfaceError(msg) assert False - stage0.seek(0) - stage1.seek(0) uploaded_stage_sums = [None, None] - for stage in (0, 1): + for stage in stages: log.debug("Uploading firmware stage %d...", stage) - # I still don't know how to dynamically construct variable name - if stage == 0: - response = self.firmware_0_update(stage0) - elif stage == 1: - response = self.firmware_1_update(stage1) + fw_images[stage].seek(0) + response = fw_funcalls[stage](fw_images[stage]) log.debug("Firmware stage %d uploaded.", stage) if not response.ok: msg = "Failed to upload stage {}: {}".format( @@ -467,7 +1037,7 @@ def update_firmware(self, stage0, stage1): if fw_stage_sums != uploaded_stage_sums: log.error("Firmware SHA1SUMs: %r != uploaded SHA1SUMs", fw_stage_sums) - msg = "Firmware SHA1SUMs doesn't match to uploaded SHA1SUMs." + msg = "Firmware SHA1SUMs doesn't match uploaded SHA1SUMs." raise HighInterfaceError(msg) assert False else: @@ -504,73 +1074,63 @@ def get_formatted_timer(self): raise HighInterfaceError(msg) now = device_response["time_now"] - now_formatted = xled.util.date_from_seconds_after_midnight(now).strftime( - TIME_FORMAT - ) + now_formatted = xled.util.date_from_seconds_after_midnight(now).strftime(TIME_FORMAT) if device_response["time_on"] == -1 and device_response["time_off"] == -1: return Timer(now_formatted, False, False) on = device_response["time_on"] - on_formatted = xled.util.date_from_seconds_after_midnight(on).strftime( - TIME_FORMAT - ) + on_formatted = xled.util.date_from_seconds_after_midnight(on).strftime(TIME_FORMAT) - off = device_response["time_on"] - off_formatted = xled.util.date_from_seconds_after_midnight(off).strftime( - TIME_FORMAT - ) + off = device_response["time_off"] + off_formatted = xled.util.date_from_seconds_after_midnight(off).strftime(TIME_FORMAT) return Timer(now_formatted, on_formatted, off_formatted) - @staticmethod - def write_static_movie(file_obj, size, red, green, blue): + def set_formatted_timer(self, timestr_on, timestr_off): """ - Writes movie of single color + Sets timer on and off times, given as strings in H:M:S or H:M format - :param file_obj: file-like object to write movie to. - :param int size: numbers of triples (RGB) to write to. - :param red: integer between 0-255 representing red color - :param green: integer between 0-255 representing green color - :param blue: integer between 0-255 representing blue color + :param str timestr_on: time to turn on + :param str timestr_off: time to turn off """ - assert red in range(0, 256) - assert green in range(0, 256) - assert blue in range(0, 256) - bytes_str = struct.pack(">BBB", red, green, blue) - for position in xrange(size): - file_obj.write(bytes_str) + try: + time_on = xled.util.seconds_after_midnight_from_string(timestr_on, TIME_FORMAT) + except ValueError: + time_on = xled.util.seconds_after_midnight_from_string(timestr_on, SHORT_TIME_FORMAT) + try: + time_off = xled.util.seconds_after_midnight_from_string(timestr_off, TIME_FORMAT) + except ValueError: + time_off = xled.util.seconds_after_midnight_from_string(timestr_off, SHORT_TIME_FORMAT) - def set_static_color(self, red, green, blue): - """ - Sets static color for all leds - - :param red: integer between 0-255 representing red color - :param green: integer between 0-255 representing green color - :param blue: integer between 0-255 representing blue color - """ - assert red in range(0, 256) - assert green in range(0, 256) - assert blue in range(0, 256) - response = self.get_device_info() - number_of_led = response["number_of_led"] - with io.BytesIO() as output: - self.write_static_movie(output, number_of_led, red, green, blue) - self.led_reset() - output.seek(0) - self.set_led_movie_full(output) - self.set_led_movie_config(1, 1, number_of_led) + return self.set_timer(time_on, time_off) def turn_on(self): """ Turns on the device. + + Sets the mode to the last used mode before turn_off(). + If the last mode is not known, sets 'movie' mode if there is an + uploaded movie, else 'effect' mode. """ - return self.set_mode("movie") + if self.last_mode: + return self.set_mode(self.last_mode) + else: + if self.family == 'D' or self.version < (2, 5, 6): + response = self.get_led_movie_config()['frames_number'] + else: + response = self.get_movies()['movies'] + return self.set_mode("effect" if not response else "movie") def turn_off(self): """ Turns off the device. + + Remembers the previous mode, so that turn_on() can return to it. """ + mode = self.get_mode()["mode"] + if mode != "off" and mode != "rt": + self.last_mode = mode return self.set_mode("off") def is_on(self): @@ -578,3 +1138,607 @@ def is_on(self): Returns True if device is on """ return self.get_mode()["mode"] != "off" + + def set_mode(self, mode): + """ + Sets new LED operation mode. + + :param str mode: Mode to set. One of 'movie', 'playlist', 'rt', 'demo', 'effect' or 'off'. + This function is a wrapper around the low-level ControlInterface.set_mode, + to remember the currently used mode. + """ + assert mode in ("movie", "playlist", "rt", "demo", "effect", "off") + self.curr_mode = mode + if mode != "off" and mode != "rt": + self.last_mode = mode + if mode == "rt": + self.last_rt_time = time.time() + super(HighControlInterface, self).set_mode(mode) + + # Functions for selecting what to show + + def show_movie(self, movie_or_id, fps=None): + """ + Either starts playing an already uploaded movie with the provided id, + or uploads a new movie and starts playing it at the provided frames-per-second. + Note: if the movie do not fit in the remaining capacity, the old movie list is cleared. + Switches to movie mode if necessary. + The movie is an object suitable created with to_movie or make_func_movie. + + :param movie_or_id: either an integer id or a file-like object that points to movie + :param fps: frames per second, or None if a movie id is given + """ + if self.family == 'D' or self.version < (2, 5, 6): + if isinstance(movie_or_id, int) and fps is None: + if movie_or_id != 0: + return False + else: + assert fps + movie = movie_or_id + numframes = movie.seek(0, 2) // (self.led_bytes * self.num_leds) + movie.seek(0) + self.set_led_movie_config(1000 // max(1, fps), numframes, self.num_leds) + self.set_led_movie_full(movie) + else: + if isinstance(movie_or_id, int) and fps is None: + movies = self.get_movies()['movies'] + if movie_or_id in [entry['id'] for entry in movies]: + self.set_movies_current(movie_or_id) + else: + return False + else: + assert fps + movie = movie_or_id + numframes = movie.seek(0, 2) // (self.led_bytes * self.num_leds) + movie.seek(0) + res = self.get_movies() + capacity = res['available_frames'] - 1 + if numframes > capacity or len(res['movies']) > 15: + if self.curr_mode == 'movie' or self.curr_mode == 'playlist': + self.set_mode('off') + self.delete_movies() + self.set_movies_new("", + str(uuid.uuid4()), + self.led_profile.lower() + "_raw", + self.num_leds, + numframes, + fps) + self.set_movies_full(movie) + if self.curr_mode != 'movie': + self.set_mode('movie') + return True + + def upload_movie(self, movie, fps, force=False): + """ + Uploads a new movie with the provided frames-per-second. + Note: if the movie does not fit in the remaining capacity, and force is + not set to True, the function just returns False, in which case the user + can try clear_movies first. + Does not switch to movie mode, use show_movie instead for that. + The movie is an object suitable created with to_movie or make_func_movie. + Returns the new movie id, which can be used in calls to show_movie or + show_playlist. + + :param movie: a file-like object that points to movie + :param fps: frames per second, or None if a movie id is given + :param bool force: if remaining capacity is too low, previous movies will be removed + :rtype: int + """ + numframes = movie.seek(0, 2) // (self.led_bytes * self.num_leds) + movie.seek(0) + if self.family == 'D' or self.version < (2, 5, 6): + self.set_led_movie_config(1000 // fps, numframes, self.num_leds) + self.set_led_movie_full(movie) + return 0 + else: + res = self.get_movies() + capacity = res['available_frames'] - 1 + if numframes > capacity or len(res['movies']) > 15: + if force: + if self.curr_mode == 'movie' or self.curr_mode == 'playlist': + self.set_mode('effect') + self.delete_movies() + else: + return False + if self.curr_mode == 'movie': + oldid = self.get_movies_current()['id'] + res = self.set_movies_new("", + str(uuid.uuid4()), + self.led_profile.lower() + "_raw", + self.num_leds, + numframes, + fps) + self.set_movies_full(movie) + if self.curr_mode == 'movie': + self.set_movies_current(oldid) # Dont change currently shown movie + return res['id'] + + def show_pattern(self, pat): + """ + Uploads a single pattern as a static movie, and shows it. + Switches to movie mode if necessary. + The parameter is a pattern object eg created with make_solid_pattern or make_func_pattern. + + :param pat: list of byte strings representing a single frame pattern + """ + self.show_movie(self.to_movie(pat), 1) + + def show_playlist(self, lst_or_id, duration=None): + """ + Either switches to the movie with the given id in the playlist, + or uploads a new playlist in the form of a list where each entry is + either an id, or a tuple with an id and a duration. The optional + parameter duration is used for those entries without a duration. + Switches to playlist mode if necessary. + + :param lst_or_id: integer movie id, or list of ids and durations + :param duration: default duration to use for entries without duration + """ + if self.family == 'D' or self.version < (2, 5, 6): + return False + else: + if isinstance(lst_or_id, int) and duration is None: + plist = self.get_playlist()['entries'] + if lst_or_id in [entry['id'] for entry in plist]: + if self.curr_mode != 'playlist': + self.set_mode('playlist') + self.set_playlist_current(lst_or_id) + else: + return False + else: + assert isinstance(lst_or_id, list) + mlist = self.get_movies()['movies'] + mdict = {entry['id']: entry['unique_id'] for entry in mlist} + plist = [] + for ele in lst_or_id: + if isinstance(ele, int): + plist.append({'unique_id': mdict[ele], 'duration': duration or 60}) + else: + plist.append({'unique_id': mdict[ele[0]], 'duration': ele[1]}) + self.set_playlist(plist) + if self.curr_mode != 'playlist': + self.set_mode('playlist') + return True + + def show_rt_frame(self, frame): + """ + Uploads a frame as the next real time frame, and shows it. + Switches to rt mode if necessary. + The frame is either a pattern or a one-frame movie + + :param frame: a pattern or file-like object representing the frame + """ + if self.is_pattern(frame): + frame = self.to_movie(frame) + if self.curr_mode != 'rt' or self.last_rt_time + 50.0 < time.time(): + self.set_mode('rt') + else: + self.last_rt_time = time.time() + frame.seek(0) + # self.set_rt_frame_rest(frame) + if self.family == 'D': + self.set_rt_frame_socket(frame, 1, self.num_leds) + elif self.version < (2, 4, 14): + self.set_rt_frame_socket(frame, 2) + else: + self.set_rt_frame_socket(frame, 3) + + def show_effect(self, effect_id): + """ + Shows the builtin effect with the provided id. + Switches to effect mode if necessary. + + :param int effect_id: The effect id to show + """ + self.set_led_effects_current(effect_id) + if self.curr_mode != 'effect': + self.set_mode('effect') + + def show_demo(self, effect_id=None): + """ + Switches to demo mode if not there already. + Starts from the optional provided effect id. + + :param effect_id: The optional effect id to start demo from + """ + if effect_id: + self.set_led_effects_current(effect_id) + if self.curr_mode != 'demo': + self.set_mode('demo') + + def clear_movies(self): + """ + Removes all uploaded movies and any playlist. + If the current mode is 'movie' or 'playlist' it switches mode to 'effect' + """ + if self.curr_mode == 'movie' or self.curr_mode == 'playlist': + self.set_mode('effect') + if self.family == 'D' or self.version < (2, 5, 6): + # No list of movies to remove in this version, + # but disable movie mode until new movie is uploaded + self.set_led_movie_config(1000, 0, self.num_leds) + else: + # The playlist is removed automatically when movies are removed + self.delete_movies() + + # Functions for creating and manipulating movies and patterns (single frames of movies) + + def make_func_movie(self, numframes, func): + """ + Creates a movie of a number of frames by calling a function to create each frame. + The function is expected to take the frame index as argument and to return a + pattern object representing the frame. + + :param int numframes: The number of frames for the movie + :param function func: A function to produce each frame + :rtype: _io.BytesIO + """ + pl = [] + for i in range(numframes): + pl.append(func(i)) + return self.to_movie(pl) + + def make_empty_movie(self): + """ + Creates a movie of zero frames. + Meant to be followed by several calls to add_to_movie to add frames to it. + + :rtype: _io.BytesIO + """ + movie = io.BytesIO() + return movie + + def is_pattern(self, pat): + """ + Checks whether the given argument has the format of a single frame pattern. + + :param pat: object to check whether it is a pattern + :rtype: bool + """ + return isinstance(pat, list) and len(pat) == self.num_leds and isinstance(pat[0], bytes) + + def is_movie(self, movie): + """ + Checks whether the given argument has the format of a movie. + + :param movie: object to check whether it is a movie + :rtype: bool + """ + return isinstance(movie, io.BytesIO) + + def add_to_movie(self, movie, pat): + """ + Adds one pattern as a frame to the end of a movie. + + :param movie: file-like object representing the movie + :param pat: object representing the pattern + :rtype: _io.BytesIO + """ + assert self.is_pattern(pat) + movie.seek(0, 2) + movie.write(b''.join(pat)) + movie.seek(0, 0) + + def to_movie(self, patlst): + """ + Creates a movie from either a single pattern or a list of patterns. + + :param patlst: pattern or list of patterns + :rtype: _io.BytesIO + """ + movie = io.BytesIO() + if isinstance(patlst, list): + for ele in patlst: + if isinstance(ele, list): + ele = b''.join(ele) + movie.write(ele) + else: + movie.write(patlst) + movie.seek(0) + return movie + + def circind(self, ind): + """ + Internal function used to fascilitate linear or circular effects. That + is, if the device consists of two strings, flip the led indices of one + of the strings so they start at the extreme end of the first string + and runs into the middle where the strings meet and then continue out + on the other string. If the extreme ends of the two strings are + arranged to meet again, it allows for circular patterns. + """ + if len(self.string_config) == 2 and ind < self.string_config[0]['length']: + return self.string_config[0]['length'] - 1 - ind + else: + return ind + + def make_pixel(self, r, g, b): + """ + Internal function to produce one pixel of a pattern from given r, g + and b values. Handles both RGB and RGBW led profiles (for now always + setting the white led to zero). + + :param int r: red component + :param int g: green component + :param int b: blue component + :rtype: bytes + """ + if self.led_bytes == 4: + return struct.pack(">BBBB", 0, r, g, b) + else: + return struct.pack(">BBB", r, g, b) + + def make_solid_pattern(self, rgb): + """ + Creates a one-colored pattern with the given rgb value tuple. + + :param tuple rgb: color as an rgb tuple + :rtype: list representing the pattern + """ + pat = [self.make_pixel(*rgb)] * self.num_leds + return pat + + def make_func_pattern(self, func, circular=False): + """ + Creates a pattern by calling the given function for each led. + The function is expected to take the led index as argument and to + return a color as an rgb tuple for that led. + + :param function func: function to return the color of each pixel + :param bool circular: Flip the led indices on two-string devices to enable circular patterns + :rtype: list representing the pattern + """ + pat = [False] * self.num_leds + for i in range(self.num_leds): + (r, g, b) = func(i) + if circular: + pat[self.circind(i)] = self.make_pixel(r, g, b) + else: + pat[i] = self.make_pixel(r, g, b) + return pat + + def fetch_layout(self): + res = self.get_led_layout() + if res['source'] == '3d': + self.layout = [(p['x'], p['y'], p['z']) for p in res['coordinates']] + dim = 3 + elif res['source'] == '2d': + self.layout = [(p['x'], p['y']) for p in res['coordinates']] + dim = 2 + else: + self.layout = [(p['x'], ) for p in res['coordinates']] + dim = 1 + bounds = [] + cent = [] + rad = 0.0 + for d in range(dim): + vals = [p[d] for p in self.layout] + bounds.append((min(vals), max(vals))) + cent.append(sum(vals) / len(vals)) + for p in self.layout: + r2 = sum([(p[d] - cent[d])**2 for d in range(dim)]) + if r2 > rad: + rad = r2 + self.layout_bounds = {'dim': dim, 'bounds': bounds, 'center': cent, 'radius': rad**0.5} + if dim == 3: + crad = 0.0 + for p in self.layout: + r2 = (p[0] - cent[0])**2 + (p[2] - cent[2])**2 + if r2 > crad: + crad = r2 + self.layout_bounds['cylradius'] = crad**0.5 + + def layout_transform(self, pos, style): + # style == 'square', 'rect', 'centered', 'cylinder', 'sphere' + if style == 'square': # Stretch everything into [0, 1] in each coordinate + return tuple((v - b[0]) / (b[1] - b[0]) for v, b in zip(pos, self.layout_bounds['bounds'])) + elif style == 'rect': # Keep aspect ratio, largest into [-1,1] + cent = ((b[0] + b[1]) / 2 for b in self.layout_bounds['bounds']) + width = max((b[1] - b[0]) / 2 for b in self.layout_bounds['bounds']) + return tuple((v - c) / width for v, c in zip(pos, cent)) + elif style == 'centered': # Origo in center, max radius 1.0 + rad = self.layout_bounds['radius'] + return tuple((v - c) / rad for v, c in zip(pos, self.layout_bounds['center'])) + elif style == 'cylinder' and self.layout_bounds['dim'] == 3: # xz-radius max 1, angle in [-180,180], y in [0, 1] + crad = self.layout_bounds['cylradius'] + ybounds = self.layout_bounds['bounds'][1] + p = ((v - c) / crad for v, c in zip(pos, self.layout_bounds['center'])) + return (m.sqrt(p[0]**2 + p[2]**2), + m.atan2(p[2], p[0]) * 180.0 / m.pi, + (p[1] * crad - ybounds[0]) / (ybounds[1] - ybounds[0])) + elif style == 'sphere' and self.layout_bounds['dim'] == 3: # radius max 1, longitude [-180,180], latitude [-90,90] + rad = self.layout_bounds['radius'] + p = ((v - c) / rad for v, c in zip(pos, self.layout_bounds['center'])) + return (m.sqrt(p[0]**2 + p[1]**2 + p[2]**2), + m.atan2(p[2], p[0]) * 180.0 / m.pi, + m.atan2(p[1], m.sqrt(p[0]**2 + p[2]**2)) * 180.0 / m.pi) + else: + return pos + + def make_layout_pattern(self, func, style=None, index=False): + """ + Creates a pattern by calling the given function for each led. + The function is expected to take the led physical position as + argument (1d, 2d, or 3d depending on the layout source) and to + return a color as an rgb tuple for that led. + + :param function func: function to return the color of each pixel + :rtype: list representing the pattern + """ + if not self.layout: + self.fetch_layout() + pat = [False] * self.num_leds + for i in range(self.num_leds): + pos = self.layout_transform(self.layout[i], style) + if index: + (r, g, b) = func(pos, i) + else: + (r, g, b) = func(pos) + pat[i] = self.make_pixel(r, g, b) + return pat + + def copy_pattern(self, pat): + """ + Make a copy of a pattern. + In case you want to make destructive operations on one of them. + + :param pat: object representing the pattern + :rtype: list representing the pattern + """ + return [ele for ele in pat] + + def modify_pattern(self, pat, ind, rgb, circular=False): + """ + Modifies one pixel in a pattern. + Changes the pattern in place. Make sure to copy it if you need the old one. + + :param pat: object representing the pattern + :param int ind: led index in the pattern + :param tuple rgb: color as an rgb tuple + :param bool circular: Flip the led indices on two-string devices to enable circular patterns + :rtype: list representing the pattern (the same object as pat) + """ + if circular: + pat[self.circind(ind)] = self.make_pixel(*rgb) + else: + pat[ind] = self.make_pixel(*rgb) + return pat + + def shift_pattern(self, pat, step, rgb, circular=False): + """ + Shifts the pattern a number of steps, padding with the provided rgb color. + Non-destructive, leaving the original pattern unmodified. + + :param pat: object representing the pattern + :param int step: steps to shift, can be positive or negative + :param tuple rgb: color as an rgb tuple + :param bool circular: Flip the led indices on two-string devices to enable circular patterns + :rtype: list representing the pattern + """ + pix = self.make_pixel(*rgb) + if circular and len(self.string_config) == 2: + n1 = self.string_config[0]['length'] + n2 = self.num_leds + p1 = pat[0:n1] + p2 = pat[n1:n2] + if step > 0: + for i in range(step): + p2 = p1[:1] + p2[:-1] + p1 = p1[1:] + [pix] + else: + for i in range(-step): + p1 = p2[:1] + p1[:-1] + p2 = p2[1:] + [pix] + pat = p1 + p2 + else: + if step > 0: + pat = [pix] * step + pat[:-step] + else: + pat = pat[-step:] + [pix] * -step + return pat + + def rotate_pattern(self, pat, step, circular=False): + """ + Shifts the pattern a number of steps with rotation, so that pixels + shifted out at one end emerges at the other end. + Non-destructive, leaving the original pattern unmodified. + + :param pat: object representing the pattern + :param int step: steps to shift, can be positive or negative + :param bool circular: Flip the led indices on two-string devices to enable circular patterns + :rtype: list representing the pattern + """ + if circular and len(self.string_config) == 2: + n1 = self.string_config[0]['length'] + n2 = self.num_leds + p1 = pat[0:n1] + p2 = pat[n1:n2] + if step > 0: + for i in range(step): + tmp = p1[0] + p1 = p1[1:] + p2[-1:] + p2 = [tmp] + p2[:-1] + else: + for i in range(-step): + tmp = p1[-1] + p1 = p2[:1] + p1[:-1] + p2 = p2[1:] + [tmp] + pat = p1 + p2 + else: + pat = pat[-step:] + pat[:-step] + return pat + + def permute_pattern(self, pat, perm, circular=False): + """ + Permutes the pattern according to the provided permutation list. + The new index 'i' will get the same color as the old index 'perm[i]'. + Non-destructive, leaving the original pattern unmodified. + + :param pat: object representing the pattern + :param list perm: permutation list of source indices + :param bool circular: Flip the led indices on two-string devices to enable circular patterns + :rtype: list representing the pattern + """ + newpat = [False] * len(pat) + if circular: + for i, k in enumerate(perm): + newpat[self.circind(i)] = pat[self.circind(k)] + else: + for i, k in enumerate(perm): + newpat[i] = pat[k] + return newpat + + def save_movie(self, name, movie, fps): + """ + Save the movie object on file. + The movie file is text based and starts with a header containing + the number of frames, number of leds, number of bytes per led, and + the suggested frames per second. After the header follows one line per + frame as a hexadecimal string. This format makes it easier to share + movies between different devices and even different led profiles. + """ + bytesperframe = self.led_bytes * self.num_leds + numframes = movie.seek(0, 2) // bytesperframe + movie.seek(0) + f = open(name, "w") + f.write("{} {} {} {}\n".format(numframes, self.num_leds, self.led_bytes, fps)) + for i in range(numframes): + f.write(binascii.hexlify(movie.read(bytesperframe)).decode() + "\n") + f.close() + + def load_movie(self, name): + """ + Read a movie from a file (produced by save_movie). + Returns both the movie object and the suggested frames-per-second in a tuple. + Some effort is made to convert movies between different devices: + If the number of leds are different, each frame is padded or truncated + at both ends. If the led profile is different, the white component is + removed or added (as zero). + """ + f = open(name, "r") + head = list(map(int, f.readline().strip("\n").split(" "))) + numframes = head[0] + fps = head[3] + movie = io.BytesIO() + if head[1] == self.num_leds and head[2] == self.led_bytes: + for i in range(numframes): + movie.write(binascii.unhexlify(f.readline().strip("\n"))) + else: + for i in range(numframes): + s = binascii.unhexlify(f.readline().strip("\n")) + if head[2] == 3 and self.led_bytes == 4: + s = b''.join([chr(0) + s[3 * i : 3 * i + 3] for i in range(len(s) // 3)]) + elif head[2] == 4 and self.led_bytes == 3: + s = b''.join([s[4 * i + 1 : 4 * i + 4] for i in range(len(s) // 4)]) + if head[1] < self.num_leds: + diff = self.num_leds - head[1] + s = chr(0) * (diff // 2 * self.led_bytes) + s + chr(0) * ((diff - diff // 2) * self.led_bytes) + elif head[1] > self.num_leds: + hdiff = (head[1] - self.num_leds) // 2 + s = s[hdiff : hdiff + self.num_leds * self.led_bytes] + movie.write(s) + movie.seek(0) + return (movie, fps) + + def set_static_color(self, red, green, blue): + # This function can really be removed now, as there are several fuctions for creating patterns + self.show_pattern(self.make_solid_pattern((red, green, blue))) diff --git a/xled/effect_base.py b/xled/effect_base.py new file mode 100644 index 0000000..672a458 --- /dev/null +++ b/xled/effect_base.py @@ -0,0 +1,72 @@ +import sys +import time + +if sys.version_info.major == 2: + from threading import _Timer + TimerX = _Timer +else: + from threading import Timer + TimerX = Timer + +class RepeatedTimer(TimerX): + def run(self): + lasttime = time.time() + self.function(*self.args, **self.kwargs) + while not self.finished.wait(max(0.0, self.interval - (time.time() - lasttime))): + lasttime = time.time() + self.function(*self.args, **self.kwargs) + +effect_timer = None + +class Effect(object): + def __init__(self, ctr): + self.ctr = ctr + self.preferred_frames = 120 + self.preferred_fps = 8 + + def reset(self, numframes=False): + pass # provided by subclass + + def getnext(self): + pass # provided by subclass + + def launch_rt(self): + global effect_timer + def doit(): + self.ctr.show_rt_frame(self.getnext()) + if effect_timer: + effect_timer.cancel() + effect_timer = RepeatedTimer(1.0 / self.preferred_fps, doit) + self.reset(False) + effect_timer.start() + return True + + def stop_rt(self): + global effect_timer + if effect_timer: + effect_timer.cancel() + effect_timer = None + + def make_movie(self, numframes): + frames = [] + self.reset(numframes) + for i in range(numframes): + frames.append(self.getnext()) + return self.ctr.to_movie(frames) + + def launch_movie(self): + self.stop_rt() + self.ctr.show_movie(self.make_movie(self.preferred_frames), + self.preferred_fps) + + def save_movie(self, name): + self.ctr.save_movie(name, + self.make_movie(self.preferred_frames), + self.preferred_fps) + + +def stop_rt(): + global effect_timer + if effect_timer: + effect_timer.cancel() + effect_timer = None diff --git a/xled/effects.py b/xled/effects.py new file mode 100644 index 0000000..67f66d8 --- /dev/null +++ b/xled/effects.py @@ -0,0 +1,603 @@ +""" +xled.effects +~~~~~~~~~~~~ + +Author: Anders Holst (anders.holst@ri.se), 2021 + +A collection of moving effects created as subclasses of the Effect base class. +Mostly layout-independent effects, i.e with randomized positions, such as +glowing, breathing, and sparkling. +Most effects are highly configurable. Some specific examples of each effect +type are also provided. +""" + +from xled.effect_base import Effect +from xled.colormeander import ColorMeander +from xled.pattern import blendcolors, randompoisson, random_hsl_color_func, sprinkle_pattern +from xled.ledcolor import hsl_color +import random + + +""" +Glowing effect + +Similar to the Glow effect in the app, but seamless when it wraps around. +Check out the specific examples: Charcoal, Fire, Water, Aurora, Meadow. +""" + + +class Glowbit(): + + def __init__(self, cols, bend, steps, initstep=False, loop=False): + self.count = 0 + self.lastcol = (0, 0, 0) + self.nextcol = (0, 0, 0) + self.currcol = (0, 0, 0) + self.loop = loop + self.currind = initstep if initstep else steps + self.steps = steps + self.cols = cols + self.bend = bend + if self.loop: + self.initcol1 = hsl_color(*self.cols[int((random.random()**self.bend) * len(self.cols))]) + self.initcol2 = hsl_color(*self.cols[int((random.random()**self.bend) * len(self.cols))]) + self.lastcol = self.initcol1 + self.nextcol = self.initcol2 + + def getnext(self): + if self.currind == self.steps: + self.lastcol = self.nextcol + if self.loop and self.count + self.steps >= self.loop: + self.nextcol = self.initcol2 + elif self.loop and self.count + 2 * self.steps >= self.loop: + self.nextcol = self.initcol1 + else: + self.nextcol = hsl_color(*self.cols[int((random.random()**self.bend) * len(self.cols))]) + self.currind = 0 + self.currind += 1 + self.count += 1 + return blendcolors(self.lastcol, self.nextcol, float(self.currind) / self.steps) + + +class GlowEffect(Effect): + + def __init__(self, ctr, cols, bend, cycles, fps=False): + super(GlowEffect, self).__init__(ctr) + if fps: + self.preferred_fps = fps + self.cols = cols + self.bend = bend + self.cycles = cycles + + def reset(self, numframes): + if type(self.cycles) == int: + steps = [self.cycles] + elif numframes: + steps = [] + for n in range(self.cycles[0], self.cycles[-1] + 1): + if numframes % n == 0: + steps.append(n) + if not steps: + steps = list(range(self.cycles[0], self.cycles[-1] + 1)) + else: + steps = list(range(self.cycles[0], self.cycles[-1] + 1)) + pr1 = 13 if len(steps) % 13 != 0 else 7 + pr2 = 11 if len(steps) % 11 != 0 else 7 + self.glowarray = [Glowbit(self.cols, self.bend, + steps[(i * pr1) % len(steps)], + (i * pr2) % steps[(i * pr1) % len(steps)], + numframes) + for i in range(self.ctr.num_leds)] + + def getnext(self): + return self.ctr.make_func_pattern(lambda i: self.glowarray[i].getnext()) + + +class Charcoal(GlowEffect): + def __init__(self, ctr): + cols = [[0.6057, 1.0, -0.99], [0.6171, 1.0, -0.8865], [0.6254, 1.0, -0.8083], [0.5796, 1.0, -0.7257], [0.6222, 1.0, -0.6779], [0.5606, 1.0, -0.6080], [0.5956, 1.0, -0.4838]] + super(Charcoal, self).__init__(ctr, cols, 2, [2, 4], 8) + + +class Fire(GlowEffect): + def __init__(self, ctr): + cols = [[0.5689, 1.0, -0.2847], [0.5413, 1.0, -0.1809], [0.5119, 1.0, -0.0685], [0.6185, 1.0, -0.4416], [0.6206, 1.0, -0.6780], [0.5068, 1.0, 0.1797], [0.5603, 1.0, -0.0170], [0.45, 1.0, 0.1]] + super(Fire, self).__init__(ctr, cols, 2, [3, 6], 20) + + +class Water(GlowEffect): + def __init__(self, ctr): + cols = [[0.0, 1.0, -0.2], [0.0, 1.0, -0.5], [0.05, 1.0, -0.3], [0.05, 1.0, 0.0], [0.1, 1.0, -0.5], [0.1, 1.0, 0.0], [0.15, 1.0, 0.0], [0.0, 1.0, 0.8]] + super(Water, self).__init__(ctr, cols, 2, [3, 6], 20) + + +class Meadow(GlowEffect): + def __init__(self, ctr): + cols = [[0.2427, 1.0, -0.6294], [0.2556, 1.0, -0.3245], [0.2692, 1.0, -0.0834], [0.2456, 1.0, 0.0243], [0.2901, 1.0, 0.2506], [0.400, 1.0, 0.4219], [0.6065, 1.0, -0.1989], [0.7709, 1.0, -0.3420], [0.7833, 0.1259, 0.1001]] + super(Meadow, self).__init__(ctr, cols, 2, [4, 8]) + + +class Aurora(GlowEffect): + def __init__(self, ctr): + cols = [[0.0, 0.0, -0.8], [0.0, 0.0, -0.5], [0.0, 0.0, 0.0], [0.7401, 0.3679, 0.1246], [0.2744, 0.3180, 0.1759], [0.2789, 0.7024, 0.2672], [0.7435, 0.7191, 0.2302], [0.7483, 1.0, 0.1677], [0.1928, 1.0, -0.1506], [0.1, 1.0, 0.9]] + super(Aurora, self).__init__(ctr, cols, 2, [6, 10]) + + +class Brown(GlowEffect): + def __init__(self, ctr): + cols = [(0.5, 0.0, -1.0), (0.435, 1.0, -0.98), (0.477, 1.0, -0.96), (0.497, 1.0, -0.81), (0.45, 1.0, -0.765), (0.52, 1.0, -0.76)] + super(Brown, self).__init__(ctr, cols, 3, [6, 10]) + + +class GlowCP(GlowEffect): + def __init__(self, ctr, cols): + super(GlowCP, self).__init__(ctr, cols, 3, [4, 8]) + + +""" +Sparkling effect + +Similar to the Bright Twinkle effect in the app, but more versatile. +Again there are specific examples, but check out SparkleStars which has +varied color temperature of whites, unlike the origonal. +""" + + +class SparkleEffect(Effect): + + def __init__(self, ctr, freq, nfunc, sfunc, icol=(0, 0, 0)): + super(SparkleEffect, self).__init__(ctr) + self.freq = freq + self.newfunc = nfunc + self.stepfunc = sfunc + self.initialcol = icol + + def reset(self, numframes): + self.pattern = self.ctr.make_solid_pattern(self.initialcol) + self.time = -1 + self.slist = [] + self.olist = list(range(0, self.ctr.num_leds)) + self.numframes = False # intentionally set to False here + if numframes: + # walk a number of steps, count the sfunc cycle, and record the poisson outcomes + self.blocktime = {} + tmp = self.newfunc(0, 0) + while self.stepfunc(0, tmp, self.time + 1, self.initialcol) is not False: + self.getnext() + self.leadintime = self.time + self.leadin = [ele for ele in self.slist] + self.numframes = numframes + + def getnext(self): + self.time += 1 + if self.numframes: + self.pattern = self.ctr.copy_pattern(self.pattern) # only needed for movie + if self.numframes and self.time >= self.numframes: + for (ind, coldesc, tm) in self.leadin: + if tm == self.time - self.numframes: + self.slist.append((ind, coldesc, self.time)) + else: + if self.numframes and self.time >= self.numframes - self.leadintime: + for (ind, coldesc, tm) in self.leadin: + if tm <= self.time - self.numframes + self.leadintime and ind in self.olist: + self.olist.remove(ind) + n = randompoisson(self.freq) + for j in range(n): + if self.olist: + pos = random.randint(0, len(self.olist) - 1) + ind = self.olist[pos] + coldesc = self.newfunc(ind, self.time) + self.slist.append((ind, coldesc, self.time)) + del self.olist[pos] + remlst = [] + for pos, (ind, coldesc, stime) in enumerate(self.slist): + col = self.stepfunc(ind, coldesc, self.time - stime, self.initialcol) + if col is False: + remlst.append(pos) + self.olist.append(ind) + elif col is True: + remlst.append(pos) + self.olist.append(ind) + self.ctr.modify_pattern(self.pattern, ind, self.initialcol) + else: + self.ctr.modify_pattern(self.pattern, ind, col) + for pos in reversed(sorted(remlst)): + del self.slist[pos] + return self.pattern + + +def randomhue(ind, tm): + hue = random.random() + return hue + + +def circularhuefunc(cycle): + return lambda ind, tm: (tm / float(cycle)) % 1.0 + + +def randomrgbfunc(hue=False, sat=False, light=False): + func = random_hsl_color_func(hue, sat, light) + return lambda ind, tm: func(0) + + +def randomwhitergb(ind, tm): + r = random.random() + return hsl_color(0.0 if r < 0.5 else 0.5, 1.0, 1.0 - abs(r - 0.5)) + + +def selectedrgbfunc(cols): + return lambda ind, tm : random.choice(cols) + + +def droplight(ind, hue, tm, initcol=False): + if tm < 21: + return hsl_color(hue, 1.0, 1.0 - tm / 10.0) + elif tm == 21: + return initcol or (0, 0, 0) + else: + return False + + +def pulselight(ind, hue, tm, initcol=False): + if tm < 21: + return hsl_color(hue, 1.0, -abs(tm - 10) / 10.0) + elif tm == 21: + return initcol or (0, 0, 0) + else: + return False + + +def looplight(ind, hue, tm, initcol=False): + if tm < 25: + return hsl_color(hue, 1.0, -1.0 + tm / 8.0) if tm < 16 else hsl_color(hue, 0.0, 1.0 - (tm - 16) / 4.0) + elif tm == 25: + return initcol or (0, 0, 0) + else: + return False + + +def revlooplight(ind, hue, tm, initcol=False): + if tm < 25: + return hsl_color(hue, 0.0, -1.0 + tm / 4.0) if tm < 8 else hsl_color(hue, 1.0, 1.0 - (tm - 8) / 8.0) + elif tm == 25: + return initcol or (0, 0, 0) + else: + return False + + +def flashlight_rgb(ind, rgb, tm, initcol=False): + if tm == 0: + return rgb + elif tm == 1: + return initcol or (0, 0, 0) + else: + return False + + +def flashlight_hue(ind, hue, tm, initcol=False): + if tm == 0: + return hsl_color(hue, 1.0, 0.0) + elif tm == 1: + return initcol or (0, 0, 0) + else: + return False + + +def flashcolor_rgb(ind, rgb, tm, initcol=False): + if tm == 0: + return hsl_color(0.0, 0.0, 1.0) + elif tm < 6: + return rgb + elif tm < 12: + return blendcolors(rgb, initcol or (0, 0, 0), (tm - 6) / 5.0) + else: + return False + + +def pulsecolor_rgb(ind, rgb, tm, initcol=False): + if tm < 17: + return blendcolors(rgb, initcol or (0, 0, 0), max(0.0, abs(tm - 8) / 8.0 - 0.0)) + else: + return False + + +class SimpleStars(SparkleEffect): + def __init__(self, ctr): + super(SimpleStars, self).__init__(ctr, 8, randomhue, flashlight_hue) + + +class Pulselight(SparkleEffect): + def __init__(self, ctr): + super(Pulselight, self).__init__(ctr, 3, randomhue, pulselight) + + +class Looplight(SparkleEffect): + def __init__(self, ctr, reverse=False): + super(Looplight, self).__init__(ctr, 3, randomhue, looplight if not reverse else revlooplight) + + +class LooplightSpectrum(SparkleEffect): + def __init__(self, ctr): + super(LooplightSpectrum, self).__init__(ctr, 3, circularhuefunc(120), looplight) + + +class SparkleRandom(SparkleEffect): + def __init__(self, ctr, hue=False, sat=False, light=False): + super(SparkleRandom, self).__init__(ctr, 5, randomrgbfunc(hue, sat, light), pulsecolor_rgb) + + +class SparkleStars(SparkleEffect): + def __init__(self, ctr): + super(SparkleStars, self).__init__(ctr, 4, randomwhitergb, pulsecolor_rgb) + + +class SparkleCP(SparkleEffect): + def __init__(self, ctr, cols): + colsrgb = list(map(lambda hsl: hsl_color(*hsl), cols)) + super(SparkleCP, self).__init__(ctr, 5, selectedrgbfunc(colsrgb), pulsecolor_rgb) + + +""" +Breathing effect + +Each led has a fixed color but slowly pulsing brightness. +""" + + +class Breathbit(): + + def __init__(self, col, lspan, steps, initstep=False): + self.currind = initstep if initstep else steps + self.steps = steps + self.hsteps = (steps - 1) / 2.0 + self.col1 = hsl_color(col[0], col[1], col[2]) + self.col2 = tuple(map(lambda x: int(round((1.0 - lspan) * x)), self.col1)) + self.preferred_fps = 6 + self.preferred_frames = 60 + + def getnext(self): + self.currind += 1 + if self.currind >= self.steps: + self.currind = 0 + prop = abs(self.currind - self.hsteps) / self.hsteps + return blendcolors(self.col1, self.col2, prop) + + +class BreathEffect(Effect): + + def __init__(self, ctr, cols, bend, lspan, cycles, fps=False): + super(BreathEffect, self).__init__(ctr) + if fps: + self.preferred_fps = fps + self.cols = cols + self.bend = bend + self.lspan = lspan + self.cycles = cycles + + def reset(self, numframes): + if type(self.cycles) == int: + steps = [self.cycles] + elif numframes: + steps = [] + for n in range(self.cycles[0], self.cycles[-1] + 1): + if numframes % n == 0: + steps.append(n) + if not steps: + steps = list(range(self.cycles[0], self.cycles[-1] + 1)) + else: + steps = list(range(self.cycles[0], self.cycles[-1] + 1)) + pr1 = 13 if len(steps) % 13 != 0 else 7 + pr2 = 11 if len(steps) % 11 != 0 else 7 + colarray = [self.cols[int((random.random()**self.bend) * len(self.cols))] + for i in range(self.ctr.num_leds)] + self.brarray = [Breathbit(colarray[i], self.lspan, + steps[(i * pr1) % len(steps)], + (i * pr2) % steps[(i * pr1) % len(steps)]) + for i in range(self.ctr.num_leds)] + + def getnext(self): + return self.ctr.make_func_pattern(lambda i: self.brarray[i].getnext()) + + +class BreathCP(BreathEffect): + def __init__(self, ctr, cols): + super(BreathCP, self).__init__(ctr, cols, 1, 0.75, [12, 30]) + + +""" +Glitter effect + +Similar to the Sparkles effect in the app, of brigh flashing leds against a +solid background. +Specific examples are included in an attempt to mimic the "metallic luster" +effects of AWW leds: Gold, Silver, Bronze, RoseGold +""" + + +class GlitterEffect(Effect): + + def __init__(self, ctr, freq, cols, icol=(0, 0, 0)): + super(GlitterEffect, self).__init__(ctr) + self.freq = freq + self.cols = list(map(lambda hsl: hsl_color(*hsl), cols)) + self.initialcol = hsl_color(*icol) + self.preferred_fps = 10 + self.preferred_frames = 100 + + def reset(self, numframes): + self.pattern = self.ctr.make_solid_pattern(self.initialcol) + + def getnext(self): + return sprinkle_pattern(self.ctr, self.pattern, self.cols, self.freq) + + +class Silver(GlitterEffect): + def __init__(self, ctr): + super(Silver, self).__init__(ctr, 10, [(0.0, 1.0, 0.8)], (0.0, 1.0, 0.3)) + + +class Gold(GlitterEffect): + def __init__(self, ctr): + super(Gold, self).__init__(ctr, 10, [(0.5, 1.0, 0.8)], (0.5, 1.0, -0.2)) + + +class RoseGold(GlitterEffect): + def __init__(self, ctr): + super(RoseGold, self).__init__(ctr, 10, [(0.57, 1.0, 0.8)], (0.57, 1.0, 0.05)) + + +class Bronze(GlitterEffect): + def __init__(self, ctr): + super(Bronze, self).__init__(ctr, 10, [(0.58, 1.0, 0.5)], (0.58, 1.0, -0.4)) + + +class StainlessSteel(GlitterEffect): + def __init__(self, ctr): + super(StainlessSteel, self).__init__(ctr, 10, [(0.0, 0.0, 1.0)], (0.0, 0.0, 0.0)) + + +class GlitterCP(GlitterEffect): + def __init__(self, ctr, cols): + if len(cols) == 1: + lcol = (cols[0][0], cols[0][1], min(1.0, cols[0][2] + 0.5)) + cols.append(lcol) + super(Silver, self).__init__(ctr, 10, cols[1:], cols[0]) + + +""" +Rotating pattern effect + +Each led passes through a sequence by "rotating" the entire pattern. +Two scattered spectrum effects are included as examples, where each led +rapidly passes through the spectrum wheras the overall impression stays +constant. +""" + + +class RotateEffect(Effect): + + def __init__(self, ctr, pat, perm, step=1, speed=20): + super(RotateEffect, self).__init__(ctr) + self.origpattern = pat + self.perm = perm + self.step = step + self.preferred_frames = ctr.num_leds // step + self.preferred_fps = speed + + def reset(self, numframes): + self.pattern = self.ctr.copy_pattern(self.origpattern) + + def getnext(self): + currpattern = self.ctr.permute_pattern(self.pattern, self.perm, circular=True) if self.perm else self.pattern + self.pattern = self.ctr.rotate_pattern(self.pattern, self.step, circular=True) + return currpattern + + +class Spectrum(RotateEffect): + def __init__(self, ctr, scattered=False, lightness=0.0, step=1): + numleds = ctr.num_leds + pat = ctr.make_func_pattern(lambda i: hsl_color(i / float(numleds), 1.0, lightness), circular=True) + if scattered: + perm = list(range(numleds)) + random.shuffle(perm) + else: + perm = False # [i if i 0.0031308 else x * 12.92 + + +def invcolorgamma_image(x): + return pow((x + 0.055) / 1.055, 2.4) if x > 0.04045 else x / 12.92 + + +def color_brightness(r, g, b): + return sum(map(lambda c, br: c * br, [r, g, b], led_brightness)) + + +# Entry points + +def set_color_style(style): + """ + Set the color circle and lightness policy to use in the color model. + Possible color circles are: "3col", "4col", "6col" and "8col". + Possible lightness policies are: "linear" and "equilight". + + :param str style: color circle or lightness policy to use. + :rtype: tuple + """ + global col_style, col_styles_dict + if style in ["linear", "equilight"]: + col_style = (col_style[0], style) + return col_style + elif style in col_styles_dict: + col_style = (style, col_style[1]) + return col_style + else: + return False + + +def get_color_style(): + """ + Return the currently used color circle and lightness policy as a tuple. + + :rtype: tuple + """ + global col_style + return col_style + + +def rgb_color(r, g, b): + """ + Takes r, g and b values in the range 0.0 - 1.0, and converts it to an + rgb tuple in the range 0-255, adjusted for white balance of the leds. + + :param float r: red component (0.0 - 1.0) + :param float g: green component (0.0 - 1.0) + :param float b: blue component (0.0 - 1.0) + :rtype: tuple + """ + global led_balance + return tuple(map(lambda c, b: max(0, min(255, int(255 * b * colorgamma(c)))), + [r, g, b], led_balance)) + + +def image_to_led_rgb(r, g, b): + """ + Converts rgb values for a computer image pixel into rgb values for a led, + compensating for gamma correction in the image. + The input values and the returned rgb values are all in the range 0-255. + + :param float r: red component (0 - 255) + :param float g: green component (0 - 255) + :param float b: blue component (0 - 255) + :rtype: tuple + """ + return tuple(map(lambda c, bal: max(0, min(255, int(255 * bal * colorgamma(invcolorgamma_image(c / 255.0))))), + [r, g, b], led_balance)) + + +def led_to_image_rgb(r, g, b): + """ + Converts rgb values for a led into rbg values for a computer image pixel, + compensating for gamma correction in the image. + The input values and the returned rgb values are all in the range 0-255. + + :param float r: red component (0 - 255) + :param float g: green component (0 - 255) + :param float b: blue component (0 - 255) + :rtype: tuple + """ + return tuple(map(lambda c, bal: max(0, min(255, int(255 * colorgamma_image(invcolorgamma(c / (bal * 255.0)))))), + [r, g, b], led_balance)) + + +def hsl_color(h, s, l): + """ + Takes hue (0.0 - 1.0), saturation (0.0 - 1.0), and lightness (-1.0 - 1.0) + values and converts it to an rgb tuple in the range 0-255. + + :param float h: hue component (0.0 - 1.0) + :param float s: saturation component (0.0 - 1.0) + :param float l: lightness component (-1.0 - 1.0) + :rtype: tuple + """ + global col_style, col_styles_dict + hramp = col_styles_dict[col_style[0]] + ir = 1.0 / led_balance[0] + ig = 1.0 / led_balance[1] + ib = 1.0 / led_balance[2] + irg = min(ir, ig) + irb = min(ir, ib) + igb = min(ig, ib) + iramp = [(0, 0, ib), (0, igb / 2, igb / 2), (0, ig, 0), (irg / 2, irg / 2, 0), (ir, 0, 0), (irb / 2, 0, irb / 2), (0, 0, ib)] + i = 0 + while h > hramp[i + 1]: + i += 1 + p = (h - hramp[i]) / (hramp[i + 1] - hramp[i]) + (r, g, b) = tuple(map(lambda x1, x2: p * (x2 - x1) + x1, iramp[i], iramp[i + 1])) + nrm = max(r / ir, g / ig, b / ib) + (r, g, b) = tuple(map(lambda x: x / nrm, (r, g, b))) + ll = (l + 1.0) * 0.5 + if col_style[1] == "linear": + if ll < 0.5: + t1 = l + 1.0 + t2 = 0.0 + else: + t1 = 1.0 - l + t2 = l + else: + br = color_brightness(r, g, b) + # make the hue get its maximum dynamic saturation, up till maximum green, then linearly decreasing + e = max(r, g, b) + p = min(1.0, (1.0 - ll / e) / (1.0 - br), (1.0 - ll * led_balance[1]) / (1.0 - led_brightness[1])) + t1 = ll * p / ((br - e) * p + e) + t2 = max(0.0, ll - t1 * br) + t1 = s * t1 + t2 = s * t2 + ll * (1.0 - s) + return rgb_color(r * t1 + t2, g * t1 + t2, b * t1 + t2) diff --git a/xled/pattern.py b/xled/pattern.py new file mode 100644 index 0000000..cbf8874 --- /dev/null +++ b/xled/pattern.py @@ -0,0 +1,198 @@ +""" +xled.pattern +~~~~~~~~~~~~ + +Author: Anders Holst (anders.holst@ri.se), 2021 + +This module contains a number of utility functions useful when creating +patterns, as well as several functions for creating various patterns. +The latter are meant primarily as a collection of examples. It should be +easy to construct many more types of patterns with just a few lines of code. + +All the functions for creating patterns take a parameter ctr, which is a +HighControlInterface connected to the led lights on which the pattern will fit. +""" + +from xled.ledcolor import hsl_color, rgb_color +import random +import math as m + + +# Some utility functions + +def randomdiscrete(probs): + """ + Takes a list of probabilities that should sum to one, and returns a random + list index according to the probabilities in the list. + """ + n = len(probs) - 1 + acc = 0.0 + ind = -1 + r = random.random() + while acc <= r and ind < n: + ind += 1 + acc += probs[ind] + return ind + + +def randompoisson(lam): + """ + Returns a random number from a Poisson distribution with parameter lam. + """ + lp = 0.0 + k = -1 + while lp < lam: + lp -= m.log(random.random()) + k += 1 + return k + + +def dimcolor(rgb, prop): + """ + Dims a given rgb color to prop, which should be in the interval 0.0 - 1.0. + """ + return tuple(map(lambda x: int(round(x * prop)), rgb)) + + +def blendcolors(rgb1, rgb2, prop): + """ + Blends two rgb colors so that prop comes from rgb2 and (1-prop) from rgb1, + where prop should be a proportion in the interval 0.0 - 1.0. + """ + return tuple(map(lambda c1, c2: int(round(c1 + (c2 - c1) * prop)), rgb1, rgb2)) + + +def random_color(): + """ + Returns a random color drawn uniformly from the whole rgb-cube. + """ + return rgb_color(random.random(), random.random(), random.random()) + + +def random_hsl_color_func(hue=False, sat=False, light=False): + """ + Returns a function that generates random colors within certain intervals. + Each of the parameters hue, sat, and light can be either False, a constant, + or an interval. If it is False (or not given at all), the parameter is + randomized throughout its range, if it is an interval it is randomly drawn + within this range, and if it is a constant it is set to that value. + With this function you can thus construct a wide variety of random color + generating functions. + """ + + def isnum(x): + return type(x) in [float, int] + + def lightexp(y, x): + return 1 - (1 - y)**x if y > 0 else (1 + y)**x - 1 if y < 0 else 0.0 + + if not isnum(light): + le = 1 + (1 if not isnum(sat) else 0) + (1 if not isnum(hue) else 0) + if type(light) in [tuple, list]: + l0 = lightexp(light[0], le) + ld = lightexp(light[1], le) - l0 + else: + l0 = -1.0 + ld = 2.0 + else: + l0 = light + ld = 0.0 + if not isnum(sat): + se = 1 + (1 if not isnum(hue) else 0) + if type(sat) in [tuple, list]: + s0 = sat[0]**se + sd = sat[1]**se - s0 + else: + s0 = 0.0 + sd = 1.0 + else: + s0 = sat + sd = 0.0 + if not isnum(hue): + if type(hue) in [tuple, list]: + h0 = hue[0] + hd = (hue[1] - hue[0]) % 1.0 + else: + h0 = 0.0 + hd = 1.0 + else: + h0 = hue + hd = 0.0 + + def func(*args): + light = lightexp(l0 + random.random() * ld, 1.0 / le) if ld != 0.0 else l0 + sat = (s0 + random.random() * sd)**(1.0 / se) if sd != 0.0 else s0 + hue = (h0 + random.random() * hd) % 1.0 if hd != 0.0 else h0 + return hsl_color(hue, sat, light) + + return func + + +def sprinkle_pattern(ctr, pat, rgblst, freq): + """ + Returns a copy of pat where a random number of pixels (with freq as + expected number) are changed to a randomly picked color from rgblst. + """ + pat = ctr.copy_pattern(pat) + n = randompoisson(freq) + inds = random.sample(range(ctr.num_leds), n) + for i in inds: + ctr.modify_pattern(pat, i, random.choice(rgblst)) + return pat + + +# Example functions for creating patterns + +def make_alternating_color_pattern(ctr, rgblst): + """ + Return a pattern of alternating colors from rgblst. + """ + n = len(rgblst) + return ctr.make_func_pattern(lambda i: rgblst[i % n]) + + +def make_color_spectrum_pattern(ctr, offset=0, lightness=0.0): + """ + Return a pattern of the color spectrum along the string. + """ + return ctr.make_func_pattern(lambda i: hsl_color(((i - offset) / float(ctr.num_leds)) % 1.0, 1.0, lightness), + circular=True) + + +def make_random_select_color_pattern(ctr, rgblst, prop=False): + """ + Return a pattern of randomly selected colors from rgblst, optionally with + the probabilities given by prop. + """ + if prop and len(prop) == len(rgblst): + return ctr.make_func_pattern(lambda i: rgblst[randomdiscrete(prop)]) + else: + return ctr.make_func_pattern(lambda i: rgblst[random.randint(0, len(rgblst) - 1)]) + + +def make_random_blend_color_pattern(ctr, rgb1, rgb2): + """ + Return a pattern of random blends of the two given colors. + """ + return ctr.make_func_pattern(lambda i: blendcolors(rgb1, rgb2, random.random())) + + +def make_random_colors_pattern(ctr, lightness=0.0): + """ + Return a pattern of randomly drawn hues of the same lightness. + """ + return ctr.make_func_pattern(lambda i: hsl_color(random.random(), 1.0, lightness)) + + +def make_random_lightness_pattern(ctr, hue): + """ + Return a pattern with the same hue but randomly drawn lightnesses. + """ + return ctr.make_func_pattern(lambda i: hsl_color(hue, 1.0, random.random() * 2 - 1.0)) + + +def make_random_hsl_pattern(ctr, hue=False, sat=False, light=False): + """ + Return a pattern with random colors in the ranges specified by hue, sat, and light. + """ + return ctr.make_func_pattern(random_hsl_color_func(hue, sat, light)) diff --git a/xled/security.py b/xled/security.py index 0d430c9..ba55a18 100644 --- a/xled/security.py +++ b/xled/security.py @@ -42,7 +42,7 @@ SHARED_KEY_CHALLANGE = b"evenmoresecret!!" #: Default key to encrypt WiFi password -SHARED_KEY_WIFI = "supersecretkey!!" +SHARED_KEY_WIFI = b"supersecretkey!!" #: Read buffer size for sha1sum BUFFER_SIZE = 65536 @@ -146,8 +146,10 @@ def encrypt_wifi_password(password, mac_address, key=SHARED_KEY_WIFI): :return: Base 64 encoded string of ciphertext of input password :rtype: str """ + if not isinstance(password, bytes): + password = bytes(password, "utf-8") secret_key = derive_key(key, mac_address) - data = password.ljust(64, "\x00") + data = password.ljust(64, b"\x00") rc4_encoded = rc4(data, secret_key) return base64.b64encode(rc4_encoded) diff --git a/xled/test_colorsphere.py b/xled/test_colorsphere.py new file mode 100644 index 0000000..49f8f59 --- /dev/null +++ b/xled/test_colorsphere.py @@ -0,0 +1,9 @@ +from xled.control import HighControlInterface +from xled.discover import discover +from xled.colorsphere import launch_colorpicker + +dev = discover() +ctr = HighControlInterface(dev.ip_address) + +launch_colorpicker(ctr, True, True) + diff --git a/xled/test_high_control.py b/xled/test_high_control.py new file mode 100644 index 0000000..110ddca --- /dev/null +++ b/xled/test_high_control.py @@ -0,0 +1,94 @@ +from xled.control import HighControlInterface +from xled.discover import discover +import time + +dev = discover() + +# Start of test sequence + +ctr = HighControlInterface(dev.ip_address) + +ctr.get_formatted_timer() + +ctr.set_formatted_timer("18:59:00", "19:00:00") + +ctr.get_formatted_timer() + +ctr.disable_timer() + +ctr.get_formatted_timer() + +ctr.turn_on() + +ctr.is_on() + +ctr.turn_off() + +ctr.show_demo(3) + +ctr.show_effect(2) + +def blendcolors(rgb1, rgb2, prop): + return tuple(map(lambda c1, c2: int(round(c1 + (c2 - c1) * prop)), rgb1, rgb2)) + +m1 = ctr.make_empty_movie() + +for i in range(0, 21): + ctr.add_to_movie(m1, ctr.make_solid_pattern(blendcolors((230, 0, 0), (0, 255, 0), i/20.0))) + +m2 = ctr.make_func_movie(21, lambda i: ctr.make_solid_pattern(blendcolors((0, 255, 0), (0, 0, 163), i/20.0))) + +lst = [ctr.make_solid_pattern(blendcolors((0, 0, 163), (230, 0, 0), i/20.0)) for i in range(0, 21)] + +m3 = ctr.to_movie(lst) + +ctr.is_pattern(lst[0]) + +ctr.is_movie(m3) + +ctr.show_movie(m1, 5) + +ctr.show_movie(m2, 5) + +ctr.upload_movie(m3, 5) + +ctr.show_playlist([0, 1, 2], 10) + +ctr.clear_movies() + +numleds = ctr.get_device_info()['number_of_led'] + +pat = ctr.make_solid_pattern((0, 0, 0)) + +for i in range(0, 10): + pat = ctr.modify_pattern(pat, i*7, (230, 255, 163)) + +perm = list(reversed(range(0, numleds))) + +ctr.show_pattern(pat) + +pat = ctr.permute_pattern(pat, perm, circular=True) + +ctr.show_pattern(pat) + +pat = ctr.make_func_pattern(lambda i: blendcolors((230, 0, 82), (0, 255, 82), abs(i-numleds//2)*2.0/numleds), circular=True) + +for i in range(0, 3*numleds): + ctr.show_rt_frame(pat) + time.sleep(0.02) + pat = ctr.rotate_pattern(pat, 1, circular=True) + +for i in range(0, 3*numleds): + pat = ctr.make_layout_pattern(lambda pos: blendcolors((230, 0, 82), (0, 255, 82), abs(((pos[0] + pos[1] + float(i)/numleds) % 2.0) - 1.0)), style='square') + ctr.show_rt_frame(pat) + time.sleep(0.02) + +ctr.save_movie("testmovie.txt", m1, 8) + +(m4, fps) = ctr.load_movie("testmovie.txt") + +ctr.show_movie(m4, fps) + +ctr.turn_off() + +# End of test sequence diff --git a/xled/test_low_control.py b/xled/test_low_control.py new file mode 100644 index 0000000..c9697ea --- /dev/null +++ b/xled/test_low_control.py @@ -0,0 +1,192 @@ +from xled.control import ControlInterface +from xled.discover import discover + +import io +import struct +import time + +def make_solid_movie(ctr, r, g, b): + num_leds = ctr.get_device_info()['number_of_led'] + pat = [struct.pack(">BBB", r, g, b)] * num_leds + movie = io.BytesIO() + movie.write(b''.join(pat)) + movie.seek(0) + return movie + +def rotate90(coords): + return [{'x': 1.0-2*ele['y'], 'y': (ele['x']+1.0)/2, 'z': ele['z']} + for ele in coords] + +dev = discover() + +# Start of test sequence + +ctr = ControlInterface(dev.ip_address, dev.hw_address) + +ctr.check_status()._data + +ctr.firmware_version()._data + +vers = tuple(map(int, ctr.firmware_version()['version'].split('.'))) + +ctr.get_device_info()._data + +numleds = ctr.get_device_info()['number_of_led'] + +name = ctr.get_device_name()['name'] + +ctr.set_device_name("Dummyname")._data + +ctr.get_device_name()._data + +ctr.set_device_name(name)._data + +ctr.get_led_config()._data + +ctr.get_network_status()._data + +ctr.network_scan()._data + +time.sleep(1) +ctr.network_scan_results()._data + +ctr.set_timer(3600, 7200)._data + +ctr.get_timer()._data + +ctr.set_timer(-1, -1)._data + +ctr.get_mode()._data + +ctr.set_mode('effect') + +ctr.get_mode()._data + +ctr.get_led_effects()._data + +ctr.get_led_effects_current()._data + +ctr.set_led_effects_current(2)._data + +ctr.led_reset()._data + +if vers > (2, 4, 2): + + res = ctr.get_mqtt_config()._data + + ctr.set_mqtt_config("127.0.0.1", None, res['client_id'], 'Pizza', 3600)._data + + ctr.get_mqtt_config()._data + + ctr.set_mqtt_config(res['broker_host'], res['broker_port'], res['client_id'], res['user'], res['keep_alive_interval'])._data + + ctr.get_brightness()._data + + ctr.set_brightness(50)._data + + ctr.get_brightness()._data + + ctr.set_brightness(-20, relative=True)._data + + ctr.get_brightness()._data + + ctr.set_brightness(100, enabled=False)._data + + ctr.get_saturation()._data + + ctr.set_saturation(90)._data + + ctr.get_saturation()._data + + ctr.set_saturation(-30, relative=True)._data + + ctr.get_saturation()._data + + ctr.set_saturation(+80, enabled=False, relative=True)._data + + ctr.get_saturation()._data + +m_white = make_solid_movie(ctr, 230, 255, 160) +m_green = make_solid_movie(ctr, 0, 255, 0) +m_yellow = make_solid_movie(ctr, 230, 170, 0) +m_lime = make_solid_movie(ctr, 100, 255, 0) +m_orange = make_solid_movie(ctr, 230, 85, 0) + +ctr.get_led_movie_config()._data + +ctr.set_led_movie_full(m_white) + +ctr.set_led_movie_config(1000, 1, numleds) + +ctr.set_mode('movie') + +if vers > (2, 5, 6): + + ctr.get_movies()._data + + ctr.get_movies_current()._data + + ctr.set_movies_new("green", "00000000-0000-0000-000A-000000000001", "rgb_raw", numleds, 1, 1)._data + + ctr.set_movies_full(m_green)._data + + ctr.set_movies_new("lime", "00000000-0000-0000-000A-000000000002", "rgb_raw", numleds, 1, 1) + + ctr.set_movies_full(m_lime)._data + + ctr.get_movies_current()._data + + ctr.set_movies_current(1)._data + + lst = ctr.get_movies()['movies'] + + pl = [{'unique_id': ele['unique_id'], 'duration': 5} for ele in lst] + + ctr.get_playlist()._data + + ctr.set_playlist(pl)._data + + ctr.get_playlist()._data + + ctr.set_mode('playlist') + + ctr.get_playlist_current()._data + + ctr.set_playlist_current(2)._data + + ctr.delete_playlist()._data + + ctr.get_mode()._data + + ctr.set_mode('effect') + + ctr.delete_movies()._data + + ctr.get_movies()._data + +m_yellow.seek(0) +m_orange.seek(0) +m_lime.seek(0) +m_green.seek(0) + +ctr.set_mode('rt') + +ctr.set_rt_frame_rest(m_orange) + +ctr.set_rt_frame_socket(m_yellow, 1, min(255, numleds)) + +ctr.set_rt_frame_socket(m_lime, 2) + +ctr.set_rt_frame_socket(m_green, 3) + +if vers > (2, 4, 2): # Uncertain about first version with layout + + ctr.set_mode('effect') + + layout = ctr.get_led_layout()._data + + ctr.set_led_layout(layout['source'], rotate90(layout['coordinates']), layout['synthesized']) + +ctr.set_mode('off') + +# End of test sequence diff --git a/xled/util.py b/xled/util.py index cb22983..6936abf 100644 --- a/xled/util.py +++ b/xled/util.py @@ -20,9 +20,14 @@ def seconds_after_midnight(): def date_from_seconds_after_midnight(seconds): now = datetime.datetime.now() - then = now + datetime.timedelta(seconds=seconds) + then = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(seconds=seconds) return then def seconds_after_midnight_from_time(hours, minutes): return hours * 60 * 60 + minutes * 60 + + +def seconds_after_midnight_from_string(timestr, form): + dt = datetime.datetime.strptime(timestr, form) + return dt.hour * 3600 + dt.minute * 60 + dt.second diff --git a/xled/windowmgr.py b/xled/windowmgr.py new file mode 100644 index 0000000..9f0750c --- /dev/null +++ b/xled/windowmgr.py @@ -0,0 +1,151 @@ + +""" +xled.windowmgr +~~~~~~~~~~~~~~ + +Author: Anders Holst (anders.holst@ri.se), 2021 + +This is a helper-module to create simple interactive interfaces in python, +consisting of a single window with several sub-windows or widgets inside. +It takes care of event handling and dispatches the events to the appropriate +widgets. +""" + +import matplotlib.pyplot as plt +import matplotlib as mpl + +mpl.interactive(True) +mpl.rcParams['toolbar'] = 'None' + + +class WindowMgr(): + def __init__(self, name, width, height, numx, numy, marg=0, dir='horizontal'): + self.maxind = (numx, numy) + self.dir = dir + self.dxm = marg / width + self.dym = marg / height + self.dx = (1.0 - self.dxm) / self.maxind[0] + self.dy = (1.0 - self.dym) / self.maxind[1] + self.nextind = (0, 0) + self.targetdict = {} + self.lastkeytarget = None + self.lastbuttontarget = None + self.motion_hook = [] + self.resize_hook = [] + self.close_hook = [] + self.globalkeydict = {} + self.fig = plt.figure(name) + self.pixpt = 72.0 / self.fig.dpi + self.fig.set_size_inches((width / self.fig.dpi, height / self.fig.dpi)) + self.fig.canvas.mpl_connect('key_press_event', self.key_press_callback) + self.fig.canvas.mpl_connect('key_release_event', self.key_release_callback) + self.fig.canvas.mpl_connect('scroll_event', self.scroll_callback) + self.fig.canvas.mpl_connect('button_press_event', self.button_press_callback) + self.fig.canvas.mpl_connect('motion_notify_event', self.button_motion_callback) + self.fig.canvas.mpl_connect('button_release_event', self.button_release_callback) + self.fig.canvas.mpl_connect('resize_event', self.resize_callback) + self.fig.canvas.mpl_connect('close_event', self.close_callback) + + def get_figure(self): + return self.fig + + def set_background(self, rgb): + self.fig.set_facecolor(rgb) + + def get_next_rect(self): + (nx, ny) = self.nextind + if nx < 0 or ny < 0: + return False + rect = (nx * self.dx + self.dxm, 1.0 - (ny + 1) * self.dy, self.dx - self.dxm, self.dy - self.dym) + if self.dir == 'vertical': + ny += 1 + if ny >= self.maxind[1]: + ny = 0 + nx += 1 + if nx >= self.maxind[0]: + nx = -1 + else: + nx += 1 + if nx >= self.maxind[0]: + nx = 0 + ny += 1 + if ny >= self.maxind[1]: + ny = -1 + self.nextind = (nx, ny) + return rect + + def register_target(self, rect, target): + self.targetdict[rect] = target + + def add_motion_callback(self, func): + self.motion_hook.append(func) + + def add_resize_callback(self, func): + self.resize_hook.append(func) + + def add_close_callback(self, func): + self.close_hook.append(func) + + def clear_targets(self): + self.targetdict = {} + self.nextind = (0, 0) + + def get_callback_target(self, event): + pos = self.fig.transFigure.inverted().transform((event.x, event.y)) + for rect in self.targetdict: + if pos[0] >= rect[0] and pos[0] < rect[0] + rect[2] and pos[1] >= rect[1] and pos[1] < rect[1] + rect[3]: + return self.targetdict[rect] + return None + + def install_key_action(self, key, func): + self.globalkeydict[key] = func + + def key_press_callback(self, event): + if event.key in self.globalkeydict: + self.globalkeydict[event.key]() + else: + self.lastkeytarget = self.get_callback_target(event) + if self.lastkeytarget is not None and 'key_press_event' in dir(self.lastkeytarget): + self.lastkeytarget.key_press_event(event) + + def key_release_callback(self, event): + # The release goes to the same target as the press + if self.lastkeytarget is not None and 'key_release_event' in dir(self.lastkeytarget): + self.lastkeytarget.key_release_event(event) + + def scroll_callback(self, event): + # Scrolls are special - no release + target = self.get_callback_target(event) + if target is not None and 'scroll_event' in dir(target): + target.scroll_event(event) + + def button_press_callback(self, event): + self.lastbuttontarget = self.get_callback_target(event) + if self.lastbuttontarget is not None and 'button_press_event' in dir(self.lastbuttontarget): + self.lastbuttontarget.button_press_event(event) + + def button_motion_callback(self, event): + # The motion goes to the same target as the press. + # Only motion events while pressed, unless specific motion callback + if self.lastbuttontarget is not None and 'motion_notify_event' in dir(self.lastbuttontarget): + self.lastbuttontarget.motion_notify_event(event) + elif self.motion_hook: + for func in self.motion_hook: + func(event) + + def button_release_callback(self, event): + # The release goes to the same target as the press + target = self.lastbuttontarget + self.lastbuttontarget = None + if target is not None and 'button_release_event' in dir(target): + target.button_release_event(event) + + def resize_callback(self, event): + if self.resize_hook: + for func in self.resize_hook: + func(event) + + def close_callback(self, event): + if self.close_hook: + for func in self.close_hook: + func(event)