Skip to content

Feature/Schedule-Control-Dry-Feeders - #209

Merged
jjjonesjr33 merged 4 commits into
jjjonesjr33:devfrom
C4-Dimitri:Feature/Schedule-Control
Mar 30, 2026
Merged

Feature/Schedule-Control-Dry-Feeders#209
jjjonesjr33 merged 4 commits into
jjjonesjr33:devfrom
C4-Dimitri:Feature/Schedule-Control

Conversation

@C4-Dimitri

@C4-Dimitri C4-Dimitri commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Proposed change:

Closes #26, Closes #79, Closes #195, Closes #196

This PR adds full feeding plan management for all dry food feeders, including the ability to add, edit, enable, disable, skip, and delete individual scheduled feeding plans directly from Home Assistant.


Services

Two new services have been added:

petlibro.add_feeding_plan — Add a new scheduled feeding plan to a dry food feeder. Takes a device target, feed time, number of portions, an optional label, optional repeat days, and an optional meal call sound toggle.

Screenshot 2026-03-29 171358

petlibro.edit_feeding_plan — Edit an existing scheduled feeding plan on a dry food feeder. Only the fields you provide will be changed — anything left blank keeps its current value. The Plan ID can be found in the feeder's Feeding Schedule binary sensor attributes OR in the new select entity, where it is named "Label - Plan ID".

Screenshot 2026-03-29 171323

Note on portions: Both services use raw portions rather than the user-configured unit. Services dont support dynamically changing the UOM based on the integration settings - we would have no way to replace the slider with a dropdown for the cups for example.


Select Entities

Two new select entities are added per dry feeder:

  • Feeding Schedule (feeding_plan_select) — Lists all plans in the recurring schedule. Used to target a specific plan for the enable, disable, and delete buttons.
  • Today's Feeding Schedule (feeding_plan_today_select) — Lists only today's scheduled feeds. Used to target a specific plan for the skip and un-skip buttons.
Screenshot 2026-03-29 170911 Screenshot 2026-03-29 170921

Button Entities

Seven new button entities are added per dry feeder for direct control of the schedule:

Button Description
Enable Selected Plan Enables the plan currently selected in the Feeding Schedule selector
Disable Selected Plan Disables the plan currently selected in the Feeding Schedule selector
Delete Selected Plan Permanently removes the plan currently selected in the Feeding Schedule selector
Skip Selected Plan Today Skips the plan currently selected in Today's Feeding Schedule selector for today only
Un-skip Selected Plan Today Restores a previously skipped plan in Today's Feeding Schedule selector
Enable Today's Feeding Schedule Enables all feeding plan events for today
Disable Today's Feeding Schedule Disables all feeding plan events for today

Note on skip + un-skip: These will give formatted responses directly from the API if you are trying to skip an event that has already passed. "Failed to perform the action button/press. Code: 1217, Message: FEEDING_PLAN_HAS_EXPIRED". didnt feel that it was neccesary to give a custom error message, as i though this was clear enough already.

Screenshot 2026-03-29 171011 Screenshot 2026-03-29 171022 Screenshot 2026-03-29 171033 Screenshot 2026-03-29 171043 Screenshot 2026-03-29 171059 Screenshot 2026-03-29 171111 Screenshot 2026-03-29 171123 Screenshot 2026-03-29 171135

Binary Sensor Entities

The existing feeding_plan_state and feeding_schedule sensors have been converted from sensors to binary sensors (on = schedule globally enabled, off = disabled):

  • Feeding Schedule (feeding_plan_state) — State reflects whether the feeding schedule is globally enabled. Attributes contain today's scheduled feeds with formatted weight and volume amounts, and their current status (Pending / Skipped / Completed).
  • Today's Feeding Schedule (feeding_schedule) — Same on/off state. Attributes contain the full recurring schedule with time, portions, repeat days, and sound settings.
Screenshot 2026-03-29 170943 Screenshot 2026-03-29 170957

Entity Naming

As part of this PR, all entity friendly names that refer to the entire set of planned feeds have been updated to use the word "Schedule", while individual planned feed events are referred to as a "Plan". The rationale:

  • A plan is a single scheduled feed event.
  • A schedule is the full collection of plans.

This distinction was necessary given the number of new controls — users now interact with both the schedule as a whole and individual plans within it, so clear naming avoids confusion.

Important: This is a breaking change for people who are acting on the schedule sensor, unlikely, but possible, as it is now a binary_sensor.


Type of change:

  • New device.
  • Bug fix (non-breaking change which fixes an issue).
  • New feature or enhancement (non-breaking change which adds functionality).
  • Documentation only.
  • Other (please explain).

Checklist:

  • If applicable, I have added corresponding documentation changes.
  • If applicable, I have reviewed the feature / enhancement guidelines before submitting my request.
  • If applicable, I have tested my code for new features & regressions on the latest version of Home Assistant.

Additional notes:

Feature/Schedule-Control
Fix Naming For Schedule Binary Sensor's
Fix Trailing Comma
Last Plan vs Schedule naming updates
@C4-Dimitri

Copy link
Copy Markdown
Contributor Author

@cristianchelu for your HA card:
https://github.com/cristianchelu/dispenser-schedule-card

:)

@jjjonesjr33 jjjonesjr33 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work man! Let's give it a try!

@github-project-automation github-project-automation Bot moved this from To triage to In review in Petlibro Mar 30, 2026
@jjjonesjr33
jjjonesjr33 merged commit dc700b3 into jjjonesjr33:dev Mar 30, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in Petlibro Mar 30, 2026
@cristianchelu

Copy link
Copy Markdown

Thanks for the heads up @C4-Dimitri !

I'll try to add this to the card soon -- I don't have a feeder to test with, though.

Just two things from my perspective:
From a quick look at the code it seems that to get full functionality a user needs to provide two entities to the card -- one for today's statuses, and the other for the full weekly schedule. Is it possible to merge the daily status into the weekly schedule to make it easier to configure? I figure it would also simplify any templates/automations too?

The same schedules are now two binary sensors with 4 buttons to turn them off/on. Any reason why these 6 can't be converted to two switch entities that reset to on daily?

@C4-Dimitri

Copy link
Copy Markdown
Contributor Author

Just two things from my perspective: From a quick look at the code it seems that to get full functionality a user needs to provide two entities to the card -- one for today's statuses, and the other for the full weekly schedule. Is it possible to merge the daily status into the weekly schedule to make it easier to configure? I figure it would also simplify any templates/automations too?

To be clear, the 2 schedules provided are 1 for today correct, but this is because any interaction with this schedule only applies to the day, so skipping an event only Skips it for that day.

Second is not a weekly schedule per se, its just the entire schedule of all plans that may exist for a feeder, whether they are set to repeat, was a one-off whatever. So you could create a one-off feed event that will show in this schedule forever, but it will NEVER repeat, unless you go and edit it to do so.

All of the today's feed events show in the main schedule too, not just in the today's schedule.

Not quite sure what you mean by merging them? I think it would be very confusing to not have clear view of which are today and which are just in the whole schedule.

The same schedules are now two binary sensors with 4 buttons to turn them off/on. Any reason why these 6 can't be converted to two switch entities that reset to on daily?

You are correct that at least turning on/off the individual schedules COULD be a switch eliminating the binary_sensor, and the on/off buttons for each.
I am just following the current integration convention, as historically we had some issues with switches having some bounce to them, so you flicked them and they'd flick back, and then back to what you selected.

@cristianchelu

Copy link
Copy Markdown

All of the today's feed events show in the main schedule too, not just in the today's schedule.

Sorry, I should have added more detail. The state for today's plans is missing from feeding_schedule, and repeat_days/enabled/sound from feeding_plan_state.

# feeding_plan_state
plan_data.get(str(plan["planId"]), {}).get("label") or f"plan_{plan.get('index', plan['planId'])}": {
    "time": plan.get("time"),
    "amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
    "amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
    "state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(plan.get("state"), "Unknown"),
    "repeat": plan.get("repeat"),
    "planID": plan.get("planId"),
}

# feeding_schedule
plan.get("label") or f"plan_{plan_id}": {
    "planID": int(plan_id),
    "time": plan.get("executionTime"),
    "amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
    "amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
    "enabled": plan.get("enable", False),
    "repeat_days": plan.get("repeatDay", "[]"),
    "sound": plan.get("enableAudio", False),
}

Right now, when setting up the card config, I would need a user to select feeding_plan_state to show how today's timeline is progressing, and then separately a feeding_schedule entity for the Add/Edit UI, to display the existing plan configurations (i.e. repeat days, play sound)

type: custom:dispenser-schedule-card
device:
  type: petlibro
  plan_state_entity: binary_sensor.feeding_schedule # opportunity for user confusion
  schedule_entity: binary_sensor.feeding_plan_state # config entities swapped
  # etc

If on feeding_schedule, for the planIDs that are active today, I would have the state property, the card would only require one entity select for its config. I would filter the plans with statuses to display them for today's timeline, while also having the full schedule configuration to populate the edit form inputs.

Does that make sense?
I can definitely use this as-is, but maybe this can simplify the user experience a bit.

I am just following the current integration convention, as historically we had some issues with switches having some bounce to them

Understood. From the card POV it's the same UX issue of making the user supply more configuration for each on/off action.

Maybe I can go through the deviceId and auto-discover related actions/entities, but I prefer simplicity when/if I can have it :)

@C4-Dimitri

C4-Dimitri commented Mar 30, 2026

Copy link
Copy Markdown
Contributor Author

Ah, i understand now!

what we can do is:

plan.get("label") or f"plan_{plan_id}": {
    "planID": int(plan_id),
    "time": plan.get("executionTime"),
    "amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
    "amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
    "enabled": plan.get("enable", False),
    "repeat_days": plan.get("repeatDay", "[]"),
    "sound": plan.get("enableAudio", False),
    "state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(plan.get("state"), "Unknown"),
}

but crucially, this will only populate for events that are actually active in Todays schedule, so we should have a state for "Not in Today's Schedule:", What would be good to have as well, is just whether an event is enabled or disabled overall:

plan.get("label") or f"plan_{plan_id}": {
    "planID": int(plan_id),
    "time": plan.get("executionTime"),
    "amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
    "amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
    "enabled": plan.get("enable", False),
    "repeat_days": plan.get("repeatDay", "[]"),
    "sound": plan.get("enableAudio", False),
    "feed_state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(plan.get("state"), "Unknown"), 
    # Note, i will need to reference the daily feed schedule here instead. ⌃
    "state": {false: "Disabled", true: "Enabled"}.get(plan.get("enable"), "Unknown"),
}

is that what you mean?

@cristianchelu

cristianchelu commented Mar 30, 2026

Copy link
Copy Markdown

but crucially, this will only populate for events that are actually active in Todays schedule, so we should have a state for "Not in Today's Schedule:", What would be good to have as well, is just whether an event is enabled or disabled overall

Yes, exactly.

@C4-Dimitri

Copy link
Copy Markdown
Contributor Author

I can 100% do that. Ill see if I can get to it today :)

@cristianchelu

Copy link
Copy Markdown

@C4-Dimitri

Note on portions: Both services use raw portions rather than the user-configured unit. Services dont support dynamically changing the UOM based on the integration settings - we would have no way to replace the slider with a dropdown for the cups for example.

"amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
"amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",

This means the actions expect raw grainNum as input, right? I would also need feed_conv_factor present in the payload somewhere to be able to reverse Unit.convert_feed, in that case. e.g.:

"extra_attributes": {
  "weight_unit": "g",
  "volume_unit": "cups",
  "conversion_factor": 2.5,
  "plans": [
    { "planID": 123, "amount_weight": 25, "amount_volume": 25 } // grainNum: 10
  ]
}

Or just grainNum raw value if it makes more sense, in which case the unit conversion can be done on the frontend depending on the HA/Card preferences.

P.S. just out of curiosity. Any reason for amount (weight) instead of amount_weight as the key, with a string "12 oz" instead of number for the values?

@C4-Dimitri

C4-Dimitri commented Mar 30, 2026

Copy link
Copy Markdown
Contributor Author

This means the actions expect raw grainNum as input, right?

correct.
i would have a much easier time just adding grainNum as another field.

if i were to add conversion factor, weight unit, volume unit, i need a bit of help from @FeliGoblin, as i still dont fully understand how that magic works...

as for why its amount (weight) instead of amount_weight, i will defer to Feli again :)

so in summary:

plan.get("label") or f"plan_{plan_id}": {
    "planID": int(plan_id),
    "time": plan.get("executionTime"),
    "amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
    "amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
    "amount (raw)": plan.get('grainNum', 0)",
    "enabled": plan.get("enable", False),
    "repeat_days": plan.get("repeatDay", "[]"),
    "sound": plan.get("enableAudio", False),
    "feed_state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(plan.get("state"), "Unknown"), 
    # Note, i will need to reference the daily feed schedule here instead. ⌃
}

realised we already have whether the plan is enabled or disabled, as the "enabled" in the above, so a second state wasnt needed.

@FeliGoblin

Copy link
Copy Markdown
Contributor

@cristianchelu @C4-Dimitri

Ah you're quite right, using numbers instead of strings would make infinitely more sense there. Adding the raw feed value makes sense as well, and using amount_weight etc.

{
    "amount_weight": Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True),
    "amount_volume": Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True),
    "amount_raw": plan.get('grainNum', 0),
    "feed_conv_factor": conv,
}

Would it be simpler/better to just have all 4 feed units converted in there?

from .const import Unit, APIKey as API, VALID_UNIT_TYPES

...

{
    **{
        f"amount_{unit.symbol.lower()}": Unit.convert_feed(plan.get('grainNum', 0) * conv, None, unit, True)
        for unit in VALID_UNIT_TYPES[API.FEED_UNIT] if unit
    },
    "amount_raw": plan.get('grainNum', 0),
    "feed_conv_factor": conv,
}
Screenshot_3

Or just the user's chosen one?

unit = self.member.feedUnitType

...

{
    f"amount_{unit.symbol.lower()}": Unit.convert_feed(plan.get('grainNum', 0) * conv, None, unit, True),
    "amount_raw": plan.get('grainNum', 0),
    "feed_conv_factor": conv,
}

@cristianchelu

Copy link
Copy Markdown

@FeliGoblin

Would it be simpler/better to just have all 4 feed units converted in there?

From the card developer perspective, as long as I have the raw amount to plug back into the edit action/service, I'm fine.

From a HA user perspective, my 2¢ say attributes don't take up space in the history DB, they don't crowd any dashboard or device page, and are otherwise invisible in normal HA usage. Having all easily available to use in templates / automations would make sense to me.

Or just the user's chosen one?

Having keys disappear breaking automations because of a settings change in a different app would be weird to me.

@C4-Dimitri
C4-Dimitri deleted the Feature/Schedule-Control branch April 20, 2026 07:58
@C4-Dimitri

Copy link
Copy Markdown
Contributor Author

@cristianchelu
example feeding schedule outputs:

Whole schedule:

attributes:
  device_class: running
  icon: mdi:calendar-clock
  friendly_name: Jet's Feeder Feeding Schedule
  New1st:
    planID: 3907147
    time: "06:00"
    amount_cups: 0.083
    amount_oz: 0.35
    amount_g: 10
    amount_ml: 20
    amount_raw: 1
    feed_conv_factor: 1
    enabled: true
    repeat_days: "[5,1,6,4,2,3,7]"
    sound: false
    feed_state: Completed
  New2nd:
    planID: 3907149
    time: "14:00"
    amount_cups: 0.083
    amount_oz: 0.35
    amount_g: 10
    amount_ml: 20
    amount_raw: 1
    feed_conv_factor: 1
    enabled: true
    repeat_days: "[7,1,2,3,4,5,6]"
    sound: false
    feed_state: Pending
  Backup:
    planID: 5030885
    time: "22:30"
    amount_cups: 0.083
    amount_oz: 0.35
    amount_g: 10
    amount_ml: 20
    amount_raw: 1
    feed_conv_factor: 1
    enabled: true
    repeat_days: "[5,1,6,7,4,2,3]"
    sound: false
    feed_state: Pending

Today's Schedule:

  New1st:
    planID: 3907147
    time: "06:00"
    amount_cups: 0.083
    amount_oz: 0.35
    amount_g: 10
    amount_ml: 20
    amount_raw: 1
    feed_conv_factor: 1
    enabled: true
    repeat_days: "[5,1,6,4,2,3,7]"
    sound: false
    feed_state: Completed
    repeat: true
  New2nd:
    planID: 3907149
    time: "14:00"
    amount_cups: 0.083
    amount_oz: 0.35
    amount_g: 10
    amount_ml: 20
    amount_raw: 1
    feed_conv_factor: 1
    enabled: true
    repeat_days: "[7,1,2,3,4,5,6]"
    sound: false
    feed_state: Pending
    repeat: true
  Backup:
    planID: 5030885
    time: "22:30"
    amount_cups: 0.083
    amount_oz: 0.35
    amount_g: 10
    amount_ml: 20
    amount_raw: 1
    feed_conv_factor: 1
    enabled: true
    repeat_days: "[5,1,6,7,4,2,3]"
    sound: false
    feed_state: Pending
    repeat: true

let me know if anything you feel is still missing.
we only have 1 device, PLAF108, that currently uses a custom conversion factor of 2.

@cristianchelu

Copy link
Copy Markdown

@C4-Dimitri,
Thanks! I've started work on this, and I think I misunderstood the skip/disable single PlanID behaviour.

As far as I can tell from the code, there's not also an action (service) to disable, skip, or delete a plan ID directly?
i.e. My card would have to first change the selection of the plan_select entity, wait for it to confirm, then call a second action of button.press on the skip/disable/unskip/re-enable?

Also, from what I understand, the skip and enable on the Petkit api side is 3 separate feeding_plan_toggle, feeding_plan_delete and feeding_plan_today_skip endpoints.

Could you expose petlibro.toggle_feeding_plan(device_id, plan_id, enable), petlibro.delete_feeding_plan(device_id, plan_id), and petlibro.skip_feeding_plan_today(device_id, plan_id, skip) so the card can just call them like add/edit?

Either that, or petlibro.delete_feeding_plan(device_id, plan_id), and fold enabled and/or skip into the add/edit service call, while chaining the edit+toggle or edit+skip api requests transparently within the integration?

In the meantime I'll release a -pre version of the card a bit later today so you can test the basic add/edit/display functionality. :)

@C4-Dimitri

C4-Dimitri commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

@cristianchelu, Ok, so you are correct, there is no service for disabling, skipping or deleting a plan ID, you are correct, that is done via the selection of a plan in the select, and pressing one of the relevant buttons.

i can make services for this as well, no worries there, but the user will need to know what plan ID to put into the service (same as edit plan service that currently exists, so no issue there).

Also, from what I understand, the skip and enable on the Petkit api side is 3 separate feeding_plan_toggle, feeding_plan_delete and feeding_plan_today_skip endpoints.
Correct, Petlibro, but correct.

dont see any harm in making more services for each thing. lets the users have a way to do it from the device using the entities, but also do it via automation without interacting with the entities themselves.

will make another pull request later today to add those services in.

@cristianchelu

Copy link
Copy Markdown

If you have time, I would suggest you wait for the -prerelease version of the card so you can test all functionality, so you can open a single PR addressing anything else we might catch.

i.e. right now plan entries are spread between normal home-assistant attributes, I have to filter them out and trust that what remains are our plan keys (New1st, etc). If HA ever adds a new common attribute, we'd assume it's a new schedule entry and crash while parsing.:

/** Attribute keys that are not feeding plans. */
const RESERVED_ATTRIBUTE_KEYS = new Set([
  "device_class",
  "icon",
  "friendly_name",
  "unit_of_measurement",
  "state_class",
  "attribution",
  "supported_features",
  "assumed_state",
  "restored",
]);

I'm developing the device handler blindly (have no petlibro feeder) by having Opus double-check against your implementation, so I'm lost without real-world validation from your side :)

I'm on the petlibro discord under the same username if you want to talk realtime.

@C4-Dimitri

Copy link
Copy Markdown
Contributor Author

@cristianchelu , sounds good, just sent you a friend request on Discord :)

@jjjonesjr33 jjjonesjr33 linked an issue Jun 2, 2026 that may be closed by this pull request
@jjjonesjr33 jjjonesjr33 added the Completed Completed tasks label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment