AMoo-Miki / homebridge-tuya-lan

Homebridge plugin for IoT devices that use Tuya Smart's platform
MIT License
198 stars 50 forks source link

Blinds not working #227

Open vijaydembla1 opened 3 years ago

vijaydembla1 commented 3 years ago

Hi,

I have used this plug-in and love it. It is really convenient once set up in home kit. I've got many accessories working rock solid except the blinds. I have configured them twice and obtained the new keys, but the accessory doesn't really change the state regardless of the commands given to the accessory on home kit. The accessory works fine with the tuya app though.

My config below:

"platform": "TuyaLan", "devices": [{ "name": "Bedroom Blinds", "type": "SimpleBlinds", "manufacturer": "Dumbs", "model": "Roller Switch", "id": "xxxxxxxxxxxxxxxxxxx", "key": "xxxxxxxxxxxxxxxxxx", "timeToOpen": 37, "timeToTighten": 0, "flipState": true

The accessory appears, but have the following issues:

  1. Regardless of the command passed on the homekit, there's no change in the state of the blinds
  2. The blinds always appear open on homekit
  3. The message on the terminal is as below:

[TuyaAccessory] Blinds asked to move from 100 to 44 [TuyaAccessory] Sending Bedroom Blinds {"1":"1"} [TuyaAccessory] Blinds will stop in 20720ms [TuyaAccessory] Blinds assumed started xxxxxx; 1ms ago [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw change to undefined [TuyaAccessory] Blinds asked to move from 100 to 40 [TuyaAccessory] Blinds closing had started xxxxxx; 285ms ago [TuyaAccessory] Blinds' adjusted assumedPosition is 99.22972972972973 [TuyaAccessory] Sending Bedroom Blinds {"1":"1"} [TuyaAccessory] Blinds will stop in 21915ms [TuyaAccessory] Blinds assumed started xxxxxx; 286ms ago [TuyaAccessory] Blinds asked to stop [TuyaAccessory] Sending Bedroom Blinds {"1":"3"} [TuyaAccessory] Blinds asked to move from 99.22972972972973 to 0 [TuyaAccessory] Blinds closing had started xxxxx; 29847ms ago [TuyaAccessory] Blinds' adjusted assumedPosition is 19.332432432432427 [TuyaAccessory] Sending Bedroom Blinds {"1":"1"}

  1. The log is updated with the following info when the tuya app is controlling the blinds:

[TuyaAccessory] Heard back from Garage Door with command 8 [TuyaAccessory] GarageDoor changed: {"1":true,"7":0,"101":false} [TuyaAccessory] Heard back from Garage Door with command 8 [TuyaAccessory] GarageDoor changed: {"1":false,"7":0,"101":false} [TuyaAccessory] Heard back from Garage Door with command 8 [TuyaAccessory] Heard back from Garage Door with command 8 [TuyaAccessory] GarageDoor changed: {"1":true,"7":0,"101":false} [TuyaAccessory] Heard back from Garage Door with command 8 [TuyaAccessory] GarageDoor changed: {"1":false,"7":0,"101":false} [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw change to undefined [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw change to undefined

Any help would be appreciated

Thanks

SamGeens commented 3 years ago

Which blinds are you using? I’ve had the same problem with my Maxcio blinds.

After searching for a while I discovered that they listen to open, close, stop instead of 1,2,3. I simply changed these parameters in the SimpleBlindsAccessory.js file and everything is working fine now.

You can check the command when using the tuya app to control the blinds and watch the output in the Homebridge console log (mike you did). You should see something like: [TuyaAccessory] Blinds saw change to {"1":"open"}. If you compare this you see you’re currently sending: [TuyaAccessory] Sending Bedroom Blinds {"1":"1"}

vijaydembla1 commented 3 years ago

Thanks a million for the response, have been waiting to get some help from someone on this. I'm using Tuy smart blinds, similar to this: https://www.kogan.com/au/buy/kogan-smarterhome-smart-blinds-driver/

Every time I control the blinds using Tuya app, I get the following message on homebridge terminal:

[TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw a change to undefined

I have installed Homebridge as a docker container on RPI3. I tried looking for the file SimpleBlindsAccessory.js in the homebridge folder, but couldn't find it. Can you please help me locate it, I have a feeling that I might just get lucky :-)

Appreciate your quick response, thanks again

SamGeens commented 3 years ago

First of all make sure you installed version 1.5.0-rc.12 with npm i -g homebridge-tuya-lan@1.5.0-rc.12.

Location the source code of this npm package is done in the global node_modules folder. For more information: Where does npm install packages?.

The install location is shown during installation or if already installed npm will show the location again: root@raspberrypi:~# npm i -g homebridge-tuya-lan /usr/bin/tuya-lan-find -> /usr/lib/node_modules/homebridge-tuya-lan/bin/cli.js + homebridge-tuya-lan@1.5.0-rc.12 updated 1 package in 13.186s

So the location of SimpleBlindsAccessory.js is /usr/lib/node_modules/homebridge-tuya-lan/lib/SimpleBlindsAccessory.js.

Things you should try

  1. Copy the SimpleBlindsAccessory.js to SimpleBlindsAccessory.js.old for a backup and replace the content of SimpleBlindsAccessory.js with the content of the SimpleBlindsAccessory.js in this GitHub repository. It seems that the code in this repository has updates that are not in the npm package.

  2. With console.log you can log what happens. I changed (around line 80): this.device.on('change', changes => { console.log("[TuyaAccessory] Blinds saw change to " + changes[this.dpAction]); to this.device.on('change', changes => { console.log("[TuyaAccessory] Raw changes " + JSON.stringify(changes)); console.log("[TuyaAccessory] Blinds saw change to " + changes[this.dpAction]); This way you should be able to see what is actually in the undefined message.

  3. When I used the blinds with an other app I saw [TuyaAccessory] Blinds saw change to {"1":"open"} and it was sending [TuyaAccessory] Sending Bedroom Blinds {"1":"1"}. So I figured I had to change the numbers to open,close and stop (lines 36-46)

It seems that you blinds do not say which command they use to open/close. This might be solved with an update to 1.5.0-rc.12 otherwise this could be in the undefined message which can be seen with step 2.

Hopefully this helps you and have enough javascript knowledge to full this off.

Kind regards

vijaydembla1 commented 3 years ago

Hi Sam,

Thanks again for spending time and proving this detailed response.

I have the exact same version of the plug in. I managed to find the location of the SimpleBlindsjs file, modified the line 71 (in your case is 80) to the one you mentioned above From: _this.device.on('change', changes => { console.log("[TuyaAccessory] Blinds saw change to " + changes['1']);___

To: this.device.on('change', changes => { console.log("[TuyaAccessory] Raw changes " + JSON.stringify(changes)); console.log("[TuyaAccessory] Blinds saw change to " + changes['1']);

Restarted homebridge Made changes to the blind using the Tuya app, the message in console is:

[TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw change to undefined [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw change to undefined [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds saw change to undefined

Do you think you can provide me the link to your Blind accessory. I just might copy it and see if it does any better for me. Once again, I truly appreciate your help, I have got all the accessories working on homekit, except this one :-(

Thanks

SamGeens commented 3 years ago

I have the Maxcio WF-CS01

The only left to say is that you might have done something incorrrent since the TuyaAccessory is still showing [TuyaAccessory] Blinds saw change to undefined, this is really weird behaviour since you added the [TuyaAccessory] Raw changes ... line right above it. So it should definitely print both those lines in the Homebridge logs.

So the way I see it either you did something wrong or the printed line is not reached yet.

To help I added my complete SimpleBlindsAccessory.js file below (be aware that I changed the numbers to open, close and stop this might be different for you):

// Change this file with the SimpleBlindsAccessory.js file in the node module 

const BaseAccessory = require('./BaseAccessory');

const BLINDS_OPENING = 'opening';
const BLINDS_CLOSING = 'closing';
const BLINDS_STOPPED = 'stopped';

const BLINDS_OPEN = 100;
const BLINDS_CLOSED = 0;

class SimpleBlindsAccessory extends BaseAccessory {
    static getCategory(Categories) {
        return Categories.WINDOW_COVERING;
    }

    constructor(...props) {
        super(...props);
    }

    _registerPlatformAccessory() {
        const {Service} = this.hap;

        this.accessory.addService(Service.WindowCovering, this.device.context.name);

        super._registerPlatformAccessory();
    }

    _registerCharacteristics(dps) {
        const {Service, Characteristic} = this.hap;
        const service = this.accessory.getService(Service.WindowCovering);
        this._checkServiceName(service, this.device.context.name);

        this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1';

        let _cmdOpen = 'open';
        if (this.device.context.cmdOpen) {
            _cmdOpen = ('' + this.device.context.cmdOpen).trim();
        }

        let _cmdClose = 'close';
        if (this.device.context.cmdClose) {
            _cmdClose = ('' + this.device.context.cmdClose).trim();
        }

        this.cmdStop = 'stop';
        if (this.device.context.cmdStop) {
            this.cmdStop = ('' + this.device.context.cmdStop).trim();
        }

        this.cmdOpen = _cmdOpen;
        this.cmdClose = _cmdClose;
        if (!!this.device.context.flipState) {
            this.cmdOpen = _cmdClose;
            this.cmdClose = _cmdOpen;
        }

        this.duration = parseInt(this.device.context.timeToOpen) || 45;
        const endingDuration = parseInt(this.device.context.timeToTighten) || 0;
        this.minPosition = endingDuration ? Math.round(endingDuration * -100 / (this.duration - endingDuration)) : BLINDS_CLOSED;

        // If the blinds are closed, note it; if not, assume open because there is no way to know where it is
        this.assumedPosition = dps[this.dpAction] === this.cmdClose ? this.minPosition : BLINDS_OPEN;
        this.assumedState = BLINDS_STOPPED;
        this.changeTime = this.targetPosition = false;

        const characteristicCurrentPosition = service.getCharacteristic(Characteristic.CurrentPosition)
            .updateValue(this._getCurrentPosition(dps[this.dpAction]))
            .on('get', this.getCurrentPosition.bind(this));

        const characteristicTargetPosition = service.getCharacteristic(Characteristic.TargetPosition)
            .updateValue(this._getTargetPosition(dps[this.dpAction]))
            .on('get', this.getTargetPosition.bind(this))
            .on('set', this.setTargetPosition.bind(this));

        const characteristicPositionState = service.getCharacteristic(Characteristic.PositionState)
            .updateValue(this._getPositionState())
            .on('get', this.getPositionState.bind(this));

        this.device.on('change', changes => {
            // Added extra debug line here
            console.log("[TuyaAccessory] Blinds saw (full) change to " + JSON.stringify(changes));
            console.log("[TuyaAccessory] Blinds saw change to " + changes[this.dpAction]);
            if (changes.hasOwnProperty(this.dpAction)) {
                switch (changes[this.dpAction]) {
                    case this.cmdOpen:  // Starting to open
                        this.assumedState = BLINDS_OPENING;
                        characteristicPositionState.updateValue(Characteristic.PositionState.INCREASING);

                        // Only if change was external or someone internally asked for open
                        if (this.targetPosition === false || this.targetPosition === BLINDS_OPEN) {
                            this.targetPosition = false;

                            const durationToOpen = Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10;
                            this.changeTime = Date.now() - durationToOpen;

                            console.log("[TuyaAccessory] Blinds will be marked open in " + durationToOpen + "ms");

                            if (this.changeTimeout) clearTimeout(this.changeTimeout);
                            this.changeTimeout = setTimeout(() => {
                                characteristicCurrentPosition.updateValue(BLINDS_OPEN);
                                characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED);
                                this.changeTime = false;
                                this.assumedPosition = BLINDS_OPEN;
                                this.assumedState = BLINDS_STOPPED;
                                console.log("[TuyaAccessory] Blinds marked open");
                            }, durationToOpen);
                        }
                        break;

                    case this.cmdClose:  // Starting to close
                        this.assumedState = BLINDS_CLOSING;
                        characteristicPositionState.updateValue(Characteristic.PositionState.DECREASING);

                        // Only if change was external or someone internally asked for close
                        if (this.targetPosition === false || this.targetPosition === BLINDS_CLOSED) {
                            this.targetPosition = false;

                            const durationToClose = Math.abs(this.assumedPosition - BLINDS_CLOSED) * this.duration * 10;
                            this.changeTime = Date.now() - durationToClose;

                            console.log("[TuyaAccessory] Blinds will be marked closed in " + durationToClose + "ms");

                            if (this.changeTimeout) clearTimeout(this.changeTimeout);
                            this.changeTimeout = setTimeout(() => {
                                characteristicCurrentPosition.updateValue(BLINDS_CLOSED);
                                characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED);
                                this.changeTime = false;
                                this.assumedPosition = this.minPosition;
                                this.assumedState = BLINDS_STOPPED;
                                console.log("[TuyaAccessory] Blinds marked closed");
                            }, durationToClose);
                        }
                        break;

                    case this.cmdStop:  // Stopped in middle
                        if (this.changeTimeout) clearTimeout(this.changeTimeout);

                        console.log("[TuyaAccessory] Blinds last change was " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago');

                        if (this.changeTime) {
                            /*
                            this.assumedPosition = Math.min(100 - this.minPosition, Math.max(0, Math.round((Date.now() - this.changeTime) / (10 * this.duration))));
                            if (this.assumedState === BLINDS_CLOSING) this.assumedPosition = 100 - this.assumedPosition;
                            else this.assumedPosition += this.minPosition;
                             */
                            const disposition = ((Date.now() - this.changeTime) / (10 * this.duration));
                            if (this.assumedState === BLINDS_CLOSING) {
                                this.assumedPosition = BLINDS_OPEN - disposition;
                            } else {
                                this.assumedPosition = this.minPosition + disposition;
                            }
                        }

                        const adjustedPosition = Math.max(0, Math.round(this.assumedPosition));
                        characteristicCurrentPosition.updateValue(adjustedPosition);
                        characteristicTargetPosition.updateValue(adjustedPosition);
                        characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED);
                        console.log("[TuyaAccessory] Blinds marked stopped at " + adjustedPosition + "; assumed to be at " + this.assumedPosition);

                        this.changeTime = this.targetPosition = false;
                        this.assumedState = BLINDS_STOPPED;
                        break;
                }
            }
        });
    }

    getCurrentPosition(callback) {
        this.getState(this.dpAction, (err, dp) => {
            if (err) return callback(err);

            callback(null, this._getCurrentPosition(dp));
        });
    }

    _getCurrentPosition(dp) {
        switch (dp) {
            case this.cmdOpen:
                return BLINDS_OPEN;

            case this.cmdClose:
                return BLINDS_CLOSED;

            default:
                return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition));
        }
    }

    getTargetPosition(callback) {
        this.getState(this.dpAction, (err, dp) => {
            if (err) return callback(err);

            callback(null, this._getTargetPosition(dp));
        });
    }

    _getTargetPosition(dp) {
        switch (dp) {
            case this.cmdOpen:
                return BLINDS_OPEN;

            case this.cmdClose:
                return BLINDS_CLOSED;

            default:
                return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition));
        }
    }

    setTargetPosition(value, callback) {
        console.log('[TuyaAccessory] Blinds asked to move from ' + this.assumedPosition + ' to ' + value);

        if (this.changeTimeout) clearTimeout(this.changeTimeout);
        this.targetPosition = value;

        if (this.changeTime !== false) {
            console.log("[TuyaAccessory] Blinds " + (this.assumedState === BLINDS_CLOSING ? 'closing' : 'opening') + " had started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago');
            const disposition = ((Date.now() - this.changeTime) / (10 * this.duration));
            if (this.assumedState === BLINDS_CLOSING) {
                this.assumedPosition = BLINDS_OPEN - disposition;
            } else {
                this.assumedPosition = this.minPosition + disposition;
            }
            console.log("[TuyaAccessory] Blinds' adjusted assumedPosition is " + this.assumedPosition);
        }

        const duration = Math.abs(this.assumedPosition - value) * this.duration * 10;

        if (Math.abs(value - this.assumedPosition) < 1) {
            return this.setState(this.dpAction, this.cmdStop, callback);
        } else if (value > this.assumedPosition) {
            this.assumedState = BLINDS_OPENING;
            this.setState(this.dpAction, this.cmdOpen, callback);
            this.changeTime = Date.now() -  Math.abs(this.assumedPosition - this.minPosition) * this.duration * 10;
        } else {
            this.assumedState = BLINDS_CLOSING;
            this.setState(this.dpAction, this.cmdClose, callback);
            this.changeTime = Date.now() -  Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10;
        }

        if (value !== BLINDS_OPEN && value !== BLINDS_CLOSED) {
            console.log("[TuyaAccessory] Blinds will stop in " + duration + "ms");
            console.log("[TuyaAccessory] Blinds assumed started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago');
            this.changeTimeout = setTimeout(() => {
                console.log("[TuyaAccessory] Blinds asked to stop");
                this.setState(this.dpAction, this.cmdStop);
            }, duration);
        }
    }

    getPositionState(callback) {
        const state = this._getPositionState();
        process.nextTick(() => {
            callback(null, state);
        });
    }

    _getPositionState() {
        const {Characteristic} = this.hap;

        switch (this.assumedState) {
            case BLINDS_OPENING:
                return Characteristic.PositionState.INCREASING;

            case BLINDS_CLOSING:
                return Characteristic.PositionState.DECREASING;

            default:
                return Characteristic.PositionState.STOPPED;
        }
    }
}

module.exports = SimpleBlindsAccessory;
vijaydembla1 commented 3 years ago

Hi Sam,

A little success here. Since i installed the homebridge in a docker, my plug was installed in a different place: /usr/local/lib/node_modules/homebridge-tuya-lan/lib#

After replacing the all the contents of simpleblinds with the one you provided, it now shows the messages received on console as below. Still no change on the blinds from homekit. Once again, appreciate you help, feels like we are getting there :-)

[TuyaAccessory] Blinds received: {"2":0} [TuyaAccessory] Blinds saw change to undefined [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds received: {"7":"opening"} [TuyaAccessory] Blinds saw change to undefined [TuyaAccessory] Heard back from Bedroom Blinds with command 8 [TuyaAccessory] Blinds received: {"3":0} [TuyaAccessory] Blinds saw change to undefined

What config do you think I should make to this to make it work. My config json is as below:

{ "name": "Bedroom Blinds", "type": "SimpleBlinds", "manufacturer": "Tuya", "model": "Roller Switch", "id": "", "key": "", "timeToOpen": 17, "timeToTighten": 4, "flipState": true, "cmdOpen": "1", "cmdClose": "2", "cmdStop": "3", "sendEmptyUpdate": true },

SamGeens commented 3 years ago

From now on you should try to figure out what the correct commands for your blinds are. I don't know which commands you executed, so the output log isn't really usefull for me. But I guess the Blinds received: {"2":0} line is the line where you opened the blinds? So if that is the case you should make the plugin send {"2":0} to open the blinds and so on.

vijaydembla1 commented 3 years ago

Hi Sam,

I'm loving this testing and hopefully will be able to resolve it. Not good with java but seeking some help known who is.

I can explain the commands and the console messages (Please ignore "Hello", I've added to for some testing):

The command for closing at 100%: First message on console after the tuya command is given [TuyaAccessory] Blinds received: Hello {"7":"closing"} MEssage to show that command is to close at 100% [TuyaAccessory] Blinds received: Hello {"2":100} Message when the command given is completed [TuyaAccessory] Blinds received: Hello {"3":100}

The command for opening at 27%:
First message on console after the tuya command is given [TuyaAccessory] Blinds received: Hello {"7":"opening"} MEssage to show that command is to open at 27% [TuyaAccessory] Blinds received: Hello {"2":27} Message when the command given is completed [TuyaAccessory] Blinds received: Hello {"3":27}

I'll keep playing, if you have a quicker way of helping, please share, if I'm successful, I'll post it here :-)

SamGeens commented 2 years ago

Hi vijaydembla1

The way I tested this way by manually changing the command just before sending it, so it always sent the command I wanted to test if the blinds were responding. After that I had to make sure the sent command wasn't hard coded anymore and is being brought over from the blindsaccessory code.

The part I'm referring to is in TuyaAccessory.js: if (hasDataPoint) { console.log("[TuyaAccessory] Sending", this.context.name, JSON.stringify(dps));

You can change the dps with the .id and .key identifier.

Good luck!

justinjsp commented 2 years ago

Anyone succeed in getting these to work?

vijaydembla1 commented 2 years ago

Hi

After giving a several attempts, I managed to get this working via Home Assistant.

Home Assistant can easily detect the blinds and their respective state, and with the HomeKit integration, Home Assistant can then expose the accessory to homekit. Blinds on HomeKit worked like a charm.

All the best

justinjsp commented 2 years ago

Thank you so much! I’ve noticed it working better lately, after a few updates. Were you able to get the percentage state to report back correctly? I’d love to learn how to help build plugins like this. Loving the community effort on this! Thanks again, so much!

Justin Prest
@.*** (819) 635-3583

On Nov 11, 2021, at 08:13, vijaydembla1 @.***> wrote:

 Hi

After giving a several attempts, I managed to get this working via Home Assistant.

Home Assistant can easily detect the blinds and their respective state, and with the HomeKit integration, Home Assistant can then expose the accessory to homekit. Blinds on HomeKit worked like a charm.

All the best

— You are receiving this because you commented. Reply to this email directly, view it on GitHub, or unsubscribe.

vijaydembla1 commented 2 years ago

Hi Justin,

I Couldn't get the blinds to work with the homebridge, therefore installed a home assistant. After installing home assistant, i then installed the tuya plug in (cloud-based) and home kit plug in. this allowed all the devices in the home assistant to be exposed to home kit.

Blinds in particular, is fully controllable from the homekit, reports back the open %. Works really great.

I played a bit more and then installed HACS, and then Tuya local plug in. This allowed me to configure all my Tuya devices locally using their ID, IP address and local keys. This has enabled a instant response to the tuya items. The lights/switches respond on the press of a button without any lags, I'm playing my way to get the RGN lights and Bl;inds on to Tuya lan, to make them work instantly as well, but they require a little bit of tinkering to get the right config.

All the best :-)

justinjsp commented 2 years ago

Ahhhh! I’ve been having problems with the cloud plugin. Maybe it’s just a delay I'm experiencing, though. Tuya’s cloud is pretty flaky haha. I need to learn more, though! I’ve been trying to figure out what I don’t know, so I can know what I need to learn, you know? 😂😂😂😂😂

Justin Prest
@.*** (819) 635-3583

On Nov 11, 2021, at 21:16, vijaydembla1 @.***> wrote:

 Hi Justin,

I Couldn't get the blinds to work with the homebridge, therefore installed a home assistant. After installing home assistant, i then installed the tuya plug in (cloud-based) and home kit plug in. this allowed all the devices in the home assistant to be exposed to home kit.

Blinds in particular, is fully controllable from the homekit, reports back the open %. Works really great.

I played a bit more and then installed HACS, and then Tuya local plug in. This allowed me to configure all my Tuya devices locally using their ID, IP address and local keys. This has enabled a instant response to the tuya items. The lights/switches respond on the press of a button without any lags, I'm playing my way to get the RGN lights and Bl;inds on to Tuya lan, to make them work instantly as well, but they require a little bit of tinkering to get the right config.

All the best :-)

— You are receiving this because you commented. Reply to this email directly, view it on GitHub, or unsubscribe.