Repository: ad-ha/kidschores-ha Branch: main Commit: d8f5f8817e47 Files: 29 Total size: 691.8 KB Directory structure: gitextract_644k2xfj/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 01-issue_report.yml │ │ ├── 02-feature_reques.yml │ │ └── config.yml │ └── workflows/ │ ├── hassfest.yaml │ └── validate.yaml ├── .gitignore ├── LICENSE ├── README.md ├── custom_components/ │ └── kidschores/ │ ├── __init__.py │ ├── button.py │ ├── calendar.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── flow_helpers.py │ ├── kc_helpers.py │ ├── manifest.json │ ├── notification_action_handler.py │ ├── notification_helper.py │ ├── options_flow.py │ ├── select.py │ ├── sensor.py │ ├── services.py │ ├── services.yaml │ ├── storage_manager.py │ └── translations/ │ ├── en.json │ └── es.json └── hacs.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: varetas3d thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/01-issue_report.yml ================================================ --- name: Issue Report description: Create an issue report to help us improve title: "[ISSUE] " labels: bug assignees: [] body: - type: markdown attributes: value: | **Please fill out this form to report a bug with the KidsChore Integration.** - type: input id: home_assistant_version attributes: label: Home Assistant Version description: "What version of Home Assistant are you using?" placeholder: "e.g., 2024.11.1" validations: required: true - type: input id: integration_version attributes: label: KidsChore Integration Version description: "What version of the integration are you using?" placeholder: "e.g., 0.4.8" validations: required: true - type: dropdown id: installation_method attributes: label: Installation Method description: "How did you install the integration?" options: - HACS - Manual validations: required: true - type: checkboxes id: prior_issue_check attributes: label: Did you check for existing issues? description: "You should check if there's a current or closed issue." options: - label: Yes, I have checked for existing issues required: true - label: No, I have not checked for existing issues - type: checkboxes id: debug_enabled attributes: label: Did you enable debug logging before and are ready to post logs? options: - label: Yes, I have enabled debug logging required: true - label: No, I have not enabled debug logging - type: textarea id: issue_description attributes: label: Describe the Issue description: "A clear and concise description of what the bug is." placeholder: "Provide a detailed description..." validations: required: true - type: markdown attributes: value: | ### **Logs** Please add the following to your `configuration.yaml` on your Home Assistant and restart: ```yaml logger: default: warning logs: custom_components.kidschores: debug ``` See [Home Assistant Logger Documentation](https://www.home-assistant.io/integrations/logger) for more information. - type: textarea id: logs attributes: label: Logs description: "Paste your logs here." render: yaml - type: textarea id: additional_context attributes: label: Additional Context description: "Add any other context about the problem here." placeholder: "Any additional information..." --- ================================================ FILE: .github/ISSUE_TEMPLATE/02-feature_reques.yml ================================================ --- name: Feature Request description: Suggest an idea for this project title: "[REQ] " labels: enhancement assignees: [] body: - type: markdown attributes: value: | **Please describe the feature you would like to see added to the KidsChores Integration.** - type: checkboxes id: problem_exists attributes: label: Is your feature request related to a problem? options: - label: "Yes" - type: textarea id: problem attributes: label: Please describe the problem description: A clear and concise description of what the problem is. placeholder: Ex. I'm always frustrated when [...] validations: required: false - type: textarea id: solution attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. validations: required: true - type: textarea id: alternatives attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. - type: textarea id: additional_context attributes: label: Additional context description: Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/workflows/hassfest.yaml ================================================ name: Validate with hassfest on: push: pull_request: schedule: - cron: "0 0 * * *" jobs: validate: runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v3" - uses: home-assistant/actions/hassfest@master ================================================ FILE: .github/workflows/validate.yaml ================================================ name: HACS Action on: push: pull_request: schedule: - cron: "0 0 * * *" workflow_dispatch: jobs: validate-hacs: runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v3" - name: HACS validation uses: "hacs/action@main" with: category: integration ================================================ FILE: .gitignore ================================================ __pycache__/ *.py[cod] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ > [!IMPORTANT] > **⚠️ ACTIVE DEVELOPMENT HAS MOVED TO CHOREOPS** > > `KidsChores` has officially evolved into a new, expanded integration called **[ChoreOps](https://github.com/ccpk1/choreops)**. > > Based on incredible feedback from this community, the backend has been completely re-architected to Home Assistant Platinum standards to support *everyone* in the household (not just kids!), along with an entirely new "Over-The-Air" dashboard system. > > **Please migrate to ChoreOps to access all new features and continued support.** > Upgrading is safe and easy: ChoreOps includes a built-in migration tool and runs safely in parallel with KidsChores. You can set it up, migrate your data, and ensure you are completely happy before removing your old configuration. > > 👉 **[Get started with ChoreOps here!](https://github.com/ccpk1/choreops)** --- [![HACS Custom](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) ![GitHub Release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/ad-ha/kidschores-ha?include_prereleases) ![GitHub Downloads (all assets, latest release)](https://img.shields.io/github/downloads/ad-ha/kidschores-ha/latest/total) [![GitHub Actions](https://github.com/ad-ha/kidschores-ha/actions/workflows/validate.yaml/badge.svg)](https://github.com/ad-ha/kidschores-ha/actions/workflows/validate.yaml) [![Hassfest](https://github.com/ad-ha/kidschores-ha/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/ad-ha/kidschores-ha/actions/workflows/hassfest.yaml)

KidsChores

KidsChores logo

Buy Me A Coffee



# 🏆 KidsChores: The Ultimate Home Assistant Chore & Reward System **The easiest-to-use and most feature-rich chore management system for Home Assistant.** Get up and running in **10 minutes or less**, with **unmatched capabilities** to gamify the process and keep kids engaged! ✅ **Track chores effortlessly** – Assign chores, set due dates, and track completions. ✅ **Gamify the experience** – **Badges, Achievements, and Challenges** keep kids motivated. ✅ **Bonuses & Penalties** – Reward extra effort and enforce accountability. ✅ **Customizable Rewards** – Give coins, stars, points, or any currency system you choose. ✅ **Built-in User Access Control** – Restricts actions based on roles (kids, parents, admins). ✅ **Smart Notifications** – Notify kids and parents; parents can approve chores & rewards from their phone or watch. ✅ **Calendar Integration & Custom Scheduling** – Automatically manage recurring chores and sync with Home Assistant’s calendar. ✅ **Works Offline & Keeps Data Local** – Everything is processed locally for **privacy & security**.

**"Designed for kids, but flexible for the whole family—assign chores to anyone, from toddlers to teens to adults!"** 📖 **[System Overviews, FAQ's, Tips & Tricks, and Usage Examples in the Wiki →](https://github.com/ad-ha/kidschores-ha/wiki)** --- ## ⚡ Quick Installation 📌 **Via HACS (Recommended)** 1. Ensure **HACS** is installed. ([HACS Setup Guide](https://hacs.xyz/docs/installation/manual)) 2. In Home Assistant, go to **HACS > Custom Repositories**. 3. Add `https://github.com/ad-ha/kidschores-ha` as an **Integration**. 4. Search for **KidsChores**, install, and **restart Home Assistant**. 📖 **[Full Setup & Configuration Guide →](https://github.com/ad-ha/kidschores-ha/wiki/Installation-&-Setup)** --- ## 🌟 Key Features ### 👧👦 Multi-User Management - **Profile Creation & Customization:** - Create and manage individual profiles for multiple kids and parents. - Track each child’s progress, achievements, and performance with ease. - **Effortless Management:** - Handle multiple kids with a single integration while monitoring individual statistics and trends. - **Built-in Access Control** (Restrict actions based on user roles to prevent unauthorized changes). **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Access-Control:-Overview-&-Best-Practices)** --- ### ⭐ **Customizable Points System** - Personalize the points system by choosing your own name and icon (e.g., Stars, Bucks, Coins) to better resonate with your family. --- ### 🧹 **Chore Management** - **Assign & Track Chores:** - Easily define chores with descriptions, icons, due dates, and customizable recurring schedules. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Chore-Status-and-Recurrence-Handling)** - Supports **individual chores** (assigned to a single kid) and **shared chores** (requiring participation from multiple kids). - **Labels** can be used to **group chores** by type, location, or difficulty—or to **exclude specific chores** based on your family's needs. - **Smart Notifications & Workflow Approvals:** - Parents and kids receive **dynamic notifications** for **chore claims, approvals, and overdue tasks**. - Notifications are **actionable** on **phones, tablets, and smartwatches**, allowing parents to **approve or reject** tasks with a single tap. - **Customizable reminders** help ensure chores stay on track and are completed on time. - **Dynamic Chore States & Actions:** - Leverage dynamic buttons to claim, approve, or disapprove chores—completion with built-in authorization and contextual notifications. - Monitor progress with sensors that update on a per-kid and global level. --- ### 🎁 **Reward System** - Rewards help **motivate kids** by offering incentives they want while reinforcing responsibility. Parents can **create a list of rewards**, assign a **point cost**, and let kids claim them when they have enough points. - **Customizable & Goal-Oriented:** - Add rewards tailored to your kid’s interests (e.g., extra screen time, a special outing). - Assign point values to **encourage saving** and **set goals**. - **Seamless Claim & Workflow Approval Process:** - Kids can **claim rewards** when they meet the point requirement. - Parents receive an **approval notification**; once approved, **points are automatically deducted**, and the parent is responsible for delivering the reward. --- ### 🏅 **Badge System** - Badges reward **milestone achievements** and encourage consistency by tracking progress over time. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Badges:-Overview-&-Examples)** - **Earned Through Chores & Points:** - Kids can unlock badges by **completing chores** or **earning points** (e.g., 100 chores or 100 points). - Badge progress is **tracked from the start**, so kids receive credit for past achievements. - **Multipliers & Tracking:** - Badges can apply a **points multiplier** to boost future earnings (e.g., 1.5x points per chore). - Tracks each kid’s **highest badge earned** and **full badge history**. --- ### ⚖️ **Bonuses & Penalties** - Bonuses and penalties allow parents to **reinforce positive behavior** and **correct missteps** by adjusting points dynamically. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Bonuses-&-Penalties:-Overview-&-Examples)** - **Bonuses: Reward Extra Effort** - Award **extra points** for exceptional behavior, teamwork, or going above expectations. - Can be applied manually or automatically through the system, with **custom labels and tracking**. - **Penalties: Encourage Accountability** - Deduct points for missed chores or rule-breaking to **reinforce responsibility**. - Easily track applied penalties and ensure fair, transparent adjustments. --- ### 🏆 **Challenges & Achievements** - Challenges and achievements **motivate kids with structured goals**, rewarding consistency beyond daily chore completions. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Challenges-&-Achievements:-Overview-&-Functionality)** - **Achievements: Personal Milestones** - Earned by **completing a set number of chores** or **maintaining streaks** over time (e.g., 100 total chores, 30-day streak). - Tracks individual progress and provides **long-term motivation**. - **Challenges: Time-Bound Goals** - Require kids to **complete specific tasks within a set timeframe** (e.g., 50 chores in a month). - Can be **individual or shared**, encouraging teamwork toward a common goal. --- ### 📅 **Calendar Integration** - KidsChores integrates with **Home Assistant’s calendar**, allowing chores and challenges to displayed alongside other household events. - **Sync Chores to Calendar:** - View **due dates** for individual and shared chores directly in the Home Assistant calendar. - Helps parents and kids **plan ahead** and stay organized. - **Track Challenges & Time-Sensitive Goals:** - Challenges with set timeframes (e.g., "Complete 50 chores in a month") appear in the calendar for **easy progress tracking**. - Provides a **visual timeline** of ongoing and upcoming challenges. --- ### 📊 **Detailed Statistics & Advanced Controls** - KidsChores provides **comprehensive tracking** through **real-time sensors and interactive buttons**, giving parents full insight into chore activity and progress. - **Comprehensive Sensors & Data Tracking:** - Monitor **daily, weekly, and monthly stats** on chore completions, points earned, rewards redeemed, badges awarded, and penalties applied. - Analyze **historical trends** to celebrate progress, adjust incentives, and identify areas for improvement. - **Interactive Controls & Automation:** - Use dynamic buttons for **claiming chores, approving rewards, and applying bonuses or penalties** directly from the UI. - Seamlessly integrate with Home Assistant automations for **custom alerts, reports, and dashboard insights**. - 📖 **[View the Full List of Sensors & Actions →](https://github.com/ad-ha/kidschores-ha/wiki/Sensors-&-Buttons)** --- ### 🛠 Customization & User-Friendly Interface - **🛠 Dynamic Buttons & Actions:** - Manage chores and points directly from the Home Assistant UI with buttons for claiming, approving, redeeming, and adjusting points. - **🌐 Multilingual Support:** - Currently available in English and Spanish to cater to a diverse user base. - **🔧 Easy Setup & Maintenance:** - KidsChores offers a **fully interactive Options Flow** with a **user-friendly setup wizard** and **comprehensive configuration menus**, allowing you to manage everything **directly from the Home Assistant UI**—**no YAML or coding required**. With an intuitive frontend interface, you can effortlessly configure: - **Points** - **Kids & Parents** - **Chores** - **Rewards** - **Badges** - **Penalties & Bonuses** - **Achievements & Challenges** - **Organize with Home Assistant Labels:** - Use **labels** to categorize and manage chores, rewards, penalties, badges, and challenges—making it easier to filter, group, or exclude specific tasks based on your needs. --- ### ⚙️ Make KidsChores Your Own --- - If that's still not enough for you—**this is Home Assistant!** With a little customization, you can make KidsChores work exactly how you want. 📅 **Want to set schedules from your Google Calendar?** 📲 **Want to claim chores using NFC tags?** ✅ **Want to automatically approve specific chores?** ⏳ **Want to automatically apply a penalty or a custom alert when a chore goes overdue?** The **[Tips & Tricks](https://github.com/ad-ha/kidschores-ha/wiki/Tips-&-Tricks)** section of the Wiki is packed with ideas to help you **customize, automate, and extend** KidsChores to fit your family's needs. --- ## 🔐 **Security & Privacy** 🔹 **100% Local & Private** – Your data stays on your Home Assistant instance, ensuring complete privacy. 🔹 **No External Data Sharing** – No cloud services, no third-party access—everything runs securely on your local network. 🔹 **Built-in User Access Control** – Restrict actions based on roles to prevent unauthorized changes. With **KidsChores**, your family’s information remains private, secure, and fully under your control. --- ## 🤝 Join the Community & Contribute 🚀 **Get Help & Share Ideas** - 💬 **Join Community Discussions** → [Home Assistant Forum](https://community.home-assistant.io/t/kidschores-family-chore-management-integration) - 🛠️ **Report Issues & Request Features** → [GitHub Issues](https://github.com/ad-ha/kidschores-ha/issues) 👨‍💻 **Want to contribute?** - Submit a **pull request**: [GitHub Contributions](https://github.com/ad-ha/kidschores-ha/pulls). - Help with **translations** and **documentation updates**. --- KidsChores makes managing chores effortless, engaging, and rewarding for the whole family. With built-in gamification, smart automation, and flexible tracking, it turns daily routines into a fun and structured experience. Whether you want to **encourage responsibility**, **motivate with rewards**, or simply **streamline household tasks**, KidsChores has you covered. **Get started today and transform how your family manages chores, rewards, and accountability!** --- ## LICENSE This project is licensed under the [GPL-3.0 license](LICENSE). See the LICENSE file for details. --- ## DISCLAIMER THIS PROJECT IS NOT AFFILIATED WITH OR ENDORSED BY ANY OFFICIAL ENTITY. The information provided is for educational purposes only, and the developers assume no legal responsibility for the functionality or security of your devices. ================================================ FILE: custom_components/kidschores/__init__.py ================================================ # File: __init__.py """Initialization file for the KidsChores integration. Handles setting up the integration, including loading configuration entries, initializing data storage, and preparing the coordinator for data handling. Key Features: - Config entry setup and unload support. - Coordinator initialization for data synchronization. - Storage management for persistent data handling. """ from __future__ import annotations import asyncio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.exceptions import ConfigEntryNotReady from .const import ( DOMAIN, LOGGER, NOTIFICATION_EVENT, STORAGE_KEY, PLATFORMS, ) from .coordinator import KidsChoresDataCoordinator from .notification_action_handler import async_handle_notification_action from .storage_manager import KidsChoresStorageManager from .services import async_setup_services, async_unload_services async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the integration from a config entry.""" LOGGER.info("Starting setup for KidsChores entry: %s", entry.entry_id) # Initialize the storage manager to handle persistent data. storage_manager = KidsChoresStorageManager(hass, STORAGE_KEY) # Initialize new file. await storage_manager.async_initialize() # Create the data coordinator for managing updates and synchronization. coordinator = KidsChoresDataCoordinator(hass, entry, storage_manager) try: # Perform the first refresh to load data. await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as e: LOGGER.error("Failed to refresh coordinator data: %s", e) raise ConfigEntryNotReady from e # Store the coordinator and data manager in hass.data. hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "coordinator": coordinator, "storage_manager": storage_manager, } # Set up services required by the integration. async_setup_services(hass) # Forward the setup to supported platforms (sensors, buttons, etc.). await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Listen for notification actions from the companion app. hass.bus.async_listen( NOTIFICATION_EVENT, lambda event: asyncio.run_coroutine_threadsafe( async_handle_notification_action(hass, event), hass.loop ), ) LOGGER.info("KidsChores setup complete for entry: %s", entry.entry_id) return True async def async_unload_entry(hass, entry): """Unload a config entry. Args: hass: Home Assistant instance. entry: Config entry to unload. """ LOGGER.info("Unloading KidsChores entry: %s", entry.entry_id) # Unload platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) # Await service unloading await async_unload_services(hass) return unload_ok async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle removal of a config entry.""" LOGGER.info("Removing KidsChores entry: %s", entry.entry_id) # Safely check if data exists before attempting to access it if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]: storage_manager: KidsChoresStorageManager = hass.data[DOMAIN][entry.entry_id][ "storage_manager" ] await storage_manager.async_delete_storage() LOGGER.info("KidsChores entry data cleared: %s", entry.entry_id) ================================================ FILE: custom_components/kidschores/button.py ================================================ # File: button.py """Buttons for KidsChores integration. Features: 1) Chore Buttons (Claim & Approve) with user-defined or default icons. 2) Reward Buttons using user-defined or default icons. 3) Penalty Buttons using user-defined or default icons. 4) Bonus Buttons using user-defined or default icons. 5) PointsAdjustButton: manually increments/decrements a kid's points (e.g., +1, -1, +2, -2, etc.). 6) ApproveRewardButton: allows parents to approve rewards claimed by kids. """ from homeassistant.auth.models import User from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.exceptions import HomeAssistantError from .const import ( ATTR_LABELS, BUTTON_BONUS_PREFIX, BUTTON_DISAPPROVE_CHORE_PREFIX, BUTTON_DISAPPROVE_REWARD_PREFIX, BUTTON_PENALTY_PREFIX, BUTTON_REWARD_PREFIX, CONF_POINTS_LABEL, DATA_PENDING_CHORE_APPROVALS, DATA_PENDING_REWARD_APPROVALS, DEFAULT_BONUS_ICON, DEFAULT_CHORE_APPROVE_ICON, DEFAULT_CHORE_CLAIM_ICON, DEFAULT_DISAPPROVE_ICON, DEFAULT_PENALTY_ICON, DEFAULT_POINTS_ADJUST_MINUS_ICON, DEFAULT_POINTS_ADJUST_MINUS_MULTIPLE_ICON, DEFAULT_POINTS_ADJUST_PLUS_ICON, DEFAULT_POINTS_ADJUST_PLUS_MULTIPLE_ICON, DEFAULT_POINTS_LABEL, DEFAULT_REWARD_ICON, DOMAIN, ERROR_NOT_AUTHORIZED_ACTION_FMT, LOGGER, ) from .coordinator import KidsChoresDataCoordinator from .kc_helpers import ( is_user_authorized_for_global_action, is_user_authorized_for_kid, get_friendly_label, ) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ): """Set up dynamic buttons. - Chores (Claim & Approve & Disapprove) - Rewards (Redeem & Approve & Disapprove) - Penalties - Kid points adjustments (e.g., +1, -1, +10, -10, etc.) - Approve Reward Workflow """ data = hass.data[DOMAIN][entry.entry_id] coordinator: KidsChoresDataCoordinator = data["coordinator"] points_label = entry.options.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL) entities = [] # Create buttons for chores (Claim, Approve & Disapprove) for chore_id, chore_info in coordinator.chores_data.items(): chore_name = chore_info.get("name", f"Chore {chore_id}") assigned_kids_ids = chore_info.get("assigned_kids", []) # If user defined an icon, use it; else fallback to default for chore claim chore_claim_icon = chore_info.get("icon", DEFAULT_CHORE_CLAIM_ICON) # For "approve," use a distinct icon chore_approve_icon = chore_info.get("icon", DEFAULT_CHORE_APPROVE_ICON) for kid_id in assigned_kids_ids: kid_name = coordinator._get_kid_name_by_id(kid_id) or f"Kid {kid_id}" # Claim Button entities.append( ClaimChoreButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, chore_id=chore_id, chore_name=chore_name, icon=chore_claim_icon, ) ) # Approve Button entities.append( ApproveChoreButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, chore_id=chore_id, chore_name=chore_name, icon=chore_approve_icon, ) ) # Disapprove Button entities.append( DisapproveChoreButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, chore_id=chore_id, chore_name=chore_name, ) ) # Create reward buttons (Redeem, Approve & Disapprove) for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") for reward_id, reward_info in coordinator.rewards_data.items(): # If no user-defined icon, fallback to DEFAULT_REWARD_ICON reward_icon = reward_info.get("icon", DEFAULT_REWARD_ICON) # Redeem Reward Button entities.append( RewardButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, reward_id=reward_id, reward_name=reward_info.get("name", f"Reward {reward_id}"), icon=reward_icon, ) ) # Approve Reward Button entities.append( ApproveRewardButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, reward_id=reward_id, reward_name=reward_info.get("name", f"Reward {reward_id}"), icon=reward_info.get("icon", DEFAULT_REWARD_ICON), ) ) # Disapprove Reward Button entities.append( DisapproveRewardButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, reward_id=reward_id, reward_name=reward_info.get("name", f"Reward {reward_id}"), ) ) # Create penalty buttons for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") for penalty_id, penalty_info in coordinator.penalties_data.items(): # If no user-defined icon, fallback to DEFAULT_PENALTY_ICON penalty_icon = penalty_info.get("icon", DEFAULT_PENALTY_ICON) entities.append( PenaltyButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, penalty_id=penalty_id, penalty_name=penalty_info.get("name", f"Penalty {penalty_id}"), icon=penalty_icon, ) ) # Create bonus buttons for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") for bonus_id, bonus_info in coordinator.bonuses_data.items(): # If no user-defined icon, fallback to DEFAULT_BONUS_ICON bonus_icon = bonus_info.get("icon", DEFAULT_BONUS_ICON) entities.append( BonusButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, bonus_id=bonus_id, bonus_name=bonus_info.get("name", f"Bonus {bonus_id}"), icon=bonus_icon, ) ) # Create "points adjustment" buttons for each kid (±1, ±2, ±10, etc.) POINT_DELTAS = [+1, -1, +2, -2, +10, -10] for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") for delta in POINT_DELTAS: entities.append( PointsAdjustButton( coordinator=coordinator, entry=entry, kid_id=kid_id, kid_name=kid_name, delta=delta, points_label=points_label, ) ) async_add_entities(entities) # ------------------ Chore Buttons ------------------ class ClaimChoreButton(CoordinatorEntity, ButtonEntity): """Button to claim a chore as done (set chore state=claimed).""" _attr_has_entity_name = True _attr_translation_key = "claim_chore_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, chore_id: str, chore_name: str, icon: str, ): """Initialize the claim chore button.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_claim" self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } self.entity_id = f"button.kc_{kid_name}_chore_claim_{chore_name}" async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_kid( self.hass, user_id, self._kid_id ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("claim chores") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None user_name = user_obj.name if user_obj else "Unknown" self.coordinator.claim_chore( kid_id=self._kid_id, chore_id=self._chore_id, user_name=user_name, ) LOGGER.info( "Chore '%s' claimed by kid '%s' (user: %s)", self._chore_name, self._kid_name, user_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to claim chore '%s' for kid '%s': %s", self._chore_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to claim chore '%s' for kid '%s': %s", self._chore_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) stored_labels = chore_info.get("chore_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes class ApproveChoreButton(CoordinatorEntity, ButtonEntity): """Button to approve a claimed chore for a kid (set chore state=approved or partial).""" _attr_has_entity_name = True _attr_translation_key = "approve_chore_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, chore_id: str, chore_name: str, icon: str, ): """Initialize the approve chore button.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_approve" self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } self.entity_id = f"button.kc_{kid_name}_chore_approval_{chore_name}" async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "approve_chore" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("approve chores") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "ParentOrAdmin" self.coordinator.approve_chore( parent_name=parent_name, kid_id=self._kid_id, chore_id=self._chore_id, ) LOGGER.info( "Chore '%s' approved for kid '%s'", self._chore_name, self._kid_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to approve chore '%s' for kid '%s': %s", self._chore_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to approve chore '%s' for kid '%s': %s", self._chore_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) stored_labels = chore_info.get("chore_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes class DisapproveChoreButton(CoordinatorEntity, ButtonEntity): """Button to disapprove a chore.""" _attr_has_entity_name = True _attr_translation_key = "disapprove_chore_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, chore_id: str, chore_name: str, icon: str = DEFAULT_DISAPPROVE_ICON, ): """Initialize the disapprove chore button.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = ( f"{entry.entry_id}_{BUTTON_DISAPPROVE_CHORE_PREFIX}{kid_id}_{chore_id}" ) self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } self.entity_id = f"button.kc_{kid_name}_chore_disapproval_{chore_name}" async def async_press(self): """Handle the button press event.""" try: # Check if there's a pending approval for this kid and chore. pending_approvals = self.coordinator._data.get( DATA_PENDING_CHORE_APPROVALS, [] ) if not any( approval["kid_id"] == self._kid_id and approval["chore_id"] == self._chore_id for approval in pending_approvals ): raise HomeAssistantError( f"No pending approval found for chore '{self._chore_name}' for kid '{self._kid_name}'." ) user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "disapprove_chore" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("disapprove chores") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "ParentOrAdmin" self.coordinator.disapprove_chore( parent_name=parent_name, kid_id=self._kid_id, chore_id=self._chore_id, ) LOGGER.info( "Chore '%s' disapproved for kid '%s' by parent '%s'", self._chore_name, self._kid_name, parent_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to disapprove chore '%s' for kid '%s': %s", self._chore_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to disapprove chore '%s' for kid '%s': %s", self._chore_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) stored_labels = chore_info.get("chore_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes # ------------------ Reward Buttons ------------------ class RewardButton(CoordinatorEntity, ButtonEntity): """Button to redeem a reward for a kid.""" _attr_has_entity_name = True _attr_translation_key = "claim_reward_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, reward_id: str, reward_name: str, icon: str, ): """Initialize the reward button.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._reward_id = reward_id self._reward_name = reward_name self._attr_unique_id = ( f"{entry.entry_id}_{BUTTON_REWARD_PREFIX}{kid_id}_{reward_id}" ) self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "reward_name": reward_name, } self.entity_id = f"button.kc_{kid_name}_reward_claim_{reward_name}" async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_kid( self.hass, user_id, self._kid_id ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("redeem rewards") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "Unknown" self.coordinator.redeem_reward( parent_name=parent_name, kid_id=self._kid_id, reward_id=self._reward_id, ) LOGGER.info( "Reward '%s' redeemed for kid '%s' by parent '%s'", self._reward_name, self._kid_name, parent_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to redeem reward '%s' for kid '%s': %s", self._reward_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to redeem reward '%s' for kid '%s': %s", self._reward_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) stored_labels = reward_info.get("reward_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes class ApproveRewardButton(CoordinatorEntity, ButtonEntity): """Button for parents to approve a reward claimed by a kid. Prevents unauthorized or premature reward approvals. """ _attr_has_entity_name = True _attr_translation_key = "approve_reward_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, reward_id: str, reward_name: str, icon: str, ): """Initialize the approve reward button.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._reward_id = reward_id self._reward_name = reward_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_approve_reward" self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "reward_name": reward_name, } self.entity_id = f"button.kc_{kid_name}_reward_approval_{reward_name}" async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "approve_reward" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("approve rewards") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "ParentOrAdmin" # Approve the reward self.coordinator.approve_reward( parent_name=parent_name, kid_id=self._kid_id, reward_id=self._reward_id, ) LOGGER.info( "Reward '%s' approved for kid '%s' by parent '%s'", self._reward_name, self._kid_name, parent_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to approve reward '%s' for kid '%s': %s", self._reward_name, self._kid_name, e, ) # Send a persistent notification for the error if user_id: self.hass.components.persistent_notification.create( f"Failed to approve reward '{self._reward_name}' for {self._kid_name}: {e}", title="Reward Approval Failed", notification_id=f"approve_reward_error_{self._reward_id}", ) except Exception as e: LOGGER.error( "Failed to approve reward '%s' for kid '%s': %s", self._reward_name, self._kid_name, e, ) # Send a persistent notification for the unexpected error if user_id: self.hass.components.persistent_notification.create( f"An unexpected error occurred while approving reward '{self._reward_name}' for {self._kid_name}", title="Reward Approval Error", notification_id=f"approve_reward_unexpected_error_{self._reward_id}", ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) stored_labels = reward_info.get("reward_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes class DisapproveRewardButton(CoordinatorEntity, ButtonEntity): """Button to disapprove a reward.""" _attr_has_entity_name = True _attr_translation_key = "disapprove_reward_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, reward_id: str, reward_name: str, icon: str = DEFAULT_DISAPPROVE_ICON, ): """Initialize the disapprove reward button.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._reward_id = reward_id self._reward_name = reward_name self._attr_unique_id = ( f"{entry.entry_id}_{BUTTON_DISAPPROVE_REWARD_PREFIX}{kid_id}_{reward_id}" ) self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "reward_name": reward_name, } self.entity_id = f"button.kc_{kid_name}_reward_disapproval_{reward_name}" async def async_press(self): """Handle the button press event.""" try: # Check if there's a pending approval for this kid and reward. pending_approvals = self.coordinator._data.get( DATA_PENDING_REWARD_APPROVALS, [] ) if not any( approval["kid_id"] == self._kid_id and approval["reward_id"] == self._reward_id for approval in pending_approvals ): raise HomeAssistantError( f"No pending approval found for reward '{self._reward_name}' for kid '{self._kid_name}'." ) user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "disapprove_reward" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("disapprove rewards") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "ParentOrAdmin" self.coordinator.disapprove_reward( parent_name=parent_name, kid_id=self._kid_id, reward_id=self._reward_id, ) LOGGER.info( "Reward '%s' disapproved for kid '%s' by parent '%s'", self._reward_name, self._kid_name, parent_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to disapprove reward '%s' for kid '%s': %s", self._reward_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to disapprove reward '%s' for kid '%s': %s", self._reward_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) stored_labels = reward_info.get("reward_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes # ------------------ Penalty Button ------------------ class PenaltyButton(CoordinatorEntity, ButtonEntity): """Button to apply a penalty for a kid. Uses user-defined or default penalty icon. """ _attr_has_entity_name = True _attr_translation_key = "penalty_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, penalty_id: str, penalty_name: str, icon: str, ): """Initialize the penalty button.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._penalty_id = penalty_id self._penalty_name = penalty_name self._attr_unique_id = ( f"{entry.entry_id}_{BUTTON_PENALTY_PREFIX}{kid_id}_{penalty_id}" ) self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "penalty_name": penalty_name, } self.entity_id = f"button.kc_{kid_name}_penalty_{penalty_name}" async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "apply_penalty" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("apply penalties") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "Unknown" self.coordinator.apply_penalty( parent_name=parent_name, kid_id=self._kid_id, penalty_id=self._penalty_id, ) LOGGER.info( "Penalty '%s' applied to kid '%s' by '%s'", self._penalty_name, self._kid_name, parent_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to apply penalty '%s' for kid '%s': %s", self._penalty_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to apply penalty '%s' for kid '%s': %s", self._penalty_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" penalty_info = self.coordinator.penalties_data.get(self._penalty_id, {}) stored_labels = penalty_info.get("penalty_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes # ------------------ Points Adjust Button ------------------ class PointsAdjustButton(CoordinatorEntity, ButtonEntity): """Button that increments or decrements a kid's points by 'delta'. For example: +1, -1, +10, -10, etc. Uses icons from const.py for plus/minus, or fallback if desired. """ _attr_has_entity_name = True _attr_translation_key = "manual_adjustment_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, delta: int, points_label: str, ): """Initialize the points adjust buttons.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._delta = delta self._points_label = str(points_label) sign_label = f"+{delta}" if delta >= 0 else f"-{delta}" sign_text = f"plus_{delta}" if delta >= 0 else f"minus_{delta}" self._attr_unique_id = f"{entry.entry_id}_{kid_id}_adjust_points_{delta}" self._attr_translation_placeholders = { "kid_name": kid_name, "sign_label": sign_label, "points_label": points_label, } self.entity_id = f"button.kc_{kid_name}_{sign_text}_points" # Decide the icon based on whether delta is positive or negative if delta >= 2: self._attr_icon = DEFAULT_POINTS_ADJUST_PLUS_MULTIPLE_ICON elif delta > 0: self._attr_icon = DEFAULT_POINTS_ADJUST_PLUS_ICON elif delta <= -2: self._attr_icon = DEFAULT_POINTS_ADJUST_MINUS_MULTIPLE_ICON elif delta < 0: self._attr_icon = DEFAULT_POINTS_ADJUST_MINUS_ICON else: self._attr_icon = DEFAULT_POINTS_ADJUST_PLUS_ICON async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "adjust_points" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("adjust points") ) current_points = self.coordinator.kids_data[self._kid_id]["points"] new_points = current_points + self._delta self.coordinator.update_kid_points( kid_id=self._kid_id, new_points=new_points, ) LOGGER.info( "Adjusted points for kid '%s' by %d => total %d", self._kid_name, self._delta, new_points, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to adjust points for kid '%s' by %d: %s", self._kid_name, self._delta, e, ) except Exception as e: LOGGER.error( "Failed to adjust points for kid '%s' by %d: %s", self._kid_name, self._delta, e, ) class BonusButton(CoordinatorEntity, ButtonEntity): """Button to apply a bonus for a kid. Uses user-defined or default bonus icon. """ _attr_has_entity_name = True _attr_translation_key = "bonus_button" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, bonus_id: str, bonus_name: str, icon: str, ): """Initialize the bonus button.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._bonus_id = bonus_id self._bonus_name = bonus_name self._attr_unique_id = ( f"{entry.entry_id}_{BUTTON_BONUS_PREFIX}{kid_id}_{bonus_id}" ) self._attr_icon = icon self._attr_translation_placeholders = { "kid_name": kid_name, "bonus_name": bonus_name, } self.entity_id = f"button.kc_{kid_name}_bonus_{bonus_name}" async def async_press(self): """Handle the button press event.""" try: user_id = self._context.user_id if self._context else None if user_id and not await is_user_authorized_for_global_action( self.hass, user_id, "apply_bonus" ): raise HomeAssistantError( ERROR_NOT_AUTHORIZED_ACTION_FMT.format("apply bonus") ) user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None parent_name = user_obj.name if user_obj else "Unknown" self.coordinator.apply_bonus( parent_name=parent_name, kid_id=self._kid_id, bonus_id=self._bonus_id, ) LOGGER.info( "Bonus '%s' applied to kid '%s' by '%s'", self._bonus_name, self._kid_name, parent_name, ) await self.coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error( "Authorization failed to apply bonus '%s' for kid '%s': %s", self._bonus_name, self._kid_name, e, ) except Exception as e: LOGGER.error( "Failed to apply bonus '%s' for kid '%s': %s", self._bonus_name, self._kid_name, e, ) @property def extra_state_attributes(self): """Include extra state attributes for the button.""" bonus_info = self.coordinator.bonuses_data.get(self._bonus_id, {}) stored_labels = bonus_info.get("bonus_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_LABELS: friendly_labels, } return attributes ================================================ FILE: custom_components/kidschores/calendar.py ================================================ # File: calendar.py import datetime from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.util import dt as dt_util from .const import ( DOMAIN, FREQUENCY_BIWEEKLY, FREQUENCY_CUSTOM, FREQUENCY_DAILY, FREQUENCY_MONTHLY, FREQUENCY_NONE, FREQUENCY_WEEKLY, LOGGER, WEEKDAY_OPTIONS, ATTR_KID_NAME, ) # Map weekday integers (0=Monday, …) to e.g. "mon","tue","wed" in WEEKDAY_OPTIONS. WEEKDAY_MAP = {i: key for i, key in enumerate(WEEKDAY_OPTIONS.keys())} # For chores without a due_date, we generate up to 3 months FOREVER_DURATION = datetime.timedelta(days=90) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ): """Set up the KidsChores calendar platform.""" try: coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] except KeyError: LOGGER.error("Coordinator not found in hass.data for entry %s", entry.entry_id) return entities = [] for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") entities.append(KidsChoresCalendarEntity(coordinator, kid_id, kid_name, entry)) async_add_entities(entities) class KidsChoresCalendarEntity(CalendarEntity): """Calendar entity representing a kid's combined chores + challenges.""" def __init__(self, coordinator, kid_id: str, kid_name: str, config_entry): super().__init__() self.coordinator = coordinator self._kid_id = kid_id self._kid_name = kid_name self._config_entry = config_entry self._attr_name = f"KidsChores Calendar: {kid_name}" self._attr_unique_id = f"{config_entry.entry_id}_{kid_id}_calendar" self.entity_id = f"calendar.kc_{kid_name}" async def async_get_events( self, hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> list[CalendarEvent]: """ Return CalendarEvent objects for: - chores assigned to this kid - challenges assigned to this kid overlapping [start, end]. """ local_tz = dt_util.get_time_zone(self.hass.config.time_zone) if start.tzinfo is None: start = start.replace(tzinfo=local_tz) if end.tzinfo is None: end = end.replace(tzinfo=local_tz) events: list[CalendarEvent] = [] # 1) Generate chore events for chore in self.coordinator.chores_data.values(): if self._kid_id in chore.get("assigned_kids", []): events.extend(self._generate_events_for_chore(chore, start, end)) # 2) Generate challenge events for challenge in self.coordinator.challenges_data.values(): if self._kid_id in challenge.get("assigned_kids", []): evs = self._generate_events_for_challenge(challenge, start, end) events.extend(evs) return events def _generate_events_for_chore( self, chore: dict, window_start: datetime.datetime, window_end: datetime.datetime, ) -> list[CalendarEvent]: """Same recurring-chores logic from earlier solutions.""" events: list[CalendarEvent] = [] summary = chore.get("name", "Unnamed Chore") description = chore.get("description", "") recurring = chore.get("recurring_frequency", FREQUENCY_NONE) applicable_days = chore.get("applicable_days", []) # Parse chore due_date if any due_date_str = chore.get("due_date") due_dt: datetime.datetime | None = None if due_date_str: dt_parsed = dt_util.parse_datetime(due_date_str) if dt_parsed: due_dt = dt_util.as_local(dt_parsed) def is_midnight(dt_obj: datetime.datetime) -> bool: return (dt_obj.hour, dt_obj.minute, dt_obj.second) == (0, 0, 0) def overlaps(ev: CalendarEvent) -> bool: """Check if event overlaps [window_start, window_end].""" sdt = ev.start edt = ev.end if isinstance(sdt, datetime.date) and not isinstance( sdt, datetime.datetime ): tz = dt_util.get_time_zone(self.hass.config.time_zone) sdt = datetime.datetime.combine(sdt, datetime.time.min, tzinfo=tz) if isinstance(edt, datetime.date) and not isinstance( edt, datetime.datetime ): tz = dt_util.get_time_zone(self.hass.config.time_zone) edt = datetime.datetime.combine(edt, datetime.time.min, tzinfo=tz) if not sdt or not edt: return False return (edt > window_start) and (sdt < window_end) # --- Non-recurring chores --- if recurring == FREQUENCY_NONE: if due_dt: # single event if in window if window_start <= due_dt <= window_end: if is_midnight(due_dt): e = CalendarEvent( summary=summary, start=due_dt.date(), end=due_dt.date() + datetime.timedelta(days=1), description=description, ) else: e = CalendarEvent( summary=summary, start=due_dt, end=due_dt + datetime.timedelta(hours=1), description=description, ) if overlaps(e): events.append(e) else: # No due_date => possibly show on applicable_days for next 3 months if applicable_days: gen_start = window_start gen_end = min( window_end, dt_util.as_local(datetime.datetime.now() + FOREVER_DURATION), ) current = gen_start while current <= gen_end: if WEEKDAY_MAP[current.weekday()] in applicable_days: e = CalendarEvent( summary=summary, start=current.date(), end=current.date() + datetime.timedelta(days=1), description=description, ) if overlaps(e): events.append(e) current += datetime.timedelta(days=1) return events # --- Recurring chores with a due_date --- if due_dt: cutoff = min(due_dt, window_end) if cutoff < window_start: return events if recurring == FREQUENCY_DAILY: if window_start <= due_dt <= window_end: if is_midnight(due_dt): e = CalendarEvent( summary=summary, start=due_dt.date(), end=due_dt.date() + datetime.timedelta(days=1), description=description, ) else: e = CalendarEvent( summary=summary, start=due_dt, end=due_dt + datetime.timedelta(hours=1), description=description, ) if overlaps(e): events.append(e) elif recurring == FREQUENCY_WEEKLY: start_event = due_dt - datetime.timedelta(weeks=1) end_event = due_dt if start_event < window_end and end_event > window_start: e = CalendarEvent( summary=summary, start=start_event.date(), end=(end_event.date() + datetime.timedelta(days=1)), description=description, ) if overlaps(e): events.append(e) elif recurring == FREQUENCY_BIWEEKLY: start_event = due_dt - datetime.timedelta(weeks=2) end_event = due_dt if start_event < window_end and end_event > window_start: e = CalendarEvent( summary=summary, start=start_event.date(), end=(end_event.date() + datetime.timedelta(days=1)), description=description, ) if overlaps(e): events.append(e) elif recurring == FREQUENCY_MONTHLY: first_day = due_dt.replace(day=1) if first_day < window_end and due_dt > window_start: e = CalendarEvent( summary=summary, start=first_day.date(), end=(due_dt.date() + datetime.timedelta(days=1)), description=description, ) if overlaps(e): events.append(e) elif recurring == FREQUENCY_CUSTOM: interval = chore.get("custom_interval", 1) unit = chore.get("custom_interval_unit", "days") if unit == "days": start_event = due_dt - datetime.timedelta(days=interval) elif unit == "weeks": start_event = due_dt - datetime.timedelta(weeks=interval) elif unit == "months": start_event = due_dt - datetime.timedelta(days=30 * interval) else: start_event = due_dt if start_event < window_end and due_dt > window_start: e = CalendarEvent( summary=summary, start=start_event.date(), end=(due_dt.date() + datetime.timedelta(days=1)), description=description, ) if overlaps(e): events.append(e) return events # --- Recurring chores without a due_date => next 3 months gen_start = window_start future_limit = dt_util.as_local(datetime.datetime.now() + FOREVER_DURATION) cutoff = min(window_end, future_limit) if recurring == FREQUENCY_DAILY: current = gen_start while current <= cutoff: if ( applicable_days and WEEKDAY_MAP[current.weekday()] not in applicable_days ): current += datetime.timedelta(days=1) continue e = CalendarEvent( summary=summary, start=current.date(), end=current.date() + datetime.timedelta(days=1), description=description, ) if overlaps(e): events.append(e) current += datetime.timedelta(days=1) return events if recurring in (FREQUENCY_WEEKLY, FREQUENCY_BIWEEKLY): week_delta = 7 if recurring == FREQUENCY_WEEKLY else 14 current = gen_start # align to Monday while current.weekday() != 0: current += datetime.timedelta(days=1) while current <= cutoff: # multi-day block from Monday..Sunday (or 2 weeks for biweekly) block_days = 6 if recurring == FREQUENCY_WEEKLY else 13 start_block = current end_block = current + datetime.timedelta(days=block_days) e = CalendarEvent( summary=summary, start=start_block.date(), end=end_block.date() + datetime.timedelta(days=1), description=description, ) if overlaps(e): events.append(e) current += datetime.timedelta(days=week_delta) return events if recurring == FREQUENCY_MONTHLY: cur = gen_start while cur <= cutoff: first_day = cur.replace(day=1) next_month = first_day + datetime.timedelta(days=32) next_month = next_month.replace(day=1) last_day = next_month - datetime.timedelta(days=1) e = CalendarEvent( summary=summary, start=first_day.date(), end=last_day.date() + datetime.timedelta(days=1), description=description, ) if overlaps(e): events.append(e) cur = next_month return events if recurring == FREQUENCY_CUSTOM: interval = chore.get("custom_interval", 1) unit = chore.get("custom_interval_unit", "days") if unit == "days": step = datetime.timedelta(days=interval) elif unit == "weeks": step = datetime.timedelta(weeks=interval) elif unit == "months": step = datetime.timedelta(days=30 * interval) else: step = datetime.timedelta(days=interval) current = gen_start while current <= cutoff: # Check applicable days if ( applicable_days and WEEKDAY_MAP[current.weekday()] not in applicable_days ): current += step continue e = CalendarEvent( summary=summary, start=current.date(), end=current.date() + step, description=description, ) if overlaps(e): events.append(e) current += step return events return events def _generate_events_for_challenge( self, challenge: dict, window_start: datetime.datetime, window_end: datetime.datetime, ) -> list[CalendarEvent]: """ Produce a single multi-day event for each challenge that has valid start_date/end_date. Only if it overlaps the requested [window_start, window_end]. """ events: list[CalendarEvent] = [] challenge_name = challenge.get("name", "Unnamed Challenge") description = challenge.get("description", "") start_str = challenge.get("start_date") end_str = challenge.get("end_date") if not start_str or not end_str: return events # no valid date range => skip start_dt = dt_util.parse_datetime(start_str) end_dt = dt_util.parse_datetime(end_str) if not start_dt or not end_dt: return events # parsing failed => skip # Convert to local local_start = dt_util.as_local(start_dt) local_end = dt_util.as_local(end_dt) # If the challenge times are midnight-based, we can treat them as all-day. # But let's keep it simpler => always treat as an all-day block from date(start) to date(end)+1 # so the user sees a big multi-day block. if local_start > window_end or local_end < window_start: return events # out of range # Build an all-day event from local_start.date() to local_end.date() + 1 day ev = CalendarEvent( summary=f"Challenge: {challenge_name}", start=local_start.date(), end=local_end.date() + datetime.timedelta(days=1), description=description, ) # Overlap check (similar logic): def overlaps(e: CalendarEvent) -> bool: sdt = e.start edt = e.end # convert if needed tz = dt_util.get_time_zone(self.hass.config.time_zone) if isinstance(sdt, datetime.date) and not isinstance( sdt, datetime.datetime ): sdt = datetime.datetime.combine(sdt, datetime.time.min, tzinfo=tz) if isinstance(edt, datetime.date) and not isinstance( edt, datetime.datetime ): edt = datetime.datetime.combine(edt, datetime.time.min, tzinfo=tz) return bool(sdt and edt and (edt > window_start) and (sdt < window_end)) if overlaps(ev): events.append(ev) return events @property def event(self) -> CalendarEvent | None: """ Return a single "current" event (chore or challenge) if one is active now (±1h). Otherwise None. """ now = dt_util.as_local(datetime.datetime.utcnow()) window_start = now - datetime.timedelta(hours=1) window_end = now + datetime.timedelta(hours=1) all_events = self._generate_all_events(window_start, window_end) for e in all_events: # Convert date->datetime for comparison tz = dt_util.get_time_zone(self.hass.config.time_zone) sdt = e.start edt = e.end if isinstance(sdt, datetime.date) and not isinstance( sdt, datetime.datetime ): sdt = datetime.datetime.combine(sdt, datetime.time.min, tzinfo=tz) if isinstance(edt, datetime.date) and not isinstance( edt, datetime.datetime ): edt = datetime.datetime.combine(edt, datetime.time.min, tzinfo=tz) if sdt and edt and sdt <= now < edt: return e return None def _generate_all_events( self, window_start: datetime.datetime, window_end: datetime.datetime ) -> list[CalendarEvent]: """Generate chores + challenges for this kid in the given window.""" events = [] # chores for chore in self.coordinator.chores_data.values(): if self._kid_id in chore.get("assigned_kids", []): events.extend( self._generate_events_for_chore(chore, window_start, window_end) ) # challenges for challenge in self.coordinator.challenges_data.values(): if self._kid_id in challenge.get("assigned_kids", []): events.extend( self._generate_events_for_challenge( challenge, window_start, window_end ) ) return events @property def extra_state_attributes(self): return {ATTR_KID_NAME: self._kid_name} ================================================ FILE: custom_components/kidschores/config_flow.py ================================================ # File: config_flow.py """Multi-step config flow for the KidsChores integration, storing entities by internal_id. Ensures that all add/edit/delete operations reference entities via internal_id for consistency. """ import datetime import uuid import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util from typing import Any, Optional from .const import ( ACHIEVEMENT_TYPE_STREAK, CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW, CONF_APPLICABLE_DAYS, CONF_ACHIEVEMENTS, CONF_BADGES, CONF_CHALLENGES, CONF_CHORES, CONF_KIDS, CONF_NOTIFY_ON_APPROVAL, CONF_NOTIFY_ON_CLAIM, CONF_NOTIFY_ON_DISAPPROVAL, CONF_PARENTS, CONF_PENALTIES, CONF_POINTS_ICON, CONF_POINTS_LABEL, CONF_REWARDS, CONF_BONUSES, DEFAULT_APPLICABLE_DAYS, DEFAULT_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_DISAPPROVAL, DEFAULT_POINTS_ICON, DEFAULT_POINTS_LABEL, FREQUENCY_CUSTOM, DOMAIN, LOGGER, ) from .flow_helpers import ( build_points_schema, build_kid_schema, build_parent_schema, build_chore_schema, build_badge_schema, build_reward_schema, build_penalty_schema, build_achievement_schema, build_challenge_schema, ensure_utc_datetime, build_bonus_schema, ) class KidsChoresConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config Flow for KidsChores with internal_id-based entity management.""" VERSION = 1 def __init__(self) -> None: """Initialize the config flow.""" self._data: dict[str, Any] = {} self._kids_temp: dict[str, dict[str, Any]] = {} self._parents_temp: dict[str, dict[str, Any]] = {} self._chores_temp: dict[str, dict[str, Any]] = {} self._badges_temp: dict[str, dict[str, Any]] = {} self._rewards_temp: dict[str, dict[str, Any]] = {} self._achievements_temp: dict[str, dict[str, Any]] = {} self._challenges_temp: dict[str, dict[str, Any]] = {} self._penalties_temp: dict[str, dict[str, Any]] = {} self._bonuses_temp: dict[str, dict[str, Any]] = {} self._kid_count: int = 0 self._parents_count: int = 0 self._chore_count: int = 0 self._badge_count: int = 0 self._reward_count: int = 0 self._achievement_count: int = 0 self._challenge_count: int = 0 self._penalty_count: int = 0 self._bonus_count: int = 0 self._kid_index: int = 0 self._parents_index: int = 0 self._chore_index: int = 0 self._badge_index: int = 0 self._reward_index: int = 0 self._achievement_index: int = 0 self._challenge_index: int = 0 self._penalty_index: int = 0 self._bonus_index: int = 0 async def async_step_user(self, user_input: Optional[dict[str, Any]] = None): """Start the config flow with an intro step.""" # Check if there's an existing KidsChores entry if any(self._async_current_entries()): return self.async_abort(reason="single_instance_allowed") # Continue your normal flow return await self.async_step_intro() async def async_step_intro(self, user_input=None): """Intro / welcome step. Press Next to continue.""" if user_input is not None: return await self.async_step_points_label() return self.async_show_form(step_id="intro", data_schema=vol.Schema({})) async def async_step_points_label(self, user_input=None): """Let the user define a custom label for points.""" errors = {} if user_input is not None: points_label = user_input.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL) points_icon = user_input.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON) self._data[CONF_POINTS_LABEL] = points_label self._data[CONF_POINTS_ICON] = points_icon return await self.async_step_kid_count() points_schema = build_points_schema( default_label=DEFAULT_POINTS_LABEL, default_icon=DEFAULT_POINTS_ICON ) return self.async_show_form( step_id="points_label", data_schema=points_schema, errors=errors ) # -------------------------------------------------------------------------- # KIDS # -------------------------------------------------------------------------- async def async_step_kid_count(self, user_input=None): """Ask how many kids to define initially.""" errors = {} if user_input is not None: try: self._kid_count = int(user_input["kid_count"]) if self._kid_count < 0: raise ValueError if self._kid_count == 0: return await self.async_step_chore_count() self._kid_index = 0 return await self.async_step_kids() except ValueError: errors["base"] = "invalid_kid_count" schema = vol.Schema({vol.Required("kid_count", default=1): vol.Coerce(int)}) return self.async_show_form( step_id="kid_count", data_schema=schema, errors=errors ) async def async_step_kids(self, user_input=None): """Collect each kid's info using internal_id as the primary key. Store in self._kids_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: kid_name = user_input["kid_name"].strip() ha_user_id = user_input.get("ha_user") or "" enable_mobile_notifications = user_input.get( "enable_mobile_notifications", True ) notify_service = user_input.get("mobile_notify_service") or "" enable_persist = user_input.get("enable_persistent_notifications", True) if not kid_name: errors["kid_name"] = "invalid_kid_name" elif any( kid_data["name"] == kid_name for kid_data in self._kids_temp.values() ): errors["kid_name"] = "duplicate_kid" else: internal_id = user_input.get("internal_id", str(uuid.uuid4())) self._kids_temp[internal_id] = { "name": kid_name, "ha_user_id": ha_user_id, "enable_notifications": enable_mobile_notifications, "mobile_notify_service": notify_service, "use_persistent_notifications": enable_persist, "internal_id": internal_id, } LOGGER.debug("Added kid: %s with ID: %s", kid_name, internal_id) self._kid_index += 1 if self._kid_index >= self._kid_count: return await self.async_step_parent_count() return await self.async_step_kids() # Retrieve HA users for linking users = await self.hass.auth.async_get_users() kid_schema = build_kid_schema( self.hass, users=users, default_kid_name="", default_ha_user_id=None, default_enable_mobile_notifications=False, default_mobile_notify_service=None, default_enable_persistent_notifications=False, ) return self.async_show_form( step_id="kids", data_schema=kid_schema, errors=errors ) # -------------------------------------------------------------------------- # PARENTS # -------------------------------------------------------------------------- async def async_step_parent_count(self, user_input=None): """Ask how many parents to define initially.""" errors = {} if user_input is not None: try: self._parents_count = int(user_input["parent_count"]) if self._parents_count < 0: raise ValueError if self._parents_count == 0: return await self.async_step_chore_count() self._parents_index = 0 return await self.async_step_parents() except ValueError: errors["base"] = "invalid_parent_count" schema = vol.Schema({vol.Required("parent_count", default=1): vol.Coerce(int)}) return self.async_show_form( step_id="parent_count", data_schema=schema, errors=errors ) async def async_step_parents(self, user_input=None): """Collect each parent's info using internal_id as the primary key. Store in self._parents_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: parent_name = user_input["parent_name"].strip() ha_user_id = user_input.get("ha_user_id") or "" associated_kids = user_input.get("associated_kids", []) enable_mobile_notifications = user_input.get( "enable_mobile_notifications", True ) notify_service = user_input.get("mobile_notify_service") or "" enable_persist = user_input.get("enable_persistent_notifications", True) if not parent_name: errors["parent_name"] = "invalid_parent_name" elif any( parent_data["name"] == parent_name for parent_data in self._parents_temp.values() ): errors["parent_name"] = "duplicate_parent" else: internal_id = user_input.get("internal_id", str(uuid.uuid4())) self._parents_temp[internal_id] = { "name": parent_name, "ha_user_id": ha_user_id, "associated_kids": associated_kids, "enable_notifications": enable_mobile_notifications, "mobile_notify_service": notify_service, "use_persistent_notifications": enable_persist, "internal_id": internal_id, } LOGGER.debug("Added parent: %s with ID: %s", parent_name, internal_id) self._parents_index += 1 if self._parents_index >= self._parents_count: return await self.async_step_chore_count() return await self.async_step_parents() # Retrieve kids for association from _kids_temp kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items() } users = await self.hass.auth.async_get_users() parent_schema = build_parent_schema( self.hass, users=users, kids_dict=kids_dict, default_parent_name="", default_ha_user_id=None, default_associated_kids=[], default_enable_mobile_notifications=False, default_mobile_notify_service=None, default_enable_persistent_notifications=False, internal_id=None, ) return self.async_show_form( step_id="parents", data_schema=parent_schema, errors=errors ) # -------------------------------------------------------------------------- # CHORES # -------------------------------------------------------------------------- async def async_step_chore_count(self, user_input=None): """Ask how many chores to define.""" errors = {} if user_input is not None: try: self._chore_count = int(user_input["chore_count"]) if self._chore_count < 0: raise ValueError if self._chore_count == 0: return await self.async_step_badge_count() self._chore_index = 0 return await self.async_step_chores() except ValueError: errors["base"] = "invalid_chore_count" schema = vol.Schema({vol.Required("chore_count", default=1): vol.Coerce(int)}) return self.async_show_form( step_id="chore_count", data_schema=schema, errors=errors ) async def async_step_chores(self, user_input=None): """Collect chore details using internal_id as the primary key. Store in self._chores_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: chore_name = user_input["chore_name"].strip() internal_id = user_input.get("internal_id", str(uuid.uuid4())) if user_input.get("due_date"): raw_due = user_input["due_date"] try: due_date_str = ensure_utc_datetime(self.hass, raw_due) due_dt = dt_util.parse_datetime(due_date_str) if due_dt and due_dt < dt_util.utcnow(): errors["due_date"] = "due_date_in_past" except ValueError: errors["due_date"] = "invalid_due_date" due_date_str = None else: due_date_str = None if not chore_name: errors["chore_name"] = "invalid_chore_name" elif any( chore_data["name"] == chore_name for chore_data in self._chores_temp.values() ): errors["chore_name"] = "duplicate_chore" if errors: kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items() } # Re-show the form with the user's current input and errors: default_data = user_input.copy() return self.async_show_form( step_id="chores", data_schema=build_chore_schema(kids_dict, default_data), errors=errors, ) if user_input.get("recurring_frequency") != FREQUENCY_CUSTOM: user_input.pop("custom_interval", None) user_input.pop("custom_interval_unit", None) # If no errors, store the chore self._chores_temp[internal_id] = { "name": chore_name, "default_points": user_input["default_points"], "partial_allowed": user_input["partial_allowed"], "shared_chore": user_input["shared_chore"], "assigned_kids": user_input["assigned_kids"], "allow_multiple_claims_per_day": user_input[ "allow_multiple_claims_per_day" ], "description": user_input.get("chore_description", ""), "chore_labels": user_input.get("chore_labels", []), "icon": user_input.get("icon", ""), "recurring_frequency": user_input.get("recurring_frequency", "none"), "custom_interval": user_input.get("custom_interval"), "custom_interval_unit": user_input.get("custom_interval_unit"), "due_date": due_date_str, "applicable_days": user_input.get( CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS ), "notify_on_claim": user_input.get( CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM ), "notify_on_approval": user_input.get( CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL ), "notify_on_disapproval": user_input.get( CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL ), "internal_id": internal_id, } LOGGER.debug("Added chore: %s with ID: %s", chore_name, internal_id) self._chore_index += 1 if self._chore_index >= self._chore_count: return await self.async_step_badge_count() return await self.async_step_chores() # Use flow_helpers.build_chore_schema, passing the current kids kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items() } default_data = {} chore_schema = build_chore_schema(kids_dict, default_data) return self.async_show_form( step_id="chores", data_schema=chore_schema, errors=errors ) # -------------------------------------------------------------------------- # BADGES # -------------------------------------------------------------------------- async def async_step_badge_count(self, user_input=None): """Ask how many badges to define.""" errors = {} if user_input is not None: try: self._badge_count = int(user_input["badge_count"]) if self._badge_count < 0: raise ValueError if self._badge_count == 0: return await self.async_step_reward_count() self._badge_index = 0 return await self.async_step_badges() except ValueError: errors["base"] = "invalid_badge_count" schema = vol.Schema({vol.Required("badge_count", default=0): vol.Coerce(int)}) return self.async_show_form( step_id="badge_count", data_schema=schema, errors=errors ) async def async_step_badges(self, user_input=None): """Collect badge details using internal_id as the primary key. Store in self._badges_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: badge_name = user_input["badge_name"].strip() internal_id = user_input.get("internal_id", str(uuid.uuid4())) if not badge_name: errors["badge_name"] = "invalid_badge_name" elif any( badge_data["name"] == badge_name for badge_data in self._badges_temp.values() ): errors["badge_name"] = "duplicate_badge" else: self._badges_temp[internal_id] = { "name": badge_name, "threshold_type": user_input["threshold_type"], "threshold_value": user_input["threshold_value"], "points_multiplier": user_input["points_multiplier"], "icon": user_input.get("icon", ""), "internal_id": internal_id, "description": user_input.get("badge_description", ""), "badge_labels": user_input.get("badge_labels", []), } LOGGER.debug("Added badge: %s with ID: %s", badge_name, internal_id) self._badge_index += 1 if self._badge_index >= self._badge_count: return await self.async_step_reward_count() return await self.async_step_badges() badge_schema = build_badge_schema() return self.async_show_form( step_id="badges", data_schema=badge_schema, errors=errors ) # -------------------------------------------------------------------------- # REWARDS # -------------------------------------------------------------------------- async def async_step_reward_count(self, user_input=None): """Ask how many rewards to define.""" errors = {} if user_input is not None: try: self._reward_count = int(user_input["reward_count"]) if self._reward_count < 0: raise ValueError if self._reward_count == 0: return await self.async_step_penalty_count() self._reward_index = 0 return await self.async_step_rewards() except ValueError: errors["base"] = "invalid_reward_count" schema = vol.Schema({vol.Required("reward_count", default=0): vol.Coerce(int)}) return self.async_show_form( step_id="reward_count", data_schema=schema, errors=errors ) async def async_step_rewards(self, user_input=None): """Collect reward details using internal_id as the primary key. Store in self._rewards_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: reward_name = user_input["reward_name"].strip() internal_id = user_input.get("internal_id", str(uuid.uuid4())) if not reward_name: errors["reward_name"] = "invalid_reward_name" elif any( reward_data["name"] == reward_name for reward_data in self._rewards_temp.values() ): errors["reward_name"] = "duplicate_reward" else: self._rewards_temp[internal_id] = { "name": reward_name, "cost": user_input["reward_cost"], "description": user_input.get("reward_description", ""), "reward_labels": user_input.get("reward_labels", []), "icon": user_input.get("icon", ""), "internal_id": internal_id, } LOGGER.debug("Added reward: %s with ID: %s", reward_name, internal_id) self._reward_index += 1 if self._reward_index >= self._reward_count: return await self.async_step_penalty_count() return await self.async_step_rewards() reward_schema = build_reward_schema() return self.async_show_form( step_id="rewards", data_schema=reward_schema, errors=errors ) # -------------------------------------------------------------------------- # PENALTIES # -------------------------------------------------------------------------- async def async_step_penalty_count(self, user_input=None): """Ask how many penalties to define.""" errors = {} if user_input is not None: try: self._penalty_count = int(user_input["penalty_count"]) if self._penalty_count < 0: raise ValueError if self._penalty_count == 0: return await self.async_step_bonus_count() self._penalty_index = 0 return await self.async_step_penalties() except ValueError: errors["base"] = "invalid_penalty_count" schema = vol.Schema({vol.Required("penalty_count", default=0): vol.Coerce(int)}) return self.async_show_form( step_id="penalty_count", data_schema=schema, errors=errors ) async def async_step_penalties(self, user_input=None): """Collect penalty details using internal_id as the primary key. Store in self._penalties_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: penalty_name = user_input["penalty_name"].strip() penalty_points = user_input["penalty_points"] internal_id = user_input.get("internal_id", str(uuid.uuid4())) if not penalty_name: errors["penalty_name"] = "invalid_penalty_name" elif any( penalty_data["name"] == penalty_name for penalty_data in self._penalties_temp.values() ): errors["penalty_name"] = "duplicate_penalty" else: self._penalties_temp[internal_id] = { "name": penalty_name, "description": user_input.get("penalty_description", ""), "penalty_labels": user_input.get("penalty_labels", []), "points": -abs(penalty_points), # Ensure points are negative "icon": user_input.get("icon", ""), "internal_id": internal_id, } LOGGER.debug("Added penalty: %s with ID: %s", penalty_name, internal_id) self._penalty_index += 1 if self._penalty_index >= self._penalty_count: return await self.async_step_bonus_count() return await self.async_step_penalties() penalty_schema = build_penalty_schema() return self.async_show_form( step_id="penalties", data_schema=penalty_schema, errors=errors ) # -------------------------------------------------------------------------- # BONUSES # -------------------------------------------------------------------------- async def async_step_bonus_count(self, user_input=None): """Ask how many bonuses to define.""" errors = {} if user_input is not None: try: self._bonus_count = int(user_input["bonus_count"]) if self._bonus_count < 0: raise ValueError if self._bonus_count == 0: return await self.async_step_achievement_count() self._bonus_index = 0 return await self.async_step_bonuses() except ValueError: errors["base"] = "invalid_bonus_count" schema = vol.Schema({vol.Required("bonus_count", default=0): vol.Coerce(int)}) return self.async_show_form( step_id="bonus_count", data_schema=schema, errors=errors ) async def async_step_bonuses(self, user_input=None): """Collect bonus details using internal_id as the primary key. Store in self._bonuses_temp as a dict keyed by internal_id. """ errors = {} if user_input is not None: bonus_name = user_input["bonus_name"].strip() bonus_points = user_input["bonus_points"] internal_id = user_input.get("internal_id", str(uuid.uuid4())) if not bonus_name: errors["bonus_name"] = "invalid_bonus_name" elif any( bonus_data["name"] == bonus_name for bonus_data in self._bonuses_temp.values() ): errors["bonus_name"] = "duplicate_bonus" else: self._bonuses_temp[internal_id] = { "name": bonus_name, "description": user_input.get("bonus_description", ""), "bonus_labels": user_input.get("bonus_labels", []), "points": abs(bonus_points), # Ensure points are positive "icon": user_input.get("icon", ""), "internal_id": internal_id, } LOGGER.debug("Added bonus '%s' with ID: %s", bonus_name, internal_id) self._bonus_index += 1 if self._bonus_index >= self._bonus_count: return await self.async_step_achievement_count() return await self.async_step_bonuses() schema = build_bonus_schema() return self.async_show_form( step_id="bonuses", data_schema=schema, errors=errors ) # -------------------------------------------------------------------------- # ACHIEVEMENTS # -------------------------------------------------------------------------- async def async_step_achievement_count(self, user_input=None): """Ask how many achievements to define initially.""" errors = {} if user_input is not None: try: self._achievement_count = int(user_input["achievement_count"]) if self._achievement_count < 0: raise ValueError if self._achievement_count == 0: return await self.async_step_challenge_count() self._achievement_index = 0 return await self.async_step_achievements() except ValueError: errors["base"] = "invalid_achievement_count" schema = vol.Schema( {vol.Required("achievement_count", default=0): vol.Coerce(int)} ) return self.async_show_form( step_id="achievement_count", data_schema=schema, errors=errors ) async def async_step_achievements(self, user_input=None): """Collect each achievement's details using internal_id as the key.""" errors = {} if user_input is not None: achievement_name = user_input["name"].strip() if not achievement_name: errors["name"] = "invalid_achievement_name" elif any( achievement_data["name"] == achievement_name for achievement_data in self._achievements_temp.values() ): errors["name"] = "duplicate_achievement" else: _type = user_input["type"] if _type == ACHIEVEMENT_TYPE_STREAK: chore_id = user_input.get("selected_chore_id") if not chore_id or chore_id == "None": errors["selected_chore_id"] = "a_chore_must_be_selected" final_chore_id = chore_id else: # Discard chore if not streak final_chore_id = "" if not errors: internal_id = user_input.get("internal_id", str(uuid.uuid4())) self._achievements_temp[internal_id] = { "name": achievement_name, "description": user_input.get("description", ""), "achievement_labels": user_input.get("achievement_labels", []), "icon": user_input.get("icon", ""), "assigned_kids": user_input["assigned_kids"], "type": _type, "selected_chore_id": final_chore_id, "criteria": user_input.get("criteria", "").strip(), "target_value": user_input["target_value"], "reward_points": user_input["reward_points"], "internal_id": internal_id, "progress": {}, } self._achievement_index += 1 if self._achievement_index >= self._achievement_count: return await self.async_step_challenge_count() return await self.async_step_achievements() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items() } all_chores = self._chores_temp achievement_schema = build_achievement_schema( kids_dict=kids_dict, chores_dict=all_chores, default=None ) return self.async_show_form( step_id="achievements", data_schema=achievement_schema, errors=errors ) # -------------------------------------------------------------------------- # CHALLENGES # -------------------------------------------------------------------------- async def async_step_challenge_count(self, user_input=None): """Ask how many challenges to define initially.""" errors = {} if user_input is not None: try: self._challenge_count = int(user_input["challenge_count"]) if self._challenge_count < 0: raise ValueError if self._challenge_count == 0: return await self.async_step_finish() self._challenge_index = 0 return await self.async_step_challenges() except ValueError: errors["base"] = "invalid_challenge_count" schema = vol.Schema( {vol.Required("challenge_count", default=0): vol.Coerce(int)} ) return self.async_show_form( step_id="challenge_count", data_schema=schema, errors=errors ) async def async_step_challenges(self, user_input=None): """Collect each challenge's details using internal_id as the key.""" errors = {} if user_input is not None: challenge_name = user_input["name"].strip() if not challenge_name: errors["name"] = "invalid_challenge_name" elif any( challenge_data["name"] == challenge_name for challenge_data in self._challenges_temp.values() ): errors["name"] = "duplicate_challenge" else: _type = user_input["type"] if _type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: chosen_chore_id = user_input.get("selected_chore_id") if not chosen_chore_id or chosen_chore_id == "None": errors["selected_chore_id"] = "a_chore_must_be_selected" final_chore_id = chosen_chore_id else: # Discard chore if not "CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW" final_chore_id = "" # Process start_date and end_date using the helper: start_date_input = user_input.get("start_date") end_date_input = user_input.get("end_date") if start_date_input: try: start_date = ensure_utc_datetime(self.hass, start_date_input) start_dt = dt_util.parse_datetime(start_date) if start_dt and start_dt < dt_util.utcnow(): errors["start_date"] = "start_date_in_past" except Exception: errors["start_date"] = "invalid_start_date" start_date = None else: start_date = None if end_date_input: try: end_date = ensure_utc_datetime(self.hass, end_date_input) end_dt = dt_util.parse_datetime(end_date) if end_dt and end_dt <= dt_util.utcnow(): errors["end_date"] = "end_date_in_past" if start_date: # Compare start_dt and end_dt if both are valid if end_dt and start_dt and end_dt <= start_dt: errors["end_date"] = "end_date_not_after_start_date" except Exception: errors["end_date"] = "invalid_end_date" end_date = None else: end_date = None if not errors: internal_id = user_input.get("internal_id", str(uuid.uuid4())) self._challenges_temp[internal_id] = { "name": challenge_name, "description": user_input.get("description", ""), "challenge_labels": user_input.get("challenge_labels", []), "icon": user_input.get("icon", ""), "assigned_kids": user_input["assigned_kids"], "type": _type, "selected_chore_id": final_chore_id, "criteria": user_input.get("criteria", "").strip(), "target_value": user_input["target_value"], "reward_points": user_input["reward_points"], "start_date": start_date, "end_date": end_date, "internal_id": internal_id, "progress": {}, } self._challenge_index += 1 if self._challenge_index >= self._challenge_count: return await self.async_step_finish() return await self.async_step_challenges() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items() } all_chores = self._chores_temp default_data = user_input if user_input else None challenge_schema = build_challenge_schema( kids_dict=kids_dict, chores_dict=all_chores, default=default_data, ) return self.async_show_form( step_id="challenges", data_schema=challenge_schema, errors=errors ) # -------------------------------------------------------------------------- # FINISH # -------------------------------------------------------------------------- async def async_step_finish(self, user_input=None): """Finalize summary and create the config entry.""" if user_input is not None: return self._create_entry() # Create a mapping from kid_id to kid_name for easy lookup kid_id_to_name = { kid_id: data["name"] for kid_id, data in self._kids_temp.items() } # Enhance parents summary to include associated kids by name parents_summary = [] for parent in self._parents_temp.values(): associated_kids_names = [ kid_id_to_name.get(kid_id, "Unknown") for kid_id in parent.get("associated_kids", []) ] if associated_kids_names: kids_str = ", ".join(associated_kids_names) parents_summary.append(f"{parent['name']} (Kids: {kids_str})") else: parents_summary.append(parent["name"]) summary = ( f"\nKids: {', '.join(kid_data['name'] for kid_data in self._kids_temp.values()) or 'None'}\n\n" f"Parents: {', '.join(parents_summary) or 'None'}\n\n" f"Chores: {', '.join(chore_data['name'] for chore_data in self._chores_temp.values()) or 'None'}\n\n" f"Badges: {', '.join(badge_data['name'] for badge_data in self._badges_temp.values()) or 'None'}\n\n" f"Rewards: {', '.join(reward_data['name'] for reward_data in self._rewards_temp.values()) or 'None'}\n\n" f"Penalties: {', '.join(penalty_data['name'] for penalty_data in self._penalties_temp.values()) or 'None'}\n\n" f"Bonuses: {', '.join(bonus_data['name'] for bonus_data in self._bonuses_temp.values()) or 'None'}\n\n" f"Achievements: {', '.join(achievement_data['name'] for achievement_data in self._achievements_temp.values()) or 'None'}\n\n" f"Challenges: {', '.join(challenge_data['name'] for challenge_data in self._challenges_temp.values()) or 'None'}\n\n" ) return self.async_show_form( step_id="finish", data_schema=vol.Schema({}), description_placeholders={"summary": summary}, ) def _create_entry(self): """Finalize config entry with data and options using internal_id as keys.""" entry_data = {} entry_options = { CONF_POINTS_LABEL: self._data.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL), CONF_POINTS_ICON: self._data.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON), CONF_KIDS: self._kids_temp, CONF_PARENTS: self._parents_temp, CONF_CHORES: self._chores_temp, CONF_BADGES: self._badges_temp, CONF_REWARDS: self._rewards_temp, CONF_PENALTIES: self._penalties_temp, CONF_BONUSES: self._bonuses_temp, CONF_ACHIEVEMENTS: self._achievements_temp, CONF_CHALLENGES: self._challenges_temp, } LOGGER.debug( "Creating entry with data=%s, options=%s", entry_data, entry_options ) return self.async_create_entry( title="KidsChores", data=entry_data, options=entry_options ) @staticmethod @callback def async_get_options_flow(config_entry): """Return the Options Flow.""" from .options_flow import KidsChoresOptionsFlowHandler return KidsChoresOptionsFlowHandler(config_entry) ================================================ FILE: custom_components/kidschores/const.py ================================================ # File: const.py """Constants for the KidsChores integration. This file centralizes configuration keys, defaults, labels, domain names, event names, and platform identifiers for consistency across the integration. It also supports localization by defining all labels and UI texts used in sensors, services, and options flow. """ import logging from homeassistant.const import Platform # -------------------- General -------------------- # Integration Domain and Logging DOMAIN = "kidschores" LOGGER = logging.getLogger(__package__) # Supported Platforms PLATFORMS = [ Platform.BUTTON, Platform.CALENDAR, Platform.SELECT, Platform.SENSOR, ] # Storage and Versioning STORAGE_KEY = "kidschores_data" # Persistent storage key STORAGE_VERSION = 1 # Storage version # Update Interval UPDATE_INTERVAL = 5 # Update interval for coordinator (in minutes) # -------------------- Configuration -------------------- # Configuration Keys CONF_ACHIEVEMENTS = "achievements" CONF_APPLICABLE_DAYS = "applicable_days" CONF_BADGES = "badges" # Key for badges configuration CONF_CHALLENGES = "challenges" CONF_CHORES = "chores" # Key for chores configuration CONF_GLOBAL = "global" CONF_KIDS = "kids" # Key for kids configuration CONF_PARENTS = "parents" # Key for parents configuration CONF_PENALTIES = "penalties" # Key for penalties configuration CONF_POINTS_ICON = "points_icon" CONF_POINTS_LABEL = "points_label" # Custom label for points CONF_REWARDS = "rewards" # Key for rewards configuration CONF_BONUSES = "bonuses" # Options Flow Management OPTIONS_FLOW_ACHIEVEMENTS = "manage_achievements" # Edit achivements step OPTIONS_FLOW_BADGES = "manage_badges" # Edit badges step OPTIONS_FLOW_CHALLENGES = "manage_challenges" # Edit challenges step OPTIONS_FLOW_CHORES = "manage_chores" # Edit chores step OPTIONS_FLOW_KIDS = "manage_kids" # Edit kids step OPTIONS_FLOW_PARENTS = "manage_parents" # Edit parents step OPTIONS_FLOW_PENALTIES = "manage_penalties" # Edit penalties step OPTIONS_FLOW_REWARDS = "manage_rewards" # Edit rewards step OPTIONS_FLOW_BONUSES = "manage_bonuses" # Edit bonuses step # Validation Keys VALIDATION_DUE_DATE = "due_date" # Optional due date for chores VALIDATION_PARTIAL_ALLOWED = "partial_allowed" # Allow partial points in chores VALIDATION_THRESHOLD_TYPE = "threshold_type" # Badge criteria type VALIDATION_THRESHOLD_VALUE = "threshold_value" # Badge criteria value # Notification configuration keys CONF_ENABLE_MOBILE_NOTIFICATIONS = "enable_mobile_notifications" CONF_MOBILE_NOTIFY_SERVICE = "mobile_notify_service" CONF_ENABLE_PERSISTENT_NOTIFICATIONS = "enable_persistent_notifications" CONF_NOTIFY_ON_CLAIM = "notify_on_claim" CONF_NOTIFY_ON_APPROVAL = "notify_on_approval" CONF_NOTIFY_ON_DISAPPROVAL = "notify_on_disapproval" CONF_CHORE_NOTIFY_SERVICE = "chore_notify_service" NOTIFICATION_EVENT = "mobile_app_notification_action" # Achievement types ACHIEVEMENT_TYPE_STREAK = "chore_streak" # e.g., "Make bed 20 days in a row" ACHIEVEMENT_TYPE_TOTAL = "chore_total" # e.g., "Complete 100 chores overall" ACHIEVEMENT_TYPE_DAILY_MIN = ( "daily_minimum" # e.g., "Complete minimum 5 chores in one day" ) # Challenge types CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW = ( "total_within_window" # e.g., "Complete 50 chores in 30 days" ) CHALLENGE_TYPE_DAILY_MIN = "daily_minimum" # e.g., "Do 2 chores each day for 14 days" # -------------------- Defaults -------------------- # Default Icons DEFAULT_ACHIEVEMENTS_ICON = "mdi:trophy-award" # Default icon for achievements DEFAULT_BADGE_ICON = "mdi:shield-star-outline" # Default icon for badges DEFAULT_CALENDAR_ICON = "mdi:calendar" # Default icon for calendar sensors DEFAULT_CHALLENGES_ICON = "mdi:trophy" # Default icon for achievements DEFAULT_CHORE_APPROVE_ICON = "mdi:checkbox-marked-circle-outline" DEFAULT_CHORE_BINARY_ICON = ( "mdi:checkbox-blank-circle-outline" # For chore status binary sensor ) DEFAULT_CHORE_CLAIM_ICON = "mdi:clipboard-check-outline" DEFAULT_CHORE_SENSOR_ICON = ( "mdi:checkbox-blank-circle-outline" # For chore status sensor ) DEFAULT_DISAPPROVE_ICON = ( "mdi:close-circle-outline" # Default icon for disapprove buttons ) DEFAULT_ICON = "mdi:star-outline" # Default icon for general points display DEFAULT_PENALTY_ICON = "mdi:alert-outline" # Default icon for penalties DEFAULT_POINTS_ADJUST_MINUS_ICON = "mdi:minus-circle-outline" DEFAULT_POINTS_ADJUST_PLUS_ICON = "mdi:plus-circle-outline" DEFAULT_POINTS_ADJUST_MINUS_MULTIPLE_ICON = "mdi:minus-circle-multiple-outline" DEFAULT_POINTS_ADJUST_PLUS_MULTIPLE_ICON = "mdi:plus-circle-multiple-outline" DEFAULT_POINTS_ICON = "mdi:star-outline" # Default icon for points DEFAULT_STREAK_ICON = "mdi:blur-linear" # Default icon for streaks DEFAULT_BONUS_ICON = "mdi:seal" # Default icon for bonuses DEFAULT_REWARD_ICON = "mdi:gift-outline" # Default icon for rewards DEFAULT_TROPHY_ICON = "mdi:trophy" # For highest-badge sensor fallback DEFAULT_TROPHY_OUTLINE = "mdi:trophy-outline" # Default Values DEFAULT_APPLICABLE_DAYS = [] # Empty means the chore applies every day. DEFAULT_BADGE_THRESHOLD = 50 # Default points threshold for badges DEFAULT_MULTIPLE_CLAIMS_PER_DAY = False # Allow only one chore claim per day DEFAULT_PARTIAL_ALLOWED = False # Partial points not allowed by default DEFAULT_POINTS = 5 # Default points awarded for each chore DEFAULT_POINTS_MULTIPLIER = 1 # Default points multiplier for badges DEFAULT_POINTS_LABEL = "Points" # Default label for points displayed in UI DEFAULT_PENALTY_POINTS = 2 # Default points deducted for each penalty DEFAULT_BONUS_POINTS = 2 # Default points added for each bonus DEFAULT_REMINDER_DELAY = 30 # Default reminder delay in minutes DEFAULT_REWARD_COST = 10 # Default cost for each reward DEFAULT_DAILY_RESET_TIME = { "hour": 0, "minute": 0, "second": 0, } # Daily reset at midnight DEFAULT_MONTHLY_RESET_DAY = 1 # Monthly reset on the 1st day DEFAULT_WEEKLY_RESET_DAY = 0 # Weekly reset on Monday (0 = Monday, 6 = Sunday) DEFAULT_NOTIFY_ON_CLAIM = True DEFAULT_NOTIFY_ON_APPROVAL = True DEFAULT_NOTIFY_ON_DISAPPROVAL = True # -------------------- Recurring Frequencies -------------------- FREQUENCY_BIWEEKLY = "biweekly" FREQUENCY_CUSTOM = "custom" FREQUENCY_DAILY = "daily" FREQUENCY_MONTHLY = "monthly" FREQUENCY_NONE = "none" FREQUENCY_WEEKLY = "weekly" # -------------------- Data Keys -------------------- # Data Keys for Coordinator and Storage DATA_ACHIEVEMENTS = "achievements" # Key for storing achievements data DATA_BADGES = "badges" # Key for storing badges data DATA_CHALLENGES = "challenges" # Key for storing challenges data DATA_CHORES = "chores" # Key for storing chores data DATA_KIDS = "kids" # Key for storing kids data in storage DATA_PARENTS = "parents" # Key for storing parent data DATA_PENDING_CHORE_APPROVALS = "pending_chore_approvals" # Pending chore approvals DATA_PENDING_REWARD_APPROVALS = "pending_reward_approvals" # Pending reward approvals DATA_PENALTIES = "penalties" # Key for storing penalties data DATA_REWARDS = "rewards" # Key for storing rewards data DATA_BONUSES = "bonuses" # Key for storing bonuses data # -------------------- States -------------------- # Badge Threshold Types BADGE_THRESHOLD_TYPE_CHORE_COUNT = ( "chore_count" # Badges for completing a number of chores ) BADGE_THRESHOLD_TYPE_POINTS = "points" # Badges awarded for reaching points # Chore States CHORE_STATE_APPROVED = "approved" # Chore fully approved CHORE_STATE_APPROVED_IN_PART = "approved_in_part" # Chore approved for some kids CHORE_STATE_CLAIMED = "claimed" # Chore claimed by a kid CHORE_STATE_CLAIMED_IN_PART = "claimed_in_part" # Chore claimed by some kids CHORE_STATE_INDEPENDENT = "independent" # Chore is not shared CHORE_STATE_OVERDUE = "overdue" # Chore not completed before the due date CHORE_STATE_PARTIAL = "partial" # Chore approved with partial points CHORE_STATE_PENDING = "pending" # Default state: chore pending approval CHORE_STATE_UNKNOWN = "unknown" # Unknown chore state # Reward States REWARD_STATE_APPROVED = "approved" # Reward fully approved REWARD_STATE_CLAIMED = "claimed" # Reward claimed by a kid REWARD_STATE_NOT_CLAIMED = "not_claimed" # Default state: reward not claimed REWARD_STATE_UNKNOWN = "unknown" # Unknown reward state # -------------------- Events -------------------- # Event Names EVENT_CHORE_COMPLETED = "kidschores_chore_completed" # Event for chore completion EVENT_REWARD_REDEEMED = "kidschores_reward_redeemed" # Event for redeeming a reward # -------------------- Actions -------------------- # Action titles for notifications ACTION_TITLE_APPROVE = "Approve" ACTION_TITLE_DISAPPROVE = "Disapprove" ACTION_TITLE_REMIND_30 = "Remind in 30 mins" # Action identifiers ACTION_APPROVE_CHORE = "APPROVE_CHORE" ACTION_DISAPPROVE_CHORE = "DISAPPROVE_CHORE" ACTION_APPROVE_REWARD = "APPROVE_REWARD" ACTION_DISAPPROVE_REWARD = "DISAPPROVE_REWARD" ACTION_REMIND_30 = "REMIND_30" # -------------------- Sensors -------------------- # Sensor Attributes ATTR_ACHIEVEMENT_NAME = "achievement_name" ATTR_ALL_EARNED_BADGES = "all_earned_badges" ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY = "allow_multiple_claims_per_day" ATTR_APPLICABLE_DAYS = "applicable_days" ATTR_AWARDED = "awarded" ATTR_ASSIGNED_KIDS = "assigned_kids" ATTR_ASSOCIATED_CHORE = "associated_chore" ATTR_BADGES = "badges" ATTR_CHALLENGE_NAME = "challenge_name" ATTR_CHALLENGE_TYPE = "challenge_type" ATTR_CHORE_APPROVALS_COUNT = "chore_approvals_count" ATTR_CHORE_APPROVALS_TODAY = "chore_approvals_today" ATTR_CHORE_CLAIMS_COUNT = "chore_claims_count" ATTR_CHORE_CURRENT_STREAK = "chore_current_streak" ATTR_CHORE_HIGHEST_STREAK = "chore_highest_streak" ATTR_CHORE_NAME = "chore_name" ATTR_CLAIMED_ON = "Claimed on" ATTR_COST = "cost" ATTR_CRITERIA = "criteria" ATTR_CUSTOM_FREQUENCY_INTERVAL = "custom_frequency_interval" ATTR_CUSTOM_FREQUENCY_UNIT = "custom_frequency_unit" ATTR_DEFAULT_POINTS = "default_points" ATTR_DESCRIPTION = "description" ATTR_DUE_DATE = "due_date" ATTR_END_DATE = "end_date" ATTR_GLOBAL_STATE = "global_state" ATTR_HIGHEST_BADGE_THRESHOLD_VALUE = "highest_badge_threshold_value" ATTR_KID_NAME = "kid_name" ATTR_KID_STATE = "kid_state" ATTR_LABELS = "labels" ATTR_KIDS_EARNED = "kids_earned" ATTR_LAST_DATE = "last_date" ATTR_PARTIAL_ALLOWED = "partial_allowed" ATTR_PENALTY_NAME = "penalty_name" ATTR_PENALTY_POINTS = "penalty_points" ATTR_POINTS_MULTIPLIER = "points_multiplier" ATTR_POINTS_TO_NEXT_BADGE = "points_to_next_badge" ATTR_RAW_PROGRESS = "raw_progress" ATTR_RAW_STREAK = "raw_streak" ATTR_RECURRING_FREQUENCY = "recurring_frequency" ATTR_REDEEMED_ON = "Redeemed on" ATTR_REWARD_APPROVALS_COUNT = "reward_approvals_count" ATTR_REWARD_CLAIMS_COUNT = "reward_claims_count" ATTR_REWARD_NAME = "reward_name" ATTR_REWARD_POINTS = "reward_points" ATTR_BONUS_NAME = "bonus_name" ATTR_BONUS_POINTS = "bonus_points" ATTR_START_DATE = "start_date" ATTR_SHARED_CHORE = "shared_chore" ATTR_TARGET_VALUE = "target_value" ATTR_THRESHOLD_TYPE = "threshold_type" ATTR_TYPE = "type" # Calendar Attributes ATTR_CAL_SUMMARY = "summary" ATTR_CAL_START = "start" ATTR_CAL_END = "end" ATTR_CAL_ALL_DAY = "all_day" ATTR_CAL_DESCRIPTION = "description" ATTR_CAL_MANUFACTURER = "manufacturer" # Sensor Types SENSOR_TYPE_BADGES = "badges" # Sensor tracking earned badges SENSOR_TYPE_CHORE_APPROVALS = "chore_approvals" # Chore approvals sensor SENSOR_TYPE_CHORE_CLAIMS = "chore_claims" # Chore claims sensor SENSOR_TYPE_COMPLETED_DAILY = ( "completed_daily" # Sensor tracking daily chores completed ) SENSOR_TYPE_COMPLETED_MONTHLY = ( "completed_monthly" # Sensor tracking monthly chores completed ) SENSOR_TYPE_COMPLETED_WEEKLY = ( "completed_weekly" # Sensor tracking weekly chores completed ) SENSOR_TYPE_PENALTY_APPLIES = "penalty_applies" # Penalty applies sensor SENSOR_TYPE_POINTS = "points" # Sensor tracking total points SENSOR_TYPE_PENDING_CHORE_APPROVALS = ( "pending_chore_approvals" # Pending chore approvals ) SENSOR_TYPE_PENDING_REWARD_APPROVALS = ( "pending_reward_approvals" # Pending reward approvals ) SENSOR_TYPE_REWARD_APPROVALS = "reward_approvals" # Reward approvals sensor SENSOR_TYPE_REWARD_CLAIMS = "reward_claims" # Reward claims sensor SENSOR_TYPE_BONUS_APPLIES = "bonus_applies" # Bonus applies sensor # -------------------- Services -------------------- # Custom Services SERVICE_APPLY_PENALTY = "apply_penalty" # Apply penalty service SERVICE_APPROVE_CHORE = "approve_chore" # Approve chore service SERVICE_APPROVE_REWARD = "approve_reward" # Approve reward service SERVICE_CLAIM_CHORE = "claim_chore" # Claim chore service SERVICE_DISAPPROVE_CHORE = "disapprove_chore" # Disapprove chore service SERVICE_DISAPPROVE_REWARD = "disapprove_reward" # Disapprove reward service SERVICE_REDEEM_REWARD = "redeem_reward" # Redeem reward service SERVICE_RESET_ALL_CHORES = "reset_all_chores" # Reset all chores service SERVICE_RESET_ALL_DATA = "reset_all_data" # Reset all data service SERVICE_RESET_OVERDUE_CHORES = "reset_overdue_chores" # Reset overdue chores SERVICE_SET_CHORE_DUE_DATE = "set_chore_due_date" # Set or reset chores due date SERVICE_SKIP_CHORE_DUE_DATE = ( "skip_chore_due_date" # Skip chores due date and reschedule ) SERVICE_APPLY_BONUS = "apply_bonus" # Apply bonus service SERVICE_RESET_PENALTIES = "reset_penalties" # Reset penalties service SERVICE_RESET_BONUSES = "reset_bonuses" # Reset bonuses service SERVICE_RESET_REWARDS = "reset_rewards" # Reset rewards service # Field Names (for consistency across services) FIELD_CHORE_ID = "chore_id" FIELD_CHORE_NAME = "chore_name" FIELD_DUE_DATE = "due_date" FIELD_KID_NAME = "kid_name" FIELD_PARENT_NAME = "parent_name" FIELD_PENALTY_NAME = "penalty_name" FIELD_POINTS_AWARDED = "points_awarded" FIELD_REWARD_NAME = "reward_name" FIELD_BONUS_NAME = "bonus_name" # -------------------- Labels -------------------- # Labels for Sensors and UI LABEL_BADGES = "Badges" LABEL_COMPLETED_DAILY = "Daily Completed Chores" LABEL_COMPLETED_MONTHLY = "Monthly Completed Chores" LABEL_COMPLETED_WEEKLY = "Weekly Completed Chores" LABEL_POINTS = "Points" # -------------------- Buttons -------------------- # Button Prefixes for Dynamic Creation BUTTON_DISAPPROVE_CHORE_PREFIX = "disapprove_chore_button_" # Disapprove chore button BUTTON_DISAPPROVE_REWARD_PREFIX = ( "disapprove_reward_button_" # Disapprove reward button ) BUTTON_PENALTY_PREFIX = ( "penalty_button_" # Prefix for dynamically created penalty buttons ) BUTTON_REWARD_PREFIX = "reward_button_" # Prefix for dynamically created reward buttons BUTTON_BONUS_PREFIX = "bonus_button_" # Prefix for dynamically created bonus buttons # -------------------- Errors and Warnings -------------------- DUE_DATE_NOT_SET = "Not Set" ERROR_CHORE_NOT_FOUND = "Chore not found." # Error for missing chore ERROR_CHORE_NOT_FOUND_FMT = "Chore '{}' not found" # Error format for missing chore ERROR_INVALID_POINTS = "Invalid points." # Error for invalid points input ERROR_KID_NOT_FOUND = "Kid not found." # Error for non-existent kid ERROR_KID_NOT_FOUND_FMT = "Kid '{}' not found" # Error format for missing kid ERROR_NOT_AUTHORIZED_ACTION_FMT = "Not authorized to {}." # Auth error format ERROR_NOT_AUTHORIZED_FMT = ( "User not authorized to {} for this kid." # Auth error format ) ERROR_PENALTY_NOT_FOUND = "Penalty not found." # Error for missing penalty ERROR_PENALTY_NOT_FOUND_FMT = ( "Penalty '{}' not found" # Error format for missing penalty ) ERROR_REWARD_NOT_FOUND = "Reward not found." # Error for missing reward ERROR_REWARD_NOT_FOUND_FMT = "Reward '{}' not found" # Error format for missing reward ERROR_BONUS_NOT_FOUND = "Bonus not found." # Error for missing bonus ERROR_BONUS_NOT_FOUND_FMT = "Bonus '{}' not found" # Error format for missing bonus ERROR_USER_NOT_AUTHORIZED = ( "User is not authorized to perform this action." # Auth error ) MSG_NO_ENTRY_FOUND = "No KidsChores entry found" # Unknown States UNKNOWN_CHORE = "Unknown Chore" # Error for unknown chore UNKNOWN_KID = "Unknown Kid" # Error for unknown kid UNKNOWN_REWARD = "Unknown Reward" # Error for unknown reward # -------------------- Parent Approval Workflow -------------------- PARENT_APPROVAL_REQUIRED = True # Enable parent approval for certain actions HA_USERNAME_LINK_ENABLED = True # Enable linking kids to HA usernames # ---------------------------- Weekdays ----------------------------- WEEKDAY_OPTIONS = { "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday", } ================================================ FILE: custom_components/kidschores/coordinator.py ================================================ # File: coordinator.py """Coordinator for the KidsChores integration. Handles data synchronization, chore claiming and approval, badge tracking, reward redemption, penalty application, and recurring chore handling. Manages entities primarily using internal_id for consistency. """ import asyncio import uuid from calendar import monthrange from datetime import datetime, timedelta from typing import Any, Optional from homeassistant.auth.models import User from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( ACHIEVEMENT_TYPE_DAILY_MIN, ACHIEVEMENT_TYPE_STREAK, ACHIEVEMENT_TYPE_TOTAL, ACTION_APPROVE_CHORE, ACTION_APPROVE_REWARD, ACTION_DISAPPROVE_CHORE, ACTION_DISAPPROVE_REWARD, ACTION_REMIND_30, ACTION_TITLE_APPROVE, ACTION_TITLE_DISAPPROVE, ACTION_TITLE_REMIND_30, BADGE_THRESHOLD_TYPE_CHORE_COUNT, BADGE_THRESHOLD_TYPE_POINTS, CHALLENGE_TYPE_DAILY_MIN, CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW, CHORE_STATE_APPROVED, CHORE_STATE_APPROVED_IN_PART, CHORE_STATE_CLAIMED, CHORE_STATE_CLAIMED_IN_PART, CHORE_STATE_INDEPENDENT, CHORE_STATE_OVERDUE, CHORE_STATE_PARTIAL, CHORE_STATE_PENDING, CHORE_STATE_UNKNOWN, CONF_ACHIEVEMENTS, CONF_APPLICABLE_DAYS, CONF_BADGES, CONF_CHALLENGES, CONF_CHORES, CONF_ENABLE_MOBILE_NOTIFICATIONS, CONF_ENABLE_PERSISTENT_NOTIFICATIONS, CONF_KIDS, CONF_MOBILE_NOTIFY_SERVICE, CONF_NOTIFY_ON_APPROVAL, CONF_NOTIFY_ON_CLAIM, CONF_NOTIFY_ON_DISAPPROVAL, CONF_PARENTS, CONF_PENALTIES, CONF_REWARDS, CONF_BONUSES, DATA_ACHIEVEMENTS, DATA_BADGES, DATA_CHALLENGES, DATA_CHORES, DATA_KIDS, DATA_PARENTS, DATA_PENDING_CHORE_APPROVALS, DATA_PENDING_REWARD_APPROVALS, DATA_PENALTIES, DATA_REWARDS, DATA_BONUSES, DEFAULT_APPLICABLE_DAYS, DEFAULT_BADGE_THRESHOLD, DEFAULT_DAILY_RESET_TIME, DEFAULT_ICON, DEFAULT_MONTHLY_RESET_DAY, DEFAULT_MULTIPLE_CLAIMS_PER_DAY, DEFAULT_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_DISAPPROVAL, DEFAULT_PARTIAL_ALLOWED, DEFAULT_PENALTY_ICON, DEFAULT_PENALTY_POINTS, DEFAULT_POINTS, DEFAULT_POINTS_MULTIPLIER, DEFAULT_REWARD_COST, DEFAULT_REWARD_ICON, DEFAULT_BONUS_ICON, DEFAULT_BONUS_POINTS, DEFAULT_WEEKLY_RESET_DAY, DOMAIN, FREQUENCY_BIWEEKLY, FREQUENCY_CUSTOM, FREQUENCY_DAILY, FREQUENCY_MONTHLY, FREQUENCY_NONE, FREQUENCY_WEEKLY, LOGGER, UPDATE_INTERVAL, WEEKDAY_OPTIONS, ) from .storage_manager import KidsChoresStorageManager from .notification_helper import async_send_notification class KidsChoresDataCoordinator(DataUpdateCoordinator): """Coordinator for KidsChores integration. Manages data primarily using internal_id for entities. """ def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, storage_manager: KidsChoresStorageManager, ): """Initialize the KidsChoresDataCoordinator.""" super().__init__( hass, LOGGER, name=f"{DOMAIN}_coordinator", update_interval=timedelta(minutes=UPDATE_INTERVAL), ) self.config_entry = config_entry self.storage_manager = storage_manager self._data: dict[str, Any] = {} # ------------------------------------------------------------------------------------- # Migrate Data and Converters # ------------------------------------------------------------------------------------- def _migrate_datetime(self, dt_str: str) -> str: """Convert a datetime string to a UTC-aware ISO string.""" if not isinstance(dt_str, str): return dt_str try: # Try to parse using Home Assistant’s utility first: dt_obj = dt_util.parse_datetime(dt_str) if dt_obj is None: # Fallback using fromisoformat dt_obj = datetime.fromisoformat(dt_str) # If naive, assume local time and make it aware: if dt_obj.tzinfo is None: dt_obj = dt_obj.replace( tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) ) # Convert to UTC dt_obj_utc = dt_util.as_utc(dt_obj) return dt_obj_utc.isoformat() except Exception as err: LOGGER.warning("Error migrating datetime '%s': %s", dt_str, err) return dt_str def _migrate_stored_datetimes(self): """Walk through stored data and convert known datetime fields to UTC-aware ISO strings.""" # For each chore, migrate due_date, last_completed, and last_claimed for chore in self._data.get(DATA_CHORES, {}).values(): if chore.get("due_date"): chore["due_date"] = self._migrate_datetime(chore["due_date"]) if chore.get("last_completed"): chore["last_completed"] = self._migrate_datetime( chore["last_completed"] ) if chore.get("last_claimed"): chore["last_claimed"] = self._migrate_datetime(chore["last_claimed"]) # Also, migrate timestamps in pending approvals for approval in self._data.get(DATA_PENDING_CHORE_APPROVALS, []): if approval.get("timestamp"): approval["timestamp"] = self._migrate_datetime(approval["timestamp"]) for approval in self._data.get(DATA_PENDING_REWARD_APPROVALS, []): if approval.get("timestamp"): approval["timestamp"] = self._migrate_datetime(approval["timestamp"]) # Migrate datetime on Challenges for challenge in self._data.get(DATA_CHALLENGES, {}).values(): start_date = challenge.get("start_date") if not isinstance(start_date, str) or not start_date.strip(): challenge["start_date"] = None else: challenge["start_date"] = self._migrate_datetime(start_date) end_date = challenge.get("end_date") if not isinstance(end_date, str) or not end_date.strip(): challenge["end_date"] = None else: challenge["end_date"] = self._migrate_datetime(end_date) def _migrate_chore_data(self): """Migrate each chore's data to include new fields if missing. This method iterates over each chore entry in the stored data and ensures that the following keys are present: - CONF_APPLICABLE_DAYS (defaults to DEFAULT_APPLICABLE_DAYS) - CONF_NOTIFY_ON_CLAIM (defaults to DEFAULT_NOTIFY_ON_CLAIM) - CONF_NOTIFY_ON_APPROVAL (defaults to DEFAULT_NOTIFY_ON_APPROVAL) - CONF_NOTIFY_ON_DISAPPROVAL (defaults to DEFAULT_NOTIFY_ON_DISAPPROVAL) """ chores = self._data.get(DATA_CHORES, {}) for chore in chores.values(): chore.setdefault(CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS) chore.setdefault(CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM) chore.setdefault(CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL) chore.setdefault(CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL) LOGGER.info("Chore data migration complete.") # ------------------------------------------------------------------------------------- # Normalize Lists # ------------------------------------------------------------------------------------- def _normalize_kid_lists(self, kid_info: dict[str, Any]) -> None: "Normalize lists and ensuring they are not dict" for key in [ "claimed_chores", "approved_chores", "pending_rewards", "redeemed_rewards", ]: if not isinstance(kid_info.get(key), list): kid_info[key] = [] # ------------------------------------------------------------------------------------- # Periodic + First Refresh # ------------------------------------------------------------------------------------- async def _async_update_data(self): """Periodic update.""" try: # Check overdue chores await self._check_overdue_chores() # Notify entities of changes self.async_update_listeners() return self._data except Exception as err: raise UpdateFailed(f"Error updating KidsChores data: {err}") from err async def async_config_entry_first_refresh(self): """Load from storage and merge config options.""" stored_data = self.storage_manager.get_data() if stored_data: self._data = stored_data # Migrate any datetime fields in stored data to UTC-aware strings self._migrate_stored_datetimes() # Migrate chore data and add new fields self._migrate_chore_data() else: self._data = { DATA_KIDS: {}, DATA_CHORES: {}, DATA_BADGES: {}, DATA_REWARDS: {}, DATA_PARENTS: {}, DATA_PENALTIES: {}, DATA_BONUSES: {}, DATA_ACHIEVEMENTS: {}, DATA_CHALLENGES: {}, DATA_PENDING_CHORE_APPROVALS: [], DATA_PENDING_REWARD_APPROVALS: [], } if not isinstance(self._data.get(DATA_PENDING_CHORE_APPROVALS), list): self._data[DATA_PENDING_CHORE_APPROVALS] = [] if not isinstance(self._data.get(DATA_PENDING_REWARD_APPROVALS), list): self._data[DATA_PENDING_REWARD_APPROVALS] = [] # Register daily/weekly/monthly resets async_track_time_change( self.hass, self._reset_all_chore_counts, **DEFAULT_DAILY_RESET_TIME ) # Merge config entry data (options) into the stored data self._initialize_data_from_config() # Normalize all kids list fields for kid in self._data.get(DATA_KIDS, {}).values(): self._normalize_kid_lists(kid) self._persist() await super().async_config_entry_first_refresh() # ------------------------------------------------------------------------------------- # Data Initialization from Config # ------------------------------------------------------------------------------------- def _initialize_data_from_config(self): """Merge config_entry options with stored data structures using internal_id.""" options = self.config_entry.options # Retrieve configuration dictionaries from config entry options config_sections = { DATA_KIDS: options.get(CONF_KIDS, {}), DATA_PARENTS: options.get(CONF_PARENTS, {}), DATA_CHORES: options.get(CONF_CHORES, {}), DATA_BADGES: options.get(CONF_BADGES, {}), DATA_REWARDS: options.get(CONF_REWARDS, {}), DATA_PENALTIES: options.get(CONF_PENALTIES, {}), DATA_BONUSES: options.get(CONF_BONUSES, {}), DATA_ACHIEVEMENTS: options.get(CONF_ACHIEVEMENTS, {}), DATA_CHALLENGES: options.get(CONF_CHALLENGES, {}), } # Ensure minimal structure self._ensure_minimal_structure() # Initialize each section using private helper for section_key, data_dict in config_sections.items(): init_func = getattr(self, f"_initialize_{section_key}", None) if init_func: init_func(data_dict) else: self._data.setdefault(section_key, data_dict) LOGGER.warning("No initializer found for section '%s'", section_key) # Recalculate Badges on reload self._recalculate_all_badges() def _ensure_minimal_structure(self): """Ensure that all necessary data sections are present.""" for key in [ DATA_KIDS, DATA_PARENTS, DATA_CHORES, DATA_BADGES, DATA_REWARDS, DATA_PENALTIES, DATA_BONUSES, DATA_ACHIEVEMENTS, DATA_CHALLENGES, ]: self._data.setdefault(key, {}) for key in [DATA_PENDING_CHORE_APPROVALS, DATA_PENDING_REWARD_APPROVALS]: if not isinstance(self._data.get(key), list): self._data[key] = [] # ------------------------------------------------------------------------------------- # Helpers to Sync Entities from config # ------------------------------------------------------------------------------------- def _initialize_kids(self, kids_dict: dict[str, Any]): self._sync_entities(DATA_KIDS, kids_dict, self._create_kid, self._update_kid) def _initialize_parents(self, parents_dict: dict[str, Any]): self._sync_entities( DATA_PARENTS, parents_dict, self._create_parent, self._update_parent ) def _initialize_chores(self, chores_dict: dict[str, Any]): self._sync_entities( DATA_CHORES, chores_dict, self._create_chore, self._update_chore ) def _initialize_badges(self, badges_dict: dict[str, Any]): self._sync_entities( DATA_BADGES, badges_dict, self._create_badge, self._update_badge ) def _initialize_rewards(self, rewards_dict: dict[str, Any]): self._sync_entities( DATA_REWARDS, rewards_dict, self._create_reward, self._update_reward ) def _initialize_penalties(self, penalties_dict: dict[str, Any]): self._sync_entities( DATA_PENALTIES, penalties_dict, self._create_penalty, self._update_penalty ) def _initialize_achievements(self, achievements_dict: dict[str, Any]): self._sync_entities( DATA_ACHIEVEMENTS, achievements_dict, self._create_achievement, self._update_achievement, ) def _initialize_challenges(self, challenges_dict: dict[str, Any]): self._sync_entities( DATA_CHALLENGES, challenges_dict, self._create_challenge, self._update_challenge, ) def _initialize_bonuses(self, bonuses_dict: dict[str, Any]): self._sync_entities( DATA_BONUSES, bonuses_dict, self._create_bonus, self._update_bonus ) def _sync_entities( self, section: str, config_data: dict[str, Any], create_method, update_method, ): """Synchronize entities in a given data section based on config_data.""" existing_ids = set(self._data[section].keys()) config_ids = set(config_data.keys()) # Identify entities to remove entities_to_remove = existing_ids - config_ids for entity_id in entities_to_remove: # Remove entity from data del self._data[section][entity_id] # Remove entity from HA registry self._remove_entities_in_ha(section, entity_id) if section == DATA_CHORES: for kid_id in self.kids_data.keys(): self._remove_kid_chore_entities(kid_id, entity_id) # Perform general clean-up self._cleanup_all_links() # Remove deleted kids from parents list self._cleanup_parent_assignments() # Remove chore approvals on chore delete self._cleanup_pending_chore_approvals() # Remove reward approvals on reward delete if section == DATA_REWARDS: self._cleanup_pending_reward_approvals() # Add or update entities for entity_id, entity_body in config_data.items(): if entity_id not in self._data[section]: create_method(entity_id, entity_body) else: update_method(entity_id, entity_body) # Remove orphaned shared chore sensors. if section == DATA_CHORES: self.hass.async_create_task(self._remove_orphaned_shared_chore_sensors()) # Remove orphaned achievement and challenges sensors self.hass.async_create_task(self._remove_orphaned_achievement_entities()) self.hass.async_create_task(self._remove_orphaned_challenge_entities()) def _cleanup_all_links(self) -> None: """Run all cross-entity cleanup routines.""" self._cleanup_deleted_kid_references() self._cleanup_deleted_chore_references() self._cleanup_deleted_chore_in_achievements() self._cleanup_deleted_chore_in_challenges() def _remove_entities_in_ha(self, section: str, item_id: str): """Remove all platform entities whose unique_id references the given item_id.""" ent_reg = er.async_get(self.hass) for entity_entry in list(ent_reg.entities.values()): if str(item_id) in str(entity_entry.unique_id): ent_reg.async_remove(entity_entry.entity_id) LOGGER.debug( "Auto-removed entity '%s' with unique_id '%s' from registry", entity_entry.entity_id, entity_entry.unique_id, ) async def _remove_orphaned_shared_chore_sensors(self): """Remove SharedChoreGlobalStateSensor entities for chores no longer marked as shared.""" ent_reg = er.async_get(self.hass) prefix = f"{self.config_entry.entry_id}_" suffix = "_global_state" for entity_entry in list(ent_reg.entities.values()): if ( entity_entry.domain == "sensor" and entity_entry.unique_id.startswith(prefix) and entity_entry.unique_id.endswith(suffix) ): chore_id = entity_entry.unique_id[len(prefix) : -len(suffix)] chore_info = self.chores_data.get(chore_id) if not chore_info or not chore_info.get("shared_chore", False): ent_reg.async_remove(entity_entry.entity_id) LOGGER.debug( "Removed orphaned SharedChoreGlobalStateSensor: %s", entity_entry.entity_id, ) async def _remove_orphaned_achievement_entities(self) -> None: """Remove achievement progress entities for kids that are no longer assigned.""" ent_reg = er.async_get(self.hass) prefix = f"{self.config_entry.entry_id}_" suffix = "_achievement_progress" for entity_entry in list(ent_reg.entities.values()): if ( entity_entry.domain == "sensor" and entity_entry.unique_id.startswith(prefix) and entity_entry.unique_id.endswith(suffix) ): core_id = entity_entry.unique_id[len(prefix) : -len(suffix)] parts = core_id.split("_", 1) if len(parts) != 2: continue kid_id, achievement_id = parts achievement = self._data.get(DATA_ACHIEVEMENTS, {}).get(achievement_id) if not achievement or kid_id not in achievement.get( "assigned_kids", [] ): ent_reg.async_remove(entity_entry.entity_id) LOGGER.debug( "Removed orphaned achievement progress sensor '%s' because kid '%s' is not assigned to achievement '%s'", entity_entry.entity_id, kid_id, achievement_id, ) async def _remove_orphaned_challenge_entities(self) -> None: """Remove challenge progress sensor entities for kids no longer assigned.""" ent_reg = er.async_get(self.hass) prefix = f"{self.config_entry.entry_id}_" suffix = "_challenge_progress" for entity_entry in list(ent_reg.entities.values()): if ( entity_entry.domain == "sensor" and entity_entry.unique_id.startswith(prefix) and entity_entry.unique_id.endswith(suffix) ): core_id = entity_entry.unique_id[len(prefix) : -len(suffix)] parts = core_id.split("_", 1) if len(parts) != 2: continue kid_id, challenge_id = parts challenge = self._data.get(DATA_CHALLENGES, {}).get(challenge_id) if not challenge or kid_id not in challenge.get("assigned_kids", []): ent_reg.async_remove(entity_entry.entity_id) LOGGER.debug( "Removed orphaned challenge progress sensor '%s' because kid '%s' is not assigned to challenge '%s'", entity_entry.entity_id, kid_id, challenge_id, ) def _remove_kid_chore_entities(self, kid_id: str, chore_id: str) -> None: """Remove all kid-specific chore entities for a given kid and chore.""" ent_reg = er.async_get(self.hass) for entity_entry in list(ent_reg.entities.values()): if (kid_id in entity_entry.unique_id) and ( chore_id in entity_entry.unique_id ): ent_reg.async_remove(entity_entry.entity_id) LOGGER.debug( "Removed kid-specific entity '%s' for kid '%s' and chore '%s'", entity_entry.entity_id, kid_id, chore_id, ) def _cleanup_chore_from_kid(self, kid_id: str, chore_id: str) -> None: """Remove references to a specific chore from a kid's data.""" kid = self.kids_data.get(kid_id) if not kid: return # Remove from lists if present for key in ["claimed_chores", "approved_chores"]: if chore_id in kid.get(key, []): kid[key] = [c for c in kid[key] if c != chore_id] LOGGER.debug( "Removed chore '%s' from kid '%s' list '%s'", chore_id, kid_id, key ) # Remove from dictionary fields if present for dict_key in ["chore_claims", "chore_approvals"]: if chore_id in kid.get(dict_key, {}): kid[dict_key].pop(chore_id) LOGGER.debug( "Removed chore '%s' from kid '%s' dict '%s'", chore_id, kid_id, dict_key, ) # Remove from chore streaks if present if "chore_streaks" in kid and chore_id in kid["chore_streaks"]: kid["chore_streaks"].pop(chore_id) LOGGER.debug( "Removed chore streak for chore '%s' from kid '%s'", chore_id, kid_id ) # Remove any pending chore approvals for this kid and chore self._data[DATA_PENDING_CHORE_APPROVALS] = [ ap for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, []) if not (ap.get("kid_id") == kid_id and ap.get("chore_id") == chore_id) ] def _cleanup_pending_chore_approvals(self) -> None: """Remove any pending chore approvals for chore IDs that no longer exist.""" valid_chore_ids = set(self._data.get(DATA_CHORES, {}).keys()) self._data[DATA_PENDING_CHORE_APPROVALS] = [ ap for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, []) if ap.get("chore_id") in valid_chore_ids ] def _cleanup_pending_reward_approvals(self) -> None: """Remove any pending reward approvals for reward IDs that no longer exist.""" valid_reward_ids = set(self._data.get(DATA_REWARDS, {}).keys()) self._data[DATA_PENDING_REWARD_APPROVALS] = [ approval for approval in self._data.get(DATA_PENDING_REWARD_APPROVALS, []) if approval.get("reward_id") in valid_reward_ids ] def _cleanup_deleted_kid_references(self) -> None: """Remove references to kids that no longer exist from other sections.""" valid_kid_ids = set(self.kids_data.keys()) # Remove deleted kid IDs from all chore assignments for chore in self._data.get(DATA_CHORES, {}).values(): if "assigned_kids" in chore: original = chore["assigned_kids"] filtered = [kid for kid in original if kid in valid_kid_ids] if filtered != original: chore["assigned_kids"] = filtered LOGGER.debug( "Cleaned up assigned_kids in chore '%s'", chore.get("name") ) # Remove progress in achievements and challenges for section in [DATA_ACHIEVEMENTS, DATA_CHALLENGES]: for entity in self._data.get(section, {}).values(): progress = entity.get("progress", {}) keys_to_remove = [kid for kid in progress if kid not in valid_kid_ids] for kid in keys_to_remove: del progress[kid] LOGGER.debug( "Removed progress for deleted kid '%s' in section '%s'", kid, section, ) if "assigned_kids" in entity: original_assigned = entity["assigned_kids"] filtered_assigned = [ kid for kid in original_assigned if kid in valid_kid_ids ] if filtered_assigned != original_assigned: entity["assigned_kids"] = filtered_assigned LOGGER.debug( "Cleaned up assigned_kids in %s '%s'", section, entity.get("name"), ) def _cleanup_deleted_chore_references(self) -> None: """Remove references to chores that no longer exist from kid data.""" valid_chore_ids = set(self.chores_data.keys()) for kid in self.kids_data.values(): # Clean up list fields for key in ["claimed_chores", "approved_chores"]: if key in kid: original = kid[key] filtered = [chore for chore in original if chore in valid_chore_ids] if filtered != original: kid[key] = filtered # Clean up dictionary fields for dict_key in ["chore_claims", "chore_approvals"]: if dict_key in kid: kid[dict_key] = { chore: count for chore, count in kid[dict_key].items() if chore in valid_chore_ids } # Clean up chore streaks if "chore_streaks" in kid: for chore in list(kid["chore_streaks"].keys()): if chore not in valid_chore_ids: del kid["chore_streaks"][chore] LOGGER.debug( "Removed chore streak for deleted chore '%s'", chore ) def _cleanup_parent_assignments(self) -> None: """Remove any kid IDs from parent's 'associated_kids' that no longer exist.""" valid_kid_ids = set(self.kids_data.keys()) for parent in self._data.get(DATA_PARENTS, {}).values(): original = parent.get("associated_kids", []) filtered = [kid_id for kid_id in original if kid_id in valid_kid_ids] if filtered != original: parent["associated_kids"] = filtered LOGGER.debug( "Cleaned up associated_kids for parent '%s'. New list: %s", parent.get("name"), filtered, ) def _cleanup_deleted_chore_in_achievements(self) -> None: """Clear selected_chore_id in achievements if the chore no longer exists.""" valid_chore_ids = set(self.chores_data.keys()) for achievement in self._data.get(DATA_ACHIEVEMENTS, {}).values(): selected = achievement.get("selected_chore_id") if selected and selected not in valid_chore_ids: achievement["selected_chore_id"] = "" LOGGER.debug( "Cleared selected_chore_id in achievement '%s'", achievement.get("name"), ) def _cleanup_deleted_chore_in_challenges(self) -> None: """Clear selected_chore_id in challenges if the chore no longer exists.""" valid_chore_ids = set(self.chores_data.keys()) for challenge in self._data.get(DATA_CHALLENGES, {}).values(): selected = challenge.get("selected_chore_id") if selected and selected not in valid_chore_ids: challenge["selected_chore_id"] = "" LOGGER.debug( "Cleared selected_chore_id in challenge '%s'", challenge.get("name") ) # ------------------------------------------------------------------------------------- # Create/Update Entities # (Kids, Parents, Chores, Badges, Rewards, Penalties, Achievements and Challenges) # ------------------------------------------------------------------------------------- # -- Kids def _create_kid(self, kid_id: str, kid_data: dict[str, Any]): self._data[DATA_KIDS][kid_id] = { "name": kid_data.get("name", ""), "points": kid_data.get("points", 0.0), "badges": kid_data.get("badges", []), "claimed_chores": kid_data.get("claimed_chores", []), "approved_chores": kid_data.get("approved_chores", []), "completed_chores_today": kid_data.get("completed_chores_today", 0), "completed_chores_weekly": kid_data.get("completed_chores_weekly", 0), "completed_chores_monthly": kid_data.get("completed_chores_monthly", 0), "completed_chores_total": kid_data.get("completed_chores_total", 0), "ha_user_id": kid_data.get("ha_user_id"), "internal_id": kid_id, "points_multiplier": kid_data.get("points_multiplier", 1.0), "reward_claims": kid_data.get("reward_claims", {}), "reward_approvals": kid_data.get("reward_approvals", {}), "chore_claims": kid_data.get("chore_claims", {}), "chore_approvals": kid_data.get("chore_approvals", {}), "penalty_applies": kid_data.get("penalty_applies", {}), "bonus_applies": kid_data.get("bonus_applies", {}), "pending_rewards": kid_data.get("pending_rewards", []), "redeemed_rewards": kid_data.get("redeemed_rewards", []), "points_earned_today": kid_data.get("points_earned_today", 0.0), "points_earned_weekly": kid_data.get("points_earned_weekly", 0.0), "points_earned_monthly": kid_data.get("points_earned_monthly", 0.0), "max_points_ever": kid_data.get("max_points_ever", 0.0), "enable_notifications": kid_data.get("enable_notifications", True), "mobile_notify_service": kid_data.get("mobile_notify_service", ""), "use_persistent_notifications": kid_data.get( "use_persistent_notifications", True ), "chore_streaks": {}, "overall_chore_streak": 0, "last_chore_date": None, "overdue_chores": [], "overdue_notifications": {}, } self._normalize_kid_lists(self._data[DATA_KIDS][kid_id]) LOGGER.debug( "Added new kid '%s' with ID: %s", self._data[DATA_KIDS][kid_id]["name"], kid_id, ) def _update_kid(self, kid_id: str, kid_data: dict[str, Any]): kid_info = self._data[DATA_KIDS][kid_id] # Overwrite or set default if not present kid_info["name"] = kid_data.get("name", kid_info["name"]) kid_info["ha_user_id"] = kid_data.get("ha_user_id", kid_info["ha_user_id"]) kid_info.setdefault("reward_claims", kid_data.get("reward_claims", {})) kid_info.setdefault("reward_approvals", kid_data.get("reward_approvals", {})) kid_info.setdefault("chore_claims", kid_data.get("chore_claims", {})) kid_info.setdefault("chore_approvals", kid_data.get("chore_approvals", {})) kid_info.setdefault("penalty_applies", kid_data.get("penalty_applies", {})) kid_info.setdefault("bonus_applies", kid_data.get("bonus_applies", {})) kid_info.setdefault("pending_rewards", kid_data.get("pending_rewards", [])) kid_info.setdefault("redeemed_rewards", kid_data.get("redeemed_rewards", [])) kid_info.setdefault( "points_earned_today", kid_data.get("points_earned_today", 0.0) ) kid_info.setdefault( "points_earned_weekly", kid_data.get("points_earned_weekly", 0.0) ) kid_info.setdefault( "points_earned_monthly", kid_data.get("points_earned_monthly", 0.0) ) kid_info.setdefault("max_points_ever", kid_data.get("max_points_ever", 0.0)) kid_info.setdefault("points_multiplier", kid_data.get("points_multiplier", 1.0)) kid_info["enable_notifications"] = kid_data.get( "enable_notifications", kid_info.get("enable_notifications", True) ) kid_info["mobile_notify_service"] = kid_data.get( "mobile_notify_service", kid_info.get("mobile_notify_service", "") ) kid_info["use_persistent_notifications"] = kid_data.get( "use_persistent_notifications", kid_info.get("use_persistent_notifications", True), ) kid_info.setdefault("chore_streaks", {}) kid_info.setdefault("overall_chore_streak", 0) kid_info.setdefault("last_chore_date", None) kid_info.setdefault("overdue_chores", []) kid_info.setdefault("overdue_notifications", {}) self._normalize_kid_lists(self._data[DATA_KIDS][kid_id]) LOGGER.debug("Updated kid '%s' with ID: %s", kid_info["name"], kid_id) # -- Parents def _create_parent(self, parent_id: str, parent_data: dict[str, Any]): associated_kids_ids = [] for kid_id in parent_data.get("associated_kids", []): if kid_id in self.kids_data: associated_kids_ids.append(kid_id) else: LOGGER.warning( "Parent '%s': Kid ID '%s' not found. Skipping assignment to parent", parent_data.get("name", parent_id), kid_id, ) self._data[DATA_PARENTS][parent_id] = { "name": parent_data.get("name", ""), "ha_user_id": parent_data.get("ha_user_id", ""), "associated_kids": associated_kids_ids, "enable_notifications": parent_data.get("enable_notifications", True), "mobile_notify_service": parent_data.get("mobile_notify_service", ""), "use_persistent_notifications": parent_data.get( "use_persistent_notifications", True ), "internal_id": parent_id, } LOGGER.debug( "Added new parent '%s' with ID: %s", self._data[DATA_PARENTS][parent_id]["name"], parent_id, ) def _update_parent(self, parent_id: str, parent_data: dict[str, Any]): parent_info = self._data[DATA_PARENTS][parent_id] parent_info["name"] = parent_data.get("name", parent_info["name"]) parent_info["ha_user_id"] = parent_data.get( "ha_user_id", parent_info["ha_user_id"] ) # Update associated_kids updated_kids = [] for kid_id in parent_data.get("associated_kids", []): if kid_id in self.kids_data: updated_kids.append(kid_id) else: LOGGER.warning( "Parent '%s': Kid ID '%s' not found. Skipping assignment", parent_info["name"], kid_id, ) parent_info["associated_kids"] = updated_kids parent_info["enable_notifications"] = parent_data.get( "enable_notifications", parent_info.get("enable_notifications", True) ) parent_info["mobile_notify_service"] = parent_data.get( "mobile_notify_service", parent_info.get("mobile_notify_service", "") ) parent_info["use_persistent_notifications"] = parent_data.get( "use_persistent_notifications", parent_info.get("use_persistent_notifications", True), ) LOGGER.debug("Updated parent '%s' with ID: %s", parent_info["name"], parent_id) # -- Chores def _create_chore(self, chore_id: str, chore_data: dict[str, Any]): assigned_kids_ids = [] for kid_name in chore_data.get("assigned_kids", []): kid_id = self._get_kid_id_by_name(kid_name) if kid_id: assigned_kids_ids.append(kid_id) else: LOGGER.warning( "Chore '%s': Kid name '%s' not found. Skipping assignment", chore_data.get("name", chore_id), kid_name, ) # If chore is recurring, set due_date to creation date if not set freq = chore_data.get("recurring_frequency", FREQUENCY_NONE) if freq != FREQUENCY_NONE and not chore_data.get("due_date"): now_local = dt_util.utcnow().astimezone( dt_util.get_time_zone(self.hass.config.time_zone) ) # Force the time to 23:59:00 (and zero microseconds) default_due = now_local.replace(hour=23, minute=59, second=0, microsecond=0) chore_data["due_date"] = default_due.isoformat() LOGGER.debug( "Chore '%s' has freq '%s' but no due_date. Defaulting to 23:59 local time: %s", chore_data.get("name", chore_id), freq, chore_data["due_date"], ) self._data[DATA_CHORES][chore_id] = { "name": chore_data.get("name", ""), "state": chore_data.get("state", CHORE_STATE_PENDING), "default_points": chore_data.get("default_points", DEFAULT_POINTS), "allow_multiple_claims_per_day": chore_data.get( "allow_multiple_claims_per_day", DEFAULT_MULTIPLE_CLAIMS_PER_DAY ), "partial_allowed": chore_data.get( "partial_allowed", DEFAULT_PARTIAL_ALLOWED ), "description": chore_data.get("description", ""), "chore_labels": chore_data.get("chore_labels", []), "icon": chore_data.get("icon", DEFAULT_ICON), "shared_chore": chore_data.get("shared_chore", False), "assigned_kids": assigned_kids_ids, "recurring_frequency": chore_data.get( "recurring_frequency", FREQUENCY_NONE ), "custom_interval": chore_data.get("custom_interval") if chore_data.get("recurring_frequency") == FREQUENCY_CUSTOM else None, "custom_interval_unit": chore_data.get("custom_interval_unit") if chore_data.get("recurring_frequency") == FREQUENCY_CUSTOM else None, "due_date": chore_data.get("due_date"), "last_completed": chore_data.get("last_completed"), "last_claimed": chore_data.get("last_claimed"), "applicable_days": chore_data.get("applicable_days", []), "notify_on_claim": chore_data.get( "notify_on_claim", DEFAULT_NOTIFY_ON_CLAIM ), "notify_on_approval": chore_data.get( "notify_on_approval", DEFAULT_NOTIFY_ON_APPROVAL ), "notify_on_disapproval": chore_data.get( "notify_on_disapproval", DEFAULT_NOTIFY_ON_DISAPPROVAL ), "internal_id": chore_id, } LOGGER.debug( "Added new chore '%s' with ID: %s", self._data[DATA_CHORES][chore_id]["name"], chore_id, ) # Notify Kids of new chore new_name = self._data[DATA_CHORES][chore_id]["name"] due_date = self._data[DATA_CHORES][chore_id]["due_date"] for kid_id in assigned_kids_ids: due_str = due_date if due_date else "No due date set" extra_data = {"kid_id": kid_id, "chore_id": chore_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: New Chore", message=f"A new chore '{new_name}' was assigned to you! Due: {due_str}", extra_data=extra_data, ) ) def _update_chore(self, chore_id: str, chore_data: dict[str, Any]): chore_info = self._data[DATA_CHORES][chore_id] chore_info["name"] = chore_data.get("name", chore_info["name"]) chore_info["state"] = chore_data.get("state", chore_info["state"]) chore_info["default_points"] = chore_data.get( "default_points", chore_info["default_points"] ) chore_info["allow_multiple_claims_per_day"] = chore_data.get( "allow_multiple_claims_per_day", chore_info["allow_multiple_claims_per_day"] ) chore_info["partial_allowed"] = chore_data.get( "partial_allowed", chore_info["partial_allowed"] ) chore_info["description"] = chore_data.get( "description", chore_info["description"] ) chore_info["chore_labels"] = chore_data.get( "chore_labels", chore_info.get("chore_labels", []) ) chore_info["icon"] = chore_data.get("icon", chore_info["icon"]) chore_info["shared_chore"] = chore_data.get( "shared_chore", chore_info["shared_chore"] ) assigned_kids_ids = [] for kid_name in chore_data.get("assigned_kids", []): kid_id = self._get_kid_id_by_name(kid_name) if kid_id: assigned_kids_ids.append(kid_id) else: LOGGER.warning( "Chore '%s': Kid name '%s' not found. Skipping assignment", chore_data.get("name", chore_id), kid_name, ) old_assigned = set(chore_info.get("assigned_kids", [])) new_assigned = set(assigned_kids_ids) removed_kids = old_assigned - new_assigned for kid in removed_kids: self._remove_kid_chore_entities(kid, chore_id) self._cleanup_chore_from_kid(kid, chore_id) # Update the chore's assigned kids list with the new assignments chore_info["assigned_kids"] = list(new_assigned) chore_info["recurring_frequency"] = chore_data.get( "recurring_frequency", chore_info["recurring_frequency"] ) chore_info["due_date"] = chore_data.get("due_date", chore_info["due_date"]) chore_info["last_completed"] = chore_data.get( "last_completed", chore_info.get("last_completed") ) chore_info["last_claimed"] = chore_data.get( "last_claimed", chore_info.get("last_claimed") ) chore_info["applicable_days"] = chore_data.get( "applicable_days", chore_info.get("applicable_days", []) ) chore_info["notify_on_claim"] = chore_data.get( "notify_on_claim", chore_info.get("notify_on_claim", DEFAULT_NOTIFY_ON_CLAIM), ) chore_info["notify_on_approval"] = chore_data.get( "notify_on_approval", chore_info.get("notify_on_approval", DEFAULT_NOTIFY_ON_APPROVAL), ) chore_info["notify_on_disapproval"] = chore_data.get( "notify_on_disapproval", chore_info.get("notify_on_disapproval", DEFAULT_NOTIFY_ON_DISAPPROVAL), ) if chore_info["recurring_frequency"] == FREQUENCY_CUSTOM: chore_info["custom_interval"] = chore_data.get("custom_interval") chore_info["custom_interval_unit"] = chore_data.get("custom_interval_unit") else: chore_info["custom_interval"] = None chore_info["custom_interval_unit"] = None LOGGER.debug("Updated chore '%s' with ID: %s", chore_info["name"], chore_id) self.hass.async_create_task(self._check_overdue_chores()) # -- Badges def _create_badge(self, badge_id: str, badge_data: dict[str, Any]): self._data[DATA_BADGES][badge_id] = { "name": badge_data.get("name", ""), "threshold_type": badge_data.get( "threshold_type", BADGE_THRESHOLD_TYPE_POINTS ), "threshold_value": badge_data.get( "threshold_value", DEFAULT_BADGE_THRESHOLD ), "chore_count_type": badge_data.get("chore_count_type", FREQUENCY_DAILY), "earned_by": badge_data.get("earned_by", []), "points_multiplier": badge_data.get( "points_multiplier", DEFAULT_POINTS_MULTIPLIER ), "icon": badge_data.get("icon", DEFAULT_ICON), "description": badge_data.get("description", ""), "badge_labels": badge_data.get("badge_labels", []), "internal_id": badge_id, } LOGGER.debug( "Added new badge '%s' with ID: %s", self._data[DATA_BADGES][badge_id]["name"], badge_id, ) def _update_badge(self, badge_id: str, badge_data: dict[str, Any]): badge_info = self._data[DATA_BADGES][badge_id] badge_info["name"] = badge_data.get("name", badge_info["name"]) badge_info["threshold_type"] = badge_data.get( "threshold_type", badge_info.get("threshold_type", BADGE_THRESHOLD_TYPE_POINTS), ) badge_info["threshold_value"] = badge_data.get( "threshold_value", badge_info.get("threshold_value", DEFAULT_BADGE_THRESHOLD), ) badge_info["chore_count_type"] = badge_data.get( "chore_count_type", badge_info.get("chore_count_type", FREQUENCY_NONE) ) badge_info["points_multiplier"] = badge_data.get( "points_multiplier", badge_info.get("points_multiplier", DEFAULT_POINTS_MULTIPLIER), ) badge_info["icon"] = badge_data.get( "icon", badge_info.get("icon", DEFAULT_ICON) ) badge_info["description"] = badge_data.get( "description", badge_info.get("description", "") ) badge_info["badge_labels"] = badge_data.get( "badge_labels", badge_info.get("badge_labels", []) ) LOGGER.debug("Updated badge '%s' with ID: %s", badge_info["name"], badge_id) # -- Rewards def _create_reward(self, reward_id: str, reward_data: dict[str, Any]): self._data[DATA_REWARDS][reward_id] = { "name": reward_data.get("name", ""), "cost": reward_data.get("cost", DEFAULT_REWARD_COST), "description": reward_data.get("description", ""), "reward_labels": reward_data.get("reward_labels", []), "icon": reward_data.get("icon", DEFAULT_REWARD_ICON), "internal_id": reward_id, } LOGGER.debug( "Added new reward '%s' with ID: %s", self._data[DATA_REWARDS][reward_id]["name"], reward_id, ) def _update_reward(self, reward_id: str, reward_data: dict[str, Any]): reward_info = self._data[DATA_REWARDS][reward_id] reward_info["name"] = reward_data.get("name", reward_info["name"]) reward_info["cost"] = reward_data.get("cost", reward_info["cost"]) reward_info["description"] = reward_data.get( "description", reward_info["description"] ) reward_info["reward_labels"] = reward_data.get( "reward_labels", reward_info.get("reward_labels", []) ) reward_info["icon"] = reward_data.get("icon", reward_info["icon"]) LOGGER.debug("Updated reward '%s' with ID: %s", reward_info["name"], reward_id) # -- Penalties def _create_penalty(self, penalty_id: str, penalty_data: dict[str, Any]): self._data[DATA_PENALTIES][penalty_id] = { "name": penalty_data.get("name", ""), "points": penalty_data.get("points", -DEFAULT_PENALTY_POINTS), "description": penalty_data.get("description", ""), "penalty_labels": penalty_data.get("penalty_labels", []), "icon": penalty_data.get("icon", DEFAULT_PENALTY_ICON), "internal_id": penalty_id, } LOGGER.debug( "Added new penalty '%s' with ID: %s", self._data[DATA_PENALTIES][penalty_id]["name"], penalty_id, ) def _update_penalty(self, penalty_id: str, penalty_data: dict[str, Any]): penalty_info = self._data[DATA_PENALTIES][penalty_id] penalty_info["name"] = penalty_data.get("name", penalty_info["name"]) penalty_info["points"] = penalty_data.get("points", penalty_info["points"]) penalty_info["description"] = penalty_data.get( "description", penalty_info["description"] ) penalty_info["penalty_labels"] = penalty_data.get( "penalty_labels", penalty_info.get("penalty_labels", []) ) penalty_info["icon"] = penalty_data.get("icon", penalty_info["icon"]) LOGGER.debug( "Updated penalty '%s' with ID: %s", penalty_info["name"], penalty_id ) # -- Bonuses def _create_bonus(self, bonus_id: str, bonus_data: dict[str, Any]): self._data[DATA_BONUSES][bonus_id] = { "name": bonus_data.get("name", ""), "points": bonus_data.get("points", DEFAULT_BONUS_POINTS), "description": bonus_data.get("description", ""), "bonus_labels": bonus_data.get("bonus_labels", []), "icon": bonus_data.get("icon", DEFAULT_BONUS_ICON), "internal_id": bonus_id, } LOGGER.debug( "Added new bonus '%s' with ID: %s", self._data[DATA_BONUSES][bonus_id]["name"], bonus_id, ) def _update_bonus(self, bonus_id: str, bonus_data: dict[str, Any]): bonus_info = self._data[DATA_BONUSES][bonus_id] bonus_info["name"] = bonus_data.get("name", bonus_info["name"]) bonus_info["points"] = bonus_data.get("points", bonus_info["points"]) bonus_info["description"] = bonus_data.get( "description", bonus_info["description"] ) bonus_info["bonus_labels"] = bonus_data.get( "bonus_labels", bonus_info.get("bonus_labels", []) ) bonus_info["icon"] = bonus_data.get("icon", bonus_info["icon"]) LOGGER.debug("Updated bonus '%s' with ID: %s", bonus_info["name"], bonus_id) # -- Achievements def _create_achievement( self, achievement_id: str, achievement_data: dict[str, Any] ): self._data[DATA_ACHIEVEMENTS][achievement_id] = { "name": achievement_data.get("name", ""), "description": achievement_data.get("description", ""), "achievement_labels": achievement_data.get("achievement_labels", []), "icon": achievement_data.get("icon", ""), "assigned_kids": achievement_data.get("assigned_kids", []), "type": achievement_data.get("type", "individual"), "selected_chore_id": achievement_data.get("selected_chore_id", ""), "criteria": achievement_data.get("criteria", ""), "target_value": achievement_data.get("target_value", 1), "reward_points": achievement_data.get("reward_points", 0), "progress": achievement_data.get("progress", {}), "internal_id": achievement_id, } LOGGER.debug( "Added new achievement '%s' with ID: %s", self._data[DATA_ACHIEVEMENTS][achievement_id]["name"], achievement_id, ) def _update_achievement( self, achievement_id: str, achievement_data: dict[str, Any] ): achievement_info = self._data[DATA_ACHIEVEMENTS][achievement_id] achievement_info["name"] = achievement_data.get( "name", achievement_info["name"] ) achievement_info["description"] = achievement_data.get( "description", achievement_info["description"] ) achievement_info["achievement_labels"] = achievement_data.get( "achievement_labels", achievement_info.get("achievement_labels", []) ) achievement_info["icon"] = achievement_data.get( "icon", achievement_info["icon"] ) achievement_info["assigned_kids"] = achievement_data.get( "assigned_kids", achievement_info["assigned_kids"] ) achievement_info["type"] = achievement_data.get( "type", achievement_info["type"] ) achievement_info["selected_chore_id"] = achievement_data.get( "selected_chore_id", achievement_info.get("selected_chore_id", "") ) achievement_info["criteria"] = achievement_data.get( "criteria", achievement_info["criteria"] ) achievement_info["target_value"] = achievement_data.get( "target_value", achievement_info["target_value"] ) achievement_info["reward_points"] = achievement_data.get( "reward_points", achievement_info["reward_points"] ) LOGGER.debug( "Updated achievement '%s' with ID: %s", achievement_info["name"], achievement_id, ) # -- Challenges def _create_challenge(self, challenge_id: str, challenge_data: dict[str, Any]): self._data[DATA_CHALLENGES][challenge_id] = { "name": challenge_data.get("name", ""), "description": challenge_data.get("description", ""), "challenge_labels": challenge_data.get("challenge_labels", []), "icon": challenge_data.get("icon", ""), "assigned_kids": challenge_data.get("assigned_kids", []), "type": challenge_data.get("type", "individual"), "selected_chore_id": challenge_data.get("selected_chore_id", ""), "criteria": challenge_data.get("criteria", ""), "target_value": challenge_data.get("target_value", 1), "reward_points": challenge_data.get("reward_points", 0), "start_date": challenge_data.get("start_date") if challenge_data.get("start_date") not in [None, {}] else None, "end_date": challenge_data.get("end_date") if challenge_data.get("end_date") not in [None, {}] else None, "progress": challenge_data.get("progress", {}), "internal_id": challenge_id, } LOGGER.debug( "Added new challenge '%s' with ID: %s", self._data[DATA_CHALLENGES][challenge_id]["name"], challenge_id, ) def _update_challenge(self, challenge_id: str, challenge_data: dict[str, Any]): challenge_info = self._data[DATA_CHALLENGES][challenge_id] challenge_info["name"] = challenge_data.get("name", challenge_info["name"]) challenge_info["description"] = challenge_data.get( "description", challenge_info["description"] ) challenge_info["challenge_labels"] = challenge_data.get( "challenge_labels", challenge_info.get("challenge_labels", []) ) challenge_info["icon"] = challenge_data.get("icon", challenge_info["icon"]) challenge_info["assigned_kids"] = challenge_data.get( "assigned_kids", challenge_info["assigned_kids"] ) challenge_info["type"] = challenge_data.get("type", challenge_info["type"]) challenge_info["selected_chore_id"] = challenge_data.get( "selected_chore_id", challenge_info.get("selected_chore_id", "") ) challenge_info["criteria"] = challenge_data.get( "criteria", challenge_info["criteria"] ) challenge_info["target_value"] = challenge_data.get( "target_value", challenge_info["target_value"] ) challenge_info["reward_points"] = challenge_data.get( "reward_points", challenge_info["reward_points"] ) challenge_info["start_date"] = ( challenge_data.get("start_date") if challenge_data.get("start_date") not in [None, {}] else None ) challenge_info["end_date"] = ( challenge_data.get("end_date") if challenge_data.get("end_date") not in [None, {}] else None ) LOGGER.debug( "Updated challenge '%s' with ID: %s", challenge_info["name"], challenge_id ) # ------------------------------------------------------------------------------------- # Properties for Easy Access # ------------------------------------------------------------------------------------- @property def kids_data(self) -> dict[str, Any]: """Return the kids data.""" return self._data.get(DATA_KIDS, {}) @property def parents_data(self) -> dict[str, Any]: """Return the parents data.""" return self._data.get(DATA_PARENTS, {}) @property def chores_data(self) -> dict[str, Any]: """Return the chores data.""" return self._data.get(DATA_CHORES, {}) @property def badges_data(self) -> dict[str, Any]: """Return the badges data.""" return self._data.get(DATA_BADGES, {}) @property def rewards_data(self) -> dict[str, Any]: """Return the rewards data.""" return self._data.get(DATA_REWARDS, {}) @property def penalties_data(self) -> dict[str, Any]: """Return the penalties data.""" return self._data.get(DATA_PENALTIES, {}) @property def achievements_data(self) -> dict[str, Any]: """Return the achievements data.""" return self._data.get(DATA_ACHIEVEMENTS, {}) # New @property def challenges_data(self) -> dict[str, Any]: """Return the challenges data.""" return self._data.get(DATA_CHALLENGES, {}) @property def bonuses_data(self) -> dict[str, Any]: """Return the bonuses data.""" return self._data.get(DATA_BONUSES, {}) # ------------------------------------------------------------------------------------- # Parents: Add, Remove # ------------------------------------------------------------------------------------- def add_parent(self, parent_def: dict[str, Any]): """Add new parent at runtime if needed.""" parent_name = parent_def.get("name") ha_user_id = parent_def.get("ha_user_id") kid_ids = parent_def.get("associated_kids", []) if not parent_name or not ha_user_id: LOGGER.warning("Add parent: Parent must have a name and ha_user_id") return if any(p["ha_user_id"] == ha_user_id for p in self.parents_data.values()): LOGGER.warning( "Add parent: Parent with ha_user_id '%s' already exists", ha_user_id ) return valid_kids = [] for kid_id in kid_ids: if kid_id in self.kids_data: valid_kids.append(kid_id) else: LOGGER.warning( "Add parent: Kid ID '%s' not found. Skipping assignment to parent '%s'", kid_id, parent_name, ) new_id = str(uuid.uuid4()) self.parents_data[new_id] = { "name": parent_name, "ha_user_id": ha_user_id, "associated_kids": valid_kids, "internal_id": new_id, } LOGGER.debug("Added new parent '%s' with ID: %s", parent_name, new_id) self._persist() self.async_set_updated_data(self._data) def remove_parent(self, parent_id: str): """Remove a parent by ID.""" if parent_id in self.parents_data: parent_name = self.parents_data[parent_id]["name"] del self.parents_data[parent_id] LOGGER.debug("Removed parent '%s' with ID: %s", parent_name, parent_id) self._persist() self.async_set_updated_data(self._data) else: LOGGER.warning("Remove parent: Parent ID '%s' not found", parent_id) # ------------------------------------------------------------------------------------- # Chores: Claim, Approve, Disapprove, Compute Global State for Shared Chores # ------------------------------------------------------------------------------------- def claim_chore(self, kid_id: str, chore_id: str, user_name: str): """Kid claims chore => state=claimed; parent must then approve.""" if chore_id not in self.chores_data: LOGGER.warning("Chore ID '%s' not found for claim", chore_id) raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.") chore_info = self.chores_data[chore_id] if kid_id not in chore_info.get("assigned_kids", []): LOGGER.warning( "Claim chore: Chore ID '%s' not assigned to kid ID '%s'", chore_id, kid_id, ) raise HomeAssistantError( f"Chore '{chore_info.get('name')}' is not assigned to kid '{self.kids_data[kid_id]['name']}'." ) if kid_id not in self.kids_data: LOGGER.warning("Kid ID '%s' not found", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") kid_info = self.kids_data.get(kid_id) self._normalize_kid_lists(kid_info) allow_multiple = chore_info.get("allow_multiple_claims_per_day", False) if allow_multiple: # If already approved, remove it so the new claim can trigger a new approval flow kid_info["approved_chores"] = [ item for item in kid_info.get("approved_chores", []) if item != chore_id ] if not allow_multiple: if chore_id in kid_info.get( "claimed_chores", [] ) or chore_id in kid_info.get("approved_chores", []): error_message = f"Chore '{chore_info['name']}' has already been claimed today and multiple claims are not allowed." LOGGER.warning(error_message) raise HomeAssistantError(error_message) self._process_chore_state(kid_id, chore_id, CHORE_STATE_CLAIMED) # Send a notification to the parents that a kid claimed a chore if chore_info.get(CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM): actions = [ { "action": f"{ACTION_APPROVE_CHORE}|{kid_id}|{chore_id}", "title": ACTION_TITLE_APPROVE, }, { "action": f"{ACTION_DISAPPROVE_CHORE}|{kid_id}|{chore_id}", "title": ACTION_TITLE_DISAPPROVE, }, { "action": f"{ACTION_REMIND_30}|{kid_id}|{chore_id}", "title": ACTION_TITLE_REMIND_30, }, ] # Pass extra context so the event handler can route the action. extra_data = { "kid_id": kid_id, "chore_id": chore_id, } self.hass.async_create_task( self._notify_parents( kid_id, title="KidsChores: Chore Claimed", message=f"'{self.kids_data[kid_id]['name']}' claimed chore '{self.chores_data[chore_id]['name']}'", actions=actions, extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def approve_chore( self, parent_name: str, kid_id: str, chore_id: str, points_awarded: Optional[float] = None, ): """Approve a chore for kid_id if assigned.""" if chore_id not in self.chores_data: raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.") chore_info = self.chores_data[chore_id] if kid_id not in chore_info.get("assigned_kids", []): raise HomeAssistantError( f"Chore '{chore_info.get('name')}' is not assigned to kid '{self.kids_data[kid_id]['name']}'." ) if kid_id not in self.kids_data: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") kid_info = self.kids_data.get(kid_id) allow_multiple = chore_info.get("allow_multiple_claims_per_day", False) if not allow_multiple: if chore_id in kid_info.get("approved_chores", []): error_message = f"Chore '{chore_info['name']}' has already been approved today; multiple approvals not allowed." LOGGER.warning(error_message) raise HomeAssistantError(error_message) default_points = chore_info.get("default_points", DEFAULT_POINTS) multiplier = kid_info.get("points_multiplier", 1.0) awarded_points = ( points_awarded * multiplier if points_awarded is not None else default_points * multiplier ) self._process_chore_state( kid_id, chore_id, CHORE_STATE_APPROVED, points_awarded=awarded_points ) # Remove to avoid awarding duplicated points # old_points = float(kid_info["points"]) # new_points = old_points + awarded_points # self.update_kid_points(kid_id, new_points) # increment completed chores counters kid_info["completed_chores_today"] += 1 kid_info["completed_chores_weekly"] += 1 kid_info["completed_chores_monthly"] += 1 kid_info["completed_chores_total"] += 1 # Track today’s approvals for chores that allow multiple claims. if chore_info.get("allow_multiple_claims_per_day", False): kid_info.setdefault("today_chore_approvals", {}) kid_info["today_chore_approvals"][chore_id] = ( kid_info["today_chore_approvals"].get(chore_id, 0) + 1 ) chore_info["last_completed"] = dt_util.utcnow().isoformat() today = dt_util.as_local(dt_util.utcnow()).date() self._update_chore_streak_for_kid(kid_id, chore_id, today) self._update_overall_chore_streak(kid_id, today) # remove from pending approvals self._data[DATA_PENDING_CHORE_APPROVALS] = [ ap for ap in self._data[DATA_PENDING_CHORE_APPROVALS] if not (ap["kid_id"] == kid_id and ap["chore_id"] == chore_id) ] # increment chore approvals if chore_id in kid_info["chore_approvals"]: kid_info["chore_approvals"][chore_id] += 1 else: kid_info["chore_approvals"][chore_id] = 1 # Manage Achievements today = dt_util.as_local(dt_util.utcnow()).date() for achievement_id, achievement in self.achievements_data.items(): if achievement.get("type") == ACHIEVEMENT_TYPE_STREAK: selected_chore_id = achievement.get("selected_chore_id") if selected_chore_id == chore_id: # Get or create the progress dict for this kid progress = achievement.setdefault("progress", {}).setdefault( kid_id, {"current_streak": 0, "last_date": None, "awarded": False}, ) self._update_streak_progress(progress, today) # Manage Challenges today_iso = dt_util.as_local(dt_util.utcnow()).date().isoformat() for challenge_id, challenge in self.challenges_data.items(): if challenge.get("type") == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: # (Challenge update logic for total-within-window remains here) start_date_raw = challenge.get("start_date") if isinstance(start_date_raw, str): start_date = dt_util.parse_datetime(start_date_raw) if start_date and start_date.tzinfo is None: start_date = start_date.replace(tzinfo=dt_util.UTC) else: start_date = None end_date_raw = challenge.get("end_date") if isinstance(end_date_raw, str): end_date = dt_util.parse_datetime(end_date_raw) if end_date and end_date.tzinfo is None: end_date = end_date.replace(tzinfo=dt_util.UTC) else: end_date = None now = dt_util.utcnow() if start_date and end_date and start_date <= now <= end_date: progress = challenge.setdefault("progress", {}).setdefault( kid_id, {"count": 0, "awarded": False} ) progress["count"] += 1 elif challenge.get("type") == CHALLENGE_TYPE_DAILY_MIN: # Only update if the challenge is tracking a specific chore. selected_chore = challenge.get("selected_chore_id") if not selected_chore: LOGGER.warning( "Challenge '%s' of type daily_min has no selected_chore_id set. Skipping progress update.", challenge.get("name"), ) continue if selected_chore != chore_id: continue if kid_id in challenge.get("assigned_kids", []): progress = challenge.setdefault("progress", {}).setdefault( kid_id, {"daily_counts": {}, "awarded": False} ) progress["daily_counts"][today_iso] = ( progress["daily_counts"].get(today_iso, 0) + 1 ) # Send a notification to the kid that chore was approved if chore_info.get(CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL): extra_data = {"kid_id": kid_id, "chore_id": chore_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Chore Approved", message=f"Your chore '{chore_info['name']}' was approved. You earned {awarded_points} points.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def disapprove_chore(self, parent_name: str, kid_id: str, chore_id: str): """Disapprove a chore for kid_id.""" chore_info = self.chores_data.get(chore_id) if not chore_info: raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.") kid_info = self.kids_data.get(kid_id) if not kid_info: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING) # Send a notification to the kid that chore was disapproved if chore_info.get(CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL): extra_data = {"kid_id": kid_id, "chore_id": chore_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Chore Disapproved", message=f"Your chore '{chore_info['name']}' was disapproved.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def update_chore_state(self, chore_id: str, state: str): """Manually override a chore's state.""" chore_info = self.chores_data.get(chore_id) if not chore_info: LOGGER.warning("Update chore state: Chore ID '%s' not found", chore_id) return # Set state for all kids assigned to the chore: for kid_id in chore_info.get("assigned_kids", []): if kid_id: self._process_chore_state(kid_id, chore_id, state) self._persist() self.async_set_updated_data(self._data) LOGGER.debug(f"Chore ID '{chore_id}' state manually updated to '{state}'") # ------------------------------------------------------------------------------------- # Chore State Processing: Centralized Function # The most critical thing to understand when working on this function is that # chore_info["state"] is actually the global state of the chore. The individual chore # state per kid is always calculated based on whether they have any claimed, approved, or # overdue chores listed for them. # # Global state will only match if a single kid is assigned to the chore, or all kids # assigned are in the same state. # ------------------------------------------------------------------------------------- def _process_chore_state( self, kid_id: str, chore_id: str, new_state: str, *, points_awarded: Optional[float] = None, ) -> None: LOGGER.debug( "Entering _process_chore_state with kid_id=%s, chore_id=%s, new_state=%s, points_awarded=%s", kid_id, chore_id, new_state, points_awarded, ) """Centralized function to update a chore’s state for a given kid.""" kid_info = self.kids_data.get(kid_id) chore_info = self.chores_data.get(chore_id) if not kid_info or not chore_info: LOGGER.warning( "State change skipped: Kid '%s' or Chore '%s' not found", kid_id, chore_id, ) return # Clear any overdue tracking. kid_info.setdefault("overdue_chores", []) kid_info.setdefault("overdue_notifications", {}) # Remove all instances of the chore from overdue lists. kid_info["overdue_chores"] = [ entry for entry in kid_info.get("overdue_chores", []) if entry != chore_id ] if chore_id in kid_info["overdue_notifications"]: kid_info["overdue_notifications"].pop(chore_id) if new_state == CHORE_STATE_CLAIMED: # Remove all previous approvals in case of duplicate, add to claimed. kid_info["approved_chores"] = [ item for item in kid_info.get("approved_chores", []) if item != chore_id ] kid_info.setdefault("claimed_chores", []) if chore_id not in kid_info["claimed_chores"]: kid_info["claimed_chores"].append(chore_id) chore_info["last_claimed"] = dt_util.utcnow().isoformat() self._data.setdefault(DATA_PENDING_CHORE_APPROVALS, []).append( { "kid_id": kid_id, "chore_id": chore_id, "timestamp": dt_util.utcnow().isoformat(), } ) elif new_state == CHORE_STATE_APPROVED: # Remove all claims for chores in case of duplicates, add to approvals. kid_info["claimed_chores"] = [ item for item in kid_info.get("claimed_chores", []) if item != chore_id ] kid_info.setdefault("approved_chores", []) if chore_id not in kid_info["approved_chores"]: kid_info["approved_chores"].append(chore_id) chore_info["last_completed"] = dt_util.utcnow().isoformat() if points_awarded is not None: current_points = float(kid_info.get("points", 0)) self.update_kid_points(kid_id, current_points + points_awarded) today = dt_util.as_local(dt_util.utcnow()).date() self._update_chore_streak_for_kid(kid_id, chore_id, today) self._update_overall_chore_streak(kid_id, today) self._data[DATA_PENDING_CHORE_APPROVALS] = [ ap for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, []) if not (ap.get("kid_id") == kid_id and ap.get("chore_id") == chore_id) ] elif new_state == CHORE_STATE_PENDING: # Remove the chore from both claimed and approved lists. for field in ["claimed_chores", "approved_chores"]: if chore_id in kid_info.get(field, []): kid_info[field] = [c for c in kid_info[field] if c != chore_id] # Remove from pending approvals. self._data[DATA_PENDING_CHORE_APPROVALS] = [ ap for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, []) if not (ap.get("kid_id") == kid_id and ap.get("chore_id") == chore_id) ] elif new_state == CHORE_STATE_OVERDUE: # Mark as overdue. kid_info.setdefault("overdue_chores", []) if chore_id not in kid_info["overdue_chores"]: kid_info["overdue_chores"].append(chore_id) kid_info.setdefault("overdue_notifications", {}) kid_info["overdue_notifications"][chore_id] = dt_util.utcnow().isoformat() # Compute and update the chore's global state. # Given the process above is handling everything properly for each kid, computing the global state straightforward. # This process needs run every time a chore state changes, so it no longer warrants a separate function. assigned_kids = chore_info.get("assigned_kids", []) if len(assigned_kids) == 1: # if only one kid is assigned to the chore, update the chore state to new state 1:1 chore_info["state"] = new_state elif len(assigned_kids) > 1: # For chores assigned to multiple kids, you have to figure out the global state count_pending = count_claimed = count_approved = count_overdue = 0 for kid_id in assigned_kids: kid_info = self.kids_data.get(kid_id, {}) if chore_id in kid_info.get("overdue_chores", []): count_overdue += 1 elif chore_id in kid_info.get("approved_chores", []): count_approved += 1 elif chore_id in kid_info.get("claimed_chores", []): count_claimed += 1 else: count_pending += 1 total = len(assigned_kids) # If all kids are in the same state, update the chore state to new state 1:1 if ( count_pending == total or count_claimed == total or count_approved == total or count_overdue == total ): chore_info["state"] = new_state # For shared chores, recompute global state of a partial if they aren't all in the same state as checked above elif chore_info.get("shared_chore", False): if count_overdue > 0: chore_info["state"] = CHORE_STATE_OVERDUE elif count_approved > 0: chore_info["state"] = CHORE_STATE_APPROVED_IN_PART elif count_claimed > 0: chore_info["state"] = CHORE_STATE_CLAIMED_IN_PART else: chore_info["state"] = CHORE_STATE_UNKNOWN # For non-shared chores multiple assign it will be independent if they aren't all in the same state as checked above. elif chore_info.get("shared_chore", False) is False: chore_info["state"] = CHORE_STATE_INDEPENDENT else: chore_info["state"] = CHORE_STATE_UNKNOWN LOGGER.debug( "Chore '%s' global state computed as '%s'", chore_id, chore_info["state"], ) # ------------------------------------------------------------------------------------- # Kids: Update Points # ------------------------------------------------------------------------------------- def update_kid_points(self, kid_id: str, new_points: float): """Set a kid's points to 'new_points', updating daily/weekly/monthly counters.""" kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.warning("Update kid points: Kid ID '%s' not found", kid_id) return old_points = float(kid_info["points"]) delta = new_points - old_points if delta == 0: LOGGER.debug("No change in points for kid '%s'. Skipping updates", kid_id) return kid_info["points"] = new_points kid_info["points_earned_today"] += delta kid_info["points_earned_weekly"] += delta kid_info["points_earned_monthly"] += delta # Update Max Points Ever if new_points > kid_info.get("max_points_ever", 0): kid_info["max_points_ever"] = new_points # Check Badges self._check_badges_for_kid(kid_id) self._check_achievements_for_kid(kid_id) self._check_challenges_for_kid(kid_id) self._persist() self.async_set_updated_data(self._data) LOGGER.debug( "update_kid_points: Kid '%s' changed from %.2f to %.2f (delta=%.2f)", kid_id, old_points, new_points, delta, ) # ------------------------------------------------------------------------------------- # Rewards: Redeem, Approve, Disapprove # ------------------------------------------------------------------------------------- def redeem_reward(self, parent_name: str, kid_id: str, reward_id: str): """Kid claims a reward => mark as pending approval (no deduction yet).""" reward = self.rewards_data.get(reward_id) if not reward: raise HomeAssistantError(f"Reward with ID '{reward_id}' not found.") kid_info = self.kids_data.get(kid_id) if not kid_info: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") cost = reward.get("cost", 0.0) if kid_info["points"] < cost: raise HomeAssistantError( f"'{kid_info['name']}' does not have enough points ({cost} needed)." ) kid_info.setdefault("pending_rewards", []).append(reward_id) kid_info.setdefault("redeemed_rewards", []) # Add to pending approvals self._data[DATA_PENDING_REWARD_APPROVALS].append( { "kid_id": kid_id, "reward_id": reward_id, "timestamp": dt_util.utcnow().isoformat(), } ) # increment reward_claims counter if reward_id in kid_info["reward_claims"]: kid_info["reward_claims"][reward_id] += 1 else: kid_info["reward_claims"][reward_id] = 1 # Send a notification to the parents that a kid claimed a reward actions = [ { "action": f"{ACTION_APPROVE_REWARD}|{kid_id}|{reward_id}", "title": ACTION_TITLE_APPROVE, }, { "action": f"{ACTION_DISAPPROVE_REWARD}|{kid_id}|{reward_id}", "title": ACTION_TITLE_DISAPPROVE, }, { "action": f"{ACTION_REMIND_30}|{kid_id}|{reward_id}", "title": ACTION_TITLE_REMIND_30, }, ] extra_data = {"kid_id": kid_id, "reward_id": reward_id} self.hass.async_create_task( self._notify_parents( kid_id, title="KidsChores: Reward Claimed", message=f"'{kid_info['name']}' claimed reward '{reward['name']}'", actions=actions, extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def approve_reward(self, parent_name: str, kid_id: str, reward_id: str): """Parent approves the reward => deduct points.""" kid_info = self.kids_data.get(kid_id) if not kid_info: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") reward = self.rewards_data.get(reward_id) if not reward: raise HomeAssistantError(f"Reward with ID '{reward_id}' not found.") cost = reward.get("cost", 0.0) if reward_id in kid_info.get("pending_rewards", []): if kid_info["points"] < cost: raise HomeAssistantError( f"'{kid_info['name']}' does not have enough points to redeem '{reward['name']}'." ) # Deduct new_points = float(kid_info["points"]) - cost self.update_kid_points(kid_id, new_points) kid_info["pending_rewards"].remove(reward_id) kid_info["redeemed_rewards"].append(reward_id) else: # direct approval scenario if kid_info["points"] < cost: raise HomeAssistantError( f"'{kid_info['name']}' does not have enough points to redeem '{reward['name']}'." ) kid_info["points"] -= cost kid_info["redeemed_rewards"].append(reward_id) self._check_badges_for_kid(kid_id) # remove 1 claim from pending approvals approvals = self._data[DATA_PENDING_REWARD_APPROVALS] for i, ap in enumerate(approvals): if ap["kid_id"] == kid_id and ap["reward_id"] == reward_id: del approvals[i] # Remove only the first match break # Stop after the first removal # increment reward_approvals if reward_id in kid_info["reward_approvals"]: kid_info["reward_approvals"][reward_id] += 1 else: kid_info["reward_approvals"][reward_id] = 1 # Send a notification to the kid that reward was approved extra_data = {"kid_id": kid_id, "reward_id": reward_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Reward Approved", message=f"Your reward '{reward['name']}' was approved.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def disapprove_reward(self, parent_name: str, kid_id: str, reward_id: str): """Disapprove a reward for kid_id.""" reward = self.rewards_data.get(reward_id) if not reward: raise HomeAssistantError(f"Reward with ID '{reward_id}' not found.") # remove from pending approvals self._data[DATA_PENDING_REWARD_APPROVALS] = [ ap for ap in self._data[DATA_PENDING_REWARD_APPROVALS] if not (ap["kid_id"] == kid_id and ap["reward_id"] == reward_id) ] kid_info = self.kids_data.get(kid_id) if kid_info and reward_id in kid_info.get("pending_rewards", []): kid_info["pending_rewards"].remove(reward_id) # Send a notification to the kid that reward was disapproved extra_data = {"kid_id": kid_id, "reward_id": reward_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Reward Disapproved", message=f"Your reward '{reward['name']}' was disapproved.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------------------- # Badges: Add, Check, Award # ------------------------------------------------------------------------------------- def add_badge(self, badge_def: dict[str, Any]): """Add new badge at runtime if needed.""" badge_name = badge_def.get("name") if not badge_name: LOGGER.warning("Add badge: Badge must have a name") return if any(b["name"] == badge_name for b in self.badges_data.values()): LOGGER.warning("Add badge: Badge '%s' already exists", badge_name) return internal_id = str(uuid.uuid4()) self.badges_data[internal_id] = { "name": badge_name, "threshold_type": badge_def.get( "threshold_type", BADGE_THRESHOLD_TYPE_POINTS ), "threshold_value": badge_def.get( "threshold_value", DEFAULT_BADGE_THRESHOLD ), "chore_count_type": badge_def.get("chore_count_type", FREQUENCY_DAILY), "earned_by": [], "points_multiplier": badge_def.get( "points_multiplier", DEFAULT_POINTS_MULTIPLIER ), "icon": badge_def.get("icon", DEFAULT_ICON), "description": badge_def.get("description", ""), "internal_id": internal_id, } LOGGER.debug("Added new badge '%s' with ID: %s", badge_name, internal_id) self._persist() self.async_set_updated_data(self._data) def _check_badges_for_kid(self, kid_id: str): """Evaluate all badge thresholds for kid.""" kid_info = self.kids_data.get(kid_id) if not kid_info: return for badge_id, badge_data in self.badges_data.items(): if kid_id in badge_data.get("earned_by", []): continue # already earned threshold_type = badge_data.get("threshold_type") threshold_val = badge_data.get("threshold_value", 0) if threshold_type == BADGE_THRESHOLD_TYPE_POINTS: if kid_info["points"] >= threshold_val: self._award_badge(kid_id, badge_id) elif threshold_type == BADGE_THRESHOLD_TYPE_CHORE_COUNT: ctype = badge_data.get("chore_count_type", FREQUENCY_DAILY) if ctype == "total": ccount = kid_info.get("completed_chores_total", 0) else: ccount = kid_info.get(f"completed_chores_{ctype}", 0) if ccount >= threshold_val: self._award_badge(kid_id, badge_id) def _award_badge(self, kid_id: str, badge_id: str): """Add the badge to kid's 'earned_by' and kid's 'badges' list.""" badge = self.badges_data.get(badge_id) if not badge: LOGGER.error( "Attempted to award non-existent badge ID '%s' to kid ID '%s'", badge_id, kid_id, ) return if kid_id in badge.get("earned_by", []): return # already earned badge.setdefault("earned_by", []).append(kid_id) kid_info = self.kids_data.get(kid_id, {}) if badge["name"] not in kid_info.get("badges", []): kid_info.setdefault("badges", []).append(badge["name"]) self._update_kid_multiplier(kid_id) badge_name = badge["name"] kid_name = kid_info["name"] # Send a notification to the kid and parents that a new badge was earned extra_data = {"kid_id": kid_id, "badge_id": badge_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Badge Earned", message=f"You earned a new badge: '{badge_name}'!", extra_data=extra_data, ) ) self.hass.async_create_task( self._notify_parents( kid_id, title="KidsChores: Badge Earned", message=f"'{kid_name}' earned a new badge: '{badge_name}'.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def _update_kid_multiplier(self, kid_id: str): """Update the kid's points multiplier based on highest badge achieved.""" kid_info = self.kids_data.get(kid_id) if not kid_info: return earned_badges = [ b for b in self.badges_data.values() if kid_id in b.get("earned_by", []) ] if not earned_badges: kid_info["points_multiplier"] = 1.0 return highest_mult = max(b.get("points_multiplier", 1.0) for b in earned_badges) kid_info["points_multiplier"] = highest_mult def _recalculate_all_badges(self): """Global re-check of all badges for all kids.""" LOGGER.info("Starting global badge recalculation") ## Clear current references # for _, badge_info in self.badges_data.items(): # badge_info["earned_by"] = [] # for _, kid_info in self.kids_data.items(): # kid_info["badges"] = [] # Re-check thresholds for badge_id, badge_info in self.badges_data.items(): ttype = badge_info.get("threshold_type", BADGE_THRESHOLD_TYPE_POINTS) tval = badge_info.get("threshold_value", 0) for kid_id, kid_info in self.kids_data.items(): if ttype == BADGE_THRESHOLD_TYPE_POINTS: if kid_info.get("max_points_ever", 0.0) >= tval: self._award_badge(kid_id, badge_id) elif ttype == BADGE_THRESHOLD_TYPE_CHORE_COUNT: ctype = badge_info.get("chore_count_type", FREQUENCY_DAILY) if ctype == "total": ccount = kid_info.get("completed_chores_total", 0) else: ccount = kid_info.get(f"completed_chores_{ctype}", 0) if ccount >= tval: self._award_badge(kid_id, badge_id) self._persist() self.async_set_updated_data(self._data) LOGGER.info("Badge recalculation complete") # ------------------------------------------------------------------------------------- # Penalties: Apply, Add # ------------------------------------------------------------------------------------- def apply_penalty(self, parent_name: str, kid_id: str, penalty_id: str): """Apply penalty => negative points to reduce kid's points.""" penalty = self.penalties_data.get(penalty_id) if not penalty: raise HomeAssistantError(f"Penalty with ID '{penalty_id}' not found.") kid_info = self.kids_data.get(kid_id) if not kid_info: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") penalty_pts = penalty.get("points", 0) new_points = float(kid_info["points"]) + penalty_pts self.update_kid_points(kid_id, new_points) # increment penalty_applies if penalty_id in kid_info["penalty_applies"]: kid_info["penalty_applies"][penalty_id] += 1 else: kid_info["penalty_applies"][penalty_id] = 1 # Send a notification to the kid that a penalty was applied extra_data = {"kid_id": kid_id, "penalty_id": penalty_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Penalty Applied", message=f"A '{penalty['name']}' penalty was applied. Your points changed by {penalty_pts}.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def add_penalty(self, penalty_def: dict[str, Any]): """Add new penalty at runtime if needed.""" penalty_name = penalty_def.get("name") if not penalty_name: LOGGER.warning("Add penalty: Penalty must have a name") return if any(p["name"] == penalty_name for p in self.penalties_data.values()): LOGGER.warning("Add penalty: Penalty '%s' already exists", penalty_name) return internal_id = str(uuid.uuid4()) self.penalties_data[internal_id] = { "name": penalty_name, "points": penalty_def.get("points", -DEFAULT_PENALTY_POINTS), "description": penalty_def.get("description", ""), "icon": penalty_def.get("icon", DEFAULT_PENALTY_ICON), "internal_id": internal_id, } LOGGER.debug("Added new penalty '%s' with ID: %s", penalty_name, internal_id) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------- # Bonuses: Apply, Add # ------------------------------------------------------------------------- def apply_bonus(self, parent_name: str, kid_id: str, bonus_id: str): """Apply bonus => positive points to increase kid's points.""" bonus = self.bonuses_data.get(bonus_id) if not bonus: raise HomeAssistantError(f"Bonus with ID '{bonus_id}' not found.") kid_info = self.kids_data.get(kid_id) if not kid_info: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") bonus_pts = bonus.get("points", 0) new_points = float(kid_info["points"]) + bonus_pts self.update_kid_points(kid_id, new_points) # increment bonus_applies if bonus_id in kid_info["bonus_applies"]: kid_info["bonus_applies"][bonus_id] += 1 else: kid_info["bonus_applies"][bonus_id] = 1 # Send a notification to the kid that a bonus was applied extra_data = {"kid_id": kid_id, "bonus_id": bonus_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Bonus Applied", message=f"A '{bonus['name']}' bonus was applied. Your points changed by {bonus_pts}.", extra_data=extra_data, ) ) self._persist() self.async_set_updated_data(self._data) def add_bonus(self, bonus_def: dict[str, Any]): """Add new bonus at runtime if needed.""" bonus_name = bonus_def.get("name") if not bonus_name: LOGGER.warning("Add bonus: Bonus must have a name") return if any(s["name"] == bonus_name for s in self.bonuses_data.values()): LOGGER.warning("Add bonus: Bonus '%s' already exists", bonus_name) return internal_id = str(uuid.uuid4()) self.bonuses_data[internal_id] = { "name": bonus_name, "points": bonus_def.get("points", DEFAULT_BONUS_POINTS), "description": bonus_def.get("description", ""), "icon": bonus_def.get("icon", DEFAULT_BONUS_ICON), "internal_id": internal_id, } LOGGER.debug("Added new bonus '%s' with ID: %s", bonus_name, internal_id) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------- # Achievements: Check, Award # ------------------------------------------------------------------------- def _check_achievements_for_kid(self, kid_id: str): """Evaluate all achievement criteria for a given kid. For each achievement not already awarded, check its type and update progress accordingly. """ kid_info = self.kids_data.get(kid_id) if not kid_info: return now_date = dt_util.as_local(dt_util.utcnow()).date() for achievement_id, achievement in self._data[DATA_ACHIEVEMENTS].items(): progress = achievement.setdefault("progress", {}) if kid_id in progress and progress[kid_id].get("awarded", False): continue ach_type = achievement.get("type") target = achievement.get("target_value", 1) # For a streak achievement, update a streak counter: if ach_type == ACHIEVEMENT_TYPE_STREAK: progress = progress.setdefault( kid_id, {"current_streak": 0, "last_date": None, "awarded": False} ) self._update_streak_progress(progress, now_date) if progress["current_streak"] >= target: self._award_achievement(kid_id, achievement_id) # For a total achievement, simply compare total completed chores: elif ach_type == ACHIEVEMENT_TYPE_TOTAL: # Get per–kid progress for this achievement. progress = achievement.setdefault("progress", {}).setdefault( kid_id, {"baseline": None, "current_value": 0, "awarded": False} ) # Set the baseline so that we only count chores done after deployment. if "baseline" not in progress or progress["baseline"] is None: progress["baseline"] = kid_info.get("completed_chores_total", 0) # Calculate progress as (current total minus baseline) current_total = kid_info.get("completed_chores_total", 0) progress["current_value"] = current_total effective_target = progress["baseline"] + target if current_total >= effective_target: self._award_achievement(kid_id, achievement_id) # For daily minimum achievement, compare total daily chores: elif ach_type == ACHIEVEMENT_TYPE_DAILY_MIN: # Initialize progress for this achievement if missing. progress = achievement.setdefault("progress", {}).setdefault( kid_id, {"last_awarded_date": None, "awarded": False} ) today = dt_util.as_local(dt_util.utcnow()).date().isoformat() # Only award bonus if not awarded today AND the kid's daily count meets the threshold. if ( progress.get("last_awarded_date") != today and kid_info.get("completed_chores_today", 0) >= target ): self._award_achievement(kid_id, achievement_id) progress["last_awarded_date"] = today def _award_achievement(self, kid_id: str, achievement_id: str): """Award the achievement to the kid. Update the achievement progress to indicate it is earned, and send notifications to both the kid and their parents. """ achievement = self.achievements_data.get(achievement_id) if not achievement: LOGGER.error( "Attempted to award non-existent achievement '%s'", achievement_id ) return # Get or create the existing progress dictionary for this kid progress_for_kid = achievement.setdefault("progress", {}).get(kid_id) if progress_for_kid is None: # If it doesn't exist, initialize it with baseline from the kid's current total. kid_info = self.kids_data.get(kid_id, {}) progress_dict = { "baseline": kid_info.get("completed_chores_total", 0), "current_value": 0, "awarded": False, } achievement["progress"][kid_id] = progress_dict progress_for_kid = progress_dict # Mark achievement as earned for the kid by storing progress (e.g. set to target) progress_for_kid["awarded"] = True progress_for_kid["current_value"] = achievement.get("target_value", 1) # Award the extra reward points defined in the achievement extra_points = achievement.get("reward_points", 0) kid_info = self.kids_data.get(kid_id) if kid_info is not None: new_points = float(kid_info["points"]) + extra_points self.update_kid_points(kid_id, new_points) # Notify kid and parents extra_data = {"kid_id": kid_id, "achievement_id": achievement_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Achievement Earned", message=f"You have earned the achievement: '{achievement.get('name')}'.", extra_data=extra_data, ) ) self.hass.async_create_task( self._notify_parents( kid_id, title="KidsChores: Achievement Earned", message=f"{self.kids_data[kid_id]['name']} has earned the achievement: '{achievement.get('name')}'.", extra_data=extra_data, ) ) LOGGER.info( "Awarded achievement '%s' to kid '%s'", achievement.get("name"), kid_id ) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------- # Challenges: Check, Award # ------------------------------------------------------------------------- def _check_challenges_for_kid(self, kid_id: str): """Evaluate all challenge criteria for a given kid. Checks that the challenge is active and then updates progress. """ kid_info = self.kids_data.get(kid_id) if not kid_info: return now = dt_util.utcnow() for challenge_id, challenge in self.challenges_data.items(): progress = challenge.setdefault("progress", {}) if kid_id in progress and progress[kid_id].get("awarded", False): continue # Check challenge window start_date_raw = challenge.get("start_date") if isinstance(start_date_raw, str): start = dt_util.parse_datetime(start_date_raw) else: start = None end_date_raw = challenge.get("end_date") if isinstance(end_date_raw, str): end = dt_util.parse_datetime(end_date_raw) else: end = None if start and now < start: continue if end and now > end: continue target = challenge.get("target_value", 1) challenge_type = challenge.get("type") # For a total count challenge: if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: progress = progress.setdefault(kid_id, {"count": 0, "awarded": False}) if progress["count"] >= target: self._award_challenge(kid_id, challenge_id) # For a daily minimum challenge, you might store per-day counts: elif challenge_type == CHALLENGE_TYPE_DAILY_MIN: progress = progress.setdefault( kid_id, {"daily_counts": {}, "awarded": False} ) required_daily = challenge.get("required_daily", 1) start = dt_util.parse_datetime(challenge.get("start_date")) end = dt_util.parse_datetime(challenge.get("end_date")) if start and end: num_days = (end - start).days + 1 # Verify for each day: success = True for n in range(num_days): day = (start + timedelta(days=n)).date().isoformat() if progress["daily_counts"].get(day, 0) < required_daily: success = False break if success: self._award_challenge(kid_id, challenge_id) def _award_challenge(self, kid_id: str, challenge_id: str): """Award the challenge to the kid. Update progress and notify kid/parents. """ challenge = self.challenges_data.get(challenge_id) if not challenge: LOGGER.error("Attempted to award non-existent challenge '%s'", challenge_id) return # Get or create the existing progress dictionary for this kid progress_for_kid = challenge.setdefault("progress", {}).setdefault( kid_id, {"count": 0, "awarded": False} ) # Mark challenge as earned for the kid by storing progress progress_for_kid["awarded"] = True progress_for_kid["count"] = challenge.get("target_value", 1) # Award extra reward points from the challenge extra_points = challenge.get("reward_points", 0) kid_info = self.kids_data.get(kid_id) if kid_info is not None: new_points = float(kid_info["points"]) + extra_points self.update_kid_points(kid_id, new_points) # Notify kid and parents extra_data = {"kid_id": kid_id, "challenge_id": challenge_id} self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Challenge Completed", message=f"You have completed the challenge: '{challenge.get('name')}'.", extra_data=extra_data, ) ) self.hass.async_create_task( self._notify_parents( kid_id, title="KidsChores: Challenge Completed", message=f"{self.kids_data[kid_id]['name']} has completed the challenge: '{challenge.get('name')}'.", extra_data=extra_data, ) ) LOGGER.info("Awarded challenge '%s' to kid '%s'", challenge.get("name"), kid_id) self._persist() self.async_set_updated_data(self._data) def _update_streak_progress(self, progress: dict, today: datetime.date): """Update a streak progress dict. If the last approved date was yesterday, increment the streak. Otherwise, reset to 1. """ last_date = None if progress.get("last_date"): # Parse the stored ISO string using Home Assistant's dt_util last_dt = dt_util.parse_datetime(progress["last_date"]) if last_dt: # Convert to local time and get the date portion last_date = dt_util.as_local(last_dt).date() if last_date == today: # Already updated today – do nothing return elif last_date == today - timedelta(days=1): progress["current_streak"] += 1 else: progress["current_streak"] = 1 progress["last_date"] = today.isoformat() def _update_chore_streak_for_kid( self, kid_id: str, chore_id: str, completion_date: datetime.date ): """Update (or initialize) the streak for a specific chore for a kid, and update the max streak achieved so far.""" kid_info = self.kids_data.get(kid_id) if not kid_info: return # Ensure a streak dictionary exists if "chore_streaks" not in kid_info: kid_info["chore_streaks"] = {} # Initialize the streak record if not already present streak = kid_info["chore_streaks"].get( chore_id, {"current_streak": 0, "max_streak": 0, "last_date": None} ) last_date = None if streak["last_date"]: try: last_date = datetime.fromisoformat(streak["last_date"]).date() except Exception: pass if last_date == completion_date - timedelta(days=1): streak["current_streak"] += 1 else: streak["current_streak"] = 1 streak["last_date"] = completion_date.isoformat() # Update the maximum streak if the current streak is higher. if streak["current_streak"] > streak.get("max_streak", 0): streak["max_streak"] = streak["current_streak"] kid_info["chore_streaks"][chore_id] = streak def _update_overall_chore_streak(self, kid_id: str, completion_date: datetime.date): """Update the overall streak for a kid (days in a row with at least one approved chore).""" kid_info = self.kids_data.get(kid_id) if not kid_info: return last_date = None if "last_chore_date" in kid_info and kid_info["last_chore_date"]: try: last_date = datetime.fromisoformat(kid_info["last_chore_date"]).date() except Exception: pass if last_date == completion_date - timedelta(days=1): kid_info["overall_chore_streak"] = ( kid_info.get("overall_chore_streak", 0) + 1 ) else: kid_info["overall_chore_streak"] = 1 kid_info["last_chore_date"] = completion_date.isoformat() # ------------------------------------------------------------------------------------- # Recurring / Reset / Overdue # ------------------------------------------------------------------------------------- async def _check_overdue_chores(self): """Check and mark overdue chores if due date is passed. Send an overdue notification only if not sent in the last 24 hours. """ now = dt_util.utcnow() LOGGER.debug("Starting overdue check at %s", now.isoformat()) for chore_id, chore_info in self.chores_data.items(): # LOGGER.debug("Checking chore '%s' id '%s' (state=%s)", chore_info.get("name"), chore_id, chore_info.get("state")) # Get the list of assigned kids assigned_kids = chore_info.get("assigned_kids", []) # LOGGER.debug("Chore '%s' id '%s' assigned to kids: %s", chore_info.get("name"), chore_id, assigned_kids,) # Check if all assigned kids have either claimed or approved the chore all_kids_claimed_or_approved = all( chore_id in self.kids_data.get(kid_id, {}).get("claimed_chores", []) or chore_id in self.kids_data.get(kid_id, {}).get("approved_chores", []) for kid_id in assigned_kids ) # Debugging: Log the claim/approval status of each assigned kid for kid_id in assigned_kids: kid_info = self.kids_data.get(kid_id, {}) has_claimed = chore_id in kid_info.get("claimed_chores", []) has_approved = chore_id in kid_info.get("approved_chores", []) # LOGGER.debug("Kid '%s': claimed=%s, approved=%s", kid_id, has_claimed, has_approved # Log the overall result of the check # LOGGER.debug("Chore '%s': all_kids_claimed_or_approved=%s", chore_id, all_kids_claimed_or_approved) # Only skip the chore if ALL assigned kids have acted on it if all_kids_claimed_or_approved: # LOGGER.debug("Skipping chore '%s': all assigned kids have claimed or approved", chore_id,) continue due_str = chore_info.get("due_date") if not due_str: LOGGER.debug( "Chore '%s' has no due_date; checking to confirm it isn't overdue; then skipping if not", chore_id, ) # If it has no due date, but is overdue, it should be marked as pending if chore_info.get("state") == CHORE_STATE_OVERDUE: self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING) continue try: due_date = dt_util.parse_datetime(due_str) if due_date is None: raise ValueError("Parsed datetime is None") due_date = dt_util.as_utc(due_date) # LOGGER.debug("Chore '%s' due_date parsed as %s", chore_id, due_date.isoformat()) except Exception as err: LOGGER.error( "Error parsing due_date '%s' for chore '%s': %s", due_str, chore_id, err, ) continue # Check for applicable day is no longer required; the scheduling function ensures due_date matches applicable day criteria. # LOGGER.debug("Chore '%s': now=%s, due_date=%s", chore_id, now.isoformat(), due_date.isoformat() if now < due_date: # Not past due date, but before resetting the state back to pending, check if global state is currently overdue for kid_id in assigned_kids: if chore_id in kid_info.get("overdue_chores", []): self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING) LOGGER.debug( "Chore '%s' status is overdue but not yet due; cleared overdue flags", chore_id, ) continue # Handling for overdue is the same for shared and non-shared chores # Status and global status will be determined by the chore state processor assigned_kids = chore_info.get("assigned_kids", []) for kid_id in assigned_kids: kid_info = self.kids_data.get(kid_id, {}) # Skip if kid already claimed/approved on the chore. if chore_id in kid_info.get( "claimed_chores", [] ) or chore_id in kid_info.get("approved_chores", []): continue # Mark chore as overdue for this kid. self._process_chore_state(kid_id, chore_id, CHORE_STATE_OVERDUE) LOGGER.debug( "Marking chore '%s' as overdue for kid '%s'", chore_id, kid_id ) # Check notification timestamp. last_notif_str = kid_info["overdue_notifications"].get(chore_id) notify = False if last_notif_str: try: last_dt = dt_util.parse_datetime(last_notif_str) if ( (not last_dt) or (last_dt < due_date) or ((now - last_dt) >= timedelta(hours=24)) ): notify = True else: LOGGER.debug( "Chore '%s' for kid '%s' already notified within 24 hours", chore_id, kid_id, ) except Exception as err: LOGGER.error( "Error parsing overdue notification '%s' for chore '%s', kid '%s': %s", last_notif_str, chore_id, kid_id, err, ) notify = True else: notify = True if notify: kid_info["overdue_notifications"][chore_id] = now.isoformat() extra_data = {"kid_id": kid_id, "chore_id": chore_id} actions = [ { "action": f"{ACTION_APPROVE_CHORE}|{kid_id}|{chore_id}", "title": ACTION_TITLE_APPROVE, }, { "action": f"{ACTION_DISAPPROVE_CHORE}|{kid_id}|{chore_id}", "title": ACTION_TITLE_DISAPPROVE, }, { "action": f"{ACTION_REMIND_30}|{kid_id}|{chore_id}", "title": ACTION_TITLE_REMIND_30, }, ] LOGGER.debug( "Sending overdue notification for chore '%s' to kid '%s'", chore_id, kid_id, ) self.hass.async_create_task( self._notify_kid( kid_id, title="KidsChores: Chore Overdue", message=f"Your chore '{chore_info.get('name', 'Unnamed Chore')}' is overdue", extra_data=extra_data, ) ) self.hass.async_create_task( self._notify_parents( kid_id, title="KidsChores: Chore Overdue", message=f"{self._get_kid_name_by_id(kid_id)}'s chore '{chore_info.get('name', 'Unnamed Chore')}' is overdue", actions=actions, extra_data=extra_data, ) ) LOGGER.debug("Overdue check completed") async def _reset_all_chore_counts(self, now: datetime): """Trigger resets based on the current time for all frequencies.""" await self._handle_recurring_chore_resets(now) await self._reset_daily_reward_statuses() await self._check_overdue_chores() for kid in self.kids_data.values(): kid["today_chore_approvals"] = {} async def _handle_recurring_chore_resets(self, now: datetime): """Handle recurring resets for daily, weekly, and monthly frequencies.""" await self._reschedule_recurring_chores(now) # Daily if now.hour == DEFAULT_DAILY_RESET_TIME.get("hour", 0): await self._reset_chore_counts(FREQUENCY_DAILY, now) # Weekly if now.weekday() == DEFAULT_WEEKLY_RESET_DAY: await self._reset_chore_counts(FREQUENCY_WEEKLY, now) # Monthly days_in_month = monthrange(now.year, now.month)[1] reset_day = min(DEFAULT_MONTHLY_RESET_DAY, days_in_month) if now.day == reset_day: await self._reset_chore_counts(FREQUENCY_MONTHLY, now) async def _reset_chore_counts(self, frequency: str, now: datetime): """Reset chore counts and statuses based on the recurring frequency.""" # Reset counters on kids for kid_info in self.kids_data.values(): if frequency == FREQUENCY_DAILY: kid_info["completed_chores_today"] = 0 kid_info["points_earned_today"] = 0.0 elif frequency == FREQUENCY_WEEKLY: kid_info["completed_chores_weekly"] = 0 kid_info["points_earned_weekly"] = 0.0 elif frequency == FREQUENCY_MONTHLY: kid_info["completed_chores_monthly"] = 0 kid_info["points_earned_monthly"] = 0.0 LOGGER.info(f"{frequency.capitalize()} chore counts have been reset") # If daily reset -> reset statuses if frequency == FREQUENCY_DAILY: await self._reset_daily_chore_statuses([frequency]) elif frequency == FREQUENCY_WEEKLY: await self._reset_daily_chore_statuses([frequency, FREQUENCY_WEEKLY]) async def _reschedule_recurring_chores(self, now: datetime): """For chores with the given recurring frequency, reschedule due date if they are approved and past due.""" for chore_id, chore_info in self.chores_data.items(): # Only consider chores with a recurring frequency (any of the three) and a defined due_date: if chore_info.get("recurring_frequency") not in ( FREQUENCY_DAILY, FREQUENCY_WEEKLY, FREQUENCY_BIWEEKLY, FREQUENCY_MONTHLY, FREQUENCY_CUSTOM, ): continue if not chore_info.get("due_date"): continue try: due_date = dt_util.parse_datetime( chore_info["due_date"] ) or datetime.fromisoformat(chore_info["due_date"]) except Exception as e: LOGGER.warning("Error parsing due_date for chore '%s': %s", chore_id, e) continue # If the due date is in the past and the chore is approved or approved_in_part if now > due_date and chore_info.get("state") in [ CHORE_STATE_APPROVED, CHORE_STATE_APPROVED_IN_PART, ]: # Reschedule the chore self._reschedule_next_due_date(chore_info) LOGGER.debug( "Rescheduled recurring chore '%s'", chore_info.get("name", chore_id) ) self._persist() self.async_set_updated_data(self._data) LOGGER.debug("Daily rescheduling of recurring chores complete") async def _reset_daily_chore_statuses(self, target_freqs: list[str]): """Reset chore statuses and clear approved/claimed chores for chores with these freq.""" LOGGER.info("Executing _reset_daily_chore_statuses") now = dt_util.utcnow() for chore_id, chore_info in self.chores_data.items(): frequency = chore_info.get("recurring_frequency", FREQUENCY_NONE) # Only consider chores whose frequency is either in target_freqs or FREQUENCY_NONE. if frequency in target_freqs or frequency == FREQUENCY_NONE: due_date_str = chore_info.get("due_date") if due_date_str: try: due_date = dt_util.parse_datetime( due_date_str ) or datetime.fromisoformat(due_date_str) # If the due date has not yet been reached, skip resetting this chore. if now < due_date: continue except Exception as e: LOGGER.warning( "Error parsing due_date '%s' for chore '%s': %s", due_date_str, chore_id, e, ) # If no due date or the due date has passed, then reset the chore state if chore_info["state"] not in [ CHORE_STATE_PENDING, CHORE_STATE_OVERDUE, ]: previous_state = chore_info["state"] for kid_id in chore_info.get("assigned_kids", []): if kid_id: self._process_chore_state( kid_id, chore_id, CHORE_STATE_PENDING ) LOGGER.debug( "Resetting chore '%s' from '%s' to '%s'", chore_id, previous_state, CHORE_STATE_PENDING, ) # clear pending chore approvals target_chore_ids = [ chore_id for chore_id, chore_info in self.chores_data.items() if chore_info.get("recurring_frequency") in target_freqs ] self._data[DATA_PENDING_CHORE_APPROVALS] = [ ap for ap in self._data[DATA_PENDING_CHORE_APPROVALS] if ap["chore_id"] not in target_chore_ids ] self._persist() async def _reset_daily_reward_statuses(self): """Reset all kids' reward states daily.""" # Remove from global pending reward approvals self._data[DATA_PENDING_REWARD_APPROVALS] = [] LOGGER.debug("Cleared all pending reward approvals globally") # For each kid, clear pending/approved reward lists to reflect daily reset for kid_id, kid_info in self.kids_data.items(): kid_info["pending_rewards"] = [] kid_info["redeemed_rewards"] = [] LOGGER.debug( "Cleared daily reward statuses for kid ID '%s' (%s)", kid_id, kid_info.get("name", "Unknown"), ) self._persist() self.async_set_updated_data(self._data) LOGGER.info("Daily reward statuses have been reset") def _reschedule_next_due_date(self, chore_info: dict[str, Any]): """Reschedule the next due date based on the recurring frequency.""" freq = chore_info.get("recurring_frequency", FREQUENCY_NONE) if freq == FREQUENCY_CUSTOM: custom_interval = chore_info.get("custom_interval") custom_unit = chore_info.get("custom_interval_unit") if custom_interval is None or custom_unit not in [ "days", "weeks", "months", ]: LOGGER.warning( "Custom frequency set but custom_interval or unit invalid for chore '%s'", chore_info.get("name"), ) return due_date_str = chore_info.get("due_date") if not freq or freq == FREQUENCY_NONE or not due_date_str: LOGGER.debug( "Skipping reschedule: recurring_frequency=%s, due_date=%s", freq, due_date_str, ) return try: original_due = dt_util.parse_datetime(due_date_str) if not original_due: original_due = datetime.fromisoformat(due_date_str) except ValueError: LOGGER.warning("Unable to parse due_date '%s'", due_date_str) return applicable_days = chore_info.get(CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS) weekday_mapping = {i: key for i, key in enumerate(WEEKDAY_OPTIONS.keys())} # Convert next_due to local time for proper weekday checking now = dt_util.utcnow() now_local = dt_util.as_local(now) next_due = original_due next_due_local = dt_util.as_local(next_due) # Track first iteration to allow one advancement for future dates first_iteration = True # Ensure the next due date is advanced even if it's already scheduled in the future # Handle past due_date by looping until we find a future date that is also on an applicable day while ( first_iteration or next_due_local <= now_local or ( applicable_days and weekday_mapping[next_due_local.weekday()] not in applicable_days ) ): # If next_due is still in the past, increment by the full frequency period if first_iteration or (next_due_local <= now_local): if freq == FREQUENCY_DAILY: next_due += timedelta(days=1) elif freq == FREQUENCY_WEEKLY: next_due += timedelta(weeks=1) elif freq == FREQUENCY_BIWEEKLY: next_due += timedelta(weeks=2) elif freq == FREQUENCY_MONTHLY: next_due = self._add_months(next_due, 1) elif freq == FREQUENCY_CUSTOM: if custom_unit == "days": next_due += timedelta(days=custom_interval) elif custom_unit == "weeks": next_due += timedelta(weeks=custom_interval) elif custom_unit == "months": next_due = self._add_months(next_due, custom_interval) else: # Next due is in the future but not on an applicable day, # so just add one day until it falls on an applicable day. next_due += timedelta(days=1) # After first loop, only move forward if necessary first_iteration = False # Update the local time reference for the new next_due next_due_local = dt_util.as_local(next_due) LOGGER.debug( "Rescheduling chore: Original Due: %s, New Attempt: %s (Local: %s), Now: %s (Local: %s), Weekday: %s, Applicable Days: %s", original_due, next_due, next_due_local, now, now_local, weekday_mapping[next_due_local.weekday()], applicable_days, ) chore_info["due_date"] = next_due.isoformat() chore_id = chore_info.get("internal_id") # Update config_entry.options for this chore so that the new due_date is visible in Options self.hass.async_create_task( self._update_chore_due_date_in_config( chore_id, chore_info["due_date"], None, None, None ) ) # Reset the chore state to Pending for kid_id in chore_info.get("assigned_kids", []): if kid_id: self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING) LOGGER.info( "Chore '%s' rescheduled: Original due date %s, Final new due date (local) %s", chore_info.get("name", chore_id), dt_util.as_local(original_due).isoformat(), next_due_local.isoformat(), ) # Removed the _add_one_month method since _add_months method will handle all cases including adding one month. def _add_months(self, dt_in: datetime, months: int) -> datetime: """Add a specified number of months to a datetime, preserving the day if possible.""" total_month = dt_in.month + months year = dt_in.year + (total_month - 1) // 12 month = ((total_month - 1) % 12) + 1 day = dt_in.day days_in_new_month = monthrange(year, month)[1] if day > days_in_new_month: day = days_in_new_month return dt_in.replace(year=year, month=month, day=day) # Set Chore Due Date def set_chore_due_date(self, chore_id: str, due_date: Optional[datetime]) -> None: """Set the due date of a chore.""" # Retrieve the chore data; raise error if not found. chore_info = self.chores_data.get(chore_id) if chore_info is None: raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.") # Convert the due_date to an ISO-formatted string if provided; otherwise use None. new_due_date = due_date.isoformat() if due_date else None # Update the chore's due date. If the key is missing, add it. try: chore_info["due_date"] = new_due_date except KeyError as err: raise HomeAssistantError( f"Missing 'due_date' key in chore data for '{chore_id}': {err}" ) # If the due date is cleared (None), then remove any recurring frequency # and custom interval settings unless the frequency is none, daily, or weekly. if new_due_date is None: # FREQUENCY_DAILY, FREQUENCY_WEEKLY, and FREQUENCY_NONE are all OK without a due_date current_frequency = chore_info.get("recurring_frequency") if chore_info.get("recurring_frequency") not in ( FREQUENCY_NONE, FREQUENCY_DAILY, FREQUENCY_WEEKLY, ): LOGGER.debug( "Removing frequency for chore '%s': current frequency '%s' is does not work with a due date of None", chore_id, current_frequency, ) chore_info["recurring_frequency"] = FREQUENCY_NONE chore_info.pop("custom_interval", None) chore_info.pop("custom_interval_unit", None) # Update config_entry.options so that the new due date is visible in Options. # Use new_due_date here to ensure we’re passing the updated value. self.hass.async_create_task( self._update_chore_due_date_in_config( chore_id, chore_info.get("due_date"), chore_info.get("recurring_frequency"), chore_info.get("custom_interval"), chore_info.get("custom_interval_unit"), ) ) self._persist() self.async_set_updated_data(self._data) # Skip Chore Due Date def skip_chore_due_date(self, chore_id: str) -> None: """Skip the current due date of a recurring chore and reschedule it.""" chore = self.chores_data.get(chore_id) if not chore: raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.") if chore.get("recurring_frequency", FREQUENCY_NONE) == FREQUENCY_NONE: raise HomeAssistantError( f"Chore '{chore.get('name', chore_id)}' does not have a recurring frequency." ) if not chore.get("due_date"): raise HomeAssistantError( f"Chore '{chore.get('name', chore_id)}' does not have a due date set." ) # Compute the next due date and update the chore options/config. self._reschedule_next_due_date(chore) self._persist() self.async_set_updated_data(self._data) # Reset Overdue Chores def reset_overdue_chores( self, chore_id: Optional[str] = None, kid_id: Optional[str] = None ) -> None: """Reset overdue chore(s) to Pending state and reschedule.""" if chore_id: # Specific chore reset (with or without kid_id) chore = self.chores_data.get(chore_id) if not chore: raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.") # Reschedule happens at the chore level, so it is not necessary to check for kid_id # _rescheduled_next_due_date will also handle setting the status to Pending self._reschedule_next_due_date(chore) elif kid_id: # Kid-only reset: reset all overdue chores for the specified kid. # Note that reschedule happens at the chore level, so it chores assigned to this kid that are multi assigned # will show as reset for those other kids kid = self.kids_data.get(kid_id) if not kid: raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") for cid, chore in self.chores_data.items(): if kid_id in chore.get("assigned_kids", []): if cid in kid.get("overdue_chores", []): # Reschedule chore which will also set status to Pending self._reschedule_next_due_date(chore) else: # Global reset: Reset all chores that are overdue. for kid_id, kid in self.kids_data.items(): for cid, chore in self.chores_data.items(): if kid_id in chore.get("assigned_kids", []): if cid in kid.get("overdue_chores", []): # Reschedule chore which will also set status to Pending self._reschedule_next_due_date(chore) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------------------- # Penalties: Reset # ------------------------------------------------------------------------------------- def reset_penalties( self, kid_id: Optional[str] = None, penalty_id: Optional[str] = None ) -> None: """Reset penalties based on provided kid_id and penalty_id.""" if penalty_id and kid_id: # Reset a specific penalty for a specific kid kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.error("Reset Penalties: Kid with ID '%s' not found.", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") if penalty_id not in kid_info.get("penalty_applies", {}): LOGGER.error( "Reset Penalties: Penalty '%s' does not apply to kid '%s'.", penalty_id, kid_id, ) raise HomeAssistantError( f"Penalty '{penalty_id}' does not apply to kid '{kid_id}'." ) kid_info["penalty_applies"].pop(penalty_id, None) elif penalty_id: # Reset a specific penalty for all kids found = False for kid_info in self.kids_data.values(): if penalty_id in kid_info.get("penalty_applies", {}): found = True kid_info["penalty_applies"].pop(penalty_id, None) if not found: LOGGER.warning( "Reset Penalties: Penalty '%s' not found in any kid's data.", penalty_id, ) elif kid_id: # Reset all penalties for a specific kid kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.error("Reset Penalties: Kid with ID '%s' not found.", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") kid_info["penalty_applies"].clear() else: # Reset all penalties for all kids LOGGER.info("Reset Penalties: Resetting all penalties for all kids.") for kid_info in self.kids_data.values(): kid_info["penalty_applies"].clear() LOGGER.debug( "Penalties reset completed (kid_id=%s, penalty_id=%s)", kid_id, penalty_id ) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------------------- # Bonuses: Reset # ------------------------------------------------------------------------------------- def reset_bonuses( self, kid_id: Optional[str] = None, bonus_id: Optional[str] = None ) -> None: """Reset bonuses based on provided kid_id and bonus_id.""" if bonus_id and kid_id: # Reset a specific bonus for a specific kid kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.error("Reset Bonuses: Kid with ID '%s' not found.", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") if bonus_id not in kid_info.get("bonus_applies", {}): LOGGER.error( "Reset Bonuses: Bonus '%s' does not apply to kid '%s'.", bonus_id, kid_id, ) raise HomeAssistantError( f"Bonus '{bonus_id}' does not apply to kid '{kid_id}'." ) kid_info["bonus_applies"].pop(bonus_id, None) elif bonus_id: # Reset a specific bonus for all kids found = False for kid_info in self.kids_data.values(): if bonus_id in kid_info.get("bonus_applies", {}): found = True kid_info["bonus_applies"].pop(bonus_id, None) if not found: LOGGER.warning( "Reset Bonuses: Bonus '%s' not found in any kid's data.", bonus_id ) elif kid_id: # Reset all bonuses for a specific kid kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.error("Reset Bonuses: Kid with ID '%s' not found.", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") kid_info["bonus_applies"].clear() else: # Reset all bonuses for all kids LOGGER.info("Reset Bonuses: Resetting all bonuses for all kids.") for kid_info in self.kids_data.values(): kid_info["bonus_applies"].clear() LOGGER.debug( "Bonuses reset completed (kid_id=%s, bonus_id=%s)", kid_id, bonus_id ) self._persist() self.async_set_updated_data(self._data) # ------------------------------------------------------------------------------------- # Rewards: Reset # This function resets reward-related data for a specified kid and/or reward by # clearing claims, approvals, redeemed and pending rewards, and removing associated # pending reward approvals from the global data. # ------------------------------------------------------------------------------------- def reset_rewards( self, kid_id: Optional[str] = None, reward_id: Optional[str] = None ) -> None: """Reset rewards based on provided kid_id and reward_id.""" if reward_id and kid_id: # Reset a specific reward for a specific kid kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.error("Reset Rewards: Kid with ID '%s' not found.", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") kid_info["reward_claims"].pop(reward_id, None) kid_info["reward_approvals"].pop(reward_id, None) kid_info["redeemed_rewards"] = [ reward for reward in kid_info["redeemed_rewards"] if reward != reward_id ] kid_info["pending_rewards"] = [ reward for reward in kid_info["pending_rewards"] if reward != reward_id ] # Remove open claims from pending approvals for this kid and reward. self._data[DATA_PENDING_REWARD_APPROVALS] = [ ap for ap in self._data[DATA_PENDING_REWARD_APPROVALS] if not (ap["kid_id"] == kid_id and ap["reward_id"] == reward_id) ] elif reward_id: # Reset a specific reward for all kids found = False for kid_info in self.kids_data.values(): if reward_id in kid_info.get("reward_claims", {}): found = True kid_info["reward_claims"].pop(reward_id, None) if reward_id in kid_info.get("reward_approvals", {}): found = True kid_info["reward_approvals"].pop(reward_id, None) kid_info["redeemed_rewards"] = [ reward for reward in kid_info["redeemed_rewards"] if reward != reward_id ] kid_info["pending_rewards"] = [ reward for reward in kid_info["pending_rewards"] if reward != reward_id ] # Remove open claims from pending approvals for this reward (all kids). self._data[DATA_PENDING_REWARD_APPROVALS] = [ ap for ap in self._data[DATA_PENDING_REWARD_APPROVALS] if ap["reward_id"] != reward_id ] if not found: LOGGER.warning( "Reset Rewards: Reward '%s' not found in any kid's data.", reward_id, ) elif kid_id: # Reset all rewards for a specific kid kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.error("Reset Rewards: Kid with ID '%s' not found.", kid_id) raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.") kid_info["reward_claims"].clear() kid_info["reward_approvals"].clear() kid_info["redeemed_rewards"].clear() kid_info["pending_rewards"].clear() # Remove open claims from pending approvals for that kid. self._data[DATA_PENDING_REWARD_APPROVALS] = [ ap for ap in self._data[DATA_PENDING_REWARD_APPROVALS] if ap["kid_id"] != kid_id ] else: # Reset all rewards for all kids LOGGER.info("Reset Rewards: Resetting all rewards for all kids.") for kid_info in self.kids_data.values(): kid_info["reward_claims"].clear() kid_info["reward_approvals"].clear() kid_info["redeemed_rewards"].clear() kid_info["pending_rewards"].clear() # Clear all pending reward approvals. self._data[DATA_PENDING_REWARD_APPROVALS].clear() LOGGER.debug( "Rewards reset completed (kid_id=%s, reward_id=%s)", kid_id, reward_id ) self._persist() self.async_set_updated_data(self._data) # Persist new due dates on config entries # This is not being used currently, but was refactored so it calls a new function _update_chore_due_date_in_config # which can be used to update a single chore's due date and frequency. New function can be used in multiple places. async def _update_all_chore_due_dates_in_config(self) -> None: """Update due dates for all chores in config_entry.options.""" tasks = [] for chore_id, chore_info in self.chores_data.items(): if "due_date" in chore_info: tasks.append( self._update_chore_due_date_in_config( chore_id, chore_info.get("due_date"), recurring_frequency=chore_info.get("recurring_frequency"), custom_interval=chore_info.get("custom_interval"), custom_interval_unit=chore_info.get("custom_interval_unit"), ) ) # Run all updates concurrently if tasks: await asyncio.gather(*tasks) # Persist new due dates on config entries async def _update_chore_due_date_in_config( self, chore_id: str, due_date: Optional[str], recurring_frequency: Optional[str] = None, custom_interval: Optional[int] = None, custom_interval_unit: Optional[str] = None, ) -> None: """Update the due date and frequency fields for a specific chore in config_entry.options. - due_date should be an ISO-formatted string (or None). - If a frequency is passed, then that value is set. If the frequency is FREQUENCY_CUSTOM, custom_interval and custom_interval_unit are required. If the frequency is not custom, any custom interval settings are cleared. - If no frequency is passed, then do not change the frequency or custom interval settings. """ updated_options = dict(self.config_entry.options) chores_conf = dict(updated_options.get(DATA_CHORES, {})) # Get existing options for the chore. existing_options = dict(chores_conf.get(chore_id, {})) # Update due_date: set if provided; otherwise remove. if due_date is not None: existing_options["due_date"] = due_date else: existing_options.pop("due_date", None) # If a frequency is passed, update it. if recurring_frequency is not None: existing_options["recurring_frequency"] = recurring_frequency if recurring_frequency == FREQUENCY_CUSTOM: # For custom frequency, custom_interval and custom_interval_unit are required. if custom_interval is None or custom_interval_unit is None: raise HomeAssistantError( "For custom frequency, both custom_interval and custom_interval_unit are required." ) existing_options["custom_interval"] = custom_interval existing_options["custom_interval_unit"] = custom_interval_unit else: # For non-custom frequencies, clear any custom interval settings. existing_options.pop("custom_interval", None) existing_options.pop("custom_interval_unit", None) # If no frequency is passed, leave the frequency and custom fields unchanged. chores_conf[chore_id] = existing_options updated_options[DATA_CHORES] = chores_conf new_data = dict(self.config_entry.data) new_data["last_change"] = dt_util.utcnow().isoformat() update_result = self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, options=updated_options ) if asyncio.iscoroutine(update_result): await update_result # ------------------------------------------------------------------------------------- # Notifications # ------------------------------------------------------------------------------------- async def send_kc_notification( self, user_id: Optional[str], title: str, message: str, notification_id: str, ) -> None: """Send a persistent notification to a user if possible; fallback to a general persistent notification if the user is not found or not set.""" hass = self.hass if not user_id: # If no user_id is provided, use a general notification LOGGER.debug( "No user_id provided. Sending a general persistent notification" ) await hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": notification_id, }, blocking=True, ) return try: user_obj: User = await hass.auth.async_get_user(user_id) if not user_obj: LOGGER.warning( "User with ID '%s' not found. Sending fallback persistent notification", user_id, ) await hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": notification_id, }, blocking=True, ) return await hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": notification_id, }, blocking=True, ) except Exception as err: LOGGER.warning( "Failed to send user-specific notification to user_id='%s': %s. Fallback to persistent_notification", user_id, err, ) await hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": notification_id, }, blocking=True, ) async def _notify_kid( self, kid_id: str, title: str, message: str, actions: Optional[list[dict[str, str]]] = None, extra_data: Optional[dict] = None, ) -> None: """Notify a kid using their configured notification settings.""" kid_info = self.kids_data.get(kid_id) if not kid_info: return if not kid_info.get("enable_notifications", True): LOGGER.debug("Notifications disabled for kid '%s'", kid_id) return mobile_enabled = kid_info.get(CONF_ENABLE_MOBILE_NOTIFICATIONS, True) persistent_enabled = kid_info.get(CONF_ENABLE_PERSISTENT_NOTIFICATIONS, True) mobile_notify_service = kid_info.get(CONF_MOBILE_NOTIFY_SERVICE, "") if mobile_enabled and mobile_notify_service: await async_send_notification( self.hass, mobile_notify_service, title, message, actions=actions, extra_data=extra_data, use_persistent=persistent_enabled, ) elif persistent_enabled: await self.hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": f"kid_{kid_id}", }, blocking=True, ) else: LOGGER.debug("No notification method configured for kid '%s'", kid_id) async def _notify_parents( self, kid_id: str, title: str, message: str, actions: Optional[list[dict[str, str]]] = None, extra_data: Optional[dict] = None, ) -> None: """Notify all parents associated with a kid using their settings.""" for parent_id, parent_info in self.parents_data.items(): if kid_id not in parent_info.get("associated_kids", []): continue if not parent_info.get("enable_notifications", True): LOGGER.debug("Notifications disabled for parent '%s'", parent_id) continue mobile_enabled = parent_info.get(CONF_ENABLE_MOBILE_NOTIFICATIONS, True) persistent_enabled = parent_info.get( CONF_ENABLE_PERSISTENT_NOTIFICATIONS, True ) mobile_notify_service = parent_info.get(CONF_MOBILE_NOTIFY_SERVICE, "") if mobile_enabled and mobile_notify_service: await async_send_notification( self.hass, mobile_notify_service, title, message, actions=actions, extra_data=extra_data, use_persistent=persistent_enabled, ) elif persistent_enabled: await self.hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": f"parent_{parent_id}", }, blocking=True, ) else: LOGGER.debug( "No notification method configured for parent '%s'", parent_id ) async def remind_in_minutes( self, kid_id: str, minutes: int, *, chore_id: Optional[str] = None, reward_id: Optional[str] = None, ) -> None: """ Wait for the specified number of minutes and then resend the parent's notification if the chore or reward is still pending approval. If a chore_id is provided, the method checks the corresponding chore’s state. If a reward_id is provided, it checks whether that reward is still pending. """ LOGGER.info( "Scheduling reminder for kid '%s', chore '%s', reward '%s' in %d minutes", kid_id, chore_id, reward_id, minutes, ) await asyncio.sleep(minutes * 60) kid_info = self.kids_data.get(kid_id) if not kid_info: LOGGER.warning("Kid with ID '%s' not found during reminder check", kid_id) return if chore_id: chore_info = self.chores_data.get(chore_id) if not chore_info: LOGGER.warning( "Chore with ID '%s' not found during reminder check", chore_id ) return # Only resend if the chore is still in a pending-like state. if chore_info.get("state") not in [ CHORE_STATE_PENDING, CHORE_STATE_CLAIMED, CHORE_STATE_OVERDUE, ]: LOGGER.info( "Chore '%s' is no longer pending approval; no reminder sent", chore_id, ) return actions = [ { "action": f"{ACTION_APPROVE_CHORE}|{kid_id}|{chore_id}", "title": ACTION_TITLE_APPROVE, }, { "action": f"{ACTION_DISAPPROVE_CHORE}|{kid_id}|{chore_id}", "title": ACTION_TITLE_DISAPPROVE, }, { "action": f"{ACTION_REMIND_30}|{kid_id}|{chore_id}", "title": ACTION_TITLE_REMIND_30, }, ] extra_data = {"kid_id": kid_id, "chore_id": chore_id} await self._notify_parents( kid_id, title="KidsChores: Reminder for Pending Chore", message=f"Reminder: {kid_info.get('name', 'A kid')} has '{chore_info.get('name', 'Unnamed Chore')}' chore pending approval.", actions=actions, extra_data=extra_data, ) LOGGER.info("Resent reminder for chore '%s' for kid '%s'", chore_id, kid_id) elif reward_id: # Check if the reward is still pending approval. if reward_id not in kid_info.get("pending_rewards", []): LOGGER.info( "Reward '%s' is no longer pending approval for kid '%s'; no reminder sent", reward_id, kid_id, ) return actions = [ { "action": f"{ACTION_APPROVE_REWARD}|{kid_id}|{reward_id}", "title": ACTION_TITLE_APPROVE, }, { "action": f"{ACTION_DISAPPROVE_REWARD}|{kid_id}|{reward_id}", "title": ACTION_TITLE_DISAPPROVE, }, { "action": f"{ACTION_REMIND_30}|{kid_id}|{reward_id}", "title": ACTION_TITLE_REMIND_30, }, ] extra_data = {"kid_id": kid_id, "reward_id": reward_id} reward = self.rewards_data.get(reward_id, {}) reward_name = reward.get("name", "the reward") await self._notify_parents( kid_id, title="KidsChores: Reminder for Pending Reward", message=f"Reminder: {kid_info.get('name', 'A kid')} has '{reward_name}' reward pending approval.", actions=actions, extra_data=extra_data, ) LOGGER.info( "Resent reminder for reward '%s' for kid '%s'", reward_id, kid_id ) else: LOGGER.warning("No chore_id or reward_id provided for reminder action") # ------------------------------------------------------------------------------------- # Storage # ------------------------------------------------------------------------------------- def _persist(self): """Save to persistent storage.""" self.storage_manager.set_data(self._data) self.hass.add_job(self.storage_manager.async_save) # ------------------------------------------------------------------------------------- # Internal Helper for kid <-> name lookups # ------------------------------------------------------------------------------------- def _get_kid_id_by_name(self, kid_name: str) -> Optional[str]: """Help function to get kid_id by kid_name.""" for kid_id, k_info in self.kids_data.items(): if k_info.get("name") == kid_name: return kid_id return None def _get_kid_name_by_id(self, kid_id: str) -> Optional[str]: """Help function to get kid_name by kid_id.""" kid_info = self.kids_data.get(kid_id) if kid_info: return kid_info.get("name") return None ================================================ FILE: custom_components/kidschores/flow_helpers.py ================================================ # File: flow_helpers.py """Helpers for the KidsChores integration's Config and Options flow. Provides schema builders and input-processing logic for internal_id-based management. """ import datetime import uuid import voluptuous as vol from homeassistant.core import HomeAssistant from homeassistant.helpers import selector, config_validation as cv from homeassistant.util import dt as dt_util from .const import ( ACHIEVEMENT_TYPE_DAILY_MIN, ACHIEVEMENT_TYPE_STREAK, ACHIEVEMENT_TYPE_TOTAL, CHALLENGE_TYPE_DAILY_MIN, CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW, CONF_APPLICABLE_DAYS, CONF_ENABLE_MOBILE_NOTIFICATIONS, CONF_ENABLE_PERSISTENT_NOTIFICATIONS, CONF_MOBILE_NOTIFY_SERVICE, CONF_NOTIFY_ON_APPROVAL, CONF_NOTIFY_ON_CLAIM, CONF_NOTIFY_ON_DISAPPROVAL, CONF_POINTS_LABEL, CONF_POINTS_ICON, DEFAULT_APPLICABLE_DAYS, DEFAULT_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_DISAPPROVAL, DEFAULT_POINTS_MULTIPLIER, DEFAULT_POINTS_LABEL, DEFAULT_POINTS_ICON, DOMAIN, FREQUENCY_BIWEEKLY, FREQUENCY_CUSTOM, FREQUENCY_DAILY, FREQUENCY_MONTHLY, FREQUENCY_NONE, FREQUENCY_WEEKLY, WEEKDAY_OPTIONS, ) def build_points_schema( default_label=DEFAULT_POINTS_LABEL, default_icon=DEFAULT_POINTS_ICON ): """Build a schema for points label & icon.""" return vol.Schema( { vol.Required(CONF_POINTS_LABEL, default=default_label): str, vol.Optional( CONF_POINTS_ICON, default=default_icon ): selector.IconSelector(), } ) def build_kid_schema( hass, users, default_kid_name="", default_ha_user_id=None, internal_id=None, default_enable_mobile_notifications=False, default_mobile_notify_service=None, default_enable_persistent_notifications=False, ): """Build a Voluptuous schema for adding/editing a Kid, keyed by internal_id in the dict.""" user_options = [{"value": "", "label": "None"}] + [ {"value": user.id, "label": user.name} for user in users ] notify_options = [{"value": "", "label": "None"}] + _get_notify_services(hass) return vol.Schema( { vol.Required("kid_name", default=default_kid_name): str, vol.Optional( "ha_user", default=default_ha_user_id or "" ): selector.SelectSelector( selector.SelectSelectorConfig( options=user_options, mode=selector.SelectSelectorMode.DROPDOWN, multiple=False, ) ), vol.Required( CONF_ENABLE_MOBILE_NOTIFICATIONS, default=default_enable_mobile_notifications, ): selector.BooleanSelector(), vol.Optional( CONF_MOBILE_NOTIFY_SERVICE, default=default_mobile_notify_service or "" ): selector.SelectSelector( selector.SelectSelectorConfig( options=notify_options, mode=selector.SelectSelectorMode.DROPDOWN, multiple=False, ) ), vol.Required( CONF_ENABLE_PERSISTENT_NOTIFICATIONS, default=default_enable_persistent_notifications, ): selector.BooleanSelector(), vol.Required("internal_id", default=internal_id or str(uuid.uuid4())): str, } ) def build_parent_schema( hass, users, kids_dict, default_parent_name="", default_ha_user_id=None, default_associated_kids=None, default_enable_mobile_notifications=False, default_mobile_notify_service=None, default_enable_persistent_notifications=False, internal_id=None, ): """Build a Voluptuous schema for adding/editing a Parent, keyed by internal_id in the dict.""" user_options = [{"value": "", "label": "None"}] + [ {"value": user.id, "label": user.name} for user in users ] kid_options = [ {"value": kid_id, "label": kid_name} for kid_name, kid_id in kids_dict.items() ] notify_options = [{"value": "", "label": "None"}] + _get_notify_services(hass) return vol.Schema( { vol.Required("parent_name", default=default_parent_name): str, vol.Optional( "ha_user_id", default=default_ha_user_id or "" ): selector.SelectSelector( selector.SelectSelectorConfig( options=user_options, mode=selector.SelectSelectorMode.DROPDOWN, multiple=False, ) ), vol.Optional( "associated_kids", default=default_associated_kids or [] ): selector.SelectSelector( selector.SelectSelectorConfig( options=kid_options, translation_key="associated_kids", multiple=True, ) ), vol.Required( CONF_ENABLE_MOBILE_NOTIFICATIONS, default=default_enable_mobile_notifications, ): selector.BooleanSelector(), vol.Optional( CONF_MOBILE_NOTIFY_SERVICE, default=default_mobile_notify_service or "" ): selector.SelectSelector( selector.SelectSelectorConfig( options=notify_options, mode=selector.SelectSelectorMode.DROPDOWN, multiple=False, ) ), vol.Required( CONF_ENABLE_PERSISTENT_NOTIFICATIONS, default=default_enable_persistent_notifications, ): selector.BooleanSelector(), vol.Required("internal_id", default=internal_id or str(uuid.uuid4())): str, } ) def build_chore_schema(kids_dict, default=None): """Build a schema for chores, referencing existing kids by name. Uses internal_id for entity management. """ default = default or {} chore_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) kid_choices = {k: k for k in kids_dict} return vol.Schema( { vol.Required("chore_name", default=chore_name_default): str, vol.Optional( "chore_description", default=default.get("description", "") ): str, vol.Optional( "chore_labels", default=default.get("chore_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Required( "default_points", default=default.get("default_points", 5) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Required( "assigned_kids", default=default.get("assigned_kids", []) ): cv.multi_select(kid_choices), vol.Required( "shared_chore", default=default.get("shared_chore", False) ): selector.BooleanSelector(), vol.Required( "allow_multiple_claims_per_day", default=default.get("allow_multiple_claims_per_day", False), ): selector.BooleanSelector(), vol.Required( "partial_allowed", default=default.get("partial_allowed", False) ): selector.BooleanSelector(), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required( "recurring_frequency", default=default.get("recurring_frequency", FREQUENCY_NONE), ): selector.SelectSelector( selector.SelectSelectorConfig( options=[ FREQUENCY_NONE, FREQUENCY_DAILY, FREQUENCY_WEEKLY, FREQUENCY_BIWEEKLY, FREQUENCY_MONTHLY, FREQUENCY_CUSTOM, ], translation_key="recurring_frequency", ) ), vol.Optional( "custom_interval", default=default.get("custom_interval", None) ): vol.Any( None, selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=1, step=1 ) ), ), vol.Optional( "custom_interval_unit", default=default.get("custom_interval_unit", None), ): vol.Any( None, selector.SelectSelector( selector.SelectSelectorConfig( options=["", "days", "weeks", "months"], translation_key="custom_interval_unit", multiple=False, mode=selector.SelectSelectorMode.DROPDOWN, ) ), ), vol.Optional( CONF_APPLICABLE_DAYS, default=default.get(CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS), ): selector.SelectSelector( selector.SelectSelectorConfig( options=[ {"value": key, "label": WEEKDAY_OPTIONS[key]} for key in WEEKDAY_OPTIONS ], multiple=True, translation_key="applicable_days", ) ), vol.Optional("due_date", default=default.get("due_date")): vol.Any( None, selector.DateTimeSelector() ), vol.Optional( CONF_NOTIFY_ON_CLAIM, default=default.get(CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM), ): selector.BooleanSelector(), vol.Optional( CONF_NOTIFY_ON_APPROVAL, default=default.get( CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL ), ): selector.BooleanSelector(), vol.Optional( CONF_NOTIFY_ON_DISAPPROVAL, default=default.get( CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL ), ): selector.BooleanSelector(), vol.Required("internal_id", default=internal_id_default): str, } ) def build_badge_schema(default=None): """Build a schema for badges, keyed by internal_id in the dict.""" default = default or {} badge_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) points_multiplier_default = default.get( "points_multiplier", DEFAULT_POINTS_MULTIPLIER ) return vol.Schema( { vol.Required("badge_name", default=badge_name_default): str, vol.Optional( "badge_description", default=default.get("description", "") ): str, vol.Optional( "badge_labels", default=default.get("badge_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Required( "threshold_type", default=default.get("threshold_type", "points"), ): selector.SelectSelector( selector.SelectSelectorConfig( options=["points", "chore_count"], translation_key="threshold_type", ) ), vol.Required( "threshold_value", default=default.get("threshold_value", 10) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Required( "points_multiplier", default=points_multiplier_default, ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, step=0.01, min=1.0 ) ), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required("internal_id", default=internal_id_default): str, } ) def build_reward_schema(default=None): """Build a schema for rewards, keyed by internal_id in the dict.""" default = default or {} reward_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) return vol.Schema( { vol.Required("reward_name", default=reward_name_default): str, vol.Optional( "reward_description", default=default.get("description", "") ): str, vol.Optional( "reward_labels", default=default.get("reward_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Required( "reward_cost", default=default.get("cost", 10.0) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required("internal_id", default=internal_id_default): str, } ) def build_achievement_schema(kids_dict, chores_dict, default=None): """Build a schema for achievements, keyed by internal_id.""" default = default or {} achievement_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) kid_options = [ {"value": kid_id, "label": kid_name} for kid_name, kid_id in kids_dict.items() ] chore_options = [{"value": "", "label": "None"}] for chore_id, chore_data in chores_dict.items(): chore_name = chore_data.get("name", f"Chore {chore_id[:6]}") chore_options.append({"value": chore_id, "label": chore_name}) default_selected_chore = default.get("selected_chore_id", "") if not default_selected_chore or default_selected_chore not in [ option["value"] for option in chore_options ]: pass default_criteria = default.get("criteria", "") default_assigned_kids = default.get("assigned_kids", []) if not isinstance(default_assigned_kids, list): default_assigned_kids = [default_assigned_kids] return vol.Schema( { vol.Required("name", default=achievement_name_default): str, vol.Optional("description", default=default.get("description", "")): str, vol.Optional( "achievement_labels", default=default.get("achievement_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required( "assigned_kids", default=default_assigned_kids ): selector.SelectSelector( selector.SelectSelectorConfig( options=kid_options, translation_key="assigned_kids", multiple=True, ) ), vol.Required( "type", default=default.get("type", ACHIEVEMENT_TYPE_STREAK) ): selector.SelectSelector( selector.SelectSelectorConfig( options=[ {"value": ACHIEVEMENT_TYPE_STREAK, "label": "Chore Streak"}, {"value": ACHIEVEMENT_TYPE_TOTAL, "label": "Chore Total"}, { "value": ACHIEVEMENT_TYPE_DAILY_MIN, "label": "Daily Minimum Chores", }, ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), # If type == "chore_streak", let the user choose the chore to track: vol.Optional( "selected_chore_id", default=default_selected_chore ): selector.SelectSelector( selector.SelectSelectorConfig( options=chore_options, mode=selector.SelectSelectorMode.DROPDOWN, multiple=False, ) ), # For non-streak achievements the user can type criteria freely: vol.Optional("criteria", default=default_criteria): str, vol.Required( "target_value", default=default.get("target_value", 1) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Required( "reward_points", default=default.get("reward_points", 0) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Required("internal_id", default=internal_id_default): str, } ) def build_challenge_schema(kids_dict, chores_dict, default=None): """Build a schema for challenges, keyed by internal_id.""" default = default or {} challenge_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) kid_options = [ {"value": kid_id, "label": kid_name} for kid_name, kid_id in kids_dict.items() ] chore_options = [{"value": "", "label": ""}] for chore_id, chore_data in chores_dict.items(): chore_name = chore_data.get("name", f"Chore {chore_id[:6]}") chore_options.append({"value": chore_id, "label": chore_name}) default_selected_chore = default.get("selected_chore_id", "") available_values = [option["value"] for option in chore_options] if default_selected_chore not in available_values: default_selected_chore = "" default_criteria = default.get("criteria", "") default_assigned_kids = default.get("assigned_kids", []) if not isinstance(default_assigned_kids, list): default_assigned_kids = [default_assigned_kids] return vol.Schema( { vol.Required("name", default=challenge_name_default): str, vol.Optional("description", default=default.get("description", "")): str, vol.Optional( "challenge_labels", default=default.get("challenge_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required( "assigned_kids", default=default_assigned_kids ): selector.SelectSelector( selector.SelectSelectorConfig( options=kid_options, translation_key="assigned_kids", multiple=True, ) ), vol.Required( "type", default=default.get("type", CHALLENGE_TYPE_DAILY_MIN) ): selector.SelectSelector( selector.SelectSelectorConfig( options=[ { "value": CHALLENGE_TYPE_DAILY_MIN, "label": "Minimum Chores per Day", }, { "value": CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW, "label": "Total Chores within Period", }, ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), # If type == "chore_streak", let the user choose the chore to track: vol.Optional( "selected_chore_id", default=default_selected_chore ): selector.SelectSelector( selector.SelectSelectorConfig( options=chore_options, mode=selector.SelectSelectorMode.DROPDOWN, multiple=False, ) ), # For non-streak achievements the user can type criteria freely: vol.Optional("criteria", default=default_criteria): str, vol.Required( "target_value", default=default.get("target_value", 1) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Required( "reward_points", default=default.get("reward_points", 0) ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Required( "start_date", default=default.get("start_date") ): selector.DateTimeSelector(), vol.Required( "end_date", default=default.get("end_date") ): selector.DateTimeSelector(), vol.Required("internal_id", default=internal_id_default): str, } ) def build_penalty_schema(default=None): """Build a schema for penalties, keyed by internal_id in the dict. Stores penalty_points as positive in the form, converted to negative internally. """ default = default or {} penalty_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) # Display penalty points as positive for user input display_points = abs(default.get("points", 1)) if default else 1 return vol.Schema( { vol.Required("penalty_name", default=penalty_name_default): str, vol.Optional( "penalty_description", default=default.get("description", "") ): str, vol.Optional( "penalty_labels", default=default.get("penalty_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Required( "penalty_points", default=display_points ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required("internal_id", default=internal_id_default): str, } ) def build_bonus_schema(default=None): """Build a schema for bonuses, keyed by internal_id in the dict. Stores bonus_points as positive in the form, converted to negative internally. """ default = default or {} bonus_name_default = default.get("name", "") internal_id_default = default.get("internal_id", str(uuid.uuid4())) # Display bonus points as positive for user input display_points = abs(default.get("points", 1)) if default else 1 return vol.Schema( { vol.Required("bonus_name", default=bonus_name_default): str, vol.Optional( "bonus_description", default=default.get("description", "") ): str, vol.Optional( "bonus_labels", default=default.get("bonus_labels", []) ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), vol.Required( "bonus_points", default=display_points ): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, min=0, step=0.1, ) ), vol.Optional( "icon", default=default.get("icon", "") ): selector.IconSelector(), vol.Required("internal_id", default=internal_id_default): str, } ) # ----------------- HELPERS ----------------- # Penalty points are stored as negative internally, but displayed as positive in the form. def process_penalty_form_input(user_input: dict) -> dict: """Ensure penalty points are negative internally.""" data = dict(user_input) data["points"] = -abs(data["penalty_points"]) return data # Get notify services from HA def _get_notify_services(hass: HomeAssistant) -> list[dict[str, str]]: """Return a list of all notify.* services as [{'value': 'notify.foo', 'label': 'notify.foo'}, ...].""" services_list = [] all_services = hass.services.async_services() if "notify" in all_services: for service_name in all_services["notify"].keys(): fullname = f"notify.{service_name}" services_list.append({"value": fullname, "label": fullname}) return services_list # Ensure aware datetime objects def ensure_utc_datetime(hass: HomeAssistant, dt_value: any) -> str: """Convert a datetime input (or a datetime string) into an ISO string that is timezone aware (in UTC). If dt_value is naive, assume it is in the local timezone. """ # Convert dt_value to a datetime object if necessary if not isinstance(dt_value, datetime.datetime): dt_value = dt_util.parse_datetime(dt_value) if dt_value is None: raise ValueError(f"Unable to parse datetime from {dt_value}") # If the datetime is naive, assume local time using hass.config.time_zone if dt_value.tzinfo is None: local_tz = dt_util.get_time_zone(hass.config.time_zone) dt_value = dt_value.replace(tzinfo=local_tz) # Convert to UTC and return the ISO string return dt_util.as_utc(dt_value).isoformat() ================================================ FILE: custom_components/kidschores/kc_helpers.py ================================================ # File: kc_helpers.py """KidsChores helper functions and shared logic.""" from homeassistant.core import HomeAssistant from homeassistant.auth.models import User from homeassistant.helpers.label_registry import async_get from typing import Optional from .const import LOGGER, DOMAIN from .coordinator import KidsChoresDataCoordinator # -------- Get Coordinator -------- def _get_kidschores_coordinator( hass: HomeAssistant, ) -> KidsChoresDataCoordinator | None: """Retrieve KidsChores coordinator from hass.data.""" domain_entries = hass.data.get(DOMAIN, {}) if not domain_entries: return None entry_id = next(iter(domain_entries), None) if not entry_id: return None data = domain_entries.get(entry_id) if not data or "coordinator" not in data: return None return data["coordinator"] # -------- Authorization for General Actions -------- async def is_user_authorized_for_global_action( hass: HomeAssistant, user_id: str, action: str, ) -> bool: """Check if the user is allowed to do a global action (penalty, reward, points adjust) that doesn't require a specific kid_id. By default: - Admin users => authorized - Everyone else => not authorized """ if not user_id: return False # no user context => not authorized user: User = await hass.auth.async_get_user(user_id) if not user: LOGGER.warning("%s: Invalid user ID '%s'", action, user_id) return False if user.is_admin: return True # Allow non-admin users if they are registered as a parent in KidsChores. coordinator = _get_kidschores_coordinator(hass) if coordinator: for parent in coordinator.parents_data.values(): if parent.get("ha_user_id") == user.id: return True LOGGER.warning( "%s: Non-admin user '%s' is not authorized in this logic", action, user.name ) return False # -------- Authorization for Kid-Specific Actions -------- async def is_user_authorized_for_kid( hass: HomeAssistant, user_id: str, kid_id: str, ) -> bool: """Check if user is authorized to manage chores/rewards/etc. for the given kid. By default: - Admin => authorized - If kid_info['ha_user_id'] == user.id => authorized - Otherwise => not authorized """ if not user_id: return False user: User = await hass.auth.async_get_user(user_id) if not user: LOGGER.warning("Authorization: Invalid user ID '%s'", user_id) return False # Admin => automatically allowed if user.is_admin: return True # Allow non-admin users if they are registered as a parent in KidsChores. coordinator = _get_kidschores_coordinator(hass) if coordinator: for parent in coordinator.parents_data.values(): if parent.get("ha_user_id") == user.id: return True coordinator: KidsChoresDataCoordinator = _get_kidschores_coordinator(hass) if not coordinator: LOGGER.warning("Authorization: No KidsChores coordinator found") return False kid_info = coordinator.kids_data.get(kid_id) if not kid_info: LOGGER.warning( "Authorization: Kid ID '%s' not found in coordinator data", kid_id ) return False linked_ha_id = kid_info.get("ha_user_id") if linked_ha_id and linked_ha_id == user.id: return True LOGGER.warning( "Authorization: Non-admin user '%s' attempted to manage kid '%s' but is not linked", user.name, kid_info.get("name"), ) return False # ------------------ Helper Functions ------------------ def _get_kid_id_by_name(self, kid_name: str) -> Optional[str]: """Help function to get kid_id by kid_name.""" for kid_id, kid_info in self.kids_data.items(): if kid_info.get("name") == kid_name: return kid_id return None def _get_kid_name_by_id(self, kid_id: str) -> Optional[str]: """Help function to get kid_name by kid_id.""" kid_info = self.kids_data.get(kid_id) if kid_info: return kid_info.get("name") return None def get_friendly_label(hass, label_name: str) -> str: registry = async_get(hass) entries = registry.async_list_labels() label_entry = registry.async_get_label(label_name) return label_entry.name if label_entry else label_name ================================================ FILE: custom_components/kidschores/manifest.json ================================================ { "domain": "kidschores", "name": "KidsChores", "codeowners": ["@ad-ha"], "config_flow": true, "dependencies": [], "documentation": "https://github.com/ad-ha/kidschores-ha", "iot_class": "local_polling", "issue_tracker": "https://github.com/ad-ha/kidschores-ha/issues", "requirements": [], "version": "0.3.0" } ================================================ FILE: custom_components/kidschores/notification_action_handler.py ================================================ # File: notification_action_handler.py """Handle notification actions from HA companion notifications.""" from homeassistant.core import HomeAssistant, Event from homeassistant.exceptions import HomeAssistantError from .const import ( NOTIFICATION_EVENT, ACTION_APPROVE_CHORE, ACTION_APPROVE_REWARD, ACTION_DISAPPROVE_CHORE, ACTION_DISAPPROVE_REWARD, ACTION_REMIND_30, DEFAULT_REMINDER_DELAY, LOGGER, ) from .coordinator import KidsChoresDataCoordinator async def async_handle_notification_action(hass: HomeAssistant, event: Event) -> None: """Handle notification actions from HA companion notifications.""" action_field = event.data.get("action") if not action_field: LOGGER.error("No action found in event data: %s", event.data) return parts = action_field.split("|") base_action = parts[0] kid_id = None chore_id = None reward_id = None # Decide what to expect based on the base action. if base_action in (ACTION_APPROVE_REWARD, ACTION_DISAPPROVE_REWARD): if len(parts) < 3: LOGGER.error("Not enough context in reward action field: %s", action_field) return kid_id = parts[1] reward_id = parts[2] elif base_action in ( ACTION_APPROVE_CHORE, ACTION_DISAPPROVE_CHORE, ACTION_REMIND_30, ): if len(parts) < 3: LOGGER.error("Not enough context in chore action field: %s", action_field) return kid_id = parts[1] chore_id = parts[2] else: LOGGER.error("Unknown base action: %s", base_action) return # Parent name may be provided in the event data or use a default. parent_name = event.data.get("parent_name", "ParentOrAdmin") if not kid_id or not base_action: LOGGER.error("Notification action event missing required data: %s", event.data) return # Retrieve the coordinator. domain_data = hass.data.get("kidschores", {}) if not domain_data: LOGGER.error("No KidsChores data found in hass.data") return entry_id = next(iter(domain_data)) coordinator: KidsChoresDataCoordinator = domain_data[entry_id].get("coordinator") if not coordinator: LOGGER.error("No coordinator found in KidsChores data") return try: if base_action == ACTION_APPROVE_CHORE: await coordinator.approve_chore( parent_name=parent_name, kid_id=kid_id, chore_id=chore_id, ) elif base_action == ACTION_DISAPPROVE_CHORE: await coordinator.disapprove_chore( parent_name=parent_name, kid_id=kid_id, chore_id=chore_id, ) elif base_action == ACTION_APPROVE_REWARD: await coordinator.approve_reward( parent_name=parent_name, kid_id=kid_id, reward_id=reward_id, ) elif base_action == ACTION_DISAPPROVE_REWARD: await coordinator.disapprove_reward( parent_name=parent_name, kid_id=kid_id, reward_id=reward_id, ) elif base_action == ACTION_REMIND_30: await coordinator.remind_in_minutes( kid_id=kid_id, chore_id=chore_id, reward_id=reward_id, minutes=DEFAULT_REMINDER_DELAY, ) else: LOGGER.error("Received unknown notification action: %s", base_action) except HomeAssistantError as err: LOGGER.error("Error processing notification action %s: %s", base_action, err) ================================================ FILE: custom_components/kidschores/notification_helper.py ================================================ # File: notification_helper.py """Sends notifications using Home Assistant's notify services. This module implements a helper for sending notifications in the KidsChores integration. It supports sending notifications via Home Assistant’s notify services (HA Companion notifications) and includes an optional payload of actions. For actionable notifications, you must encode extra context (like kid_id and chore_id) directly into the action string. All texts and labels are referenced from constants. """ from __future__ import annotations from typing import Optional from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN, LOGGER async def async_send_notification( hass: HomeAssistant, notify_service: str, title: str, message: str, actions: Optional[list[dict[str, str]]] = None, extra_data: Optional[dict[str, str]] = None, use_persistent: bool = False, ) -> None: """Send a notification using the specified notify service.""" payload = {"title": title, "message": message} if actions: payload.setdefault("data", {})["actions"] = actions if extra_data: payload.setdefault("data", {}).update(extra_data) try: if "." not in notify_service: domain = "notify" service = notify_service else: domain, service = notify_service.split(".", 1) await hass.services.async_call(domain, service, payload, blocking=True) LOGGER.debug("Notification sent via '%s': %s", notify_service, payload) except Exception as err: LOGGER.error( "Failed to send notification via '%s': %s. Payload: %s", notify_service, err, payload, ) raise HomeAssistantError( f"Failed to send notification via '{notify_service}': {err}" ) from err ================================================ FILE: custom_components/kidschores/options_flow.py ================================================ # File: options_flow.py """Options Flow for the KidsChores integration, managing entities by internal_id. Handles add/edit/delete operations with entities referenced internally by internal_id. Ensures consistency and reloads the integration upon changes. """ import datetime import uuid import voluptuous as vol from homeassistant import config_entries from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import ( ACHIEVEMENT_TYPE_STREAK, CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW, CONF_APPLICABLE_DAYS, CONF_ACHIEVEMENTS, CONF_BADGES, CONF_CHALLENGES, CONF_CHORES, CONF_KIDS, CONF_NOTIFY_ON_APPROVAL, CONF_NOTIFY_ON_CLAIM, CONF_NOTIFY_ON_DISAPPROVAL, CONF_PARENTS, CONF_PENALTIES, CONF_POINTS_ICON, CONF_POINTS_LABEL, CONF_REWARDS, CONF_BONUSES, DEFAULT_APPLICABLE_DAYS, DEFAULT_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_DISAPPROVAL, DEFAULT_POINTS_ICON, DEFAULT_POINTS_LABEL, FREQUENCY_CUSTOM, DOMAIN, LOGGER, ) from .flow_helpers import ( build_points_schema, build_kid_schema, build_parent_schema, build_chore_schema, build_badge_schema, build_reward_schema, build_penalty_schema, build_achievement_schema, build_challenge_schema, ensure_utc_datetime, build_bonus_schema, ) def _ensure_str(value): """Convert anything to string safely.""" if isinstance(value, dict): # Attempt to get a known key or fallback return str(value.get("value", next(iter(value.values()), ""))) return str(value) class KidsChoresOptionsFlowHandler(config_entries.OptionsFlow): """Options Flow for adding/editing/deleting kids, chores, badges, rewards, penalties, and bonuses. Manages entities via internal_id for consistency and historical data preservation. """ def __init__(self, config_entry: config_entries.ConfigEntry): """Initialize the options flow.""" self._entry_options = {} self._action = None self._entity_type = None async def async_step_init(self, user_input=None): """Display the main menu for the Options Flow. Add/Edit/Delete kid, chore, badge, reward, penalty, or done. """ self._entry_options = dict(self.config_entry.options) if user_input is not None: selection = user_input["menu_selection"] if selection.startswith("manage_"): self._entity_type = selection.replace("manage_", "") # If user chose manage_points if self._entity_type == "points": return await self.async_step_manage_points() # Else manage other entities return await self.async_step_manage_entity() elif selection == "done": return self.async_abort(reason="setup_complete") main_menu = [ "manage_points", "manage_kid", "manage_parent", "manage_chore", "manage_badge", "manage_reward", "manage_penalty", "manage_bonus", "manage_achievement", "manage_challenge", "done", ] return self.async_show_form( step_id="init", data_schema=vol.Schema( { vol.Required("menu_selection"): selector.SelectSelector( selector.SelectSelectorConfig( options=main_menu, mode=selector.SelectSelectorMode.LIST, translation_key="main_menu", ) ) } ), ) async def async_step_manage_points(self, user_input=None): """Let user edit the points label/icon after initial setup.""" if user_input is not None: new_label = user_input.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL) new_icon = user_input.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON) self._entry_options = dict(self.config_entry.options) self._entry_options[CONF_POINTS_LABEL] = new_label self._entry_options[CONF_POINTS_ICON] = new_icon LOGGER.debug( "Before saving points, entry_options = %s", self._entry_options ) await self._update_and_reload() return await self.async_step_init() # Get existing values from entry options current_label = self._entry_options.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL) current_icon = self._entry_options.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON) # Build the form points_schema = build_points_schema( default_label=current_label, default_icon=current_icon ) return self.async_show_form( step_id="manage_points", data_schema=points_schema, description_placeholders={}, ) async def async_step_manage_entity(self, user_input=None): """Handle the management actions for a selected entity type. Presents add/edit/delete options for the selected entity. """ if user_input is not None: self._action = user_input["manage_action"] # Route to the corresponding step based on action if self._action == "add": return await getattr(self, f"async_step_add_{self._entity_type}")() elif self._action in ["edit", "delete"]: return await self.async_step_select_entity() elif self._action == "back": return await self.async_step_init() # Define manage action choices manage_action_choices = [ "add", "edit", "delete", "back", # Option to go back to the main menu ] return self.async_show_form( step_id="manage_entity", data_schema=vol.Schema( { vol.Required("manage_action"): selector.SelectSelector( selector.SelectSelectorConfig( options=manage_action_choices, mode=selector.SelectSelectorMode.LIST, translation_key="manage_actions", ) ) } ), description_placeholders={"entity_type": self._entity_type}, ) async def async_step_select_entity(self, user_input=None): """Select an entity (kid, chore, etc.) to edit or delete based on internal_id.""" if self._action not in ["edit", "delete"]: LOGGER.error("Invalid action '%s' for select_entity step", self._action) return self.async_abort(reason="invalid_action") entity_dict = self._get_entity_dict() entity_names = [data["name"] for data in entity_dict.values()] if user_input is not None: selected_name = _ensure_str(user_input["entity_name"]) internal_id = next( ( eid for eid, data in entity_dict.items() if data["name"] == selected_name ), None, ) if not internal_id: LOGGER.error("Selected entity '%s' not found", selected_name) return self.async_abort(reason="invalid_entity") # Store internal_id in context for later use self.context["internal_id"] = internal_id # Route to the corresponding edit/delete step return await getattr( self, f"async_step_{self._action}_{self._entity_type}" )() if not entity_names: return self.async_abort(reason=f"no_{self._entity_type}s") return self.async_show_form( step_id="select_entity", data_schema=vol.Schema( { vol.Required("entity_name"): selector.SelectSelector( selector.SelectSelectorConfig( options=entity_names, mode=selector.SelectSelectorMode.DROPDOWN, sort=True, ) ) } ), description_placeholders={ "entity_type": self._entity_type, "action": self._action, }, ) def _get_entity_dict(self): """Retrieve the appropriate entity dictionary based on entity_type.""" entity_type_to_conf = { "kid": CONF_KIDS, "parent": CONF_PARENTS, "chore": CONF_CHORES, "badge": CONF_BADGES, "reward": CONF_REWARDS, "penalty": CONF_PENALTIES, "achievement": CONF_ACHIEVEMENTS, "challenge": CONF_CHALLENGES, "bonus": CONF_BONUSES, } key = entity_type_to_conf.get(self._entity_type) if key is None: LOGGER.error( "Unknown entity_type '%s'. Cannot retrieve entity dictionary", self._entity_type, ) return {} return self._entry_options.get(key, {}) # ------------------ ADD ENTITY ------------------ async def async_step_add_kid(self, user_input=None): """Add a new kid.""" self._entry_options = dict(self.config_entry.options) errors = {} kids_dict = self._entry_options.setdefault(CONF_KIDS, {}) if user_input is not None: kid_name = user_input["kid_name"].strip() ha_user_id = user_input.get("ha_user") or "" enable_mobile_notifications = user_input.get( "enable_mobile_notifications", True ) notify_service = user_input.get("mobile_notify_service") or "" enable_persist = user_input.get("enable_persistent_notifications", True) if any(kid_data["name"] == kid_name for kid_data in kids_dict.values()): errors["kid_name"] = "duplicate_kid" else: internal_id = user_input.get("internal_id", str(uuid.uuid4())) kids_dict[internal_id] = { "name": kid_name, "ha_user_id": ha_user_id, "enable_notifications": enable_mobile_notifications, "mobile_notify_service": notify_service, "use_persistent_notifications": enable_persist, "internal_id": internal_id, } self._entry_options[CONF_KIDS] = kids_dict LOGGER.debug("Added kid '%s' with ID: %s", kid_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Retrieve HA users for linking users = await self.hass.auth.async_get_users() schema = build_kid_schema( self.hass, users=users, default_kid_name="", default_ha_user_id=None, default_enable_mobile_notifications=False, default_mobile_notify_service=None, default_enable_persistent_notifications=False, ) return self.async_show_form( step_id="add_kid", data_schema=schema, errors=errors ) async def async_step_add_parent(self, user_input=None): """Add a new parent.""" self._entry_options = dict(self.config_entry.options) errors = {} parents_dict = self._entry_options.setdefault(CONF_PARENTS, {}) if user_input is not None: parent_name = user_input["parent_name"].strip() ha_user_id = user_input.get("ha_user_id") or "" associated_kids = user_input.get("associated_kids", []) enable_mobile_notifications = user_input.get( "enable_mobile_notifications", True ) notify_service = user_input.get("mobile_notify_service") or "" enable_persist = user_input.get("enable_persistent_notifications", True) if any( parent_data["name"] == parent_name for parent_data in parents_dict.values() ): errors["parent_name"] = "duplicate_parent" else: internal_id = user_input.get("internal_id", str(uuid.uuid4())) parents_dict[internal_id] = { "name": parent_name, "ha_user_id": ha_user_id, "associated_kids": associated_kids, "enable_notifications": enable_mobile_notifications, "mobile_notify_service": notify_service, "use_persistent_notifications": enable_persist, "internal_id": internal_id, } self._entry_options[CONF_PARENTS] = parents_dict LOGGER.debug("Added parent '%s' with ID: %s", parent_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Retrieve HA users and existing kids for linking users = await self.hass.auth.async_get_users() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items() } parent_schema = build_parent_schema( self.hass, users=users, kids_dict=kids_dict, default_parent_name="", default_ha_user_id=None, default_associated_kids=[], default_enable_mobile_notifications=False, default_mobile_notify_service=None, default_enable_persistent_notifications=False, internal_id=None, ) return self.async_show_form( step_id="add_parent", data_schema=parent_schema, errors=errors ) async def async_step_add_chore(self, user_input=None): """Add a new chore.""" self._entry_options = dict(self.config_entry.options) errors = {} chores_dict = self._entry_options.setdefault(CONF_CHORES, {}) if user_input is not None: chore_name = user_input["chore_name"].strip() internal_id = user_input.get("internal_id", str(uuid.uuid4())) if user_input.get("due_date"): raw_due = user_input["due_date"] try: due_date_str = ensure_utc_datetime(self.hass, raw_due) due_dt = dt_util.parse_datetime(due_date_str) if due_dt and due_dt < dt_util.utcnow(): errors["due_date"] = "due_date_in_past" except ValueError: errors["due_date"] = "invalid_due_date" due_date_str = None else: due_date_str = None if any( chore_data["name"] == chore_name for chore_data in chores_dict.values() ): errors["chore_name"] = "duplicate_chore" if errors: kids_dict = { data["name"]: eid for eid, data in self._entry_options.get(CONF_KIDS, {}).items() } schema = build_chore_schema(kids_dict, default=user_input) return self.async_show_form( step_id="add_chore", data_schema=schema, errors=errors ) if user_input.get("recurring_frequency") != FREQUENCY_CUSTOM: user_input.pop("custom_interval", None) user_input.pop("custom_interval_unit", None) chores_dict[internal_id] = { "name": chore_name, "default_points": user_input["default_points"], "partial_allowed": user_input["partial_allowed"], "shared_chore": user_input["shared_chore"], "allow_multiple_claims_per_day": user_input[ "allow_multiple_claims_per_day" ], "assigned_kids": user_input["assigned_kids"], "description": user_input.get("chore_description", ""), "chore_labels": user_input.get("chore_labels", []), "icon": user_input.get("icon", ""), "recurring_frequency": user_input.get("recurring_frequency", "none"), "custom_interval": user_input.get("custom_interval"), "custom_interval_unit": user_input.get("custom_interval_unit"), "due_date": due_date_str, "applicable_days": user_input.get( CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS ), "notify_on_claim": user_input.get( CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM ), "notify_on_approval": user_input.get( CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL ), "notify_on_disapproval": user_input.get( CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL ), "internal_id": internal_id, } self._entry_options[CONF_CHORES] = chores_dict LOGGER.debug("Added chore '%s' with ID: %s", chore_name, internal_id) LOGGER.debug( "Final stored 'due_date' for chore '%s': %s", chore_name, due_date_str, ) await self._update_and_reload() return await self.async_step_init() # Use flow_helpers.build_chore_schema, passing current kids kids_dict = { data["name"]: eid for eid, data in self._entry_options.get(CONF_KIDS, {}).items() } schema = build_chore_schema(kids_dict) return self.async_show_form( step_id="add_chore", data_schema=schema, errors=errors ) async def async_step_add_badge(self, user_input=None): """Add a new badge.""" self._entry_options = dict(self.config_entry.options) errors = {} badges_dict = self._entry_options.setdefault(CONF_BADGES, {}) if user_input is not None: badge_name = user_input["badge_name"].strip() internal_id = user_input.get("internal_id", str(uuid.uuid4())) if any( badge_data["name"] == badge_name for badge_data in badges_dict.values() ): errors["badge_name"] = "duplicate_badge" else: badges_dict[internal_id] = { "name": badge_name, "threshold_type": user_input["threshold_type"], "threshold_value": user_input["threshold_value"], "points_multiplier": user_input["points_multiplier"], "icon": user_input.get("icon", ""), "internal_id": internal_id, "description": user_input.get("badge_description", ""), "badge_labels": user_input.get("badge_labels", []), } self._entry_options[CONF_BADGES] = badges_dict LOGGER.debug("Added badge '%s' with ID: %s", badge_name, internal_id) await self._update_and_reload() return await self.async_step_init() schema = build_badge_schema() return self.async_show_form( step_id="add_badge", data_schema=schema, errors=errors ) async def async_step_add_reward(self, user_input=None): """Add a new reward.""" self._entry_options = dict(self.config_entry.options) errors = {} rewards_dict = self._entry_options.setdefault(CONF_REWARDS, {}) if user_input is not None: reward_name = user_input["reward_name"].strip() internal_id = user_input.get("internal_id", str(uuid.uuid4())) if any( reward_data["name"] == reward_name for reward_data in rewards_dict.values() ): errors["reward_name"] = "duplicate_reward" else: rewards_dict[internal_id] = { "name": reward_name, "cost": user_input["reward_cost"], "description": user_input.get("reward_description", ""), "reward_labels": user_input.get("reward_labels", []), "icon": user_input.get("icon", ""), "internal_id": internal_id, } self._entry_options[CONF_REWARDS] = rewards_dict LOGGER.debug("Added reward '%s' with ID: %s", reward_name, internal_id) await self._update_and_reload() return await self.async_step_init() schema = build_reward_schema() return self.async_show_form( step_id="add_reward", data_schema=schema, errors=errors ) async def async_step_add_penalty(self, user_input=None): """Add a new penalty.""" self._entry_options = dict(self.config_entry.options) errors = {} penalties_dict = self._entry_options.setdefault(CONF_PENALTIES, {}) if user_input is not None: penalty_name = user_input["penalty_name"].strip() penalty_points = user_input["penalty_points"] internal_id = user_input.get("internal_id", str(uuid.uuid4())) if any( penalty_data["name"] == penalty_name for penalty_data in penalties_dict.values() ): errors["penalty_name"] = "duplicate_penalty" else: penalties_dict[internal_id] = { "name": penalty_name, "description": user_input.get("penalty_description", ""), "penalty_labels": user_input.get("penalty_labels", []), "points": -abs(penalty_points), # Ensure points are negative "icon": user_input.get("icon", ""), "internal_id": internal_id, } self._entry_options[CONF_PENALTIES] = penalties_dict LOGGER.debug( "Added penalty '%s' with ID: %s", penalty_name, internal_id ) await self._update_and_reload() return await self.async_step_init() schema = build_penalty_schema() return self.async_show_form( step_id="add_penalty", data_schema=schema, errors=errors ) async def async_step_add_bonus(self, user_input=None): """Add a new bonus.""" self._entry_options = dict(self.config_entry.options) errors = {} bonuses_dict = self._entry_options.setdefault(CONF_BONUSES, {}) if user_input is not None: bonus_name = user_input["bonus_name"].strip() bonus_points = user_input["bonus_points"] internal_id = user_input.get("internal_id", str(uuid.uuid4())) if any( bonus_data["name"] == bonus_name for bonus_data in bonuses_dict.values() ): errors["bonus_name"] = "duplicate_bonus" else: bonuses_dict[internal_id] = { "name": bonus_name, "description": user_input.get("bonus_description", ""), "bonus_labels": user_input.get("bonus_labels", []), "points": abs(bonus_points), # Ensure points are positive "icon": user_input.get("icon", ""), "internal_id": internal_id, } self._entry_options[CONF_BONUSES] = bonuses_dict LOGGER.debug("Added bonus '%s' with ID: %s", bonus_name, internal_id) await self._update_and_reload() return await self.async_step_init() schema = build_bonus_schema() return self.async_show_form( step_id="add_bonus", data_schema=schema, errors=errors ) async def async_step_add_achievement(self, user_input=None): """Add a new achievement.""" self._entry_options = dict(self.config_entry.options) errors = {} achievements_dict = self._entry_options.setdefault(CONF_ACHIEVEMENTS, {}) chores_dict = self._entry_options.get(CONF_CHORES, {}) if user_input is not None: achievement_name = user_input["name"].strip() if any( data["name"] == achievement_name for data in achievements_dict.values() ): errors["name"] = "duplicate_achievement" else: _type = user_input["type"] chore_id = "" if _type == ACHIEVEMENT_TYPE_STREAK: c = user_input.get("selected_chore_id") or "" if not c or c == "None": errors["selected_chore_id"] = "a_chore_must_be_selected" chore_id = c if not errors: internal_id = user_input.get("internal_id", str(uuid.uuid4())) achievements_dict[internal_id] = { "name": achievement_name, "description": user_input.get("description", ""), "achievement_labels": user_input.get("achievement_labels", []), "icon": user_input.get("icon", ""), "assigned_kids": user_input["assigned_kids"], "type": _type, "selected_chore_id": chore_id, "criteria": user_input.get("criteria", "").strip(), "target_value": user_input["target_value"], "reward_points": user_input["reward_points"], "internal_id": internal_id, "progress": {}, } self._entry_options["achievements"] = achievements_dict LOGGER.debug( "Added achievement '%s' with ID: %s", achievement_name, internal_id, ) await self._update_and_reload() return await self.async_step_init() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items() } achievement_schema = build_achievement_schema( kids_dict=kids_dict, chores_dict=chores_dict, default=None ) return self.async_show_form( step_id="add_achievement", data_schema=achievement_schema, errors=errors ) async def async_step_add_challenge(self, user_input=None): """Add a new challenge.""" self._entry_options = dict(self.config_entry.options) errors = {} challenges_dict = self._entry_options.setdefault(CONF_CHALLENGES, {}) chores_dict = self._entry_options.get(CONF_CHORES, {}) if user_input is not None: challenge_name = user_input["name"].strip() if any(data["name"] == challenge_name for data in challenges_dict.values()): errors["name"] = "duplicate_challenge" else: _type = user_input["type"] chore_id = "" if _type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: c = user_input.get("selected_chore_id") or "" if not c or c == "None": errors["selected_chore_id"] = "a_chore_must_be_selected" chore_id = c # Process start_date and end_date using the helper: start_date_input = user_input.get("start_date") end_date_input = user_input.get("end_date") if start_date_input: try: start_date = ensure_utc_datetime(self.hass, start_date_input) start_dt = dt_util.parse_datetime(start_date) if start_dt and start_dt < dt_util.utcnow(): errors["start_date"] = "start_date_in_past" except Exception: errors["start_date"] = "invalid_start_date" start_date = None else: start_date = None if end_date_input: try: end_date = ensure_utc_datetime(self.hass, end_date_input) end_dt = dt_util.parse_datetime(end_date) if end_dt and end_dt <= dt_util.utcnow(): errors["end_date"] = "end_date_in_past" if start_date: sdt = dt_util.parse_datetime(start_date) if sdt and end_dt and end_dt <= sdt: errors["end_date"] = "end_date_not_after_start_date" except Exception: errors["end_date"] = "invalid_end_date" end_date = None else: end_date = None if not errors: internal_id = user_input.get("internal_id", str(uuid.uuid4())) challenges_dict[internal_id] = { "name": challenge_name, "description": user_input.get("description", ""), "challenge_labels": user_input.get("challenge_labels", []), "icon": user_input.get("icon", ""), "assigned_kids": user_input["assigned_kids"], "type": _type, "selected_chore_id": chore_id, "criteria": user_input.get("criteria", "").strip(), "target_value": user_input["target_value"], "reward_points": user_input["reward_points"], "start_date": start_date, "end_date": end_date, "internal_id": internal_id, "progress": {}, } self._entry_options[CONF_CHALLENGES] = challenges_dict LOGGER.debug( "Added challenge '%s' with ID: %s", challenge_name, internal_id ) await self._update_and_reload() return await self.async_step_init() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items() } challenge_schema = build_challenge_schema( kids_dict=kids_dict, chores_dict=chores_dict, default=user_input ) return self.async_show_form( step_id="add_challenge", data_schema=challenge_schema, errors=errors ) # ------------------ EDIT ENTITY ------------------ async def async_step_edit_kid(self, user_input=None): """Edit an existing kid.""" self._entry_options = dict(self.config_entry.options) errors = {} kids_dict = self._entry_options.get(CONF_KIDS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in kids_dict: LOGGER.error("Edit kid: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_kid") kid_data = kids_dict[internal_id] if user_input is not None: new_name = user_input["kid_name"].strip() ha_user_id = user_input.get("ha_user") or "" enable_notifications = user_input.get("enable_mobile_notifications", True) mobile_notify_service = user_input.get("mobile_notify_service") or "" use_persistent = user_input.get("enable_persistent_notifications", True) # Check for duplicate names excluding current kid if any( data["name"] == new_name and eid != internal_id for eid, data in kids_dict.items() ): errors["kid_name"] = "duplicate_kid" else: kid_data["name"] = new_name kid_data["ha_user_id"] = ha_user_id kid_data["enable_notifications"] = enable_notifications kid_data["mobile_notify_service"] = mobile_notify_service kid_data["use_persistent_notifications"] = use_persistent self._entry_options[CONF_KIDS] = kids_dict LOGGER.debug("Edited kid '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Retrieve HA users for linking users = await self.hass.auth.async_get_users() schema = build_kid_schema( self.hass, users=users, default_kid_name=kid_data["name"], default_ha_user_id=kid_data.get("ha_user_id"), default_enable_mobile_notifications=kid_data.get( "enable_notifications", True ), default_mobile_notify_service=kid_data.get("mobile_notify_service"), default_enable_persistent_notifications=kid_data.get( "use_persistent_notifications", True ), internal_id=internal_id, ) return self.async_show_form( step_id="edit_kid", data_schema=schema, errors=errors ) async def async_step_edit_parent(self, user_input=None): """Edit an existing parent.""" self._entry_options = dict(self.config_entry.options) errors = {} parents_dict = self._entry_options.get(CONF_PARENTS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in parents_dict: LOGGER.error("Edit parent: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_parent") parent_data = parents_dict[internal_id] if user_input is not None: new_name = user_input["parent_name"].strip() ha_user_id = user_input.get("ha_user_id") or "" associated_kids = user_input.get("associated_kids", []) enable_notifications = user_input.get("enable_mobile_notifications", True) mobile_notify_service = user_input.get("mobile_notify_service") or "" use_persistent = user_input.get("enable_persistent_notifications", True) # Check for duplicate names excluding current parent if any( data["name"] == new_name and eid != internal_id for eid, data in parents_dict.items() ): errors["parent_name"] = "duplicate_parent" else: parent_data["name"] = new_name parent_data["ha_user_id"] = ha_user_id parent_data["associated_kids"] = associated_kids parent_data["enable_notifications"] = enable_notifications parent_data["mobile_notify_service"] = mobile_notify_service parent_data["use_persistent_notifications"] = use_persistent self._entry_options[CONF_PARENTS] = parents_dict LOGGER.debug("Edited parent '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Retrieve HA users and existing kids for linking users = await self.hass.auth.async_get_users() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items() } parent_schema = build_parent_schema( self.hass, users=users, kids_dict=kids_dict, default_parent_name=parent_data["name"], default_ha_user_id=parent_data.get("ha_user_id"), default_associated_kids=parent_data.get("associated_kids", []), default_enable_mobile_notifications=parent_data.get( "enable_notifications", True ), default_mobile_notify_service=parent_data.get("mobile_notify_service"), default_enable_persistent_notifications=parent_data.get( "use_persistent_notifications", True ), internal_id=internal_id, ) return self.async_show_form( step_id="edit_parent", data_schema=parent_schema, errors=errors ) async def async_step_edit_chore(self, user_input=None): """Edit an existing chore.""" self._entry_options = dict(self.config_entry.options) errors = {} chores_dict = self._entry_options.get(CONF_CHORES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in chores_dict: LOGGER.error("Edit chore: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_chore") chore_data = chores_dict[internal_id] if user_input is not None: new_name = user_input["chore_name"].strip() raw_due = user_input.get("due_date") # Check for duplicate names excluding current chore if any( data["name"] == new_name and eid != internal_id for eid, data in chores_dict.items() ): errors["chore_name"] = "duplicate_chore" else: if user_input.get("recurring_frequency") != FREQUENCY_CUSTOM: user_input.pop("custom_interval", None) user_input.pop("custom_interval_unit", None) chore_data["name"] = new_name chore_data["description"] = user_input.get("chore_description", "") chore_data["chore_labels"] = user_input.get("chore_labels", []) chore_data["default_points"] = user_input["default_points"] chore_data["shared_chore"] = user_input["shared_chore"] chore_data["partial_allowed"] = user_input["partial_allowed"] chore_data["allow_multiple_claims_per_day"] = user_input[ "allow_multiple_claims_per_day" ] chore_data["assigned_kids"] = user_input["assigned_kids"] chore_data["icon"] = user_input.get("icon", "") chore_data["recurring_frequency"] = user_input.get( "recurring_frequency", "none" ) chore_data["custom_interval"] = user_input.get("custom_interval") chore_data["custom_interval_unit"] = user_input.get( "custom_interval_unit" ) if raw_due: try: if isinstance(raw_due, datetime.datetime): parsed_due = raw_due else: parsed_due = dt_util.parse_datetime( raw_due ) or datetime.datetime.fromisoformat(raw_due) due_utc = dt_util.as_utc(parsed_due) if due_utc < dt_util.utcnow(): errors["due_date"] = "due_date_in_past" else: chore_data["due_date"] = due_utc.isoformat() except Exception: errors["due_date"] = "invalid_due_date" else: chore_data["due_date"] = None LOGGER.debug("No date/time provided; defaulting to None") chore_data["applicable_days"] = user_input.get("applicable_days", []) chore_data["notify_on_claim"] = user_input.get("notify_on_claim", True) chore_data["notify_on_approval"] = user_input.get( "notify_on_approval", True ) chore_data["notify_on_disapproval"] = user_input.get( "notify_on_disapproval", True ) if errors: kids_dict = { data["name"]: eid for eid, data in self._entry_options.get(CONF_KIDS, {}).items() } default_data = user_input.copy() return self.async_show_form( step_id="edit_chore", data_schema=build_chore_schema( kids_dict, default={**chore_data, **default_data} ), errors=errors, ) self._entry_options[CONF_CHORES] = chores_dict LOGGER.debug("Edited chore '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Use flow_helpers.build_chore_schema, passing current kids kids_dict = { data["name"]: eid for eid, data in self._entry_options.get(CONF_KIDS, {}).items() } # Convert stored string to datetime for DateTimeSelector existing_due_str = chore_data.get("due_date") existing_due_date = None if existing_due_str: try: # Attempt to parse using dt_util or fallback to fromisoformat parsed_date = dt_util.parse_datetime( existing_due_str ) or datetime.datetime.fromisoformat(existing_due_str) # Convert to the required format for DateTimeSelector existing_due_date = dt_util.as_local(parsed_date).strftime( "%Y-%m-%d %H:%M:%S" ) LOGGER.debug( "Processed existing_due_date for DateTimeSelector: %s", existing_due_date, ) except ValueError as e: LOGGER.error( "Failed to parse existing_due_date '%s': %s", existing_due_str, e ) schema = build_chore_schema( kids_dict, default={**chore_data, "due_date": existing_due_date} ) return self.async_show_form( step_id="edit_chore", data_schema=schema, errors=errors ) async def async_step_edit_badge(self, user_input=None): """Edit an existing badge.""" self._entry_options = dict(self.config_entry.options) errors = {} badges_dict = self._entry_options.get(CONF_BADGES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in badges_dict: LOGGER.error("Edit badge: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_badge") badge_data = badges_dict[internal_id] if user_input is not None: new_name = user_input["badge_name"].strip() # Check for duplicate names excluding current badge if any( data["name"] == new_name and eid != internal_id for eid, data in badges_dict.items() ): errors["badge_name"] = "duplicate_badge" else: badge_data["name"] = new_name badge_data["threshold_type"] = user_input["threshold_type"] badge_data["threshold_value"] = user_input["threshold_value"] badge_data["points_multiplier"] = user_input["points_multiplier"] badge_data["icon"] = user_input.get("icon", "") badge_data["description"] = user_input["badge_description"] badge_data["badge_labels"] = user_input.get("badge_labels", []) self._entry_options[CONF_BADGES] = badges_dict LOGGER.debug("Edited badge '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() schema = build_badge_schema(default=badge_data) return self.async_show_form( step_id="edit_badge", data_schema=schema, errors=errors ) async def async_step_edit_reward(self, user_input=None): """Edit an existing reward.""" self._entry_options = dict(self.config_entry.options) errors = {} rewards_dict = self._entry_options.get(CONF_REWARDS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in rewards_dict: LOGGER.error("Edit reward: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_reward") reward_data = rewards_dict[internal_id] if user_input is not None: new_name = user_input["reward_name"].strip() # Check for duplicate names excluding current reward if any( data["name"] == new_name and eid != internal_id for eid, data in rewards_dict.items() ): errors["reward_name"] = "duplicate_reward" else: reward_data["name"] = new_name reward_data["cost"] = user_input["reward_cost"] reward_data["description"] = user_input.get("reward_description", "") reward_data["reward_labels"] = user_input.get("reward_labels", []) reward_data["icon"] = user_input.get("icon", "") self._entry_options[CONF_REWARDS] = rewards_dict LOGGER.debug("Edited reward '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() schema = build_reward_schema(default=reward_data) return self.async_show_form( step_id="edit_reward", data_schema=schema, errors=errors ) async def async_step_edit_penalty(self, user_input=None): """Edit an existing penalty.""" self._entry_options = dict(self.config_entry.options) errors = {} penalties_dict = self._entry_options.get(CONF_PENALTIES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in penalties_dict: LOGGER.error("Edit penalty: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_penalty") penalty_data = penalties_dict[internal_id] if user_input is not None: new_name = user_input["penalty_name"].strip() penalty_points = user_input["penalty_points"] # Check for duplicate names excluding current penalty if any( data["name"] == new_name and eid != internal_id for eid, data in penalties_dict.items() ): errors["penalty_name"] = "duplicate_penalty" else: penalty_data["name"] = new_name penalty_data["description"] = user_input.get("penalty_description", "") penalty_data["penalty_labels"] = user_input.get("penalty_labels", []) penalty_data["points"] = -abs( penalty_points ) # Ensure points are negative penalty_data["icon"] = user_input.get("icon", "") self._entry_options[CONF_PENALTIES] = penalties_dict LOGGER.debug("Edited penalty '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Prepare data for schema (convert points to positive for display) display_data = dict(penalty_data) display_data["penalty_points"] = abs(display_data["points"]) schema = build_penalty_schema(default=display_data) return self.async_show_form( step_id="edit_penalty", data_schema=schema, errors=errors ) async def async_step_edit_bonus(self, user_input=None): """Edit an existing bonus.""" self._entry_options = dict(self.config_entry.options) errors = {} bonuses_dict = self._entry_options.get(CONF_BONUSES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in bonuses_dict: LOGGER.error("Edit bonus: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_bonus") bonus_data = bonuses_dict[internal_id] if user_input is not None: new_name = user_input["bonus_name"].strip() bonus_points = user_input["bonus_points"] # Check for duplicate names excluding current bonus if any( data["name"] == new_name and eid != internal_id for eid, data in bonuses_dict.items() ): errors["bonus_name"] = "duplicate_bonus" else: bonus_data["name"] = new_name bonus_data["description"] = user_input.get("bonus_description", "") bonus_data["bonus_labels"] = user_input.get("bonus_labels", []) bonus_data["points"] = abs(bonus_points) # Ensure points are positive bonus_data["icon"] = user_input.get("icon", "") self._entry_options[CONF_BONUSES] = bonuses_dict LOGGER.debug("Edited bonus '%s' with ID: %s", new_name, internal_id) await self._update_and_reload() return await self.async_step_init() # Prepare data for schema (convert points to positive for display) display_data = dict(bonus_data) display_data["bonus_points"] = abs(display_data["points"]) schema = build_bonus_schema(default=display_data) return self.async_show_form( step_id="edit_bonus", data_schema=schema, errors=errors ) async def async_step_edit_achievement(self, user_input=None): """Edit an existing achievement.""" self._entry_options = dict(self.config_entry.options) errors = {} achievements_dict = self._entry_options.get(CONF_ACHIEVEMENTS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in achievements_dict: LOGGER.error("Edit achievement: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_achievement") achievement_data = achievements_dict[internal_id] if user_input is not None: new_name = user_input["name"].strip() if any( data["name"] == new_name and eid != internal_id for eid, data in achievements_dict.items() ): errors["name"] = "duplicate_achievement" else: _type = user_input["type"] chore_id = "" if _type == ACHIEVEMENT_TYPE_STREAK: c = user_input.get("selected_chore_id") or "" if not c or c == "None": errors["selected_chore_id"] = "a_chore_must_be_selected" chore_id = c if not errors: achievement_data["name"] = new_name achievement_data["description"] = user_input.get("description", "") achievement_data["achievement_labels"] = user_input.get( "achievement_labels", [] ) achievement_data["icon"] = user_input.get("icon", "") achievement_data["assigned_kids"] = user_input["assigned_kids"] achievement_data["type"] = _type achievement_data["selected_chore_id"] = chore_id achievement_data["criteria"] = user_input.get( "criteria", "" ).strip() achievement_data["target_value"] = user_input["target_value"] achievement_data["reward_points"] = user_input["reward_points"] achievements_dict[internal_id] = achievement_data self._entry_options[CONF_ACHIEVEMENTS] = achievements_dict LOGGER.debug( "Edited achievement '%s' with ID: %s", new_name, internal_id ) await self._update_and_reload() return await self.async_step_init() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items() } chores_dict = self._entry_options.get(CONF_CHORES, {}) achievement_schema = build_achievement_schema( kids_dict=kids_dict, chores_dict=chores_dict, default=achievement_data ) return self.async_show_form( step_id="edit_achievement", data_schema=achievement_schema, errors=errors ) async def async_step_edit_challenge(self, user_input=None): """Edit an existing challenge.""" self._entry_options = dict(self.config_entry.options) errors = {} challenges_dict = self._entry_options.get(CONF_CHALLENGES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in challenges_dict: LOGGER.error("Edit challenge: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_challenge") challenge_data = challenges_dict[internal_id] if user_input is None: kids_dict = { data["name"]: kid_id for kid_id, data in self._entry_options.get(CONF_KIDS, {}).items() } chores_dict = self._entry_options.get(CONF_CHORES, {}) # Convert stored start/end dates to a display format (e.g. local time string) default_data = { **challenge_data, "start_date": challenge_data.get("start_date") and dt_util.as_local( dt_util.parse_datetime(challenge_data["start_date"]) ).strftime("%Y-%m-%d %H:%M:%S"), "end_date": challenge_data.get("end_date") and dt_util.as_local( dt_util.parse_datetime(challenge_data["end_date"]) ).strftime("%Y-%m-%d %H:%M:%S"), } schema = build_challenge_schema( kids_dict=kids_dict, chores_dict=chores_dict, default=default_data ) return self.async_show_form( step_id="edit_challenge", data_schema=schema, errors=errors ) start_date_input = user_input.get("start_date") if start_date_input: try: new_start_date = ensure_utc_datetime(self.hass, start_date_input) start_dt = dt_util.parse_datetime(new_start_date) if start_dt and start_dt < dt_util.utcnow(): errors["start_date"] = "start_date_in_past" except Exception: errors["start_date"] = "invalid_start_date" new_start_date = None else: new_start_date = None end_date_input = user_input.get("end_date") if end_date_input: try: new_end_date = ensure_utc_datetime(self.hass, end_date_input) end_dt = dt_util.parse_datetime(new_end_date) if end_dt and end_dt <= dt_util.utcnow(): errors["end_date"] = "end_date_in_past" if new_start_date: sdt = dt_util.parse_datetime(new_start_date) if sdt and end_dt and end_dt <= sdt: errors["end_date"] = "end_date_not_after_start_date" except Exception: errors["end_date"] = "invalid_end_date" new_end_date = None else: new_end_date = None if user_input is not None: new_name = user_input["name"].strip() if any( data["name"] == new_name and eid != internal_id for eid, data in challenges_dict.items() ): errors["name"] = "duplicate_challenge" else: _type = user_input["type"] chore_id = "" if _type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: c = user_input.get("selected_chore_id") or "" if not c or c == "None": errors["selected_chore_id"] = "a_chore_must_be_selected" chore_id = c if not errors: challenge_data["name"] = new_name challenge_data["description"] = user_input.get("description", "") challenge_data["challenge_labels"] = user_input.get( "challenge_labels", [] ) challenge_data["icon"] = user_input.get("icon", "") challenge_data["assigned_kids"] = user_input["assigned_kids"] challenge_data["type"] = _type challenge_data["selected_chore_id"] = chore_id challenge_data["criteria"] = user_input.get("criteria", "").strip() challenge_data["target_value"] = user_input["target_value"] challenge_data["reward_points"] = user_input["reward_points"] challenge_data["start_date"] = new_start_date challenge_data["end_date"] = new_end_date LOGGER.debug( "Edited challenge '%s' with ID: %s", new_name, internal_id ) await self._update_and_reload() return await self.async_step_init() kids_dict = { kid_data["name"]: kid_id for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items() } chores_dict = self._entry_options.get(CONF_CHORES, {}) default_data = { **challenge_data, "start_date": new_start_date, "end_date": new_end_date, } challenge_schema = build_challenge_schema( kids_dict=kids_dict, chores_dict=chores_dict, default=default_data ) return self.async_show_form( step_id="edit_challenge", data_schema=challenge_schema, errors=errors ) # ------------------ DELETE ENTITY ------------------ async def async_step_delete_kid(self, user_input=None): """Delete a kid.""" self._entry_options = dict(self.config_entry.options) kids_dict = self._entry_options.get(CONF_KIDS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in kids_dict: LOGGER.error("Delete kid: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_kid") kid_name = kids_dict[internal_id]["name"] if user_input is not None: kids_dict.pop(internal_id, None) self._entry_options[CONF_KIDS] = kids_dict LOGGER.debug("Deleted kid '%s' with ID: %s", kid_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_kid", data_schema=vol.Schema({}), description_placeholders={"kid_name": kid_name}, ) async def async_step_delete_parent(self, user_input=None): """Delete a parent.""" self._entry_options = dict(self.config_entry.options) parents_dict = self._entry_options.get(CONF_PARENTS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in parents_dict: LOGGER.error("Delete parent: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_parent") parent_name = parents_dict[internal_id]["name"] if user_input is not None: parents_dict.pop(internal_id, None) self._entry_options[CONF_PARENTS] = parents_dict LOGGER.debug("Deleted parent '%s' with ID: %s", parent_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_parent", data_schema=vol.Schema({}), description_placeholders={"parent_name": parent_name}, ) async def async_step_delete_chore(self, user_input=None): """Delete a chore.""" self._entry_options = dict(self.config_entry.options) chores_dict = self._entry_options.get(CONF_CHORES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in chores_dict: LOGGER.error("Delete chore: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_chore") chore_name = chores_dict[internal_id]["name"] if user_input is not None: chores_dict.pop(internal_id, None) self._entry_options[CONF_CHORES] = chores_dict LOGGER.debug("Deleted chore '%s' with ID: %s", chore_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_chore", data_schema=vol.Schema({}), description_placeholders={"chore_name": chore_name}, ) async def async_step_delete_badge(self, user_input=None): """Delete a badge.""" self._entry_options = dict(self.config_entry.options) badges_dict = self._entry_options.get(CONF_BADGES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in badges_dict: LOGGER.error("Delete badge: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_badge") badge_name = badges_dict[internal_id]["name"] if user_input is not None: badges_dict.pop(internal_id, None) self._entry_options[CONF_BADGES] = badges_dict LOGGER.debug("Deleted badge '%s' with ID: %s", badge_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_badge", data_schema=vol.Schema({}), description_placeholders={"badge_name": badge_name}, ) async def async_step_delete_reward(self, user_input=None): """Delete a reward.""" self._entry_options = dict(self.config_entry.options) rewards_dict = self._entry_options.get(CONF_REWARDS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in rewards_dict: LOGGER.error("Delete reward: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_reward") reward_name = rewards_dict[internal_id]["name"] if user_input is not None: rewards_dict.pop(internal_id, None) self._entry_options[CONF_REWARDS] = rewards_dict LOGGER.debug("Deleted reward '%s' with ID: %s", reward_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_reward", data_schema=vol.Schema({}), description_placeholders={"reward_name": reward_name}, ) async def async_step_delete_penalty(self, user_input=None): """Delete a penalty.""" self._entry_options = dict(self.config_entry.options) penalties_dict = self._entry_options.get(CONF_PENALTIES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in penalties_dict: LOGGER.error("Delete penalty: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_penalty") penalty_name = penalties_dict[internal_id]["name"] if user_input is not None: penalties_dict.pop(internal_id, None) self._entry_options[CONF_PENALTIES] = penalties_dict LOGGER.debug("Deleted penalty '%s' with ID: %s", penalty_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_penalty", data_schema=vol.Schema({}), description_placeholders={"penalty_name": penalty_name}, ) async def async_step_delete_achievement(self, user_input=None): """Delete an achievement.""" self._entry_options = dict(self.config_entry.options) achievements_dict = self._entry_options.get(CONF_ACHIEVEMENTS, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in achievements_dict: LOGGER.error("Delete achievement: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_achievement") achievement_name = achievements_dict[internal_id]["name"] if user_input is not None: achievements_dict.pop(internal_id, None) self._entry_options[CONF_ACHIEVEMENTS] = achievements_dict LOGGER.debug( "Deleted achievement '%s' with ID: %s", achievement_name, internal_id ) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_achievement", data_schema=vol.Schema({}), description_placeholders={"achievement_name": achievement_name}, ) async def async_step_delete_challenge(self, user_input=None): """Delete a challenge.""" self._entry_options = dict(self.config_entry.options) challenges_dict = self._entry_options.get(CONF_CHALLENGES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in challenges_dict: LOGGER.error("Delete challenge: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_challenge") challenge_name = challenges_dict[internal_id]["name"] if user_input is not None: challenges_dict.pop(internal_id, None) self._entry_options[CONF_CHALLENGES] = challenges_dict LOGGER.debug( "Deleted challenge '%s' with ID: %s", challenge_name, internal_id ) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_challenge", data_schema=vol.Schema({}), description_placeholders={"challenge_name": challenge_name}, ) async def async_step_delete_bonus(self, user_input=None): """Delete a bonus.""" self._entry_options = dict(self.config_entry.options) bonuses_dict = self._entry_options.get(CONF_BONUSES, {}) internal_id = self.context.get("internal_id") if not internal_id or internal_id not in bonuses_dict: LOGGER.error("Delete bonus: Invalid internal_id '%s'", internal_id) return self.async_abort(reason="invalid_bonus") bonus_name = bonuses_dict[internal_id]["name"] if user_input is not None: bonuses_dict.pop(internal_id, None) self._entry_options[CONF_BONUSES] = bonuses_dict LOGGER.debug("Deleted bonus '%s' with ID: %s", bonus_name, internal_id) await self._update_and_reload() return await self.async_step_init() return self.async_show_form( step_id="delete_bonus", data_schema=vol.Schema({}), description_placeholders={"bonus_name": bonus_name}, ) # ------------------ HELPER METHODS ------------------ async def _update_and_reload(self): """Update the config entry options and reload the integration.""" new_data = dict(self.config_entry.data) new_data["last_change"] = dt_util.utcnow().isoformat() self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, options=self._entry_options ) LOGGER.debug( "Called update_entry. Now reloading entry: %s", self.config_entry.entry_id ) await self.hass.config_entries.async_reload(self.config_entry.entry_id) LOGGER.debug("Options updated and integration reloaded") ================================================ FILE: custom_components/kidschores/select.py ================================================ # File: select.py """Select entities for the KidsChores integration. Allows the user to pick from all chores, all rewards, or all penalties in a global manner. This is useful for automations or scripts where a user wishes to select a chore/reward/penalty dynamically. """ from __future__ import annotations from typing import Optional from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER from .coordinator import KidsChoresDataCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the KidsChores select entities from a config entry. Creates three global selects: 1) ChoresSelect: lists all chore names 2) RewardsSelect: lists all reward names 3) PenaltiesSelect: lists all penalty names """ data = hass.data[DOMAIN][entry.entry_id] coordinator: KidsChoresDataCoordinator = data["coordinator"] # Create one global select entity for each category selects = [ ChoresSelect(coordinator, entry), RewardsSelect(coordinator, entry), PenaltiesSelect(coordinator, entry), ] for kid_id in coordinator.kids_data.keys(): selects.append(ChoresKidSelect(coordinator, entry, kid_id)) async_add_entities(selects) class KidsChoresSelectBase(CoordinatorEntity, SelectEntity): """Base class for the KidsChores select entities.""" _attr_has_entity_name = True _attr_translation_key = "kc_select_base" def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry): """Initialize the base select entity.""" super().__init__(coordinator) self._entry = entry self._selected_option: Optional[str] = None @property def current_option(self) -> Optional[str]: """Return the currently selected option (chore/reward/penalty name). None if nothing has been selected. """ return self._selected_option async def async_select_option(self, option: str) -> None: """When the user selects an option from the dropdown, store it. By default, no further action is taken. """ self._selected_option = option LOGGER.debug( "%s: User selected option '%s'", self._attr_name, option, ) self.async_write_ha_state() class ChoresSelect(KidsChoresSelectBase): """Global select entity listing all defined chores by name.""" _attr_has_entity_name = True _attr_translation_key = "chores_select" def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry): """Initialize the Chores select entity.""" super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_chores_select" self._attr_name = "KidsChores: All Chores" self.entity_id = f"select.kc_all_chores" @property def options(self) -> list[str]: """Return a list of chore names from the coordinator. If no chores exist, returns an empty list. """ return [ chore_info.get("name", f"Chore {chore_id}") for chore_id, chore_info in self.coordinator.chores_data.items() ] class RewardsSelect(KidsChoresSelectBase): """Global select entity listing all defined rewards by name.""" _attr_has_entity_name = True _attr_translation_key = "rewards_select" def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry): """Initialize the Rewards select entity.""" super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_rewards_select" self._attr_name = "KidsChores: All Rewards" self.entity_id = f"select.kc_all_rewards" @property def options(self) -> list[str]: """Return a list of reward names from the coordinator. If no rewards exist, returns an empty list. """ return [ reward_info.get("name", f"Reward {reward_id}") for reward_id, reward_info in self.coordinator.rewards_data.items() ] class PenaltiesSelect(KidsChoresSelectBase): """Global select entity listing all defined penalties by name.""" _attr_has_entity_name = True _attr_translation_key = "penalties_select" def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry): """Initialize the Penalties select entity.""" super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_penalties_select" self._attr_name = "KidsChores: All Penalties" self.entity_id = f"select.kc_all_penalties" @property def options(self) -> list[str]: """Return a list of penalty names from the coordinator. If no penalties exist, returns an empty list. """ return [ penalty_info.get("name", f"Penalty {penalty_id}") for penalty_id, penalty_info in self.coordinator.penalties_data.items() ] class ChoresKidSelect(KidsChoresSelectBase): """Select entity listing only the chores assigned to a specific kid.""" _attr_has_entity_name = True _attr_translation_key = "chores_kid_select" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str ): """Initialize the ChoresKidSelect.""" super().__init__(coordinator, entry) self._kid_id = kid_id kid_name = coordinator.kids_data.get(kid_id, {}).get("name", f"Kid {kid_id}") self._attr_unique_id = f"{entry.entry_id}_chores_select_{kid_id}" self._attr_name = f"KidsChores: Chores for {kid_name}" self.entity_id = f"select.kc_{kid_name}_chore_list" @property def options(self) -> list[str]: """Return a list of chore names assigned to this kid, with a 'None' option.""" # Start with a "None" entry options = ["None"] for chore_id, chore in self.coordinator.chores_data.items(): if self._kid_id in chore.get("assigned_kids", []): options.append(chore.get("name", f"Chore {chore_id}")) return options class BonusesSelect(KidsChoresSelectBase): """Global select entity listing all defined bonuses by name.""" _attr_has_entity_name = True _attr_translation_key = "bonuses_select" def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry): """Initialize the Bonuses select entity.""" super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_bonuses_select" self._attr_name = "KidsChores: All Bonuses" self.entity_id = f"select.kc_all_bonuses" @property def options(self) -> list[str]: """Return a list of bonus names from the coordinator. If no bonuses exist, returns an empty list. """ return [ bonus_info.get("name", f"Bonus {bonus_id}") for bonus_id, bonus_info in self.coordinator.bonuses_data.items() ] ================================================ FILE: custom_components/kidschores/sensor.py ================================================ # File: sensor.py """Sensors for the KidsChores integration. This file defines all sensor entities for each Kid, Chore, Reward, and Badge. Available Sensors: 01. KidPointsSensor .................... Kid's current total points 02. KidPointsEarnedDailySensor ......... Points earned by the kid today 03. KidPointsEarnedWeeklySensor ........ Points earned by the kid this week 04. KidPointsEarnedMonthlySensor ....... Points earned by the kid this month 05. KidMaxPointsEverSensor ............. The highest points total the kid has ever reached 06. CompletedChoresDailySensor ......... Chores completed by the kid today 07. CompletedChoresWeeklySensor ........ Chores completed by the kid this week 08. CompletedChoresMonthlySensor ....... Chores completed by the kid this month 09. CompletedChoresTotalSensor ......... Total chores completed by the kid 10.* KidBadgesSensor .................... Number of badges the kid currently has - DEPRECATE 11. KidHighestBadgeSensor .............. The highest (threshold) badge the kid holds 12. BadgeSensor ........................ One sensor per badge, showing its threshold & who earned it 13. ChoreStatusSensor .................. Shows current state (pending/claimed/approved, etc.) for each (kid, chore) 14. SharedChoreGlobalStateSensor ....... Shows current global state for shared chores 15. RewardStatusSensor ................. Shows current state (not claimed/claimed/approved) for each (kid, reward) 16. PenaltyAppliesSensor ............... Tracks how many times each penalty was applied for each kid 17.* RewardClaimsSensor ................. Number of times a reward was claimed by a kid - DEPRECATE 18.* RewardApprovalsSensor .............. Number of times a reward was approved for a kid - DEPRECATE 19.* ChoreClaimsSensor .................. Number of times a chore was claimed by a kid - DEPRECATE 20.* ChoreApprovalsSensor ............... Number of times a chore was approved for a kid - DEPRECATE 21. PendingChoreApprovalsSensor ........ Lists chores that are awaiting approval 22. PendingRewardApprovalsSensor ....... Lists rewards that are awaiting approval 23. AchievementSensor .................. Shows the achievement name, target value, reward points, and number of kids that have earned it 24. ChallengeSensor .................... Shows the challenge name, target, reward, and number of kids that have completed it 25. AchievementProgressSensor .......... Progress (in %) toward an achievement per kid 26. ChallengeProgressSensor ............ Progress (in %) toward a challenge per kid 27. KidHighestStreakSensor ............. The highest current streak (in days) among streak-type achievements for a kid 28.* ChoreStreakSensor .................. Current streak (in days) for a kid for a specific chore - DEPRECATE """ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.components.sensor import SensorEntity from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( ACHIEVEMENT_TYPE_DAILY_MIN, ACHIEVEMENT_TYPE_STREAK, ACHIEVEMENT_TYPE_TOTAL, ATTR_ACHIEVEMENT_NAME, ATTR_ALL_EARNED_BADGES, ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY, ATTR_APPLICABLE_DAYS, ATTR_ASSIGNED_KIDS, ATTR_ASSOCIATED_CHORE, ATTR_AWARDED, ATTR_BADGES, ATTR_CHALLENGE_NAME, ATTR_CHALLENGE_TYPE, ATTR_CLAIMED_ON, ATTR_CHORE_APPROVALS_COUNT, ATTR_CHORE_APPROVALS_TODAY, ATTR_CHORE_CLAIMS_COUNT, ATTR_CHORE_CURRENT_STREAK, ATTR_CHORE_HIGHEST_STREAK, ATTR_CHORE_NAME, ATTR_COST, ATTR_CRITERIA, ATTR_CUSTOM_FREQUENCY_INTERVAL, ATTR_CUSTOM_FREQUENCY_UNIT, ATTR_DEFAULT_POINTS, ATTR_DESCRIPTION, ATTR_DUE_DATE, ATTR_END_DATE, ATTR_HIGHEST_BADGE_THRESHOLD_VALUE, ATTR_GLOBAL_STATE, ATTR_KID_NAME, ATTR_KIDS_EARNED, ATTR_LABELS, ATTR_LAST_DATE, ATTR_PARTIAL_ALLOWED, ATTR_PENALTY_NAME, ATTR_PENALTY_POINTS, ATTR_POINTS_MULTIPLIER, ATTR_POINTS_TO_NEXT_BADGE, ATTR_RECURRING_FREQUENCY, ATTR_RAW_PROGRESS, ATTR_RAW_STREAK, ATTR_REDEEMED_ON, ATTR_REWARD_APPROVALS_COUNT, ATTR_REWARD_CLAIMS_COUNT, ATTR_REWARD_NAME, ATTR_REWARD_POINTS, ATTR_START_DATE, ATTR_SHARED_CHORE, ATTR_BONUS_NAME, ATTR_BONUS_POINTS, ATTR_TARGET_VALUE, ATTR_THRESHOLD_TYPE, ATTR_TYPE, CHALLENGE_TYPE_DAILY_MIN, CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW, CHORE_STATE_APPROVED, CHORE_STATE_CLAIMED, CHORE_STATE_OVERDUE, CHORE_STATE_PENDING, CHORE_STATE_UNKNOWN, CONF_POINTS_ICON, CONF_POINTS_LABEL, DATA_PENDING_CHORE_APPROVALS, DATA_PENDING_REWARD_APPROVALS, DEFAULT_ACHIEVEMENTS_ICON, DEFAULT_BADGE_ICON, DEFAULT_CHALLENGES_ICON, DEFAULT_CHORE_SENSOR_ICON, DEFAULT_PENALTY_ICON, DEFAULT_PENALTY_POINTS, DEFAULT_POINTS_ICON, DEFAULT_POINTS_LABEL, DEFAULT_REWARD_COST, DEFAULT_REWARD_ICON, DEFAULT_BONUS_ICON, DEFAULT_BONUS_POINTS, DEFAULT_STREAK_ICON, DEFAULT_TROPHY_ICON, DEFAULT_TROPHY_OUTLINE, DOMAIN, DUE_DATE_NOT_SET, FREQUENCY_CUSTOM, LABEL_POINTS, REWARD_STATE_APPROVED, REWARD_STATE_CLAIMED, REWARD_STATE_NOT_CLAIMED, UNKNOWN_CHORE, UNKNOWN_KID, UNKNOWN_REWARD, ) from .coordinator import KidsChoresDataCoordinator from .kc_helpers import get_friendly_label async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ): """Set up sensors for KidsChores integration.""" data = hass.data[DOMAIN][entry.entry_id] coordinator: KidsChoresDataCoordinator = data["coordinator"] points_label = entry.options.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL) points_icon = entry.options.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON) entities = [] # Sensor to detail number of Chores pending approval entities.append(PendingChoreApprovalsSensor(coordinator, entry)) # Sensor to detail number of Rewards pending approval entities.append(PendingRewardApprovalsSensor(coordinator, entry)) # For each kid, add standard sensors for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") # Points counter sensor entities.append( KidPointsSensor( coordinator, entry, kid_id, kid_name, points_label, points_icon ) ) entities.append( CompletedChoresTotalSensor(coordinator, entry, kid_id, kid_name) ) # Chores completed by each Kid during the day entities.append( CompletedChoresDailySensor(coordinator, entry, kid_id, kid_name) ) # Chores completed by each Kid during the week entities.append( CompletedChoresWeeklySensor(coordinator, entry, kid_id, kid_name) ) # Chores completed by each Kid during the month entities.append( CompletedChoresMonthlySensor(coordinator, entry, kid_id, kid_name) ) # Badges Obtained by each Kid entities.append(KidBadgesSensor(coordinator, entry, kid_id, kid_name)) # Kid Highest Badge entities.append(KidHighestBadgeSensor(coordinator, entry, kid_id, kid_name)) # Poimts obtained per Kid during the day entities.append( KidPointsEarnedDailySensor( coordinator, entry, kid_id, kid_name, points_label, points_icon ) ) # Poimts obtained per Kid during the week entities.append( KidPointsEarnedWeeklySensor( coordinator, entry, kid_id, kid_name, points_label, points_icon ) ) # Poimts obtained per Kid during the month entities.append( KidPointsEarnedMonthlySensor( coordinator, entry, kid_id, kid_name, points_label, points_icon ) ) # Maximum Points ever obtained ny a kid entities.append( KidMaxPointsEverSensor( coordinator, entry, kid_id, kid_name, points_label, points_icon ) ) # Reward Claims and Approvals for reward_id, reward_info in coordinator.rewards_data.items(): reward_name = reward_info.get("name", f"Reward {reward_id}") entities.append( RewardClaimsSensor( coordinator, entry, kid_id, kid_name, reward_id, reward_name ) ) # Rewards Approval Sensor entities.append( RewardApprovalsSensor( coordinator, entry, kid_id, kid_name, reward_id, reward_name ) ) # Chore Claims and Approvals for chore_id, chore_info in coordinator.chores_data.items(): if kid_id not in chore_info.get("assigned_kids", []): continue chore_name = chore_info.get("name", f"Chore {chore_id}") entities.append( ChoreClaimsSensor( coordinator, entry, kid_id, kid_name, chore_id, chore_name ) ) # Chore Approvals Sensor entities.append( ChoreApprovalsSensor( coordinator, entry, kid_id, kid_name, chore_id, chore_name ) ) # Chore Streak per Kid entities.append( ChoreStreakSensor( coordinator, entry, kid_id, kid_name, chore_id, chore_name ) ) # Penalty Applies for penalty_id, penalty_info in coordinator.penalties_data.items(): penalty_name = penalty_info.get("name", f"Penalty {penalty_id}") entities.append( PenaltyAppliesSensor( coordinator, entry, kid_id, kid_name, penalty_id, penalty_name ) ) # Bonus Applies for bonus_id, bonus_info in coordinator.bonuses_data.items(): bonus_name = bonus_info.get("name", f"Bonus {bonus_id}") entities.append( BonusAppliesSensor( coordinator, entry, kid_id, kid_name, bonus_id, bonus_name ) ) # Achivement Progress per Kid for achievement_id, achievement in coordinator.achievements_data.items(): if kid_id in achievement.get("assigned_kids", []): achievement_name = achievement.get( "name", f"Achievement {achievement_id}" ) entities.append( AchievementProgressSensor( coordinator, entry, kid_id, kid_name, achievement_id, achievement_name, ) ) # Challenge Progress per Kid for challenge_id, challenge in coordinator.challenges_data.items(): if kid_id in challenge.get("assigned_kids", []): challenge_name = challenge.get("name", f"Challenge {challenge_id}") entities.append( ChallengeProgressSensor( coordinator, entry, kid_id, kid_name, challenge_id, challenge_name, ) ) # Highest Streak Sensor per Kid entities.append(KidHighestStreakSensor(coordinator, entry, kid_id, kid_name)) # For each chore assigned to each kid, add a ChoreStatusSensor for chore_id, chore_info in coordinator.chores_data.items(): chore_name = chore_info.get("name", f"Chore {chore_id}") assigned_kids_ids = chore_info.get("assigned_kids", []) for kid_id in assigned_kids_ids: kid_name = coordinator._get_kid_name_by_id(kid_id) or f"Kid {kid_id}" entities.append( ChoreStatusSensor( coordinator, entry, kid_id, kid_name, chore_id, chore_name ) ) # For each shared chore, add a global state sensor for chore_id, chore_info in coordinator.chores_data.items(): if chore_info.get("shared_chore", False): chore_name = chore_info.get("name", f"Chore {chore_id}") entities.append( SharedChoreGlobalStateSensor(coordinator, entry, chore_id, chore_name) ) # For each Reward, add a RewardStatusSensor for reward_id, reward_info in coordinator.rewards_data.items(): reward_name = reward_info.get("name", f"Reward {reward_id}") # For each kid, create the reward status sensor for kid_id, kid_info in coordinator.kids_data.items(): kid_name = kid_info.get("name", f"Kid {kid_id}") entities.append( RewardStatusSensor( coordinator, entry, kid_id, kid_name, reward_id, reward_name ) ) # For each Badge, add a BadgeSensor for badge_id, badge_info in coordinator.badges_data.items(): badge_name = badge_info.get("name", f"Badge {badge_id}") entities.append(BadgeSensor(coordinator, entry, badge_id, badge_name)) # For each Achievement, add an AchievementSensor for achievement_id, achievement in coordinator.achievements_data.items(): achievement_name = achievement.get("name", f"Achievement {achievement_id}") entities.append( AchievementSensor(coordinator, entry, achievement_id, achievement_name) ) # For each Challenge, add a ChallengeSensor for challenge_id, challenge in coordinator.challenges_data.items(): challenge_name = challenge.get("name", f"Challenge {challenge_id}") entities.append( ChallengeSensor(coordinator, entry, challenge_id, challenge_name) ) async_add_entities(entities) # ------------------------------------------------------------------------------------------ class ChoreStatusSensor(CoordinatorEntity, SensorEntity): """Sensor for chore status: pending/claimed/approved/etc.""" _attr_has_entity_name = True _attr_translation_key = "chore_status_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, chore_id, chore_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._entry = entry self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_status" self.entity_id = f"sensor.kc_{kid_name}_chore_status_{chore_name}" self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } @property def native_value(self): """Return the chore's state based on shared or individual tracking.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) kid_info = self.coordinator.kids_data.get(self._kid_id, {}) # The status of the kids chore should always be their own status, it's only global status that would show independent or in-part if self._chore_id in kid_info.get("approved_chores", []): return CHORE_STATE_APPROVED elif self._chore_id in kid_info.get("claimed_chores", []): return CHORE_STATE_CLAIMED elif self._chore_id in kid_info.get("overdue_chores", []): return CHORE_STATE_OVERDUE else: return CHORE_STATE_PENDING @property def extra_state_attributes(self): """Include points, description, etc.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) shared = chore_info.get("shared_chore", False) global_state = chore_info.get("state", CHORE_STATE_UNKNOWN) assigned_kids_ids = chore_info.get("assigned_kids", []) assigned_kids_names = [ self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}" for k_id in assigned_kids_ids ] kid_info = self.coordinator.kids_data.get(self._kid_id, {}) chore_streak_data = kid_info.get("chore_streaks", {}).get(self._chore_id, {}) current_streak = chore_streak_data.get("current_streak", 0) highest_streak = chore_streak_data.get("max_streak", 0) stored_labels = chore_info.get("chore_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_KID_NAME: self._kid_name, ATTR_CHORE_NAME: self._chore_name, ATTR_DESCRIPTION: chore_info.get("description", ""), ATTR_CHORE_CLAIMS_COUNT: kid_info.get("chore_claims", {}).get( self._chore_id, 0 ), ATTR_CHORE_APPROVALS_COUNT: kid_info.get("chore_approvals", {}).get( self._chore_id, 0 ), ATTR_CHORE_CURRENT_STREAK: current_streak, ATTR_CHORE_HIGHEST_STREAK: highest_streak, ATTR_SHARED_CHORE: shared, ATTR_GLOBAL_STATE: global_state, ATTR_RECURRING_FREQUENCY: chore_info.get("recurring_frequency", "None"), ATTR_APPLICABLE_DAYS: chore_info.get("applicable_days", []), ATTR_DUE_DATE: chore_info.get("due_date", DUE_DATE_NOT_SET), ATTR_DEFAULT_POINTS: chore_info.get("default_points", 0), ATTR_PARTIAL_ALLOWED: chore_info.get("partial_allowed", False), ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY: chore_info.get( "allow_multiple_claims_per_day", False ), ATTR_ASSIGNED_KIDS: assigned_kids_names, ATTR_LABELS: friendly_labels, } if chore_info.get("allow_multiple_claims_per_day", False): today_approvals = kid_info.get("today_chore_approvals", {}).get( self._chore_id, 0 ) attributes[ATTR_CHORE_APPROVALS_TODAY] = today_approvals if chore_info.get("recurring_frequency") == FREQUENCY_CUSTOM: attributes[ATTR_CUSTOM_FREQUENCY_INTERVAL] = chore_info.get( "custom_interval" ) attributes[ATTR_CUSTOM_FREQUENCY_UNIT] = chore_info.get( "custom_interval_unit" ) return attributes @property def icon(self): """Use the chore's custom icon if set, else fallback.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON) # ------------------------------------------------------------------------------------------ class KidPointsSensor(CoordinatorEntity, SensorEntity): """Sensor for a kid's total points balance.""" _attr_has_entity_name = True _attr_translation_key = "kid_points_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._points_label = points_label self._points_icon = points_icon self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points" self._attr_state_class = "measurement" self._attr_translation_placeholders = { "kid_name": kid_name, "points": self._points_label, } self.entity_id = f"sensor.kc_{kid_name}_points" @property def native_value(self): """Return the kid's total points.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("points", 0) @property def native_unit_of_measurement(self): """Return the points label.""" return self._points_label or LABEL_POINTS @property def icon(self): """Use the points' custom icon if set, else fallback.""" return self._points_icon or DEFAULT_POINTS_ICON # ------------------------------------------------------------------------------------------ class KidMaxPointsEverSensor(CoordinatorEntity, SensorEntity): """Sensor showing the maximum points a kid has ever reached.""" _attr_has_entity_name = True _attr_translation_key = "kid_max_points_ever_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._points_label = points_label self._points_icon = points_icon self._attr_unique_id = f"{entry.entry_id}_{kid_id}_max_points_ever" self._entry = entry self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_points_max_ever" @property def native_value(self): """Return the highest points total the kid has ever reached.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("max_points_ever", 0) @property def icon(self): """Use the same icon as points or any custom icon you prefer.""" return self._points_icon or DEFAULT_POINTS_ICON @property def native_unit_of_measurement(self): """Optionally display the same points label for consistency.""" return self._points_label or LABEL_POINTS # ------------------------------------------------------------------------------------------ class CompletedChoresTotalSensor(CoordinatorEntity, SensorEntity): """Sensor tracking the total number of chores a kid has completed since integration start.""" _attr_has_entity_name = True _attr_translation_key = "chores_completed_total_sensor" def __init__(self, coordinator, entry, kid_id, kid_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_total" self._attr_native_unit_of_measurement = "chores" self._attr_icon = "mdi:clipboard-check-outline" self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_chores_completed_total" @property def native_value(self): """Return the total number of chores completed by the kid.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("completed_chores_total", 0) # ------------------------------------------------------------------------------------------ class CompletedChoresDailySensor(CoordinatorEntity, SensorEntity): """How many chores kid completed today.""" _attr_has_entity_name = True _attr_translation_key = "chores_completed_daily_sensor" def __init__(self, coordinator, entry, kid_id, kid_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_daily" self._attr_native_unit_of_measurement = "chores" self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_chores_completed_daily" @property def native_value(self): """Return the number of chores completed today.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("completed_chores_today", 0) # ------------------------------------------------------------------------------------------ class CompletedChoresWeeklySensor(CoordinatorEntity, SensorEntity): """How many chores kid completed this week.""" _attr_has_entity_name = True _attr_translation_key = "chores_completed_weekly_sensor" def __init__(self, coordinator, entry, kid_id, kid_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_weekly" self._attr_native_unit_of_measurement = "chores" self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_chores_completed_weekly" @property def native_value(self): """Return the number of chores completed this week.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("completed_chores_weekly", 0) # ------------------------------------------------------------------------------------------ class CompletedChoresMonthlySensor(CoordinatorEntity, SensorEntity): """How many chores kid completed this month.""" _attr_has_entity_name = True _attr_translation_key = "chores_completed_monthly_sensor" def __init__(self, coordinator, entry, kid_id, kid_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_monthly" self._attr_native_unit_of_measurement = "chores" self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_chores_completed_monthly" @property def native_value(self): """Return the number of chores completed this month.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("completed_chores_monthly", 0) # DEPRECATE -------------------------------------------------------------------------------- class KidBadgesSensor(CoordinatorEntity, SensorEntity): """Sensor: number of badges earned + attribute with the list.""" _attr_has_entity_name = True _attr_translation_key = "kid_badges_sensor" def __init__(self, coordinator, entry, kid_id, kid_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_badges" self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_badges" @property def native_value(self): """Return the number of badges the kid has earned.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return len(kid_info.get("badges", [])) @property def extra_state_attributes(self): """Include the list of badges the kid has earned.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return {ATTR_BADGES: kid_info.get("badges", [])} # ------------------------------------------------------------------------------------------ class KidHighestBadgeSensor(CoordinatorEntity, SensorEntity): """Sensor that returns the "highest" badge the kid currently has.""" _attr_has_entity_name = True _attr_translation_key = "kids_highest_badge_sensor" def __init__(self, coordinator, entry, kid_id, kid_name): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_highest_badge" self._attr_translation_placeholders = {"kid_name": kid_name} self.entity_id = f"sensor.kc_{kid_name}_highest_badge" def _find_highest_badge(self): """Determine which badge has the highest ranking.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) earned_badge_names = kid_info.get("badges", []) highest_badge = None highest_value = -1 for badge_name in earned_badge_names: # Find badge by name badge_data = next( ( info for bid, info in self.coordinator.badges_data.items() if info.get("name") == badge_name ), None, ) if not badge_data: continue # skip if not found or invalid threshold_val = badge_data.get("threshold_value", 0) if threshold_val > highest_value: highest_value = threshold_val highest_badge = badge_name return highest_badge, highest_value @property def native_value(self) -> str: """Return the badge name of the highest-threshold badge the kid has earned. If the kid has none, return "None". """ highest_badge, _ = self._find_highest_badge() return highest_badge if highest_badge else "None" @property def icon(self): """Return the icon for the highest badge. Fall back if none found.""" highest_badge, _ = self._find_highest_badge() if highest_badge: badge_data = next( ( info for bid, info in self.coordinator.badges_data.items() if info.get("name") == highest_badge ), {}, ) return badge_data.get("icon", DEFAULT_TROPHY_ICON) return DEFAULT_TROPHY_OUTLINE @property def extra_state_attributes(self): """Provide additional details.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) highest_badge, highest_val = self._find_highest_badge() current_multiplier = 1.0 friendly_labels = [] if highest_badge: badge_data = next( ( info for bid, info in self.coordinator.badges_data.items() if info.get("name") == highest_badge ), {}, ) current_multiplier = badge_data.get("points_multiplier", 1.0) stored_labels = badge_data.get("badge_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] # Compute points needed for next badge: current_points = kid_info.get("points", 0) # Gather thresholds for badges that are higher than current points thresholds = [ badge.get("threshold_value", 0) for badge in self.coordinator.badges_data.values() if badge.get("threshold_value", 0) > current_points ] if thresholds: next_threshold = min(thresholds) points_to_next_badge = next_threshold - current_points else: points_to_next_badge = 0 return { ATTR_KID_NAME: self._kid_name, ATTR_ALL_EARNED_BADGES: kid_info.get("badges", []), ATTR_HIGHEST_BADGE_THRESHOLD_VALUE: highest_val if highest_badge else 0, ATTR_POINTS_MULTIPLIER: current_multiplier, ATTR_POINTS_TO_NEXT_BADGE: points_to_next_badge, ATTR_LABELS: friendly_labels, } # ------------------------------------------------------------------------------------------ class BadgeSensor(CoordinatorEntity, SensorEntity): """Sensor representing a single badge in KidsChores.""" _attr_has_entity_name = True _attr_translation_key = "badge_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, badge_id: str, badge_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._badge_id = badge_id self._badge_name = badge_name self._attr_unique_id = f"{entry.entry_id}_{badge_id}_badge_sensor" self._attr_translation_placeholders = {"badge_name": badge_name} self.entity_id = f"sensor.kc_{badge_name}_badge" @property def native_value(self) -> float: """The sensor state is the threshold_value for the badge.""" badge_info = self.coordinator.badges_data.get(self._badge_id, {}) return badge_info.get("threshold_value", 0) @property def extra_state_attributes(self): """Provide additional badge data, including which kids currently have it.""" badge_info = self.coordinator.badges_data.get(self._badge_id, {}) threshold_type = badge_info.get("threshold_type", "points") points_multiplier = badge_info.get("points_multiplier", 1.0) description = badge_info.get("description", "") kids_earned_ids = badge_info.get("earned_by", []) stored_labels = badge_info.get("badge_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] # Convert each kid_id to kid_name kids_earned_names = [] for kid_id in kids_earned_ids: kid = self.coordinator.kids_data.get(kid_id) if kid is not None: kids_earned_names.append(kid.get("name", f"Kid {kid_id}")) else: kids_earned_names.append(f"Kid {kid_id} (not found)") return { ATTR_DESCRIPTION: description, ATTR_THRESHOLD_TYPE: threshold_type, ATTR_POINTS_MULTIPLIER: points_multiplier, ATTR_KIDS_EARNED: kids_earned_names, ATTR_LABELS: friendly_labels, } @property def icon(self) -> str: """Return the badge's custom icon if set, else default.""" badge_info = self.coordinator.badges_data.get(self._badge_id, {}) return badge_info.get("icon", DEFAULT_BADGE_ICON) # ------------------------------------------------------------------------------------------ class PendingChoreApprovalsSensor(CoordinatorEntity, SensorEntity): """Sensor listing all pending chore approvals.""" _attr_has_entity_name = True _attr_translation_key = "pending_chores_approvals_sensor" def __init__(self, coordinator, entry): """Initialize the sensor.""" super().__init__(coordinator) self._attr_unique_id = f"{entry.entry_id}_pending_chore_approvals" self._attr_icon = "mdi:clipboard-check-outline" self.entity_id = f"sensor.kc_global_chore_pending_approvals" @property def native_value(self): """Return a summary of pending chore approvals.""" approvals = self.coordinator._data.get(DATA_PENDING_CHORE_APPROVALS, []) return f"{len(approvals)} pending chores" @property def extra_state_attributes(self): """Return detailed pending chores.""" approvals = self.coordinator._data.get(DATA_PENDING_CHORE_APPROVALS, []) grouped_by_kid = {} for approval in approvals: kid_name = ( self.coordinator._get_kid_name_by_id(approval["kid_id"]) or UNKNOWN_KID ) chore_info = self.coordinator.chores_data.get(approval["chore_id"], {}) chore_name = chore_info.get("name", UNKNOWN_CHORE) timestamp = approval["timestamp"] if kid_name not in grouped_by_kid: grouped_by_kid[kid_name] = [] grouped_by_kid[kid_name].append( { ATTR_CHORE_NAME: chore_name, ATTR_CLAIMED_ON: timestamp, } ) return grouped_by_kid # ------------------------------------------------------------------------------------------ class PendingRewardApprovalsSensor(CoordinatorEntity, SensorEntity): """Sensor listing all pending reward approvals.""" _attr_has_entity_name = True _attr_translation_key = "pending_rewards_approvals_sensor" def __init__(self, coordinator, entry): """Initialize the sensor.""" super().__init__(coordinator) self._attr_unique_id = f"{entry.entry_id}_pending_reward_approvals" self._attr_icon = "mdi:gift-open-outline" self.entity_id = f"sensor.kc_global_reward_pending_approvals" @property def native_value(self): """Return a summary of pending reward approvals.""" approvals = self.coordinator._data.get(DATA_PENDING_REWARD_APPROVALS, []) return f"{len(approvals)} pending rewards" @property def extra_state_attributes(self): """Return detailed pending rewards.""" approvals = self.coordinator._data.get(DATA_PENDING_REWARD_APPROVALS, []) grouped_by_kid = {} for approval in approvals: kid_name = ( self.coordinator._get_kid_name_by_id(approval["kid_id"]) or UNKNOWN_KID ) reward_info = self.coordinator.rewards_data.get(approval["reward_id"], {}) reward_name = reward_info.get("name", UNKNOWN_REWARD) timestamp = approval["timestamp"] if kid_name not in grouped_by_kid: grouped_by_kid[kid_name] = [] grouped_by_kid[kid_name].append( { ATTR_REWARD_NAME: reward_name, ATTR_REDEEMED_ON: timestamp, } ) return grouped_by_kid # DEPRECATE -------------------------------------------------------------------------------- class RewardClaimsSensor(CoordinatorEntity, SensorEntity): """Sensor tracking how many times each reward has been claimed by a kid.""" _attr_has_entity_name = True _attr_translation_key = "reward_claims_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, reward_id, reward_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._reward_id = reward_id self._reward_name = reward_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_reward_claims" self._attr_translation_placeholders = { "kid_name": kid_name, "reward_name": reward_name, } self.entity_id = f"sensor.kc_{kid_name}_reward_claims_{reward_name}" @property def native_value(self): """Return the number of times the reward has been claimed.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("reward_claims", {}).get(self._reward_id, 0) @property def icon(self): """Return the chore's custom icon if set, else fallback.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) return reward_info.get("icon", DEFAULT_REWARD_ICON) # DEPRECATE -------------------------------------------------------------------------------- class RewardApprovalsSensor(CoordinatorEntity, SensorEntity): """Sensor tracking how many times each reward has been approved for a kid.""" _attr_has_entity_name = True _attr_translation_key = "reward_approvals_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, reward_id, reward_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._reward_id = reward_id self._reward_name = reward_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_reward_approvals" self._attr_translation_placeholders = { "kid_name": kid_name, "reward_name": reward_name, } self.entity_id = f"sensor.kc_{kid_name}_reward_approvals_{reward_name}" @property def native_value(self): """Return the number of times the reward has been approved.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("reward_approvals", {}).get(self._reward_id, 0) @property def icon(self): """Return the chore's custom icon if set, else fallback.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) return reward_info.get("icon", DEFAULT_REWARD_ICON) # ------------------------------------------------------------------------------------------ class SharedChoreGlobalStateSensor(CoordinatorEntity, SensorEntity): """Sensor that shows the global state of a shared chore.""" _attr_has_entity_name = True _attr_translation_key = "shared_chore_global_status_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, chore_id: str, chore_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = f"{entry.entry_id}_{chore_id}_global_state" self._attr_translation_placeholders = { "chore_name": chore_name, } self.entity_id = f"sensor.kc_global_chore_status_{chore_name}" @property def native_value(self) -> str: """Return the global state for the chore.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) return chore_info.get("state", CHORE_STATE_UNKNOWN) @property def extra_state_attributes(self) -> dict: """Return additional attributes for the chore.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) assigned_kids_ids = chore_info.get("assigned_kids", []) assigned_kids_names = [ self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}" for k_id in assigned_kids_ids ] stored_labels = chore_info.get("chore_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] total_approvals_today = 0 for kid_id in assigned_kids_ids: kid_data = self.coordinator.kids_data.get(kid_id, {}) total_approvals_today += kid_data.get("today_chore_approvals", {}).get( self._chore_id, 0 ) attributes = { ATTR_CHORE_NAME: self._chore_name, ATTR_DESCRIPTION: chore_info.get("description", ""), ATTR_RECURRING_FREQUENCY: chore_info.get("recurring_frequency", "None"), ATTR_APPLICABLE_DAYS: chore_info.get("applicable_days", []), ATTR_DUE_DATE: chore_info.get("due_date", "Not set"), ATTR_DEFAULT_POINTS: chore_info.get("default_points", 0), ATTR_PARTIAL_ALLOWED: chore_info.get("partial_allowed", False), ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY: chore_info.get( "allow_multiple_claims_per_day", False ), ATTR_CHORE_APPROVALS_TODAY: total_approvals_today, ATTR_ASSIGNED_KIDS: assigned_kids_names, ATTR_LABELS: friendly_labels, } if chore_info.get("recurring_frequency") == FREQUENCY_CUSTOM: attributes[ATTR_CUSTOM_FREQUENCY_INTERVAL] = chore_info.get( "custom_interval" ) attributes[ATTR_CUSTOM_FREQUENCY_UNIT] = chore_info.get( "custom_interval_unit" ) return attributes @property def icon(self) -> str: """Return the icon for the chore sensor.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON) # ------------------------------------------------------------------------------------------ class RewardStatusSensor(CoordinatorEntity, SensorEntity): """Shows the status of a reward for a particular kid.""" _attr_has_entity_name = True _attr_translation_key = "reward_status_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, reward_id: str, reward_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._reward_id = reward_id self._reward_name = reward_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_reward_status" self._attr_translation_placeholders = { "kid_name": kid_name, "reward_name": reward_name, } self.entity_id = f"sensor.kc_{kid_name}_reward_status_{reward_name}" @property def native_value(self) -> str: """Return the current reward status: 'Not Claimed', 'Claimed', or 'Approved'.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) if self._reward_id in kid_info.get("pending_rewards", []): return REWARD_STATE_CLAIMED if self._reward_id in kid_info.get("redeemed_rewards", []): return REWARD_STATE_APPROVED return REWARD_STATE_NOT_CLAIMED @property def extra_state_attributes(self) -> dict: """Provide extra attributes about the reward.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) kid_info = self.coordinator.kids_data.get(self._kid_id, {}) stored_labels = reward_info.get("reward_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] attributes = { ATTR_KID_NAME: self._kid_name, ATTR_REWARD_NAME: self._reward_name, ATTR_DESCRIPTION: reward_info.get("description", ""), ATTR_COST: reward_info.get("cost", DEFAULT_REWARD_COST), ATTR_REWARD_CLAIMS_COUNT: kid_info.get("reward_claims", {}).get( self._reward_id, 0 ), ATTR_REWARD_APPROVALS_COUNT: kid_info.get("reward_approvals", {}).get( self._reward_id, 0 ), ATTR_LABELS: friendly_labels, } return attributes @property def icon(self) -> str: """Use the reward's custom icon if set, else fallback.""" reward_info = self.coordinator.rewards_data.get(self._reward_id, {}) return reward_info.get("icon", DEFAULT_REWARD_ICON) # DEPRECATE -------------------------------------------------------------------------------- class ChoreClaimsSensor(CoordinatorEntity, SensorEntity): """Sensor tracking how many times each chore has been claimed by a kid.""" _attr_has_entity_name = True _attr_translation_key = "chore_claims_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, chore_id, chore_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_chore_claims" self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } self.entity_id = f"sensor.kc_{kid_name}_chore_claims_{chore_name}" @property def native_value(self): """Return the number of times the chore has been claimed.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("chore_claims", {}).get(self._chore_id, 0) @property def icon(self): """Return the chore's custom icon if set, else fallback.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON) # DEPRECATE -------------------------------------------------------------------------------- class ChoreApprovalsSensor(CoordinatorEntity, SensorEntity): """Sensor tracking how many times each chore has been approved for a kid.""" _attr_has_entity_name = True _attr_translation_key = "chore_approvals_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, chore_id, chore_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_chore_approvals" self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } self.entity_id = f"sensor.kc_{kid_name}_chore_approvals_{chore_name}" @property def native_value(self): """Return the number of times the chore has been approved.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("chore_approvals", {}).get(self._chore_id, 0) @property def icon(self): """Return the chore's custom icon if set, else fallback.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON) # ------------------------------------------------------------------------------------------ class PenaltyAppliesSensor(CoordinatorEntity, SensorEntity): """Sensor tracking how many times each penalty has been applied to a kid.""" _attr_has_entity_name = True _attr_translation_key = "penalty_applies_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, penalty_id, penalty_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._penalty_id = penalty_id self._penalty_name = penalty_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{penalty_id}_penalty_applies" self._attr_translation_placeholders = { "kid_name": kid_name, "penalty_name": penalty_name, } self.entity_id = f"sensor.kc_{kid_name}_penalties_applied_{penalty_name}" @property def native_value(self): """Return the number of times the penalty has been applied.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("penalty_applies", {}).get(self._penalty_id, 0) @property def extra_state_attributes(self): """Expose additional details like penalty points and description.""" penalty_info = self.coordinator.penalties_data.get(self._penalty_id, {}) stored_labels = penalty_info.get("penalty_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] return { ATTR_KID_NAME: self._kid_name, ATTR_PENALTY_NAME: self._penalty_name, ATTR_DESCRIPTION: penalty_info.get("description", ""), ATTR_PENALTY_POINTS: penalty_info.get("points", DEFAULT_PENALTY_POINTS), ATTR_LABELS: friendly_labels, } @property def icon(self): """Return the chore's custom icon if set, else fallback.""" penalty_info = self.coordinator.penalties_data.get(self._penalty_id, {}) return penalty_info.get("icon", DEFAULT_PENALTY_ICON) # ------------------------------------------------------------------------------------------ class KidPointsEarnedDailySensor(CoordinatorEntity, SensorEntity): """Sensor for how many net points a kid earned today.""" _attr_has_entity_name = True _attr_translation_key = "kid_points_earned_daily_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._points_label = points_label self._points_icon = points_icon self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points_earned_daily" self._attr_translation_placeholders = { "kid_name": kid_name, } self.entity_id = f"sensor.kc_{kid_name}_points_earned_daily" @property def native_value(self): """Return how many net points the kid has earned so far today.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("points_earned_today", 0) @property def native_unit_of_measurement(self): """Return the points label.""" return self._points_label or LABEL_POINTS @property def icon(self): """Use the points' custom icon if set, else fallback.""" return self._points_icon or DEFAULT_POINTS_ICON # ------------------------------------------------------------------------------------------ class KidPointsEarnedWeeklySensor(CoordinatorEntity, SensorEntity): """Sensor for how many net points a kid earned this week.""" _attr_has_entity_name = True _attr_translation_key = "kid_points_earned_weekly_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._points_label = points_label self._points_icon = points_icon self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points_earned_weekly" self._attr_translation_placeholders = { "kid_name": kid_name, } self.entity_id = f"sensor.kc_{kid_name}_points_earned_weekly" @property def native_value(self): """Return how many net points the kid has earned this week.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("points_earned_weekly", 0) @property def native_unit_of_measurement(self): """Return the points label.""" return self._points_label or LABEL_POINTS @property def icon(self): """Use the points' custom icon if set, else fallback.""" return self._points_icon or DEFAULT_POINTS_ICON # ------------------------------------------------------------------------------------------ class KidPointsEarnedMonthlySensor(CoordinatorEntity, SensorEntity): """Sensor for how many net points a kid earned this month.""" _attr_has_entity_name = True _attr_translation_key = "kid_points_earned_monthly_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._points_label = points_label self._points_icon = points_icon self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points_earned_monthly" self._attr_translation_placeholders = { "kid_name": kid_name, } self.entity_id = f"sensor.kc_{kid_name}_points_earned_monthly" @property def native_value(self): """Return how many net points the kid has earned this month.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("points_earned_monthly", 0) @property def native_unit_of_measurement(self): """Return the points label.""" return self._points_label or LABEL_POINTS @property def icon(self): """Use the points' custom icon if set, else fallback.""" return self._points_icon or DEFAULT_POINTS_ICON # ------------------------------------------------------------------------------------------ class AchievementSensor(CoordinatorEntity, SensorEntity): """Sensor representing an achievement.""" _attr_has_entity_name = True _attr_translation_key = "achievement_state_sensor" def __init__(self, coordinator, entry, achievement_id, achievement_name): """Initialize the AchievementSensor.""" super().__init__(coordinator) self._entry = entry self._achievement_id = achievement_id self._achievement_name = achievement_name self._attr_unique_id = f"{entry.entry_id}_{achievement_id}_achievement" self._attr_native_unit_of_measurement = PERCENTAGE self._attr_translation_placeholders = { "achievement_name": achievement_name, } self.entity_id = f"sensor.kc_achievement_status_{achievement_name}" @property def native_value(self): """Return the overall progress percentage toward the achievement.""" achievement = self.coordinator.achievements_data.get(self._achievement_id, {}) target = achievement.get("target_value", 1) assigned_kids = achievement.get("assigned_kids", []) if not assigned_kids: return 0 ach_type = achievement.get("type") if ach_type == ACHIEVEMENT_TYPE_TOTAL: total_current = 0 total_effective_target = 0 for kid_id in assigned_kids: progress_data = achievement.get("progress", {}).get(kid_id, {}) baseline = ( progress_data.get("baseline", 0) if isinstance(progress_data, dict) else 0 ) current_total = self.coordinator.kids_data.get(kid_id, {}).get( "completed_chores_total", 0 ) total_current += current_total total_effective_target += baseline + target percent = ( (total_current / total_effective_target * 100) if total_effective_target > 0 else 0 ) elif ach_type == ACHIEVEMENT_TYPE_STREAK: total_current = 0 for kid_id in assigned_kids: progress_data = achievement.get("progress", {}).get(kid_id, {}) total_current += ( progress_data.get("current_streak", 0) if isinstance(progress_data, dict) else 0 ) global_target = target * len(assigned_kids) percent = (total_current / global_target * 100) if global_target > 0 else 0 elif ach_type == ACHIEVEMENT_TYPE_DAILY_MIN: total_progress = 0 for kid_id in assigned_kids: daily = self.coordinator.kids_data.get(kid_id, {}).get( "completed_chores_today", 0 ) kid_progress = ( 100 if daily >= target else (daily / target * 100) if target > 0 else 0 ) total_progress += kid_progress percent = total_progress / len(assigned_kids) else: percent = 0 return min(100, round(percent, 1)) @property def extra_state_attributes(self): """Return extra attributes for this achievement.""" achievement = self.coordinator.achievements_data.get(self._achievement_id, {}) progress = achievement.get("progress", {}) kids_progress = {} earned_by = [] for kid_id, data in progress.items(): if data.get("awarded", False): kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id earned_by.append(kid_name) associated_chore = "" selected_chore_id = achievement.get("selected_chore_id") if selected_chore_id: associated_chore = self.coordinator.chores_data.get( selected_chore_id, {} ).get("name", "") assigned_kids_ids = achievement.get("assigned_kids", []) assigned_kids_names = [ self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}" for k_id in assigned_kids_ids ] ach_type = achievement.get("type") for kid_id in assigned_kids_ids: kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id progress_data = achievement.get("progress", {}).get(kid_id, {}) if ach_type == ACHIEVEMENT_TYPE_TOTAL: kids_progress[kid_name] = progress_data.get("current_value", 0) elif ach_type == ACHIEVEMENT_TYPE_STREAK: kids_progress[kid_name] = progress_data.get("current_streak", 0) elif achievement.get("type") == ACHIEVEMENT_TYPE_DAILY_MIN: kids_progress[kid_name] = self.coordinator.kids_data.get( kid_id, {} ).get("completed_chores_today", 0) else: kids_progress[kid_name] = 0 stored_labels = achievement.get("achievement_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] return { ATTR_ACHIEVEMENT_NAME: self._achievement_name, ATTR_DESCRIPTION: achievement.get("description", ""), ATTR_ASSIGNED_KIDS: assigned_kids_names, ATTR_TYPE: ach_type, ATTR_ASSOCIATED_CHORE: associated_chore, ATTR_CRITERIA: achievement.get("criteria", ""), ATTR_TARGET_VALUE: achievement.get("target_value"), ATTR_REWARD_POINTS: achievement.get("reward_points"), ATTR_KIDS_EARNED: earned_by, ATTR_LABELS: friendly_labels, } @property def icon(self): """Return an icon; you could choose a trophy icon.""" achievement_info = self.coordinator.achievements_data.get( self._achievement_id, {} ) return achievement_info.get("icon", DEFAULT_ACHIEVEMENTS_ICON) # ------------------------------------------------------------------------------------------ class ChallengeSensor(CoordinatorEntity, SensorEntity): """Sensor representing a challenge.""" _attr_has_entity_name = True _attr_translation_key = "challenge_state_sensor" def __init__(self, coordinator, entry, challenge_id, challenge_name): """Initialize the ChallengeSensor.""" super().__init__(coordinator) self._entry = entry self._challenge_id = challenge_id self._challenge_name = challenge_name self._attr_unique_id = f"{entry.entry_id}_{challenge_id}_challenge" self._attr_native_unit_of_measurement = PERCENTAGE self._attr_translation_placeholders = { "challenge_name": challenge_name, } self.entity_id = f"sensor.kc_challenge_status_{challenge_name}" @property def native_value(self): """Return the overall progress percentage toward the challenge.""" challenge = self.coordinator.challenges_data.get(self._challenge_id, {}) target = challenge.get("target_value", 1) assigned_kids = challenge.get("assigned_kids", []) if not assigned_kids: return 0 challenge_type = challenge.get("type") total_progress = 0 for kid_id in assigned_kids: progress_data = challenge.get("progress", {}).get(kid_id, {}) if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: total_progress += progress_data.get("count", 0) elif challenge_type == CHALLENGE_TYPE_DAILY_MIN: if isinstance(progress_data, dict): daily_counts = progress_data.get("daily_counts", {}) total_progress += sum(daily_counts.values()) else: total_progress += 0 else: total_progress += 0 global_target = target * len(assigned_kids) percent = (total_progress / global_target * 100) if global_target > 0 else 0 return min(100, round(percent, 1)) @property def extra_state_attributes(self): """Return extra attributes for this challenge.""" challenge = self.coordinator.challenges_data.get(self._challenge_id, {}) progress = challenge.get("progress", {}) kids_progress = {} challenge_type = challenge.get("type") earned_by = [] for kid_id, data in progress.items(): if data.get("awarded", False): kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id earned_by.append(kid_name) associated_chore = "" selected_chore_id = challenge.get("selected_chore_id") if selected_chore_id: associated_chore = self.coordinator.chores_data.get( selected_chore_id, {} ).get("name", "") assigned_kids_ids = challenge.get("assigned_kids", []) assigned_kids_names = [ self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}" for k_id in assigned_kids_ids ] for kid_id in assigned_kids_ids: kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id progress_data = challenge.get("progress", {}).get(kid_id, {}) if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: kids_progress[kid_name] = progress_data.get("count", 0) elif challenge_type == CHALLENGE_TYPE_DAILY_MIN: if isinstance(progress_data, dict): kids_progress[kid_name] = sum( progress_data.get("daily_counts", {}).values() ) else: kids_progress[kid_name] = 0 else: kids_progress[kid_name] = 0 stored_labels = challenge.get("challenge_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] return { ATTR_CHALLENGE_NAME: self._challenge_name, ATTR_DESCRIPTION: challenge.get("description", ""), ATTR_ASSIGNED_KIDS: assigned_kids_names, ATTR_TYPE: challenge_type, ATTR_ASSOCIATED_CHORE: associated_chore, ATTR_CRITERIA: challenge.get("criteria", ""), ATTR_TARGET_VALUE: challenge.get("target_value"), ATTR_REWARD_POINTS: challenge.get("reward_points"), ATTR_START_DATE: challenge.get("start_date"), ATTR_END_DATE: challenge.get("end_date"), ATTR_KIDS_EARNED: earned_by, ATTR_LABELS: friendly_labels, } @property def icon(self): """Return an icon for challenges (you might want to choose one that fits your theme).""" challenge_info = self.coordinator.challenges_data.get(self._challenge_id, {}) return challenge_info.get("icon", DEFAULT_ACHIEVEMENTS_ICON) # ------------------------------------------------------------------------------------------ class AchievementProgressSensor(CoordinatorEntity, SensorEntity): """Sensor representing a kid's progress toward a specific achievement.""" _attr_has_entity_name = True _attr_translation_key = "achievement_progress_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, achievement_id: str, achievement_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._achievement_id = achievement_id self._achievement_name = achievement_name self._attr_unique_id = ( f"{entry.entry_id}_{kid_id}_{achievement_id}_achievement_progress" ) self._attr_native_unit_of_measurement = PERCENTAGE self._attr_translation_placeholders = { "kid_name": kid_name, "achievement_name": achievement_name, } self.entity_id = f"sensor.kc_{kid_name}_achievement_status_{achievement_name}" @property def native_value(self) -> float: """Return the progress percentage toward the achievement.""" achievement = self.coordinator.achievements_data.get(self._achievement_id, {}) target = achievement.get("target_value", 1) ach_type = achievement.get("type") if ach_type == ACHIEVEMENT_TYPE_TOTAL: progress_data = achievement.get("progress", {}).get(self._kid_id, {}) baseline = ( progress_data.get("baseline", 0) if isinstance(progress_data, dict) else 0 ) current_total = self.coordinator.kids_data.get(self._kid_id, {}).get( "completed_chores_total", 0 ) effective_target = baseline + target percent = ( (current_total / effective_target * 100) if effective_target > 0 else 0 ) elif ach_type == ACHIEVEMENT_TYPE_STREAK: progress_data = achievement.get("progress", {}).get(self._kid_id, {}) progress = ( progress_data.get("current_streak", 0) if isinstance(progress_data, dict) else 0 ) percent = (progress / target * 100) if target > 0 else 0 elif ach_type == ACHIEVEMENT_TYPE_DAILY_MIN: daily = self.coordinator.kids_data.get(self._kid_id, {}).get( "completed_chores_today", 0 ) percent = (daily / target * 100) if target > 0 else 0 else: percent = 0 return min(100, round(percent, 1)) @property def extra_state_attributes(self) -> dict: """Return extra attributes for the achievement progress.""" achievement = self.coordinator.achievements_data.get(self._achievement_id, {}) target = achievement.get("target_value", 1) progress_data = achievement.get("progress", {}).get(self._kid_id, {}) awarded = ( progress_data.get("awarded", False) if isinstance(progress_data, dict) else False ) if achievement.get("type") == ACHIEVEMENT_TYPE_TOTAL: raw_progress = ( progress_data.get("current_value", 0) if isinstance(progress_data, dict) else 0 ) elif achievement.get("type") == ACHIEVEMENT_TYPE_STREAK: raw_progress = ( progress_data.get("current_streak", 0) if isinstance(progress_data, dict) else 0 ) elif achievement.get("type") == ACHIEVEMENT_TYPE_DAILY_MIN: raw_progress = self.coordinator.kids_data.get(self._kid_id, {}).get( "completed_chores_today", 0 ) associated_chore = "" selected_chore_id = achievement.get("selected_chore_id") if selected_chore_id: associated_chore = self.coordinator.chores_data.get( selected_chore_id, {} ).get("name", "") assigned_kids_ids = achievement.get("assigned_kids", []) assigned_kids_names = [ self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}" for k_id in assigned_kids_ids ] stored_labels = achievement.get("achievement_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] return { ATTR_ACHIEVEMENT_NAME: self._achievement_name, ATTR_DESCRIPTION: achievement.get("description", ""), ATTR_ASSIGNED_KIDS: assigned_kids_names, ATTR_TYPE: achievement.get("type"), ATTR_ASSOCIATED_CHORE: associated_chore, ATTR_CRITERIA: achievement.get("criteria", ""), ATTR_TARGET_VALUE: target, ATTR_REWARD_POINTS: achievement.get("reward_points"), ATTR_RAW_PROGRESS: raw_progress, ATTR_AWARDED: awarded, ATTR_LABELS: friendly_labels, } @property def icon(self) -> str: """Return the icon for the achievement. Use the icon provided in the achievement data if set, else fallback to default. """ achievement = self.coordinator.achievements_data.get(self._achievement_id, {}) return achievement.get("icon", DEFAULT_ACHIEVEMENTS_ICON) # ------------------------------------------------------------------------------------------ class ChallengeProgressSensor(CoordinatorEntity, SensorEntity): """Sensor representing a kid's progress toward a specific challenge.""" _attr_has_entity_name = True _attr_translation_key = "challenge_progress_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, challenge_id: str, challenge_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._challenge_id = challenge_id self._challenge_name = challenge_name self._attr_unique_id = ( f"{entry.entry_id}_{kid_id}_{challenge_id}_challenge_progress" ) self._attr_native_unit_of_measurement = PERCENTAGE self._attr_translation_placeholders = { "kid_name": kid_name, "challenge_name": challenge_name, } self.entity_id = f"sensor.kc_{kid_name}_challenge_status_{challenge_name}" @property def native_value(self) -> float: """Return the challenge progress percentage.""" challenge = self.coordinator.challenges_data.get(self._challenge_id, {}) target = challenge.get("target_value", 1) challenge_type = challenge.get("type") progress_data = challenge.get("progress", {}).get(self._kid_id) if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: raw_progress = ( progress_data.get("count", 0) if isinstance(progress_data, dict) else 0 ) elif challenge_type == CHALLENGE_TYPE_DAILY_MIN: if isinstance(progress_data, dict): daily_counts = progress_data.get("daily_counts", {}) raw_progress = sum(daily_counts.values()) # Optionally, compute target as required_daily * number_of_days: start_date = dt_util.parse_datetime(challenge.get("start_date")) end_date = dt_util.parse_datetime(challenge.get("end_date")) if start_date and end_date: num_days = (end_date.date() - start_date.date()).days + 1 else: num_days = 1 required_daily = challenge.get("required_daily", 1) target = required_daily * num_days else: raw_progress = 0 else: raw_progress = 0 percent = (raw_progress / target * 100) if target > 0 else 0 return min(100, round(percent, 1)) @property def extra_state_attributes(self) -> dict: """Return extra attributes for the challenge progress.""" challenge = self.coordinator.challenges_data.get(self._challenge_id, {}) target = challenge.get("target_value", 1) challenge_type = challenge.get("type") progress_data = challenge.get("progress", {}).get(self._kid_id, {}) awarded = ( progress_data.get("awarded", False) if isinstance(progress_data, dict) else False ) if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW: raw_progress = ( progress_data.get("count", 0) if isinstance(progress_data, dict) else 0 ) elif challenge_type == CHALLENGE_TYPE_DAILY_MIN: if isinstance(progress_data, dict): daily_counts = progress_data.get("daily_counts", {}) raw_progress = sum(daily_counts.values()) else: raw_progress = 0 else: raw_progress = 0 associated_chore = "" selected_chore_id = challenge.get("selected_chore_id") if selected_chore_id: associated_chore = self.coordinator.chores_data.get( selected_chore_id, {} ).get("name", "") assigned_kids_ids = challenge.get("assigned_kids", []) assigned_kids_names = [ self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}" for k_id in assigned_kids_ids ] stored_labels = challenge.get("challenge_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] return { ATTR_CHALLENGE_NAME: self._challenge_name, ATTR_DESCRIPTION: challenge.get("description", ""), ATTR_ASSIGNED_KIDS: assigned_kids_names, ATTR_TYPE: challenge_type, ATTR_ASSOCIATED_CHORE: associated_chore, ATTR_CRITERIA: challenge.get("criteria", ""), ATTR_TARGET_VALUE: target, ATTR_REWARD_POINTS: challenge.get("reward_points"), ATTR_START_DATE: challenge.get("start_date"), ATTR_END_DATE: challenge.get("end_date"), ATTR_RAW_PROGRESS: raw_progress, ATTR_AWARDED: awarded, ATTR_LABELS: friendly_labels, } @property def icon(self) -> str: """Return the icon for the challenge. Use the icon provided in the challenge data if set, else fallback to default. """ challenge = self.coordinator.challenges_data.get(self._challenge_id, {}) return challenge.get("icon", DEFAULT_CHALLENGES_ICON) # ------------------------------------------------------------------------------------------ class KidHighestStreakSensor(CoordinatorEntity, SensorEntity): """Sensor returning the highest current streak among streak-type achievements for a kid.""" _attr_has_entity_name = True _attr_translation_key = "kid_highest_streak_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_highest_streak" self._attr_native_unit_of_measurement = UnitOfTime.DAYS self._attr_translation_placeholders = { "kid_name": kid_name, } self.entity_id = f"sensor.kc_{kid_name}_highest_streak" @property def native_value(self) -> int: """Return the highest current streak among all streak achievements for the kid.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("overall_chore_streak", 0) @property def extra_state_attributes(self) -> dict: """Return extra attributes including individual streaks per achievement.""" streaks = {} for achievement in self.coordinator.achievements_data.values(): if achievement.get("type") == ACHIEVEMENT_TYPE_STREAK: achievement_name = achievement.get("name", "Unnamed Achievement") progress_for_kid = achievement.get("progress", {}).get(self._kid_id) if isinstance(progress_for_kid, dict): streaks[achievement_name] = progress_for_kid.get( "current_streak", 0 ) elif isinstance(progress_for_kid, int): streaks[achievement_name] = progress_for_kid return {"streaks_by_achievement": streaks} @property def icon(self) -> str: """Return an icon for 'highest streak'. You can choose any default or allow config overrides.""" return DEFAULT_STREAK_ICON # ------------------------------------------------------------------------------------------ class ChoreStreakSensor(CoordinatorEntity, SensorEntity): """Sensor returning the current streak for a specific chore for a given kid.""" _attr_has_entity_name = True _attr_translation_key = "chore_streak_sensor" def __init__( self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str, kid_name: str, chore_id: str, chore_name: str, ): """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry self._kid_id = kid_id self._kid_name = kid_name self._chore_id = chore_id self._chore_name = chore_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_streak" self._attr_native_unit_of_measurement = UnitOfTime.DAYS self._attr_translation_placeholders = { "kid_name": kid_name, "chore_name": chore_name, } self.entity_id = f"sensor.kc_{kid_name}_chore_streak_{chore_name}" @property def native_value(self) -> int: """Return the current streak (in days) for this kid and chore.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) streaks = kid_info.get("chore_streaks", {}) streak_info = streaks.get(self._chore_id, {}) return streak_info.get("current_streak", 0) @property def extra_state_attributes(self) -> dict: """Return extra attributes such as the last approved date for this streak.""" attributes = {} for achievement in self.coordinator.achievements_data.values(): if ( achievement.get("type") == ACHIEVEMENT_TYPE_STREAK and achievement.get("selected_chore_id") == self._chore_id ): progress_for_kid = achievement.get("progress", {}).get(self._kid_id) if isinstance(progress_for_kid, dict): attributes[ATTR_LAST_DATE] = progress_for_kid.get("last_date") attributes[ATTR_RAW_STREAK] = progress_for_kid.get( "current_streak", 0 ) elif isinstance(progress_for_kid, int): attributes[ATTR_LAST_DATE] = None attributes[ATTR_RAW_STREAK] = progress_for_kid break return attributes @property def icon(self) -> str: """Return the chore's custom icon if set, else fallback.""" chore_info = self.coordinator.chores_data.get(self._chore_id, {}) return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON) # ------------------------------------------------------------------------------------------ class BonusAppliesSensor(CoordinatorEntity, SensorEntity): """Sensor tracking how many times each bonus has been applied to a kid.""" _attr_has_entity_name = True _attr_translation_key = "bonus_applies_sensor" def __init__(self, coordinator, entry, kid_id, kid_name, bonus_id, bonus_name): """Initialize the sensor.""" super().__init__(coordinator) self._kid_id = kid_id self._kid_name = kid_name self._bonus_id = bonus_id self._bonus_name = bonus_name self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{bonus_id}_bonus_applies" self._attr_translation_placeholders = { "kid_name": kid_name, "bonus_name": bonus_name, } self.entity_id = f"sensor.kc_{kid_name}_bonuses_applied_{bonus_name}" @property def native_value(self): """Return the number of times the bonus has been applied.""" kid_info = self.coordinator.kids_data.get(self._kid_id, {}) return kid_info.get("bonus_applies", {}).get(self._bonus_id, 0) @property def extra_state_attributes(self): """Expose additional details like bonus points and description.""" bonus_info = self.coordinator.bonuses_data.get(self._bonus_id, {}) stored_labels = bonus_info.get("bonus_labels", []) friendly_labels = [ get_friendly_label(self.hass, label) for label in stored_labels ] return { ATTR_KID_NAME: self._kid_name, ATTR_BONUS_NAME: self._bonus_name, ATTR_DESCRIPTION: bonus_info.get("description", ""), ATTR_BONUS_POINTS: bonus_info.get("points", DEFAULT_BONUS_POINTS), ATTR_LABELS: friendly_labels, } @property def icon(self): """Return the bonus's custom icon if set, else fallback.""" bonus_info = self.coordinator.bonuses_data.get(self._bonus_id, {}) return bonus_info.get("icon", DEFAULT_BONUS_ICON) ================================================ FILE: custom_components/kidschores/services.py ================================================ # File: services.py """Defines custom services for the KidsChores integration. These services allow direct actions through scripts or automations. Includes UI editor support with selectors for dropdowns and text inputs. """ import asyncio import voluptuous as vol from typing import Optional from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util from .const import ( CHORE_STATE_OVERDUE, CHORE_STATE_PENDING, DATA_CHORES, DATA_PENDING_CHORE_APPROVALS, DOMAIN, ERROR_CHORE_NOT_FOUND_FMT, ERROR_KID_NOT_FOUND_FMT, ERROR_NOT_AUTHORIZED_FMT, FIELD_CHORE_ID, FIELD_CHORE_NAME, FIELD_DUE_DATE, FIELD_KID_NAME, FIELD_PARENT_NAME, FIELD_PENALTY_NAME, FIELD_POINTS_AWARDED, FIELD_REWARD_NAME, FIELD_BONUS_NAME, LOGGER, MSG_NO_ENTRY_FOUND, SERVICE_APPLY_PENALTY, SERVICE_APPLY_BONUS, SERVICE_APPROVE_CHORE, SERVICE_APPROVE_REWARD, SERVICE_CLAIM_CHORE, SERVICE_DISAPPROVE_CHORE, SERVICE_DISAPPROVE_REWARD, SERVICE_REDEEM_REWARD, SERVICE_RESET_ALL_CHORES, SERVICE_RESET_ALL_DATA, SERVICE_RESET_OVERDUE_CHORES, SERVICE_RESET_PENALTIES, SERVICE_RESET_BONUSES, SERVICE_RESET_REWARDS, SERVICE_SET_CHORE_DUE_DATE, SERVICE_SKIP_CHORE_DUE_DATE, ) from .coordinator import KidsChoresDataCoordinator from .kc_helpers import is_user_authorized_for_global_action, is_user_authorized_for_kid from .flow_helpers import ensure_utc_datetime # --- Service Schemas --- CLAIM_CHORE_SCHEMA = vol.Schema( { vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_CHORE_NAME): cv.string, } ) APPROVE_CHORE_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_CHORE_NAME): cv.string, vol.Optional(FIELD_POINTS_AWARDED): vol.Coerce(float), } ) DISAPPROVE_CHORE_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_CHORE_NAME): cv.string, } ) REDEEM_REWARD_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_REWARD_NAME): cv.string, } ) APPROVE_REWARD_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_REWARD_NAME): cv.string, } ) DISAPPROVE_REWARD_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_REWARD_NAME): cv.string, } ) APPLY_PENALTY_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_PENALTY_NAME): cv.string, } ) APPLY_BONUS_SCHEMA = vol.Schema( { vol.Required(FIELD_PARENT_NAME): cv.string, vol.Required(FIELD_KID_NAME): cv.string, vol.Required(FIELD_BONUS_NAME): cv.string, } ) RESET_OVERDUE_CHORES_SCHEMA = vol.Schema( { vol.Optional(FIELD_CHORE_ID): cv.string, vol.Optional(FIELD_CHORE_NAME): cv.string, vol.Optional(FIELD_KID_NAME): cv.string, } ) RESET_PENALTIES_SCHEMA = vol.Schema( { vol.Optional(FIELD_KID_NAME): cv.string, vol.Optional(FIELD_PENALTY_NAME): cv.string, } ) RESET_BONUSES_SCHEMA = vol.Schema( { vol.Optional(FIELD_KID_NAME): cv.string, vol.Optional(FIELD_BONUS_NAME): cv.string, } ) RESET_REWARDS_SCHEMA = vol.Schema( { vol.Optional(FIELD_KID_NAME): cv.string, vol.Optional(FIELD_REWARD_NAME): cv.string, } ) RESET_ALL_DATA_SCHEMA = vol.Schema({}) RESET_ALL_CHORES_SCHEMA = vol.Schema({}) SET_CHORE_DUE_DATE_SCHEMA = vol.Schema( { vol.Required(FIELD_CHORE_NAME): cv.string, vol.Optional(FIELD_DUE_DATE): vol.Any(cv.string, None), } ) SKIP_CHORE_DUE_DATE_SCHEMA = vol.Schema( { vol.Optional(FIELD_CHORE_ID): cv.string, vol.Optional(FIELD_CHORE_NAME): cv.string, } ) def async_setup_services(hass: HomeAssistant): """Register KidsChores services.""" async def handle_claim_chore(call: ServiceCall): """Handle claiming a chore.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Claim Chore: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] user_id = call.context.user_id kid_name = call.data[FIELD_KID_NAME] chore_name = call.data[FIELD_CHORE_NAME] # Map kid_name and chore_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Claim Chore: " + ERROR_KID_NOT_FOUND_FMT, kid_name) raise HomeAssistantError(ERROR_KID_NOT_FOUND_FMT.format(kid_name)) chore_id = _get_chore_id_by_name(coordinator, chore_name) if not chore_id: LOGGER.warning("Claim Chore: " + ERROR_CHORE_NOT_FOUND_FMT, chore_name) raise HomeAssistantError(ERROR_CHORE_NOT_FOUND_FMT.format(chore_name)) # Check if user is authorized if user_id and not await is_user_authorized_for_kid(hass, user_id, kid_id): LOGGER.warning("Claim Chore: %s", ERROR_NOT_AUTHORIZED_FMT) raise HomeAssistantError(ERROR_NOT_AUTHORIZED_FMT.format("claim chores")) # Process chore claim coordinator.claim_chore( kid_id=kid_id, chore_id=chore_id, user_name=f"user:{user_id}" ) LOGGER.info( "Chore '%s' claimed by kid '%s' by user '%s'", chore_name, kid_name, user_id, ) await coordinator.async_request_refresh() async def handle_approve_chore(call: ServiceCall): """Handle approving a claimed chore.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Approve Chore: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] user_id = call.context.user_id parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] chore_name = call.data[FIELD_CHORE_NAME] points_awarded = call.data.get(FIELD_POINTS_AWARDED) # Map kid_name and chore_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Approve Chore: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") chore_id = _get_chore_id_by_name(coordinator, chore_name) if not chore_id: LOGGER.warning("Approve Chore: Chore '%s' not found", chore_name) raise HomeAssistantError(f"Chore '{chore_name}' not found") # Check if user is authorized if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Approve Chore: User not authorized") raise HomeAssistantError( "You are not authorized to approve chores for this kid." ) # Approve chore and assign points try: coordinator.approve_chore( parent_name=parent_name, kid_id=kid_id, chore_id=chore_id, points_awarded=points_awarded, ) LOGGER.info( "Chore '%s' approved for kid '%s' by parent '%s'. Points Awarded: %s", chore_name, kid_name, parent_name, points_awarded, ) await coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error("Approve Chore: %s", e) raise except Exception as e: LOGGER.error( "Approve Chore: Failed to approve chore '%s' for kid '%s': %s", chore_name, kid_name, e, ) raise HomeAssistantError( f"Failed to approve chore '{chore_name}' for kid '{kid_name}'." ) async def handle_disapprove_chore(call: ServiceCall): """Handle disapproving a chore.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Disapprove Chore: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] chore_name = call.data[FIELD_CHORE_NAME] # Map kid_name and chore_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Disapprove Chore: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") chore_id = _get_chore_id_by_name(coordinator, chore_name) if not chore_id: LOGGER.warning("Disapprove Chore: Chore '%s' not found", chore_name) raise HomeAssistantError(f"Chore '{chore_name}' not found") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Disapprove Chore: User not authorized") raise HomeAssistantError( "You are not authorized to disapprove chores for this kid." ) # Disapprove the chore coordinator.disapprove_chore( parent_name=parent_name, kid_id=kid_id, chore_id=chore_id, ) LOGGER.info( "Chore '%s' disapproved for kid '%s' by parent '%s'", chore_name, kid_name, parent_name, ) await coordinator.async_request_refresh() async def handle_redeem_reward(call: ServiceCall): """Handle redeeming a reward (claiming without deduction).""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Redeem Reward: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] reward_name = call.data[FIELD_REWARD_NAME] # Map kid_name and reward_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Redeem Reward: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") reward_id = _get_reward_id_by_name(coordinator, reward_name) if not reward_id: LOGGER.warning("Redeem Reward: Reward '%s' not found", reward_name) raise HomeAssistantError(f"Reward '{reward_name}' not found") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_kid(hass, user_id, kid_id): LOGGER.warning("Redeem Reward: User not authorized") raise HomeAssistantError( "You are not authorized to redeem rewards for this kid." ) # Check if kid has enough points kid_info = coordinator.kids_data.get(kid_id) reward = coordinator.rewards_data.get(reward_id) if not kid_info or not reward: LOGGER.warning("Redeem Reward: Invalid kid or reward") raise HomeAssistantError("Invalid kid or reward") if kid_info["points"] < reward.get("cost", 0): LOGGER.warning( "Redeem Reward: Kid '%s' does not have enough points to redeem reward '%s'", kid_name, reward_name, ) raise HomeAssistantError( f"Kid '{kid_name}' does not have enough points to redeem '{reward_name}'." ) # Process reward claim without deduction try: coordinator.redeem_reward( parent_name=parent_name, kid_id=kid_id, reward_id=reward_id ) LOGGER.info( "Reward '%s' claimed by kid '%s' and pending approval by parent '%s'", reward_name, kid_name, parent_name, ) await coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error("Redeem Reward: %s", e) raise except Exception as e: LOGGER.error( "Redeem Reward: Failed to claim reward '%s' for kid '%s': %s", reward_name, kid_name, e, ) raise HomeAssistantError( f"Failed to claim reward '{reward_name}' for kid '{kid_name}'." ) async def handle_approve_reward(call: ServiceCall): """Handle approving a reward claimed by a kid.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Approve Reward: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] user_id = call.context.user_id parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] reward_name = call.data[FIELD_REWARD_NAME] # Map kid_name and reward_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Approve Reward: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") reward_id = _get_reward_id_by_name(coordinator, reward_name) if not reward_id: LOGGER.warning("Approve Reward: Reward '%s' not found", reward_name) raise HomeAssistantError(f"Reward '{reward_name}' not found") # Check if user is authorized if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Approve Reward: User not authorized") raise HomeAssistantError( "You are not authorized to approve rewards for this kid." ) # Approve reward redemption and deduct points try: coordinator.approve_reward( parent_name=parent_name, kid_id=kid_id, reward_id=reward_id ) LOGGER.info( "Reward '%s' approved for kid '%s' by parent '%s'", reward_name, kid_name, parent_name, ) await coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error("Approve Reward: %s", e) raise except Exception as e: LOGGER.error( "Approve Reward: Failed to approve reward '%s' for kid '%s': %s", reward_name, kid_name, e, ) raise HomeAssistantError( f"Failed to approve reward '{reward_name}' for kid '{kid_name}'." ) async def handle_disapprove_reward(call: ServiceCall): """Handle disapproving a reward.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Disapprove Reward: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] reward_name = call.data[FIELD_REWARD_NAME] # Map kid_name and reward_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Disapprove Reward: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") reward_id = _get_reward_id_by_name(coordinator, reward_name) if not reward_id: LOGGER.warning("Disapprove Reward: Reward '%s' not found", reward_name) raise HomeAssistantError(f"Reward '{reward_name}' not found") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Disapprove Reward: User not authorized") raise HomeAssistantError( "You are not authorized to disapprove rewards for this kid." ) # Disapprove the reward coordinator.disapprove_reward( parent_name=parent_name, kid_id=kid_id, reward_id=reward_id, ) LOGGER.info( "Reward '%s' disapproved for kid '%s' by parent '%s'", reward_name, kid_name, parent_name, ) await coordinator.async_request_refresh() async def handle_apply_penalty(call: ServiceCall): """Handle applying a penalty.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Apply Penalty: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] penalty_name = call.data[FIELD_PENALTY_NAME] # Map kid_name and penalty_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Apply Penalty: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") penalty_id = _get_penalty_id_by_name(coordinator, penalty_name) if not penalty_id: LOGGER.warning("Apply Penalty: Penalty '%s' not found", penalty_name) raise HomeAssistantError(f"Penalty '{penalty_name}' not found") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Apply Penalty: User not authorized") raise HomeAssistantError( "You are not authorized to apply penalties for this kid." ) # Apply penalty try: coordinator.apply_penalty( parent_name=parent_name, kid_id=kid_id, penalty_id=penalty_id ) LOGGER.info( "Penalty '%s' applied for kid '%s' by parent '%s'", penalty_name, kid_name, parent_name, ) await coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error("Apply Penalty: %s", e) raise except Exception as e: LOGGER.error( "Apply Penalty: Failed to apply penalty '%s' for kid '%s': %s", penalty_name, kid_name, e, ) raise HomeAssistantError( f"Failed to apply penalty '{penalty_name}' for kid '{kid_name}'." ) async def handle_reset_penalties(call: ServiceCall): """Handle resetting penalties.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Reset Penalties: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] kid_name = call.data.get(FIELD_KID_NAME) penalty_name = call.data.get(FIELD_PENALTY_NAME) kid_id = _get_kid_id_by_name(coordinator, kid_name) if kid_name else None penalty_id = ( _get_penalty_id_by_name(coordinator, penalty_name) if penalty_name else None ) if kid_name and not kid_id: LOGGER.warning("Reset Penalties: Kid '%s' not found.", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found.") if penalty_name and not penalty_id: LOGGER.warning("Reset Penalties: Penalty '%s' not found.", penalty_name) raise HomeAssistantError(f"Penalty '{penalty_name}' not found.") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Reset Penalties: User not authorized.") raise HomeAssistantError("You are not authorized to reset penalties.") # Log action based on parameters provided if kid_id is None and penalty_id is None: LOGGER.info("Resetting all penalties for all kids.") elif kid_id is None: LOGGER.info("Resetting penalty '%s' for all kids.", penalty_name) elif penalty_id is None: LOGGER.info("Resetting all penalties for kid '%s'.", kid_name) else: LOGGER.info("Resetting penalty '%s' for kid '%s'.", penalty_name, kid_name) # Reset penalties coordinator.reset_penalties(kid_id=kid_id, penalty_id=penalty_id) await coordinator.async_request_refresh() async def handle_reset_bonuses(call: ServiceCall): """Handle resetting bonuses.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Reset Bonuses: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] kid_name = call.data.get(FIELD_KID_NAME) bonus_name = call.data.get(FIELD_BONUS_NAME) kid_id = _get_kid_id_by_name(coordinator, kid_name) if kid_name else None bonus_id = ( _get_bonus_id_by_name(coordinator, bonus_name) if bonus_name else None ) if kid_name and not kid_id: LOGGER.warning("Reset Bonuses: Kid '%s' not found.", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found.") if bonus_name and not bonus_id: LOGGER.warning("Reset Bonuses: Bonus '%s' not found.", bonus_name) raise HomeAssistantError(f"Bonus '{bonus_name}' not found.") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Reset Bonuses: User not authorized.") raise HomeAssistantError("You are not authorized to reset bonuses.") # Log action based on parameters provided if kid_id is None and bonus_id is None: LOGGER.info("Resetting all bonuses for all kids.") elif kid_id is None: LOGGER.info("Resetting bonus '%s' for all kids.", bonus_name) elif bonus_id is None: LOGGER.info("Resetting all bonuses for kid '%s'.", kid_name) else: LOGGER.info("Resetting bonus '%s' for kid '%s'.", bonus_name, kid_name) # Reset bonuses coordinator.reset_bonuses(kid_id=kid_id, bonus_id=bonus_id) await coordinator.async_request_refresh() async def handle_reset_rewards(call: ServiceCall): """Handle resetting rewards counts.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Reset Rewards: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] kid_name = call.data.get(FIELD_KID_NAME) reward_name = call.data.get(FIELD_REWARD_NAME) kid_id = _get_kid_id_by_name(coordinator, kid_name) if kid_name else None reward_id = ( _get_reward_id_by_name(coordinator, reward_name) if reward_name else None ) if kid_name and not kid_id: LOGGER.warning("Reset Rewards: Kid '%s' not found.", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found.") if reward_name and not reward_id: LOGGER.warning("Reset Rewards: Reward '%s' not found.", reward_name) raise HomeAssistantError(f"Reward '{reward_name}' not found.") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Reset Rewards: User not authorized.") raise HomeAssistantError("You are not authorized to reset rewards.") # Log action based on parameters provided if kid_id is None and reward_id is None: LOGGER.info("Resetting all rewards for all kids.") elif kid_id is None: LOGGER.info("Resetting reward '%s' for all kids.", reward_name) elif reward_id is None: LOGGER.info("Resetting all rewards for kid '%s'.", kid_name) else: LOGGER.info("Resetting reward '%s' for kid '%s'.", reward_name, kid_name) # Reset rewards coordinator.reset_rewards(kid_id=kid_id, reward_id=reward_id) await coordinator.async_request_refresh() async def handle_apply_bonus(call: ServiceCall): """Handle applying a bonus.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Apply Bonus: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] parent_name = call.data[FIELD_PARENT_NAME] kid_name = call.data[FIELD_KID_NAME] bonus_name = call.data[FIELD_BONUS_NAME] # Map kid_name and bonus_name to internal_ids kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Apply Bonus: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found") bonus_id = _get_bonus_id_by_name(coordinator, bonus_name) if not bonus_id: LOGGER.warning("Apply Bonus: Bonus '%s' not found", bonus_name) raise HomeAssistantError(f"Bonus '{bonus_name}' not found") # Check if user is authorized user_id = call.context.user_id if user_id and not await is_user_authorized_for_global_action( hass, user_id, kid_id ): LOGGER.warning("Apply Bonus: User not authorized") raise HomeAssistantError( "You are not authorized to apply bonuses for this kid." ) # Apply bonus try: coordinator.apply_bonus( parent_name=parent_name, kid_id=kid_id, bonus_id=bonus_id ) LOGGER.info( "Bonus '%s' applied for kid '%s' by parent '%s'", bonus_name, kid_name, parent_name, ) await coordinator.async_request_refresh() except HomeAssistantError as e: LOGGER.error("Apply Bonus: %s", e) raise except Exception as e: LOGGER.error( "Apply Bonus: Failed to apply bonus '%s' for kid '%s': %s", bonus_name, kid_name, e, ) raise HomeAssistantError( f"Failed to apply bonus '{bonus_name}' for kid '{kid_name}'." ) async def handle_reset_all_data(call: ServiceCall): """Handle manually resetting ALL data in KidsChores.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Reset All Data: No KidsChores entry found") return data = hass.data[DOMAIN].get(entry_id) if not data: LOGGER.warning("Reset All Data: No coordinator data found") return coordinator: KidsChoresDataCoordinator = data["coordinator"] # Clear everything from storage await coordinator.storage_manager.async_clear_data() # Re-init the coordinator with reload config entry await hass.config_entries.async_reload(entry_id) coordinator.async_set_updated_data(coordinator._data) LOGGER.info("Manually reset all KidsChores data. Integration is now cleared") async def handle_reset_all_chores(call: ServiceCall): """Handle manually resetting all chores to pending, clearing claims/approvals.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Reset All Chores: No KidsChores entry found") return data = hass.data[DOMAIN].get(entry_id) if not data: LOGGER.warning("Reset All Chores: No coordinator data found") return coordinator: KidsChoresDataCoordinator = data["coordinator"] # Loop over all chores, reset them to pending for chore_id, chore_info in coordinator.chores_data.items(): chore_info["state"] = CHORE_STATE_PENDING # Remove all chore approvals/claims for each kid for kid_id, kid_info in coordinator.kids_data.items(): kid_info["claimed_chores"] = [] kid_info["approved_chores"] = [] kid_info["overdue_chores"] = [] kid_info["overdue_notifications"] = {} # Clear the pending approvals queue coordinator._data[DATA_PENDING_CHORE_APPROVALS] = [] # Persist & notify coordinator._persist() coordinator.async_set_updated_data(coordinator._data) LOGGER.info("Manually reset all chores to pending, removed claims/approvals") async def handle_reset_overdue_chores(call: ServiceCall) -> None: """Handle resetting overdue chores.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Reset Overdue Chores: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] # Get parameters chore_id = call.data.get(FIELD_CHORE_ID) chore_name = call.data.get(FIELD_CHORE_NAME) kid_name = call.data.get(FIELD_KID_NAME) # If chore_id not provided but chore_name is, map it to chore_id. if not chore_id and chore_name: chore_id = _get_chore_id_by_name(coordinator, chore_name) if not chore_id: LOGGER.warning("Reset Overdue Chores: Chore '%s' not found", chore_name) raise HomeAssistantError(f"Chore '{chore_name}' not found.") # If kid_name provided, map it to kid_id. kid_id: Optional[str] = None if kid_name: kid_id = _get_kid_id_by_name(coordinator, kid_name) if not kid_id: LOGGER.warning("Reset Overdue Chores: Kid '%s' not found", kid_name) raise HomeAssistantError(f"Kid '{kid_name}' not found.") coordinator.reset_overdue_chores(chore_id=chore_id, kid_id=kid_id) LOGGER.info("Reset overdue chores (chore_id=%s, kid_id=%s)", chore_id, kid_id) await coordinator.async_request_refresh() await coordinator._check_overdue_chores() async def handle_set_chore_due_date(call: ServiceCall): """Handle setting (or clearing) the due date of a chore.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Set Chore Due Date: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] chore_name = call.data[FIELD_CHORE_NAME] due_date_input = call.data.get(FIELD_DUE_DATE) # Look up the chore by name: chore_id = _get_chore_id_by_name(coordinator, chore_name) if not chore_id: LOGGER.warning("Set Chore Due Date: Chore '%s' not found", chore_name) raise HomeAssistantError(ERROR_CHORE_NOT_FOUND_FMT.format(chore_name)) if due_date_input: try: # Convert the provided date due_date_str = ensure_utc_datetime(hass, due_date_input) due_dt = dt_util.parse_datetime(due_date_str) if due_dt and due_dt < dt_util.utcnow(): raise HomeAssistantError("Due date cannot be set in the past.") except Exception as err: LOGGER.error( "Set Chore Due Date: Invalid due date '%s': %s", due_date_input, err ) raise HomeAssistantError("Invalid due date provided.") # Update the chore’s due_date: coordinator.set_chore_due_date(chore_id, due_dt) LOGGER.info( "Set due date for chore '%s' (ID: %s) to %s", chore_name, chore_id, due_date_str, ) else: # Clear the due date by setting it to None coordinator.set_chore_due_date(chore_id, None) LOGGER.info( "Cleared due date for chore '%s' (ID: %s)", chore_name, chore_id ) await coordinator.async_request_refresh() async def handle_skip_chore_due_date(call: ServiceCall) -> None: """Handle skipping the due date on a chore by rescheduling it to the next due date.""" entry_id = _get_first_kidschores_entry(hass) if not entry_id: LOGGER.warning("Skip Chore Due Date: %s", MSG_NO_ENTRY_FOUND) return coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][ "coordinator" ] # Get parameters: either chore_id or chore_name must be provided. chore_id = call.data.get(FIELD_CHORE_ID) chore_name = call.data.get(FIELD_CHORE_NAME) if not chore_id and chore_name: chore_id = _get_chore_id_by_name(coordinator, chore_name) if not chore_id: LOGGER.warning("Skip Chore Due Date: Chore '%s' not found", chore_name) raise HomeAssistantError(f"Chore '{chore_name}' not found.") if not chore_id: raise HomeAssistantError( "You must provide either a chore_id or chore_name." ) coordinator.skip_chore_due_date(chore_id) LOGGER.info("Skipped due date for chore (chore_id=%s)", chore_id) await coordinator.async_request_refresh() # --- Register Services --- hass.services.async_register( DOMAIN, SERVICE_CLAIM_CHORE, handle_claim_chore, schema=CLAIM_CHORE_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_APPROVE_CHORE, handle_approve_chore, schema=APPROVE_CHORE_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_DISAPPROVE_CHORE, handle_disapprove_chore, schema=DISAPPROVE_CHORE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_REDEEM_REWARD, handle_redeem_reward, schema=REDEEM_REWARD_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_APPROVE_REWARD, handle_approve_reward, schema=APPROVE_REWARD_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_DISAPPROVE_REWARD, handle_disapprove_reward, schema=DISAPPROVE_REWARD_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_APPLY_PENALTY, handle_apply_penalty, schema=APPLY_PENALTY_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_RESET_ALL_DATA, handle_reset_all_data, schema=RESET_ALL_DATA_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_RESET_ALL_CHORES, handle_reset_all_chores, schema=RESET_ALL_CHORES_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_RESET_OVERDUE_CHORES, handle_reset_overdue_chores, schema=RESET_OVERDUE_CHORES_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_RESET_PENALTIES, handle_reset_penalties, schema=RESET_PENALTIES_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_RESET_BONUSES, handle_reset_bonuses, schema=RESET_BONUSES_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_RESET_REWARDS, handle_reset_rewards, schema=RESET_REWARDS_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_CHORE_DUE_DATE, handle_set_chore_due_date, schema=SET_CHORE_DUE_DATE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SKIP_CHORE_DUE_DATE, handle_skip_chore_due_date, schema=SKIP_CHORE_DUE_DATE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_APPLY_BONUS, handle_apply_bonus, schema=APPLY_BONUS_SCHEMA ) LOGGER.info("KidsChores services have been registered successfully") async def async_unload_services(hass: HomeAssistant): """Unregister KidsChores services when unloading the integration.""" services = [ SERVICE_CLAIM_CHORE, SERVICE_APPROVE_CHORE, SERVICE_DISAPPROVE_CHORE, SERVICE_REDEEM_REWARD, SERVICE_DISAPPROVE_REWARD, SERVICE_APPLY_PENALTY, SERVICE_APPLY_BONUS, SERVICE_APPROVE_REWARD, SERVICE_RESET_ALL_DATA, SERVICE_RESET_ALL_CHORES, SERVICE_RESET_OVERDUE_CHORES, SERVICE_RESET_PENALTIES, SERVICE_RESET_BONUSES, SERVICE_RESET_REWARDS, SERVICE_SET_CHORE_DUE_DATE, SERVICE_SKIP_CHORE_DUE_DATE, ] for service in services: if hass.services.has_service(DOMAIN, service): hass.services.async_remove(DOMAIN, service) LOGGER.info("KidsChores services have been unregistered") def _get_first_kidschores_entry(hass: HomeAssistant) -> Optional[str]: """Retrieve the first KidsChores config entry ID.""" domain_entries = hass.data.get(DOMAIN) if not domain_entries: return None return next(iter(domain_entries.keys()), None) def _get_kid_id_by_name( coordinator: KidsChoresDataCoordinator, kid_name: str ) -> Optional[str]: """Help function to get kid_id by kid_name.""" for kid_id, kid_info in coordinator.kids_data.items(): if kid_info.get("name") == kid_name: return kid_id return None def _get_chore_id_by_name( coordinator: KidsChoresDataCoordinator, chore_name: str ) -> Optional[str]: """Help function to get chore_id by chore_name.""" for chore_id, chore_info in coordinator.chores_data.items(): if chore_info.get("name") == chore_name: return chore_id return None def _get_reward_id_by_name( coordinator: KidsChoresDataCoordinator, reward_name: str ) -> Optional[str]: """Help function to get reward_id by reward_name.""" for reward_id, reward_info in coordinator.rewards_data.items(): if reward_info.get("name") == reward_name: return reward_id return None def _get_penalty_id_by_name( coordinator: KidsChoresDataCoordinator, penalty_name: str ) -> Optional[str]: """Help function to get penalty_id by penalty_name.""" for penalty_id, penalty_info in coordinator.penalties_data.items(): if penalty_info.get("name") == penalty_name: return penalty_id return None def _get_bonus_id_by_name( coordinator: KidsChoresDataCoordinator, bonus_name: str ) -> Optional[str]: """Help function to get bonus_id by bonus_name.""" for bonus_id, bonus_info in coordinator.bonuses_data.items(): if bonus_info.get("name") == bonus_name: return bonus_id return None ================================================ FILE: custom_components/kidschores/services.yaml ================================================ # File: services.yaml # # Custom services documentation for the KidsChores integration. # These services allow direct actions through scripts or automations. # Includes UI editor support with selectors for text inputs and numbers. claim_chore: name: "Claim Chore" description: "A kid claims a chore, marking it as 'claimed' for parental approval." fields: kid_name: name: "Kid Name" description: "The name of the kid claiming the chore." example: "Alice" required: true selector: text: chore_name: name: "Chore Name" description: "The name of the chore to claim." example: "Wash Dishes" required: true selector: text: approve_chore: name: "Approve Chore" description: "Parent approves a chore, awarding points (full or partial)." fields: parent_name: name: "Parent Name" description: "The parent approving the chore." example: "Mom" required: true selector: text: kid_name: name: "Kid Name" description: "The name of the kid who performed the chore." example: "Alice" required: true selector: text: chore_name: name: "Chore Name" description: "The name of the chore being approved." example: "Wash Dishes" required: true selector: text: points_awarded: name: "Points Awarded" description: "Points to award (optional; defaults to the chore's points)." example: 3 required: false selector: number: min: 0 max: 1000 mode: box disapprove_chore: name: "Disapprove Chore" description: "Parent disapproves a chore for a kid, reverting its status." fields: parent_name: name: "Parent Name" description: "The parent disapproving the chore." example: "Mom" required: true selector: text: kid_name: name: "Kid Name" description: "The name of the kid whose chore is being disapproved." example: "Bob" required: true selector: text: chore_name: name: "Chore Name" description: "The name of the chore being disapproved." example: "Clean Room" required: true selector: text: redeem_reward: name: "Redeem Reward" description: "A kid redeems a reward, marking it as 'pending' for parental approval." fields: parent_name: name: "Parent Name" description: "The parent authorizing the reward redemption." example: "Mom" required: true selector: text: kid_name: name: "Kid Name" description: "The kid redeeming the reward." example: "Alice" required: true selector: text: reward_name: name: "Reward Name" description: "The name of the reward to redeem." example: "Extra Screen Time" required: true selector: text: approve_reward: name: "Approve Reward" description: "Parent approves a reward claimed by a kid, deducting points." fields: parent_name: name: "Parent Name" description: "The parent approving the reward." example: "Mom" required: true selector: text: kid_name: name: "Kid Name" description: "The kid who is redeeming the reward." example: "Alice" required: true selector: text: reward_name: name: "Reward Name" description: "The name of the reward being approved." example: "Extra Screen Time" required: true selector: text: disapprove_reward: name: "Disapprove Reward" description: "Parent disapproves a reward redemption for a kid." fields: parent_name: name: "Parent Name" description: "The parent disapproving the reward." example: "Dad" required: true selector: text: kid_name: name: "Kid Name" description: "The kid whose reward redemption is being disapproved." example: "Bob" required: true selector: text: reward_name: name: "Reward Name" description: "The name of the reward being disapproved." example: "Extra Screen Time" required: true selector: text: apply_penalty: name: "Apply Penalty" description: "A parent applies a penalty, deducting points from a kid." fields: parent_name: name: "Parent Name" description: "The parent applying the penalty." example: "Dad" required: true selector: text: kid_name: name: "Kid Name" description: "The kid receiving the penalty." example: "Bob" required: true selector: text: penalty_name: name: "Penalty Name" description: "The name of the penalty to apply." example: "Yelling" required: true selector: text: apply_bonus: name: "Apply Bonus" description: "A parent applies a bonus to award extra points." fields: parent_name: name: "Parent Name" description: "The parent applying the bonus." example: "Dad" required: true selector: text: kid_name: name: "Kid Name" description: "The kid receiving the bonus." example: "Bob" required: true selector: text: bonus_name: name: "Bonus Name" description: "The name of the bonus to apply." example: "Extra Helpful" required: true selector: text: reset_all_data: name: "Reset All Data" description: "Completely clears the KidsChores data from storage." fields: {} reset_all_chores: name: "Reset All Chores" description: "Manually reset chores to pending state, removing claims and approvals." fields: {} reset_overdue_chores: name: "Reset Overdue Chores" description: > Reset overdue chore(s) back to the Pending state and reschedule them based on their recurring frequency and previous due date. You may optionally provide a chore_id (or chore_name) to reset a specific chore and optionally a kid_name to reset the chore only for that kid. fields: chore_id: name: "Chore ID" description: "The internal ID of the chore to reset (optional if chore_name is provided)." example: "abc123" required: false selector: text: chore_name: name: "Chore Name" description: "The name of the chore to reset (optional if chore_id is provided)." example: "Wash Dishes" required: false selector: text: kid_name: name: "Kid Name" description: "If provided, reset the chore only for this kid." example: "Alice" required: false selector: text: set_chore_due_date: name: "Set Chore Due Date" description: > Set (or clear) the due date for a chore. Provide the chore name and, if desired, a new due date. If no due date is provided the existing due date will be cleared. The service will reject due dates set in the past. fields: chore_name: name: "Chore Name" description: "The name of the chore to update." example: "Wash Dishes" required: true selector: text: due_date: name: "Due Date" description: > The new due date for the chore. Use the date/time selector to choose a valid date and time (in your local timezone). Leave empty to clear the due date. example: "2025-03-01T23:59:00Z" required: false selector: datetime: {} skip_chore_due_date: name: "Skip Chore Due Date" description: > Skip the current due date of a recurring chore. This service immediately reschedules the chore's due date based on its recurring frequency and resets its state to pending. Any pending claims or approvals will be removed. fields: chore_id: name: "Chore ID" description: > The internal ID of the chore to update. Optional if you provide a chore name. example: "abc123" required: false selector: text: chore_name: name: "Chore Name" description: > The name of the chore to update. Optional if you provide a chore ID. example: "Weekly Laundry" required: false selector: text: reset_penalties: name: "Reset Penalties" description: > Reset all applied penalties for all kids. Optionally, provide penalty_name to reset a specific penalty across all kids. Use kid_name to reset all penalties for a specific kid. Combine both to reset a specific penalty for a specific kid. fields: kid_name: name: "Kid Name" description: "The kid penalites will be reset for." example: "Bob" required: false selector: text: penalty_name: name: "Penalty Name" description: "The name of the penalty to reset." example: "Yelling" required: false selector: text: reset_bonuses: name: "Reset Bonuses" description: > Reset all applied bonuses for all kids. Optionally, provide bonus_name to reset a specific bonus across all kids. Use kid_name to reset all bonuses for a specific kid. Combine both to reset a specific bonus for a specific kid. fields: kid_name: name: "Kid Name" description: "The kid bonuses will be reset for." example: "Bob" required: false selector: text: bonus_name: name: "Bonus Name" description: "The name of the bonus to reset." example: "Helping" required: false selector: text: reset_rewards: name: "Reset Rewards" description: > Reset all reward claim and approval counts for all kids. Optionally, provide reward_name to reset a specific reward counts across all kids. Use kid_name to reset all reward counts for a specific kid. Combine both to reset a specific reward for a specific kid. fields: kid_name: name: "Kid Name" description: "The kid reward counts will be reset for." example: "Bob" required: false selector: text: reward_name: name: "Reward Name" description: "The name of the reward to reset count." example: "Ice Cream" required: false selector: text: ================================================ FILE: custom_components/kidschores/storage_manager.py ================================================ # File: storage_manager.py """Handles persistent data storage for the KidsChores integration. Uses Home Assistant's Storage helper to save and load chore-related data, ensuring the state is preserved across restarts. This includes data for kids, chores, badges, rewards, penalties, and their statuses. """ import os from homeassistant.helpers.storage import Store from .const import ( DATA_ACHIEVEMENTS, DATA_BADGES, DATA_BONUSES, DATA_CHALLENGES, DATA_CHORES, DATA_KIDS, DATA_PARENTS, DATA_PENALTIES, DATA_PENDING_CHORE_APPROVALS, DATA_PENDING_REWARD_APPROVALS, DATA_REWARDS, LOGGER, STORAGE_KEY, STORAGE_VERSION, ) class KidsChoresStorageManager: """Manages loading, saving, and accessing data from Home Assistant's storage. Utilizes internal_id as the primary key for all entities. """ def __init__(self, hass, storage_key=STORAGE_KEY): """Initialize the storage manager. Args: hass: Home Assistant core object. storage_key: Key to identify storage location (default: STORAGE_KEY). """ self.hass = hass self._storage_key = storage_key self._store = Store(hass, STORAGE_VERSION, storage_key) self._data = {} # In-memory data cache for quick access. async def async_initialize(self): """Load data from storage during startup. If no data exists, initializes with an empty structure. """ LOGGER.debug("KidsChoresStorageManager: Loading data from storage") existing_data = await self._store.async_load() if existing_data is None: # No existing data, create a new default structure. LOGGER.info("No existing storage found; initializing new data") self._data = { DATA_KIDS: {}, # Dictionary of kids keyed by internal_id. DATA_CHORES: {}, # Dictionary of chores keyed by internal_id. DATA_BADGES: {}, # Dictionary of badges keyed by internal_id. DATA_REWARDS: {}, # Dictionary of rewards keyed by internal_id. DATA_PENALTIES: {}, # Dictionary of penalties keyed by internal_id. DATA_BONUSES: {}, # Dictionary of bonuses keyed by internal_id. DATA_PARENTS: {}, # Dictionary of parents keyed by internal_id. DATA_ACHIEVEMENTS: {}, # Dictionary of achievements keyed by internal_id. DATA_CHALLENGES: {}, # Dictionary of challenges keyed by internal_id. DATA_PENDING_CHORE_APPROVALS: [], # List of pending chore approvals keyed by internal_id. DATA_PENDING_REWARD_APPROVALS: [], # List of pending rewar approvals keyed by internal_id. } else: # Load existing data into memory. self._data = existing_data LOGGER.info("Storage data loaded successfully") @property def data(self): """Retrieve the in-memory data cache.""" return self._data def get_data(self): """Retrieve the data structure (alternative getter).""" return self._data def set_data(self, new_data: dict): """Replace the entire in-memory data structure.""" self._data = new_data def get_kids(self): """Retrieve the kids data.""" return self._data.get(DATA_KIDS, {}) def get_parents(self): """Retrieve the parents data.""" return self._data.get(DATA_PARENTS, {}) def get_chores(self): """Retrieve the chores data.""" return self._data.get(DATA_CHORES, {}) def get_badges(self): """Retrieve the badges data.""" return self._data.get(DATA_BADGES, {}) def get_rewards(self): """Retrieve the rewards data.""" return self._data.get(DATA_REWARDS, {}) def get_penalties(self): """Retrieve the penalties data.""" return self._data.get(DATA_PENALTIES, {}) def get_bonuses(self): """Retrieve the bonuses data.""" return self._data.get(DATA_BONUSES, {}) def get_achievements(self): """Retrieve the achievements data.""" return self._data.get(DATA_ACHIEVEMENTS, {}) def get_challenges(self): """Retrieve the challenges data.""" return self._data.get(DATA_CHALLENGES, {}) def get_pending_chore_approvals(self): """Retrieve the pending chore approvals data.""" return self._data.get(DATA_PENDING_CHORE_APPROVALS, []) def get_pending_reward_aprovals(self): """Retrieve the pending reward approvals data.""" return self._data.get(DATA_PENDING_REWARD_APPROVALS, []) async def link_user_to_kid(self, user_id, kid_id): """Link a Home Assistant user ID to a specific kid by internal_id.""" if "linked_users" not in self._data: self._data["linked_users"] = {} self._data["linked_users"][user_id] = kid_id await self._save() async def unlink_user(self, user_id): """Unlink a Home Assistant user ID from any kid.""" if "linked_users" in self._data and user_id in self._data["linked_users"]: del self._data["linked_users"][user_id] await self._save() async def get_linked_kids(self): """Get all linked users and their associated kids.""" return self._data.get("linked_users", {}) async def async_save(self): """Save the current data structure to storage asynchronously.""" try: await self._store.async_save(self._data) LOGGER.info("Data saved successfully to storage") except Exception as e: LOGGER.error("Failed to save data to storage: %s", e) async def async_clear_data(self): """Clear all stored data and reset to default structure.""" LOGGER.warning("Clearing all KidsChores data and resetting storage") self._data = { DATA_KIDS: {}, DATA_CHORES: {}, DATA_BADGES: {}, DATA_REWARDS: {}, DATA_PARENTS: {}, DATA_PENALTIES: {}, DATA_BONUSES: {}, DATA_ACHIEVEMENTS: {}, DATA_CHALLENGES: {}, DATA_PENDING_REWARD_APPROVALS: [], DATA_PENDING_CHORE_APPROVALS: [], } await self.async_save() async def async_delete_storage(self) -> None: """Delete the storage file completely from disk.""" # First clear in-memory data await self.async_clear_data() # Remove the file if it exists if os.path.isfile(self._store._path): try: os.remove(self._store._path) LOGGER.info("Storage file removed: %s", self._store._path) except Exception as e: LOGGER.error("Failed to remove storage file: %s", e) else: LOGGER.info("Storage file not found: %s", self._store._path) async def async_update_data(self, key, value): """Update a specific section of the data structure.""" if key in self._data: LOGGER.debug("Updating data for key: %s", key) self._data[key] = value await self.async_save() else: LOGGER.warning("Attempted to update unknown data key: %s", key) ================================================ FILE: custom_components/kidschores/translations/en.json ================================================ { "title": "KidsChores", "config": { "step": { "intro": { "title": "Welcome to KidsChores", "description": "This wizard will guide you through setting up KidsChores." }, "points_label": { "title": "Points Label", "description": "Choose a label and icon for points.", "data": { "points_label": "Points Label", "points_icon": "Points Icon" } }, "kid_count": { "title": "Number of Kids", "description": "How many kids do you want to manage?", "data": { "kid_count": "Number of Kids" } }, "kids": { "title": "Define Kid", "description": "Enter the name for each kid.", "data": { "kid_name": "Kid Name", "internal_id": "Internal ID", "ha_user": "Home Assistant User", "enable_mobile_notifications": "Enable Mobile Notifications", "mobile_notify_service": "Notify Service", "enable_persistent_notifications": "Enable Persistent Notifications" } }, "parent_count": { "title": "Number of Parents", "description": "How many parents do you want to define initially?", "data": { "parent_count": "Number of Parents" } }, "parents": { "title": "Define Parent", "description": "Enter details for each parent.", "data": { "parent_name": "Parent Name", "ha_user_id": "Home Assistant User", "associated_kids": "Associated Kids", "enable_mobile_notifications": "Enable Mobile Notifications", "mobile_notify_service": "Notify Service", "enable_persistent_notifications": "Enable Persistent Notifications", "internal_id": "Internal ID" } }, "chore_count": { "title": "Number of Chores", "description": "How many chores do you want to define?", "data": { "chore_count": "Number of Chores" } }, "chores": { "title": "Define Chore", "description": "Enter details for each chore.", "data": { "chore_name": "Chore Name", "internal_id": "Internal ID", "default_points": "Default Points", "allow_multiple_claims_per_day": "Allow Multiple Claims per Day?", "partial_allowed": "Allow Partial Points?", "shared_chore": "Shared Chore?", "assigned_kids": "Assigned Kids", "chore_description": "Description (optional)", "chore_labels": "Chore Labels", "icon": "Icon (mdi:xxx)", "recurring_frequency": "Recurring Frequency", "custom_interval": "Custom Recurring Frequency Interval (only use if Custom Recurring Frequency is set)", "custom_interval_unit": "Custom Recurring Frequency Period", "applicable_days": "Applicable Days", "due_date": "Due Date", "notify_on_claim": "Notify on Claim", "notify_on_approval": "Notify on Approval", "notify_on_disapproval": "Notify on Disapproval" } }, "badge_count": { "title": "Number of Badges", "description": "How many badges do you want to define?", "data": { "badge_count": "Badge Count" } }, "badges": { "title": "Define Badge", "description": "Enter details for each badge.", "data": { "badge_name": "Badge Name", "internal_id": "Internal ID", "threshold_type": "Threshold Type", "threshold_value": "Threshold Value", "points_multiplier": "Points Multiplier", "icon": "Icon (mdi:xxx)", "badge_description": "Description (optional)", "badge_labels": "Badge Labels" } }, "reward_count": { "title": "Number of Rewards", "description": "How many rewards do you want to define?", "data": { "reward_count": "Reward Count" } }, "rewards": { "title": "Define Reward", "description": "Enter details for each reward.", "data": { "reward_name": "Reward Name", "internal_id": "Internal ID", "reward_cost": "Reward Cost", "reward_description": "Description (optional)", "reward_labels": "Reward Labels", "icon": "Icon (mdi:xxx)" } }, "penalty_count": { "title": "Number of Penalties", "description": "How many penalties do you want to define?", "data": { "penalty_count": "Penalty Count" } }, "penalties": { "title": "Define Penalty", "description": "Enter details for each penalty.", "data": { "penalty_name": "Penalty Name", "penalty_description": "Description (optional)", "penalty_labels": "Penalty Labels", "internal_id": "Internal ID", "penalty_points": "Penalty Points (negative)", "icon": "Icon (mdi:xxx)" } }, "bonus_count": { "title": "Number of Bonuses", "description": "How many bonuses do you want to define?", "data": { "bonus_count": "Bonuses Count" } }, "bonuses": { "title": "Define Bonus", "description": "Enter details for each bonus.", "data": { "bonus_name": "Bonus Name", "bonus_description": "Description (optional)", "bonus_labels": "Bonus Labels", "internal_id": "Internal ID", "bonus_points": "Bonus Points", "icon": "Icon (mdi:xxx)" } }, "achievement_count": { "title": "Number of Achievements", "description": "How many achievements do you want to define?", "data": { "achievement_count": "Achievement Count" } }, "achievements": { "title": "Define Achievement", "description": "Enter details for each achievement.", "data": { "name": "Achievement Name", "description": "Description (optional)", "achievement_labels": "Achievement Labels", "icon": "Icon (mdi:xxx)", "assigned_kids": "Assigned Kids", "type": "Type of Achievement", "selected_chore_id": "Select Chore Associated", "criteria": "Criteria (optional)", "target_value": "Achievement Target", "reward_points": "Extra Points for Completing Achievement", "internal_id": "Internal ID" } }, "challenge_count": { "title": "Number of Challenges", "description": "How many challenges do you want to define?", "data": { "challenge_count": "Challenge Count" } }, "challenges": { "title": "Define Challenge", "description": "Enter details for each challenge.", "data": { "name": "Challenge Name", "description": "Description (optional)", "challenge_labels": "Challenge Labels", "icon": "Icon (mdi:xxx)", "assigned_kids": "Assigned Kids", "type": "Type of Challenge", "selected_chore_id": "Select Chore Associated with Challenge (Optional)", "criteria": "Criteria (optional)", "target_value": "Challenges Target", "reward_points": "Extra Points for Completing Challenge", "start_date": "Start Date", "end_date": "End Date", "internal_id": "Internal ID" } }, "finish": { "title": "Review & Finish", "description": "Review the setup:\n{summary}\nClick Submit to finalize." } }, "error": { "a_chore_must_be_selected": "A chore must be selected", "duplicate_achievement": "An achievement with this name already exists", "duplicate_badge": "A badge with this name already exists", "duplicate_challenge": "A challenge with this name already exists", "duplicate_chore": "A chore with this name already exists", "duplicate_kid": "A kid with this name already exists", "duplicate_parent": "A parent with this name already exists", "duplicate_penalty": "A penalty with this name already exists", "duplicate_reward": "A reward with this name already exists", "duplicate_bonus": "A bonus with this name already exists", "due_date_in_past": "Due date must be in the future.", "end_date_in_past": "End Date must be in the future.", "end_date_not_after_start_date": "End date must be later than start date", "invalid_achievement_count": "Invalid achievement count", "invalid_achievement_name": "Invalid achievement name", "invalid_badge": "Invalid badge", "invalid_badge_count": "Invalid badge count", "invalid_badge_name": "Invalid badge name", "invalid_challenge_count": "Invalid challenge count", "invalid_challenge_name": "Invalid challenge name", "invalid_chore": "Invalid chore", "invalid_chore_count": "Invalid chore count", "invalid_chore_name": "Invalid chore name", "invalid_due_date": "Invalid due date", "invalid_end_date": "Invalid end date.", "invalid_kid_count": "Invalid kid count", "invalid_kid_name": "Invalid kid name", "invalid_parent_count": "Invalid parent count", "invalid_parent_name": "Invalid parent name", "invalid_penalty": "Invalid penalty", "invalid_penalty_count": "Invalid penalty count", "invalid_penalty_name": "Invalid penalty name", "invalid_reward": "Invalid reward", "invalid_reward_count": "Invalid reward count", "invalid_reward_name": "Invalid reward name", "invalid_bonus": "Invalid bonus", "invalid_bonus_count": "Invalid bonus count", "invalid_bonus_name": "Invalid bonus name", "invalid_start_date": "Invalid start date.", "start_date_in_past": "Start Date must be in the future." }, "abort": { "single_instance_allowed": "Only a single KidsChores instance can be configured." } }, "options": { "step": { "init": { "title": "KidsChores Options", "description": "Manage kids, chores, badges, rewards, penalties, bonuses, or finish.", "data": { "menu_selection": "Select an Option" } }, "manage_entity": { "title": "Select Action", "description": "Add, edit or delete options.", "data": { "manage_action": "Select an Action" } }, "select_entity": { "title": "Select {entity_type}", "description": "Select the {entity_type} you want to {action}.", "data": { "entity_name": "Name" } }, "manage_points": { "title": "Edit Points Label & Icon", "description": "Change the label and icon used to represent points.", "data": { "points_label": "Points Label", "points_icon": "Points Icon" } }, "add_kid": { "title": "Add Kid", "description": "Provide the details for the new kid.", "data": { "kid_name": "Kid Name", "ha_user": "Home Assistant User", "enable_mobile_notifications": "Enable Mobile Notifications", "mobile_notify_service": "Notify Service", "enable_persistent_notifications": "Enable Persistent Notifications", "internal_id": "Internal ID" } }, "add_parent": { "title": "Add Parent", "description": "Provide the details for the new parent.", "data": { "parent_name": "Parent Name", "ha_user_id": "Home Assistant User", "associated_kids": "Associated Kids", "enable_mobile_notifications": "Enable Mobile Notifications", "mobile_notify_service": "Notify Service", "enable_persistent_notifications": "Enable Persistent Notifications", "internal_id": "Internal ID" } }, "add_chore": { "title": "Add Chore", "description": "Provide the details for the new chore.", "data": { "chore_name": "Chore Name", "internal_id": "Internal ID", "default_points": "Default Points", "allow_multiple_claims_per_day": "Allow Multiple Claims per Day?", "partial_allowed": "Allow Partial Points?", "shared_chore": "Shared Chore?", "assigned_kids": "Assigned Kids", "chore_description": "Description (optional)", "chore_labels": "Chore Labels", "icon": "Icon (mdi:xxx)", "recurring_frequency": "Recurring Frequency", "custom_interval": "Custom Recurring Frequency Interval (only use if Custom Recurring Frequency is set)", "custom_interval_unit": "Custom Recurring Frequency Period", "applicable_days": "Applicable Days", "due_date": "Due Date", "notify_on_claim": "Notify on Claim", "notify_on_approval": "Notify on Approval", "notify_on_disapproval": "Notify on Disapproval" } }, "add_badge": { "title": "Add Badge", "description": "Provide the details for the new badge.", "data": { "badge_name": "Badge Name", "internal_id": "Internal ID", "threshold_type": "Threshold Type", "threshold_value": "Threshold Value", "points_multiplier": "Points Multiplier", "icon": "Icon (mdi:xxx)", "badge_description": "Description (optional)", "badge_labels": "Badge Labels" } }, "add_reward": { "title": "Add Reward", "description": "Provide the details for the new reward.", "data": { "reward_name": "Reward Name", "internal_id": "Internal ID", "reward_cost": "Reward Cost", "reward_description": "Description (optional)", "reward_labels": "Reward Labels", "icon": "Icon (mdi:xxx)" } }, "add_penalty": { "title": "Add Penalty", "description": "Provide the details for the new penalty.", "data": { "penalty_name": "Penalty Name", "penalty_description": "Description (optional)", "penalty_labels": "Penalty Labels", "internal_id": "Internal ID", "penalty_points": "Penalty Points (negative)", "icon": "Icon (mdi:xxx)" } }, "add_bonus": { "title": "Add Bonus", "description": "Provide the details for the new bonus.", "data": { "bonus_name": "Bonus Name", "bonus_description": "Description (optional)", "bonus_labels": "Bonus Labels", "internal_id": "Internal ID", "bonus_points": "Bonus Points", "icon": "Icon (mdi:xxx)" } }, "add_achievement": { "title": "Define Achievement", "description": "Enter details for each achievement.", "data": { "name": "Achievement Name", "description": "Description (optional)", "achievement_labels": "Achievement Labels", "icon": "Icon (mdi:xxx)", "assigned_kids": "Assigned Kids", "type": "Type of Achievement", "selected_chore_id": "Select Chore Associated", "criteria": "Criteria (optional)", "target_value": "Achievement Target", "reward_points": "Extra Points for Completing Achievement", "internal_id": "Internal ID" } }, "add_challenge": { "title": "Define Challenge", "description": "Enter details for each challenge.", "data": { "name": "Challenge Name", "description": "Description (optional)", "challenge_labels": "Challenge Labels", "icon": "Icon (mdi:xxx)", "assigned_kids": "Assigned Kids", "type": "Type of Challenge", "selected_chore_id": "Select Chore Associated with Challenge (Optional)", "criteria": "Criteria (optional)", "target_value": "Challenges Target", "reward_points": "Extra Points for Completing Challenge", "start_date": "Start Date", "end_date": "End Date", "internal_id": "Internal ID" } }, "edit_kid": { "title": "Edit Kid", "description": "Modify the details of the selected kid.", "data": { "kid_name": "Kid Name", "ha_user": "Home Assistant User", "enable_mobile_notifications": "Enable Mobile Notifications", "mobile_notify_service": "Notify Service", "enable_persistent_notifications": "Enable Persistent Notifications", "internal_id": "Internal ID" } }, "edit_parent": { "title": "Edit Parent", "description": "Modify the details of the selected parent.", "data": { "parent_name": "Parent Name", "ha_user_id": "Home Assistant User", "associated_kids": "Associated Kids", "enable_mobile_notifications": "Enable Mobile Notifications", "mobile_notify_service": "Notify Service", "enable_persistent_notifications": "Enable Persistent Notifications", "internal_id": "Internal ID" } }, "edit_chore": { "title": "Edit Chore", "description": "Modify the details of the selected chore.", "data": { "chore_name": "Chore Name", "internal_id": "Internal ID", "default_points": "Default Points", "allow_multiple_claims_per_day": "Allow Multiple Claims per Day?", "partial_allowed": "Allow Partial Points?", "shared_chore": "Shared Chore?", "assigned_kids": "Assigned Kids", "chore_description": "Description (optional)", "chore_labels": "Chore Labels", "icon": "Icon (mdi:xxx)", "recurring_frequency": "Recurring Frequency", "custom_interval": "Custom Recurring Frequency Interval (only use if Custom Recurring Frequency is set)", "custom_interval_unit": "Custom Recurring Frequency Period", "applicable_days": "Applicable Days", "due_date": "Due Date", "notify_on_claim": "Notify on Claim", "notify_on_approval": "Notify on Approval", "notify_on_disapproval": "Notify on Disapproval" } }, "edit_badge": { "title": "Edit Badge", "description": "Modify the details of the selected badge.", "data": { "badge_name": "Badge Name", "internal_id": "Internal ID", "threshold_type": "Threshold Type", "threshold_value": "Threshold Value", "points_multiplier": "Points Multiplier", "icon": "Icon (mdi:xxx)", "badge_description": "Description (optional)", "badge_labels": "Badge Labels" } }, "edit_reward": { "title": "Edit Reward", "description": "Modify the details of the selected reward.", "data": { "reward_name": "Reward Name", "internal_id": "Internal ID", "reward_cost": "Reward Cost", "reward_description": "Description (optional)", "reward_labels": "Reward Labels", "icon": "Icon (mdi:xxx)" } }, "edit_penalty": { "title": "Edit Penalty", "description": "Modify the details of the selected penalty.", "data": { "penalty_name": "Penalty Name", "penalty_description": "Description (optional)", "penalty_labels": "Penalty Labels", "internal_id": "Internal ID", "penalty_points": "Penalty Points (negative)", "icon": "Icon (mdi:xxx)" } }, "edit_bonus": { "title": "Edit Bonus", "description": "Modify the details of the selected bonus.", "data": { "bonus_name": "Bonus Name", "bonus_description": "Description (optional)", "bonus_labels": "Bonus Labels", "internal_id": "Internal ID", "bonus_points": "Bonus Points", "icon": "Icon (mdi:xxx)" } }, "edit_achievement": { "title": "Define Achievement", "description": "Enter details for each achievement.", "data": { "name": "Achievement Name", "description": "Description (optional)", "achievement_labels": "Achievement Labels", "icon": "Icon (mdi:xxx)", "assigned_kids": "Assigned Kids", "type": "Type of Achievement", "selected_chore_id": "Select Chore Associated", "criteria": "Criteria (optional)", "target_value": "Achievement Target", "reward_points": "Extra Points for Completing Achievement", "internal_id": "Internal ID" } }, "edit_challenge": { "title": "Define Challenge", "description": "Enter details for each challenge.", "data": { "name": "Challenge Name", "description": "Description (optional)", "challenge_labels": "Challenge Labels", "icon": "Icon (mdi:xxx)", "assigned_kids": "Assigned Kids", "type": "Type of Challenge", "selected_chore_id": "Select Chore Associated with Challenge (Optional)", "criteria": "Criteria (optional)", "target_value": "Challenges Target", "reward_points": "Extra Points for Completing Challenge", "start_date": "Start Date", "end_date": "End Date", "internal_id": "Internal ID" } }, "delete_kid": { "title": "Delete Kid", "description": "Are you sure you want to delete the kid {kid_name}?", "data": {} }, "delete_parent": { "title": "Delete Parent", "description": "Are you sure you want to delete the parent {parent_name}?", "data": {} }, "delete_chore": { "title": "Delete Chore", "description": "Are you sure you want to delete the chore {chore_name}?", "data": {} }, "delete_badge": { "title": "Delete Badge", "description": "Are you sure you want to delete the badge {badge_name}?", "data": {} }, "delete_reward": { "title": "Delete Reward", "description": "Are you sure you want to delete the reward {reward_name}?", "data": {} }, "delete_penalty": { "title": "Delete Penalty", "description": "Are you sure you want to delete the penalty {penalty_name}?", "data": {} }, "delete_bonus": { "title": "Delete Bonus", "description": "Are you sure you want to delete the bonus {bonus_name}?", "data": {} }, "delete_achievement": { "title": "Delete Achievement", "description": "Are you sure you want to delete the achievement {achievement_name}?", "data": {} }, "delete_challenge": { "title": "Delete Challenge", "description": "Are you sure you want to delete the challenge {challenge_name}?", "data": {} } }, "error": { "a_chore_must_be_selected": "A chore must be selected", "duplicate_achievement": "An achievement with this name already exists", "duplicate_badge": "A badge with this name already exists", "duplicate_challenge": "A challenge with this name already exists", "duplicate_chore": "A chore with this name already exists", "duplicate_kid": "A kid with this name already exists", "duplicate_parent": "A parent with this name already exists", "duplicate_penalty": "A penalty with this name already exists", "duplicate_reward": "A reward with this name already exists", "duplicate_bonus": "A bonus with this name already exists", "due_date_in_past": "Due date must be in the future.", "end_date_in_past": "End Date must be in the future.", "end_date_not_after_start_date": "End date must be later than start date", "invalid_badge": "Invalid badge", "invalid_badge_count": "Invalid badge count", "invalid_chore": "Invalid chore", "invalid_chore_count": "Invalid chore count", "invalid_due_date": "Invalid due date", "invalid_end_date": "Invalid end date.", "invalid_kid_count": "Invalid kid count", "invalid_kid_name": "Invalid kid name", "invalid_penalty": "Invalid penalty", "invalid_penalty_count": "Invalid penalty count", "invalid_bonus": "Invalid bonus", "invalid_bonus_count": "Invalid bonus count", "invalid_reward": "Invalid reward", "invalid_reward_count": "Invalid reward count", "invalid_start_date": "Invalid start date.", "start_date_in_past": "Start Date must be in the future." }, "abort": { "invalid_action": "Invalid Action", "invalid_achievement": "Invalid Achievement", "invalid_badge": "Invalid Badge", "invalid_challenge": "Invalid Challenge", "invalid_chore": "Invalid Chore", "invalid_entity": "Invalid Entity", "invalid_kid": "Invalid Kid", "invalid_parent": "Invalid Parent", "invalid_penalty": "Invalid Penalty", "invalid_reward": "Invalid Reward", "invalid_bonus": "Invalid Bonus", "no_kid": "No Kids are setup for edit. Add one entry first.", "no_parent": "No Parents are setup for edit. Add one entry first.", "no_chore": "No Chores are setup for edit. Add one entry first.", "no_badge": "No Badges are setup for edit. Add one entry first.", "no_reward": "No Rewards are setup for edit. Add one entry first.", "no_penalty": "No Penalties are setup for edit. Add one entry first.", "no_bonus": "No Bonuses are setup for edit. Add one entry first.", "no_achievement": "No Achievements are setup for edit. Add one entry first.", "no_challenge": "No Challenges are setup for edit. Add one entry first.", "setup_complete": "Setup Complete" } }, "selector": { "main_menu": { "options": { "manage_points": "Manage Points", "manage_kid": "Manage Kid", "manage_parent": "Manage Parent", "manage_chore": "Manage Chore", "manage_badge": "Manage Badge", "manage_reward": "Manage Reward", "manage_penalty": "Manage Penalty", "manage_bonus": "Manage Bonus", "manage_achievement": "Manage Achievement", "manage_challenge": "Manage Challenge", "done": "Finish Setup" } }, "manage_actions": { "options": { "add": "Add", "edit": "Edit", "delete": "Delete", "back": "Back to Main Menu" } }, "recurring_frequency": { "options": { "none": "None", "daily": "Daily", "weekly": "Weekly", "biweekly": "Biweekly", "monthly": "Monthly", "custom": "Custom" } }, "custom_interval_unit": { "options": { "days": "Days", "weeks": "Weeks", "months": "Months" } }, "applicable_days": { "options": { "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday" } }, "threshold_type": { "options": { "points": "Points", "chore_count": "Chore Count" } } }, "services": { "claim_chore": { "name": "Claim Chore", "description": "A kid claims a chore, marking it as 'claimed' for parental approval.", "fields": { "kid_name": { "name": "Kid Name", "description": "The name of the kid claiming the chore.", "example": "Alice" }, "chore_name": { "name": "Chore Name", "description": "The name of the chore to claim.", "example": "Wash Dishes" } } }, "approve_chore": { "name": "Approve Chore", "description": "Parent approves the chore, awarding points.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent approving the chore.", "example": "Mom" }, "kid_name": { "name": "Kid Name", "description": "The name of the kid who performed the chore.", "example": "Alice" }, "chore_name": { "name": "Chore Name", "description": "The name of the chore being approved.", "example": "Wash Dishes" }, "points_awarded": { "name": "Points Awarded", "description": "Points to award (optional; defaults to the chore's points).", "example": 3 } } }, "disapprove_chore": { "name": "Disapprove Chore", "description": "Parent disapproves a chore for a kid, reverting its status.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent disapproving the chore.", "example": "Mom" }, "kid_name": { "name": "Kid Name", "description": "The name of the kid whose chore is being disapproved.", "example": "Alice" }, "chore_name": { "name": "Chore Name", "description": "The name of the chore being disapproved.", "example": "Clean Room" } } }, "redeem_reward": { "name": "Redeem Reward", "description": "A parent redeems a reward for a kid, deducting points.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent authorizing the reward redemption.", "example": "Mom" }, "kid_name": { "name": "Kid Name", "description": "The kid redeeming the reward.", "example": "Alice" }, "reward_name": { "name": "Reward Name", "description": "The name of the reward to redeem.", "example": "Extra Screen Time" } } }, "approve_reward": { "name": "Approve Reward", "description": "Parent approves a reward claimed by a kid.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent approving the reward.", "example": "Mom" }, "kid_name": { "name": "Kid Name", "description": "The kid who is redeeming the reward.", "example": "Alice" }, "reward_name": { "name": "Reward Name", "description": "The name of the reward being approved.", "example": "Extra Screen Time" } } }, "disapprove_reward": { "name": "Disapprove Reward", "description": "Parent disapproves a reward redemption for a kid.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent disapproving the reward.", "example": "Dad" }, "kid_name": { "name": "Kid Name", "description": "The kid whose reward redemption is being disapproved.", "example": "Alice" }, "reward_name": { "name": "Reward Name", "description": "The name of the reward being disapproved.", "example": "Extra Screen Time" } } }, "apply_penalty": { "name": "Apply Penalty", "description": "A parent applies a penalty to deduct points.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent applying the penalty.", "example": "Dad" }, "kid_name": { "name": "Kid Name", "description": "The kid receiving the penalty.", "example": "Alice" }, "penalty_name": { "name": "Penalty Name", "description": "The name of the penalty to apply.", "example": "Yelling" } } }, "apply_bonus": { "name": "Apply Bonus", "description": "A parent applies a bonus to a kid, awarding points.", "fields": { "parent_name": { "name": "Parent Name", "description": "The parent applying the bonus.", "example": "Mom" }, "kid_name": { "name": "Kid Name", "description": "The kid receiving the bonus.", "example": "Alice" }, "bonus_name": { "name": "Bonus Name", "description": "The name of the bonus to apply.", "example": "Extra Helpful" } } }, "reset_all_data": { "name": "Reset All Data", "description": "Completely clears the KidsChores data from storage." }, "reset_all_chores": { "name": "Reset All Chores", "description": "Manually reset chores to pending state, removing claims and approvals." }, "reset_overdue_chores": { "name": "Reset Overdue Chores", "description": "Reset overdue chore(s) back to the Pending state and reschedule them based on their recurring frequency and previous due date. You may optionally provide a chore_id (or chore_name) to reset a specific chore and optionally a kid_name to reset the chore only for that kid.", "fields": { "chore_id": { "name": "Chore ID", "description": "The internal ID of the chore to reset (optional if chore_name is provided).", "example": "abc123" }, "chore_name": { "name": "Chore Name", "description": "The name of the chore to reset (optional if chore_id is provided).", "example": "Wash Dishes" }, "kid_name": { "name": "Kid Name", "description": "The kid receiving the penalty.", "example": "Alice" } } }, "set_chore_due_date": { "name": "Set/Reset Chore Due Date", "description": "Set (or clear) the due date for a chore. Provide the chore name and, if desired, a new due date. If no due date is provided the existing due date will be cleared. The service will reject due dates set in the past.", "fields": { "chore_name": { "name": "Chore Name", "description": "The name of the chore to update", "example": "Wash Dishes" }, "due_date": { "name": "Due Date", "description": "The new due date for the chore. Use the date/time selector to choose a valid date and time (in your local timezone). Leave empty to clear the due date.", "example": "2025-03-01T23:59:00Z" } } }, "skip_chore_due_date": { "name": "Skip Chore Due Date", "description": "Skip the current due date of a recurring chore. This service immediately reschedules the chore's due date based on its recurring frequency and resets its state to pending. Any pending claims or approvals will be removed.", "fields": { "chore_id": { "name": "Chore ID", "description": "The internal ID of the chore to reset (optional if chore_name is provided).", "example": "abc123" }, "chore_name": { "name": "Chore Name", "description": "The name of the chore to reset (optional if chore_id is provided).", "example": "Wash Dishes" } } }, "reset_penalties": { "name": "Reset Penalties", "description": "Reset all applied penalties for all kids. Optionally, provide penalty name to reset a specific penalty across all kids. Use kid name to reset all penalties for a specific kid. Combine both to reset a specific penalty for a specific kid.", "fields": { "kid_name": { "name": "Kid Name", "description": "The kid for which penalties will be reset.", "example": "Alice" }, "penalty_name": { "name": "Penalty Name", "description": "The name of the penalty to reset.", "example": "Yelling" } } }, "reset_bonuses": { "name": "Reset Bonuses", "description": "Reset all applied bonuses for all kids. Optionally, provide bonus name to reset a specific bonus across all kids. Use kid name to reset all bonuses for a specific kid. Combine both to reset a specific bonus for a specific kid.", "fields": { "kid_name": { "name": "Kid Name", "description": "The kid for which bonuses will be reset.", "example": "Alice" }, "bonus_name": { "name": "Bonus Name", "description": "The name of the bonus to reset.", "example": "Helping" } } }, "reset_rewards": { "name": "Reset Rewards", "description": "Reset all reward claim and approval counts for all kids. Optionally, provide reward name to reset a specific reward counts across all kids. Use kid name to reset all reward counts for a specific kid. Combine both to reset a specific reward for a specific kid.", "fields": { "kid_name": { "name": "Kid Name", "description": "The kid for which reward counts will be reset.", "example": "Alice" }, "reward_name": { "name": "Reward Name", "description": "The name of the reward to reset.", "example": "Ice Cream" } } } }, "entity": { "sensor": { "chore_status_sensor": { "name": "{kid_name} - Status - {chore_name}", "state": { "pending": "Pending", "approved": "Approved", "claimed": "Claimed", "overdue": "Overdue", "unknown": "Unknown", "none": "None", "approved_in_part": "Approved (in-part)", "claimed_in_part": "Claimed (in-part)" }, "state_attributes": { "kid_name": { "name": "Kid Name" }, "chore_name": { "name": "Chore Name" }, "shared_chore": { "name": "Shared Chore", "state": { "true": "Yes", "false": "No" } }, "recurring_frequency": { "name": "Recurring Frequency", "state": { "none": "None", "daily": "Daily", "weekly": "Weekly", "biweekly": "Biweekly", "monthly": "Monthly", "custom": "Custom" } }, "applicable_days": { "name": "Applicable Days", "state": { "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday" } }, "due_date": { "name": "Due Date" }, "default_points": { "name": "Default Points" }, "description": { "name": "Description" }, "chore_claims_count": { "name": "Chore Claims Count" }, "chore_approvals_count": { "name": "Chore Approvals Count" }, "chore_current_streak": { "name": "Chore Current Streak" }, "chore_highest_streak": { "name": "Chore Highest Streak" }, "global_state": { "name": "Global State", "state": { "pending": "Pending", "approved": "Approved", "claimed": "Claimed", "overdue": "Overdue", "unknown": "Unknown", "none": "None", "approved_in_part": "Approved (in-part)", "claimed_in_part": "Claimed (in-part)", "independent": "Independent" } }, "partial_allowed": { "name": "Partially Allowed", "state": { "true": "Yes", "false": "No" } }, "allow_multiple_claims_per_day": { "name": "Allow Multiple Claims per Day", "state": { "true": "Yes", "false": "No" } }, "assigned_kids": { "name": "Assigned Kids" }, "custom_frequency_interval": { "name": "Custom Frequency" }, "custom_frequency_unit": { "name": "Custom Frequency Period", "state": { "days": "Days", "weeks": "Weeks", "months": "Months" } }, "chore_approvals_today": { "name": "Chore Approvals Today" }, "labels": { "name": "Labels" } } }, "kid_points_sensor": { "name": "{kid_name} - {points}" }, "kid_max_points_ever_sensor": { "name": "{kid_name} - Maximum Points Ever" }, "chores_completed_total_sensor": { "name": "{kid_name} - Chores Completed - Total" }, "chores_completed_daily_sensor": { "name": "{kid_name} - Chores Completed - Daily" }, "chores_completed_weekly_sensor": { "name": "{kid_name} - Chores Completed - Weekly" }, "chores_completed_monthly_sensor": { "name": "{kid_name} - Chores Completed - Monthly" }, "kid_badges_sensor": { "name": "{kid_name} - Badges Earned" }, "kids_highest_badge_sensor": { "name": "{kid_name} - Badge", "state_attributes": { "kid_name": { "name": "Kid Name" }, "all_earned_badges": { "name": "All Earned Badges" }, "highest_badge_threshold_value": { "name": "Highest Badge Threshold" }, "points_multiplier": { "name": "Points Multiplier" }, "points_to_next_badge": { "name": "Points to Next Badge" }, "labels": { "name": "Labels" } } }, "badge_sensor": { "name": "Badge - {badge_name}", "state_attributes": { "threshold_type": { "name": "Threshold Type", "state": { "points": "Points", "chore_count": "Chore Count" } }, "points_multiplier": { "name": "Points Multiplier" }, "description": { "name": "Description" }, "kids_earned": { "name": "Kids Earned" }, "labels": { "name": "Labels" } } }, "pending_chores_approvals_sensor": { "name": "Pending Chore Approvals" }, "pending_rewards_approvals_sensor": { "name": "Pending Reward Approvals" }, "reward_claims_sensor": { "name": "{kid_name} - Claims - {reward_name}" }, "reward_approvals_sensor": { "name": "{kid_name} - Approvals - {reward_name}" }, "shared_chore_global_status_sensor": { "name": "{chore_name} - Global Status", "state": { "pending": "Pending", "approved": "Approved", "claimed": "Claimed", "overdue": "Overdue", "unknown": "Unknown", "none": "None", "approved_in_part": "Approved (in-part)", "claimed_in_part": "Claimed (in-part)", "independent": "Independent" }, "state_attributes": { "chore_name": { "name": "Chore Name" }, "description": { "name": "Description" }, "recurring_frequency": { "name": "Recurring Frequency", "state": { "none": "None", "daily": "Daily", "weekly": "Weekly", "biweekly": "Biweekly", "monthly": "Monthly", "custom": "Custom" } }, "applicable_days": { "name": "Applicable Days", "state": { "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday" } }, "due_date": { "name": "Due Date" }, "default_points": { "name": "Default Points" }, "partial_allowed": { "name": "Partially Allowed", "state": { "true": "Yes", "false": "No" } }, "allow_multiple_claims_per_day": { "name": "Allow Multiple Claims per Day", "state": { "true": "Yes", "false": "No" } }, "assigned_kids": { "name": "Assigned Kids" }, "chore_approvals_today": { "name": "Chore Approvals Today" }, "labels": { "name": "Labels" } } }, "reward_status_sensor": { "name": "{kid_name} - Reward Status - {reward_name}", "state": { "not_claimed": "Not Claimed", "approved": "Approved", "claimed": "Claimed", "unknown": "Unknown", "none": "None" }, "state_attributes": { "kid_name": { "name": "Kid Name" }, "reward_name": { "name": "Reward Name" }, "reward_cost": { "name": "Reward Cost" }, "description": { "name": "Description" }, "labels": { "name": "Labels" } } }, "chore_claims_sensor": { "name": "{kid_name} - Claims - {chore_name}" }, "chore_approvals_sensor": { "name": "{kid_name} - Approvals - {chore_name}" }, "penalty_applies_sensor": { "name": "{kid_name} - Penalties Applied - {penalty_name}", "state_attributes": { "kid_name": { "name": "Kid Name" }, "penalty_name": { "name": "Penalty Name" }, "description": { "name": "Description" }, "penalty_points": { "name": "Penalty Points" }, "labels": { "name": "Labels" } } }, "bonus_applies_sensor": { "name": "{kid_name} - Bonus Applies - {bonus_name}", "state_attributes": { "kid_name": { "name": "Kid Name" }, "bonus_name": { "name": "Bonus Name" }, "description": { "name": "Description" }, "bonus_points": { "name": "Bonus Points" }, "labels": { "name": "Labels" } } }, "kid_points_earned_daily_sensor": { "name": "{kid_name} - Points Earned - Daily" }, "kid_points_earned_weekly_sensor": { "name": "{kid_name} - Points Earned - Weekly" }, "kid_points_earned_monthly_sensor": { "name": "{kid_name} - Points Earned - Monthly" }, "achievement_state_sensor": { "name": "Achievement - {achievement_name}", "state_attributes": { "achievement_name": { "name": "Achievement Name" }, "description": { "name": "Description" }, "assigned_kids": { "name": "Assigned Kids" }, "type": { "name": "Type", "state": { "chore_total": "Chore Total", "chore_streak": "Chore Streak", "daily_minimum": "Daily Minimum" } }, "associated_chore": { "name": "Associated Chore" }, "critera": { "name": "Criteria" }, "target_value": { "name": "Target" }, "reward_points": { "name": "Reward Points" }, "kids_earned": { "name": "Earned by" }, "labels": { "name": "Labels" } } }, "challenge_state_sensor": { "name": "Challenge - {challenge_name}", "state_attributes": { "challenge_name": { "name": "Challenge Name" }, "description": { "name": "Description" }, "assigned_kids": { "name": "Assigned Kids" }, "type": { "name": "Type", "state": { "total_within_window": "Total Within Window", "daily_minimum": "Daily Minimum" } }, "associated_chore": { "name": "Associated Chore" }, "critera": { "name": "Criteria" }, "target_value": { "name": "Target" }, "reward_points": { "name": "Reward Points" }, "start_date": { "name": "Start Date" }, "end_date": { "name": "End Date" }, "kids_earned": { "name": "Earned by" }, "labels": { "name": "Labels" } } }, "achievement_progress_sensor": { "name": "{kid_name} - Progress - {achievement_name}", "state_attributes": { "achievement_name": { "name": "Achievement Name" }, "description": { "name": "Description" }, "assigned_kids": { "name": "Assigned Kids" }, "type": { "name": "Type", "state": { "chore_total": "Chore Total", "chore_streak": "Chore Streak", "daily_minimum": "Daily Minimum" } }, "associated_chore": { "name": "Associated Chore" }, "critera": { "name": "Criteria" }, "target_value": { "name": "Target" }, "reward_points": { "name": "Reward Points" }, "raw_progress": { "name": "Progress" }, "awarded": { "name": "Awarded", "state": { "true": "Yes", "false": "No" } }, "labels": { "name": "Labels" } } }, "challenge_progress_sensor": { "name": "{kid_name} - Progress - {challenge_name}", "state_attributes": { "challenge_name": { "name": "Challenge Name" }, "description": { "name": "Description" }, "assigned_kids": { "name": "Assigned Kids" }, "type": { "name": "Type", "state": { "total_within_window": "Total Within Window", "daily_minimum": "Daily Minimum" } }, "associated_chore": { "name": "Associated Chore" }, "critera": { "name": "Criteria" }, "target_value": { "name": "Target" }, "reward_points": { "name": "Reward Points" }, "start_date": { "name": "Start Date" }, "end_date": { "name": "End Date" }, "raw_progress": { "name": "Progress" }, "awarded": { "name": "Awarded", "state": { "true": "Yes", "false": "No" } }, "labels": { "name": "Labels" } } }, "kid_highest_streak_sensor": { "name": "{kid_name} - Highest Streak", "state_attributes": { "streaks_by_achievement": { "name": "Streaks by Achievement" } } }, "chore_streak_sensor": { "name": "{kid_name} - Streak - {chore_name}", "state_attributes": { "last_date": { "name": "Last Date" }, "raw_streak": { "name": "Current Streak" } } } }, "button": { "claim_chore_button": { "name": "{kid_name} - Claim Chore - {chore_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "approve_chore_button": { "name": "{kid_name} - Approve Chore - {chore_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "disapprove_chore_button": { "name": "{kid_name} - Disapprove Chore - {chore_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "claim_reward_button": { "name": "{kid_name} - Claim Reward - {reward_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "approve_reward_button": { "name": "{kid_name} - Approve Reward - {reward_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "disapprove_reward_button": { "name": "{kid_name} - Disapprove Reward - {reward_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "penalty_button": { "name": "{kid_name} - Apply Penalty - {penalty_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "bonus_button": { "name": "{kid_name} - Apply Bonus - {bonus_name}", "state_attributes": { "labels": { "name": "Labels" } } }, "manual_adjustment_button": { "name": "{kid_name} {sign_label} {points_label}" } } } } ================================================ FILE: custom_components/kidschores/translations/es.json ================================================ { "title": "KidsChores", "config": { "step": { "intro": { "title": "Bienvenido a KidsChores", "description": "Este asistente te guiará en la configuración de KidsChores." }, "points_label": { "title": "Etiqueta de Puntos", "description": "Elige una etiqueta e ícono para los puntos.", "data": { "points_label": "Etiqueta de Puntos", "points_icon": "Ícono de Puntos" } }, "kid_count": { "title": "Número de Niños", "description": "¿Cuántos niños deseas gestionar?", "data": { "kid_count": "Número de Niños" } }, "kids": { "title": "Definir Niño/a", "description": "Introduce el nombre para cada niño.", "data": { "kid_name": "Nombre del Niño/a", "internal_id": "ID Interno", "ha_user": "Usuario de Home Assistant", "enable_mobile_notifications": "Habilitar Notificaciones Móviles", "mobile_notify_service": "Servicio de Notificaciones", "enable_persistent_notifications": "Habilitar Notificaciones Persistentes" } }, "parent_count": { "title": "Número de Padres", "description": "¿Cuántos padres deseas definir inicialmente?", "data": { "parent_count": "Número de Padres" } }, "parents": { "title": "Definir Padre/Madre", "description": "Introduce los datos de cada padre.", "data": { "parent_name": "Nombre del Padre/Madre", "ha_user_id": "Usuario de Home Assistant", "associated_kids": "Niños Asociados", "enable_mobile_notifications": "Habilitar Notificaciones Móviles", "mobile_notify_service": "Servicio de Notificaciones", "enable_persistent_notifications": "Habilitar Notificaciones Persistentes", "internal_id": "ID Interno" } }, "chore_count": { "title": "Número de Tareas", "description": "¿Cuántas tareas deseas definir?", "data": { "chore_count": "Número de Tareas" } }, "chores": { "title": "Definir Tarea", "description": "Introduce los datos de cada tarea.", "data": { "chore_name": "Nombre de la Tarea", "internal_id": "ID Interno", "default_points": "Puntos Predeterminados", "allow_multiple_claims_per_day": "¿Permitir múltiples reclamaciones por día?", "partial_allowed": "¿Permitir puntos parciales?", "shared_chore": "¿Tarea compartida?", "assigned_kids": "Niños Asignados", "chore_description": "Descripción (opcional)", "chore_labels": "Etiquetas de la Tarea", "icon": "Ícono (mdi:xxx)", "recurring_frequency": "Frecuencia recurrente", "custom_interval": "Intervalo de Frecuencia Recurrente Personalizado (usar solo si se configura Frecuencia Recurrente Personalizada)", "custom_interval_unit": "Periodo de Frecuencia Recurrente Personalizada", "applicable_days": "Días aplicables", "due_date": "Fecha de vencimiento", "notify_on_claim": "Notificar al reclamar", "notify_on_approval": "Notificar al aprobar", "notify_on_disapproval": "Notificar al rechazar" } }, "badge_count": { "title": "Número de Insignias", "description": "¿Cuántas insignias deseas definir?", "data": { "badge_count": "Cantidad de Insignias" } }, "badges": { "title": "Definir Insignia", "description": "Introduce los datos de cada insignia.", "data": { "badge_name": "Nombre de la Insignia", "internal_id": "ID Interno", "threshold_type": "Tipo de umbral", "threshold_value": "Valor del umbral", "points_multiplier": "Multiplicador de puntos", "icon": "Ícono (mdi:xxx)", "badge_description": "Descripción (opcional)", "badge_labels": "Etiquetas de la Insignia" } }, "reward_count": { "title": "Número de Recompensas", "description": "¿Cuántas recompensas deseas definir?", "data": { "reward_count": "Cantidad de Recompensas" } }, "rewards": { "title": "Definir Recompensa", "description": "Introduce los datos de cada recompensa.", "data": { "reward_name": "Nombre de la Recompensa", "internal_id": "ID Interno", "reward_cost": "Costo de la Recompensa", "reward_description": "Descripción (opcional)", "reward_labels": "Etiquetas de la Recompensa", "icon": "Ícono (mdi:xxx)" } }, "penalty_count": { "title": "Número de Penalizaciones", "description": "¿Cuántas penalizaciones deseas definir?", "data": { "penalty_count": "Cantidad de Penalizaciones" } }, "penalties": { "title": "Definir Penalización", "description": "Introduce los datos de cada penalización.", "data": { "penalty_name": "Nombre de la Penalización", "penalty_description": "Descripción (opcional)", "penalty_labels": "Etiquetas de la Penalización", "internal_id": "ID Interno", "penalty_points": "Puntos de penalización (negativos)", "icon": "Ícono (mdi:xxx)" } }, "bonus_count": { "title": "Número de Bonificaciones", "description": "¿Cuántas bonificaciones deseas definir?", "data": { "bonus_count": "Cantidad de Bonificaciones" } }, "bonuses": { "title": "Definir Bonificación", "description": "Introduce los datos de cada bonificación.", "data": { "bonus_name": "Nombre de la Bonificación", "bonus_description": "Descripción (opcional)", "bonus_labels": "Etiquetas de la Bonificación", "internal_id": "ID Interno", "bonus_points": "Puntos de bonificación (positivos)", "icon": "Ícono (mdi:xxx)" } }, "achievement_count": { "title": "Número de Logros", "description": "¿Cuántos logros deseas definir?", "data": { "achievement_count": "Cantidad de Logros" } }, "achievements": { "title": "Definir Logro", "description": "Introduce los datos de cada logro.", "data": { "name": "Nombre del Logro", "description": "Descripción (opcional)", "achievement_labels": "Etiquetas del Logro", "icon": "Ícono (mdi:xxx)", "assigned_kids": "Niños asignados", "type": "Tipo de logro", "selected_chore_id": "Selecciona la tarea asociada", "criteria": "Criterios (opcional)", "target_value": "Objetivo del logro", "reward_points": "Puntos extra por completar el logro", "internal_id": "ID Interno" } }, "challenge_count": { "title": "Número de Retos", "description": "¿Cuántos retos deseas definir?", "data": { "challenge_count": "Cantidad de Retos" } }, "challenges": { "title": "Definir Reto", "description": "Introduce los datos de cada reto.", "data": { "name": "Nombre del Reto", "description": "Descripción (opcional)", "challenge_labels": "Etiquetas del Reto", "icon": "Ícono (mdi:xxx)", "assigned_kids": "Niños asignados", "type": "Tipo de reto", "selected_chore_id": "Selecciona la tarea asociada al reto (opcional)", "criteria": "Criterios (opcional)", "target_value": "Objetivo del reto", "reward_points": "Puntos extra por completar el reto", "start_date": "Fecha de inicio", "end_date": "Fecha de finalización", "internal_id": "ID Interno" } }, "finish": { "title": "Revisar y Finalizar", "description": "Revisa la configuración:\n{summary}\nHaz clic en Enviar para finalizar." } }, "error": { "a_chore_must_be_selected": "Debe seleccionarse una tarea", "duplicate_achievement": "Ya existe un logro con este nombre", "duplicate_badge": "Ya existe una insignia con este nombre", "duplicate_challenge": "Ya existe un desafío con este nombre", "duplicate_chore": "Ya existe una tarea con este nombre", "duplicate_kid": "Ya existe un niño con este nombre", "duplicate_parent": "Ya existe un padre/madre con este nombre", "duplicate_penalty": "Ya existe una penalización con este nombre", "duplicate_reward": "Ya existe una recompensa con este nombre", "duplicate_bonus": "Ya existe una bonificación con este nombre", "due_date_in_past": "La fecha de vencimiento debe estar en el futuro.", "end_date_in_past": "La fecha de fin debe estar en el futuro.", "end_date_not_after_start_date": "La fecha de fin debe ser posterior a la fecha de inicio.", "invalid_achievement_count": "Cantidad de logros no válida", "invalid_achievement_name": "Nombre de logro no válido", "invalid_badge": "Insignia no válida", "invalid_badge_count": "Cantidad de insignias no válida", "invalid_badge_name": "Nombre de insignia no válido", "invalid_challenge_count": "Cantidad de desafíos no válida", "invalid_challenge_name": "Nombre de desafío no válido", "invalid_chore": "Tarea no válida", "invalid_chore_count": "Cantidad de tareas no válida", "invalid_chore_name": "Nombre de tarea no válido", "invalid_due_date": "Fecha de vencimiento no válida", "invalid_end_date": "Fecha de fin no válida.", "invalid_kid_count": "Cantidad de niños no válida", "invalid_kid_name": "Nombre de niño no válido", "invalid_parent_count": "Cantidad de padres no válida", "invalid_parent_name": "Nombre de padre/madre no válido", "invalid_penalty": "Penalización no válida", "invalid_penalty_count": "Cantidad de penalizaciones no válida", "invalid_penalty_name": "Nombre de penalización no válido", "invalid_reward": "Recompensa no válida", "invalid_reward_count": "Cantidad de recompensas no válida", "invalid_reward_name": "Nombre de recompensa no válido", "invalid_start_date": "Fecha de inicio no válida.", "invalid_bonus": "Bonificación no válida", "invalid_bonus_count": "Cantidad de bonificaciones no válida", "invalid_bonus_name": "Nombre de bonificación no válido", "start_date_in_past": "La fecha de inicio debe estar en el futuro." }, "abort": { "single_instance_allowed": "Solo se puede configurar una única instancia de KidsChores." } }, "options": { "step": { "init": { "title": "Opciones de KidsChores", "description": "Gestiona niños, tareas, insignias, recompensas, penalizaciones o finaliza.", "data": { "menu_selection": "Selecciona una opción" } }, "manage_entity": { "title": "Seleccionar Acción", "description": "Opciones para añadir, editar o eliminar.", "data": { "manage_action": "Selecciona una acción" } }, "select_entity": { "title": "Selecciona {entity_type}", "description": "Selecciona el {entity_type} que deseas {action}.", "data": { "entity_name": "Nombre" } }, "manage_points": { "title": "Editar Etiqueta e Ícono de Puntos", "description": "Cambia la etiqueta e ícono utilizados para representar los puntos.", "data": { "points_label": "Etiqueta de Puntos", "points_icon": "Ícono de Puntos" } }, "add_kid": { "title": "Añadir Niño/a", "description": "Proporciona los datos para el nuevo niño.", "data": { "kid_name": "Nombre del Niño/a", "ha_user": "Usuario de Home Assistant", "enable_mobile_notifications": "Habilitar Notificaciones Móviles", "mobile_notify_service": "Servicio de Notificaciones", "enable_persistent_notifications": "Habilitar Notificaciones Persistentes", "internal_id": "ID Interno" } }, "add_parent": { "title": "Añadir Padre/Madre", "description": "Proporciona los datos para el nuevo padre.", "data": { "parent_name": "Nombre del Padre/Madre", "ha_user_id": "Usuario de Home Assistant", "associated_kids": "Niños Asociados", "enable_mobile_notifications": "Habilitar Notificaciones Móviles", "mobile_notify_service": "Servicio de Notificaciones", "enable_persistent_notifications": "Habilitar Notificaciones Persistentes", "internal_id": "ID Interno" } }, "add_chore": { "title": "Añadir Tarea", "description": "Proporciona los datos de la nueva tarea.", "data": { "chore_name": "Nombre de la Tarea", "internal_id": "ID Interno", "default_points": "Puntos Predeterminados", "allow_multiple_claims_per_day": "¿Permitir múltiples reclamaciones por día?", "partial_allowed": "¿Permitir puntos parciales?", "shared_chore": "¿Tarea compartida?", "assigned_kids": "Niños Asignados", "chore_description": "Descripción (opcional)", "chore_labels": "Etiquetas de la Tarea", "icon": "Ícono (mdi:xxx)", "recurring_frequency": "Frecuencia recurrente", "custom_interval": "Intervalo de Frecuencia Recurrente Personalizado (usar solo si se configura Frecuencia Recurrente Personalizada)", "custom_interval_unit": "Periodo de Frecuencia Recurrente Personalizada", "applicable_days": "Días aplicables", "due_date": "Fecha de vencimiento", "notify_on_claim": "Notificar al reclamar", "notify_on_approval": "Notificar al aprobar", "notify_on_disapproval": "Notificar al rechazar" } }, "add_badge": { "title": "Añadir Insignia", "description": "Proporciona los datos de la nueva insignia.", "data": { "badge_name": "Nombre de la Insignia", "internal_id": "ID Interno", "threshold_type": "Tipo de umbral", "threshold_value": "Valor del umbral", "points_multiplier": "Multiplicador de puntos", "icon": "Ícono (mdi:xxx)", "badge_description": "Descripción (opcional)", "badge_labels": "Etiquetas de la Insignia" } }, "add_reward": { "title": "Añadir Recompensa", "description": "Proporciona los datos de la nueva recompensa.", "data": { "reward_name": "Nombre de la Recompensa", "internal_id": "ID Interno", "reward_cost": "Costo de la Recompensa", "reward_description": "Descripción (opcional)", "reward_labels": "Etiquetas de la Recompensa", "icon": "Ícono (mdi:xxx)" } }, "add_penalty": { "title": "Añadir Penalización", "description": "Proporciona los datos de la nueva penalización.", "data": { "penalty_name": "Nombre de la Penalización", "penalty_description": "Descripción (opcional)", "penalty_labels": "Etiquetas de la Penalización", "internal_id": "ID Interno", "penalty_points": "Puntos de penalización (negativos)", "icon": "Ícono (mdi:xxx)" } }, "add_bonus": { "title": "Añadir Bonificación", "description": "Proporciona los datos de la nueva bonificación.", "data": { "bonus_name": "Nombre de la Bonificación", "bonus_description": "Descripción (opcional)", "bonus_labels": "Etiquetas de la Bonificación", "internal_id": "ID Interno", "bonus_points": "Puntos de bonificación (positivos)", "icon": "Ícono (mdi:xxx)" } }, "add_achievement": { "title": "Definir Logro", "description": "Introduce los datos de cada logro.", "data": { "name": "Nombre del Logro", "description": "Descripción (opcional)", "achievement_labels": "Etiquetas del Logro", "icon": "Ícono (mdi:xxx)", "assigned_kids": "Niños asignados", "type": "Tipo de logro", "selected_chore_id": "Selecciona la tarea asociada", "criteria": "Criterios (opcional)", "target_value": "Objetivo del logro", "reward_points": "Puntos extra por completar el logro", "internal_id": "ID Interno" } }, "add_challenge": { "title": "Definir Reto", "description": "Introduce los datos de cada reto.", "data": { "name": "Nombre del Reto", "description": "Descripción (opcional)", "challenge_labels": "Etiquetas del Reto", "icon": "Ícono (mdi:xxx)", "assigned_kids": "Niños asignados", "type": "Tipo de reto", "selected_chore_id": "Selecciona la tarea asociada al reto (opcional)", "criteria": "Criterios (opcional)", "target_value": "Objetivo del reto", "reward_points": "Puntos extra por completar el reto", "start_date": "Fecha de inicio", "end_date": "Fecha de finalización", "internal_id": "ID Interno" } }, "edit_kid": { "title": "Editar Niño/a", "description": "Modifica los datos del niño seleccionado.", "data": { "kid_name": "Nombre del Niño/a", "ha_user": "Usuario de Home Assistant", "enable_mobile_notifications": "Habilitar Notificaciones Móviles", "mobile_notify_service": "Servicio de Notificaciones", "enable_persistent_notifications": "Habilitar Notificaciones Persistentes", "internal_id": "ID Interno" } }, "edit_parent": { "title": "Editar Padre/Madre", "description": "Modifica los datos del padre seleccionado.", "data": { "parent_name": "Nombre del Padre/Madre", "ha_user_id": "Usuario de Home Assistant", "associated_kids": "Niños Asociados", "enable_mobile_notifications": "Habilitar Notificaciones Móviles", "mobile_notify_service": "Servicio de Notificaciones", "enable_persistent_notifications": "Habilitar Notificaciones Persistentes", "internal_id": "ID Interno" } }, "edit_chore": { "title": "Editar Tarea", "description": "Modifica los datos de la tarea seleccionada.", "data": { "chore_name": "Nombre de la Tarea", "internal_id": "ID Interno", "default_points": "Puntos Predeterminados", "allow_multiple_claims_per_day": "¿Permitir múltiples reclamaciones por día?", "partial_allowed": "¿Permitir puntos parciales?", "shared_chore": "¿Tarea compartida?", "assigned_kids": "Niños asignados", "chore_description": "Descripción (opcional)", "chore_labels": "Etiquetas de la Tarea", "icon": "Ícono (mdi:xxx)", "recurring_frequency": "Frecuencia recurrente", "custom_interval": "Intervalo de Frecuencia Recurrente Personalizado (usar solo si se configura Frecuencia Recurrente Personalizada)", "custom_interval_unit": "Periodo de Frecuencia Recurrente Personalizada", "applicable_days": "Días aplicables", "due_date": "Fecha de vencimiento", "notify_on_claim": "Notificar al reclamar", "notify_on_approval": "Notificar al aprobar", "notify_on_disapproval": "Notificar al rechazar" } }, "edit_badge": { "title": "Editar Insignia", "description": "Modifica los datos de la insignia seleccionada.", "data": { "badge_name": "Nombre de la Insignia", "internal_id": "ID Interno", "threshold_type": "Tipo de umbral", "threshold_value": "Valor del umbral", "points_multiplier": "Multiplicador de puntos", "icon": "Ícono (mdi:xxx)", "badge_description": "Descripción (opcional)", "badge_labels": "Etiquetas de la Insignia" } }, "edit_reward": { "title": "Editar Recompensa", "description": "Modifica los datos de la recompensa seleccionada.", "data": { "reward_name": "Nombre de la Recompensa", "internal_id": "ID Interno", "reward_cost": "Costo de la Recompensa", "reward_description": "Descripción (opcional)", "reward_labels": "Etiquetas de la Recompensa", "icon": "Ícono (mdi:xxx)" } }, "edit_penalty": { "title": "Editar Penalización", "description": "Modifica los datos de la penalización seleccionada.", "data": { "penalty_name": "Nombre de la Penalización", "penalty_description": "Descripción (opcional)", "penalty_labels": "Etiquetas de la Penalización", "internal_id": "ID Interno", "penalty_points": "Puntos de penalización (negativos)", "icon": "Ícono (mdi:xxx)" } }, "edit_bonus": { "title": "Editar Bonificación", "description": "Modifica los datos de la bonificación seleccionada.", "data": { "bonus_name": "Nombre de la Bonificación", "bonus_description": "Descripción (opcional)", "bonus_labels": "Etiquetas de la Bonificación", "internal_id": "ID Interno", "bonus_points": "Puntos de bonificación (positivos)", "icon": "Ícono (mdi:xxx)" } }, "edit_achievement": { "title": "Definir Logro", "description": "Introduce los datos de cada logro.", "data": { "name": "Nombre del Logro", "description": "Descripción (opcional)", "achievement_labels": "Etiquetas del Logro", "icon": "Ícono (mdi:xxx)", "assigned_kids": "Niños asignados", "type": "Tipo de logro", "selected_chore_id": "Selecciona la tarea asociada", "criteria": "Criterios (opcional)", "target_value": "Objetivo del logro", "reward_points": "Puntos extra por completar el logro", "internal_id": "ID Interno" } }, "edit_challenge": { "title": "Definir Reto", "description": "Introduce los datos de cada reto.", "data": { "name": "Nombre del Reto", "description": "Descripción (opcional)", "challenge_labels": "Etiquetas del Reto", "icon": "Ícono (mdi:xxx)", "assigned_kids": "Niños asignados", "type": "Tipo de reto", "selected_chore_id": "Selecciona la tarea asociada al reto (opcional)", "criteria": "Criterios (opcional)", "target_value": "Objetivo del reto", "reward_points": "Puntos extra por completar el reto", "start_date": "Fecha de inicio", "end_date": "Fecha de finalización", "internal_id": "ID Interno" } }, "delete_kid": { "title": "Eliminar Niño/a", "description": "¿Estás seguro de que deseas eliminar al niño {kid_name}?", "data": {} }, "delete_parent": { "title": "Eliminar Padre/Madre", "description": "¿Estás seguro de que deseas eliminar al padre {parent_name}?", "data": {} }, "delete_chore": { "title": "Eliminar Tarea", "description": "¿Estás seguro de que deseas eliminar la tarea {chore_name}?", "data": {} }, "delete_badge": { "title": "Eliminar Insignia", "description": "¿Estás seguro de que deseas eliminar la insignia {badge_name}?", "data": {} }, "delete_reward": { "title": "Eliminar Recompensa", "description": "¿Estás seguro de que deseas eliminar la recompensa {reward_name}?", "data": {} }, "delete_penalty": { "title": "Eliminar Penalización", "description": "¿Estás seguro de que deseas eliminar la penalización {penalty_name}?", "data": {} }, "delete_bonus": { "title": "Eliminar Bonificación", "description": "¿Estás seguro de que deseas eliminar la bonificación {bonus_name}?", "data": {} }, "delete_achievement": { "title": "Eliminar Logro", "description": "¿Estás seguro de que deseas eliminar el logro {achievement_name}?", "data": {} }, "delete_challenge": { "title": "Eliminar Reto", "description": "¿Estás seguro de que deseas eliminar el reto {challenge_name}?", "data": {} } }, "error": { "a_chore_must_be_selected": "Debe seleccionarse una tarea", "duplicate_achievement": "Ya existe un logro con este nombre", "duplicate_badge": "Ya existe una insignia con este nombre", "duplicate_challenge": "Ya existe un desafío con este nombre", "duplicate_chore": "Ya existe una tarea con este nombre", "duplicate_kid": "Ya existe un niño con este nombre", "duplicate_parent": "Ya existe un padre/madre con este nombre", "duplicate_penalty": "Ya existe una penalización con este nombre", "duplicate_reward": "Ya existe una recompensa con este nombre", "duplicate_bonus": "Ya existe una bonificación con este nombre", "due_date_in_past": "La fecha de vencimiento debe estar en el futuro.", "end_date_in_past": "La fecha de fin debe estar en el futuro.", "end_date_not_after_start_date": "La fecha de fin debe ser posterior a la fecha de inicio.", "invalid_badge": "Insignia no válida", "invalid_badge_count": "Cantidad de insignias no válida", "invalid_chore": "Tarea no válida", "invalid_chore_count": "Cantidad de tareas no válida", "invalid_due_date": "Fecha de vencimiento no válida", "invalid_end_date": "Fecha de fin no válida.", "invalid_kid_count": "Cantidad de niños no válida", "invalid_kid_name": "Nombre de niño no válido", "invalid_penalty": "Penalización no válida", "invalid_penalty_count": "Cantidad de penalizaciones no válida", "invalid_bonus": "Bonificación no válida", "invalid_bonus_count": "Cantidad de bonificaciones no válida", "invalid_reward": "Recompensa no válida", "invalid_reward_count": "Cantidad de recompensas no válida", "invalid_start_date": "Fecha de inicio no válida.", "start_date_in_past": "La fecha de inicio debe estar en el futuro." }, "abort": { "invalid_action": "Acción no válida", "invalid_achievement": "Logro no válido", "invalid_badge": "Insignia no válida", "invalid_challenge": "Desafío no válido", "invalid_chore": "Tarea no válida", "invalid_entity": "Entidad no válida", "invalid_kid": "Niño no válido", "invalid_parent": "Padre/Madre no válido", "invalid_penalty": "Penalización no válida", "invalid_reward": "Recompensa no válida", "invalid_bonus": "Bonificación no válida", "no_kid": "No hay niños configurados. Adiciona una entrada primero.", "no_parent": "No hay padres configurados. Adiciona una entrada primero.", "no_chore": "No hay tareas configuradas. Adiciona una entrada primero.", "no_badge": "No hay insignias configuradas. Adiciona una entrada primero.", "no_reward": "No hay recompensas configuradas. Adiciona una entrada primero.", "no_penalty": "No hay penalizaciones configuradas. Adiciona una entrada primero.", "no_bonus": "No hay bonificaciones configuradas. Adiciona una entrada primero.", "no_achievement": "No hay logros configurados. Adiciona una entrada primero.", "no_challenge": "No hay desafíos configurados. Adiciona una entrada primero.", "setup_complete": "Configuración Completa" } }, "selector": { "main_menu": { "options": { "manage_points": "Gestionar Puntos", "manage_kid": "Gestionar Niño/a", "manage_parent": "Gestionar Padre/Madre", "manage_chore": "Gestionar Tarea", "manage_badge": "Gestionar Insignia", "manage_reward": "Gestionar Recompensa", "manage_penalty": "Gestionar Penalización", "manage_bonus": "Gestionar Bonificación", "manage_achievement": "Gestionar Logro", "manage_challenge": "Gestionar Reto", "done": "Finalizar Configuración" } }, "manage_actions": { "options": { "add": "Añadir", "edit": "Editar", "delete": "Eliminar", "back": "Volver al Menú Principal" } }, "recurring_frequency": { "options": { "none": "Ninguna", "daily": "Diaria", "weekly": "Semanal", "biweekly": "—", "monthly": "Mensual", "custom": "Personalizada" } }, "custom_interval_unit": { "options": { "days": "Días", "weeks": "Semanas", "months": "Meses" } }, "applicable_days": { "options": { "mon": "Lunes", "tue": "Martes", "wed": "Miércoles", "thu": "Jueves", "fri": "Viernes", "sat": "Sábado", "sun": "Domingo" } }, "threshold_type": { "options": { "points": "Puntos", "chore_count": "Cantidad de Tareas" } } }, "services": { "claim_chore": { "name": "Reclamar Tarea", "description": "Un niño reclama una tarea, marcándola como 'reclamada' para aprobación de los padres.", "fields": { "kid_name": { "name": "Nombre del Niño/a", "description": "El nombre del niño que reclama la tarea.", "example": "Alice" }, "chore_name": { "name": "Nombre de la Tarea", "description": "El nombre de la tarea a reclamar.", "example": "Lavar los Platos" } } }, "approve_chore": { "name": "Aprobar Tarea", "description": "El padre aprueba la tarea, otorgando puntos.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que aprueba la tarea.", "example": "Mamá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El nombre del niño que realizó la tarea.", "example": "Alice" }, "chore_name": { "name": "Nombre de la Tarea", "description": "El nombre de la tarea que se está aprobando.", "example": "Lavar los Platos" }, "points_awarded": { "name": "Puntos Otorgados", "description": "Puntos a otorgar (opcional; por defecto se usan los puntos de la tarea).", "example": 3 } } }, "disapprove_chore": { "name": "Desaprobar Tarea", "description": "El padre rechaza una tarea para un niño, revirtiendo su estado.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que rechaza la tarea.", "example": "Mamá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El nombre del niño cuya tarea se está rechazando.", "example": "Alice" }, "chore_name": { "name": "Nombre de la Tarea", "description": "El nombre de la tarea que se está rechazando.", "example": "Limpiar la Habitación" } } }, "redeem_reward": { "name": "Canjear Recompensa", "description": "Un padre canjea una recompensa para un niño, descontando puntos.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que autoriza el canje de la recompensa.", "example": "Mamá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El niño que canjea la recompensa.", "example": "Alice" }, "reward_name": { "name": "Nombre de la Recompensa", "description": "El nombre de la recompensa a canjear.", "example": "Tiempo Extra de Pantalla" } } }, "approve_reward": { "name": "Aprobar Recompensa", "description": "El padre aprueba una recompensa reclamada por un niño.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que aprueba la recompensa.", "example": "Mamá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El niño que está canjeando la recompensa.", "example": "Alice" }, "reward_name": { "name": "Nombre de la Recompensa", "description": "El nombre de la recompensa que se está aprobando.", "example": "Tiempo Extra de Pantalla" } } }, "disapprove_reward": { "name": "Desaprobar Recompensa", "description": "El padre rechaza el canje de una recompensa para un niño.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que rechaza la recompensa.", "example": "Papá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El niño cuyo canje de recompensa se está rechazando.", "example": "Alice" }, "reward_name": { "name": "Nombre de la Recompensa", "description": "El nombre de la recompensa que se está rechazando.", "example": "Tiempo Extra de Pantalla" } } }, "apply_penalty": { "name": "Aplicar Penalización", "description": "Un padre aplica una penalización para descontar puntos.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que aplica la penalización.", "example": "Papá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El niño que recibe la penalización.", "example": "Alice" }, "penalty_name": { "name": "Nombre de la Penalización", "description": "El nombre de la penalización a aplicar.", "example": "Gritar" } } }, "apply_bonus": { "name": "Aplicar Bonificación", "description": "Un padre aplica una bonificación a un niño, otorgando puntos.", "fields": { "parent_name": { "name": "Nombre del Padre/Madre", "description": "El padre que aplica la bonificación.", "example": "Mamá" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El niño que recibe la bonificación.", "example": "Alice" }, "bonus_name": { "name": "Nombre de la Bonificación", "description": "El nombre de la bonificación a aplicar.", "example": "Ayuda Extra" } } }, "reset_all_data": { "name": "Restablecer Todos los Datos", "description": "Borra por completo los datos de KidsChores del almacenamiento." }, "reset_all_chores": { "name": "Restablecer Todas las Tareas", "description": "Restablece manualmente las tareas a estado Pendiente, eliminando reclamaciones y aprobaciones." }, "reset_overdue_chores": { "name": "Restablecer Tareas Vencidas", "description": "Restablece las tareas vencidas a estado Pendiente y las reprograma según su frecuencia recurrente y la fecha de vencimiento anterior. Opcionalmente, puedes proporcionar un chore_id (o chore_name) para restablecer una tarea específica y, opcionalmente, un kid_name para restablecer la tarea solo para ese niño.", "fields": { "chore_id": { "name": "ID de la Tarea", "description": "El ID interno de la tarea a restablecer (opcional si se proporciona chore_name).", "example": "abc123" }, "chore_name": { "name": "Nombre de la Tarea", "description": "El nombre de la tarea a restablecer (opcional si se proporciona chore_id).", "example": "Lavar los Platos" }, "kid_name": { "name": "Nombre del Niño/a", "description": "El nombre del niño (opcional).", "example": "Alice" } } }, "set_chore_due_date": { "name": "Establecer/Restablecer Fecha de Vencimiento de la Tarea", "description": "Establece (o borra) la fecha de vencimiento de una tarea. Proporciona el nombre de la tarea y, si lo deseas, una nueva fecha de vencimiento. Si no se proporciona una fecha, se borrará la existente. El servicio rechazará las fechas en el pasado.", "fields": { "chore_name": { "name": "Nombre de la Tarea", "description": "El nombre de la tarea a actualizar", "example": "Lavar Platos" }, "due_date": { "name": "Fecha de Vencimiento", "description": "La nueva fecha de vencimiento de la tarea. Usa el selector de fecha/hora para elegir una fecha y hora válidas (en tu zona horaria local). Déjalo vacío para borrar la fecha de vencimiento.", "example": "2025-03-01T23:59:00Z" } } }, "skip_chore_due_date": { "name": "Saltar Fecha de Vencimiento de la Tarea", "description": "Salta la fecha de vencimiento actual de una tarea recurrente. Este servicio reprograma inmediatamente la fecha de vencimiento de la tarea según su frecuencia de repetición y restablece su estado a pendiente. Se eliminarán todas las reclamaciones o aprobaciones pendientes.", "fields": { "chore_id": { "name": "ID de la Tarea", "description": "El ID interno de la tarea a restablecer (opcional si se proporciona chore_name).", "example": "abc123" }, "chore_name": { "name": "Nombre de la Tarea", "description": "El nombre de la tarea a restablecer (opcional si se proporciona chore_id).", "example": "Lavar Platos" } } }, "reset_penalties": { "name": "Restablecer Sanciones", "description": "Restablece todas las sanciones aplicadas para todos los niños. Opcionalmente, proporciona Nombre de la Sanción para restablecer una sanción específica en todos los niños. Usa Nombre del Niño/a para restablecer todas las sanciones de un/a niño/a en particular. Combina ambos para restablecer una sanción específica de un/a niño/a específico/a.", "fields": { "kid_name": { "name": "Nombre del Niño/a", "description": "El niño/a para el cual se restablecerán las sanciones.", "example": "Alicia" }, "penalty_name": { "name": "Nombre de la Sanción", "description": "El nombre de la sanción a restablecer.", "example": "Gritar" } } }, "reset_bonuses": { "name": "Restablecer Bonificaciones", "description": "Restablece todas las bonificaciones aplicadas para todos los niños. Opcionalmente, proporciona Nombre de la Bonificación para restablecer una bonificación específica en todos los niños. Usa Nombre del Niño/a para restablecer todas las bonificaciones de un/a niño/a en particular. Combina ambos para restablecer una bonificación específica de un/a niño/a específico/a.", "fields": { "kid_name": { "name": "Nombre del Niño/a", "description": "El niño/a para el cual se restablecerán las bonificaciones.", "example": "Alicia" }, "bonus_name": { "name": "Nombre de la Bonificación", "description": "El nombre de la bonificación a restablecer.", "example": "Ayudar" } } }, "reset_rewards": { "name": "Restablecer Recompensas", "description": "Restablece el conteo de reclamaciones y aprobaciones de recompensas para todos los niños. Opcionalmente, proporciona Nombre de la Recompensa para restablecer el conteo de una recompensa específica en todos los niños. Usa Nombre del Niño/a para restablecer el conteo de recompensas de un/a niño/a en particular. Combina ambos para restablecer una recompensa específica de un/a niño/a específico/a.", "fields": { "kid_name": { "name": "Nombre del Niño/a", "description": "El niño para el cual se restablecerán los conteos de recompensas.", "example": "Alicia" }, "reward_name": { "name": "Nombre de la Recompensa", "description": "El nombre de la recompensa a restablecer.", "example": "Helado" } } } }, "entity": { "sensor": { "chore_status_sensor": { "name": "{kid_name} - Estado - {chore_name}", "state": { "pending": "Pendiente", "approved": "Aprobada", "claimed": "Reclamada", "overdue": "Vencida", "unknown": "Desconocida", "none": "Ninguna", "approved_in_part": "Aprobada (parcialmente)", "claimed_in_part": "Reclamada (parcialmente)" }, "state_attributes": { "kid_name": { "name": "Nombre del Niño/a" }, "chore_name": { "name": "Nombre de la Tarea" }, "shared_chore": { "name": "Tarea Compartida", "state": { "true": "Sí", "false": "No" } }, "recurring_frequency": { "name": "Frecuencia Recurrente", "state": { "none": "Ninguna", "daily": "Diaria", "weekly": "Semanal", "biweekly": "—", "monthly": "Mensual", "custom": "Personalizada" } }, "applicable_days": { "name": "Applicable Days", "state": { "mon": "Lunes", "tue": "Martes", "wed": "Miércoles", "thu": "Jueves", "fri": "Viernes", "sat": "Sábado", "sun": "Domingo" } }, "due_date": { "name": "Fecha de Vencimiento" }, "default_points": { "name": "Puntos Predeterminados" }, "description": { "name": "Descripción" }, "chore_claims_count": { "name": "—" }, "chore_approvals_count": { "name": "—" }, "chore_current_streak": { "name": "Racha Actual de la Tarea" }, "chore_highest_streak": { "name": "Mejor Racha de la Tarea" }, "global_state": { "name": "Estado Global", "state": { "pending": "Pendiente", "approved": "Aprobada", "claimed": "Reclamada", "overdue": "Vencida", "unknown": "Desconocida", "none": "Ninguna", "approved_in_part": "Aprobada (parcialmente)", "claimed_in_part": "Reclamada (parcialmente)", "independent": "Independiente" } }, "partial_allowed": { "name": "Permitido Parcialmente", "state": { "true": "Sí", "false": "No" } }, "allow_multiple_claims_per_day": { "name": "Permitir Múltiples Reclamaciones por Día", "state": { "true": "Sí", "false": "No" } }, "assigned_kids": { "name": "Niños Asignados" }, "custom_frequency_interval": { "name": "Frecuencia Personalizada" }, "custom_frequency_unit": { "name": "Periodo de Frecuencia Personalizada", "state": { "days": "Días", "weeks": "Semanas", "months": "Meses" } }, "chore_approvals_today": { "name": "Aprobaciones de la Tarea Hoy" }, "labels": { "name": "Etiquetas" } } }, "kid_points_sensor": { "name": "{kid_name} - {points}" }, "kid_max_points_ever_sensor": { "name": "{kid_name} - Máximo de Puntos Alcanzados" }, "chores_completed_total_sensor": { "name": "{kid_name} - Tareas Completadas - Total" }, "chores_completed_daily_sensor": { "name": "{kid_name} - Tareas Completadas - Diarias" }, "chores_completed_weekly_sensor": { "name": "{kid_name} - Tareas Completadas - Semanales" }, "chores_completed_monthly_sensor": { "name": "{kid_name} - Tareas Completadas - Mensuales" }, "kid_badges_sensor": { "name": "{kid_name} - Insignias Obtenidas" }, "kids_highest_badge_sensor": { "name": "{kid_name} - Insignia", "state_attributes": { "kid_name": { "name": "Nombre del Niño/a" }, "all_earned_badges": { "name": "Todas las Insignias Obtenidas" }, "highest_badge_threshold_value": { "name": "Mayor Umbral de Insignia" }, "points_multiplier": { "name": "Multiplicador de Puntos" }, "points_to_next_badge": { "name": "Puntos para la Próxima Insignia" }, "labels": { "name": "Etiquetas" } } }, "badge_sensor": { "name": "Insignia - {badge_name}", "state_attributes": { "threshold_type": { "name": "Tipo de Umbral", "state": { "points": "Puntos", "chore_count": "Cantidad de Tareas" } }, "points_multiplier": { "name": "Multiplicador de Puntos" }, "descriptionn": { "name": "Descripción" }, "kids_earned": { "name": "Niños que la han Obtenido" }, "labels": { "name": "Etiquetas" } } }, "pending_chores_approvals_sensor": { "name": "Aprobaciones de Tareas Pendientes" }, "pending_rewards_approvals_sensor": { "name": "Aprobaciones de Recompensas Pendientes" }, "reward_claims_sensor": { "name": "{kid_name} - Reclamaciones - {reward_name}" }, "reward_approvals_sensor": { "name": "{kid_name} - Aprobaciones - {reward_name}" }, "shared_chore_global_status_sensor": { "name": "{chore_name} - Estado Global", "state": { "pending": "Pendiente", "approved": "Aprobada", "claimed": "Reclamada", "overdue": "Vencida", "unknown": "Desconocida", "none": "Ninguna", "approved_in_part": "Aprobada (parcialmente)", "claimed_in_part": "Reclamada (parcialmente)", "independent": "Independiente" }, "state_attributes": { "chore_name": { "name": "Nombre de la Tarea" }, "description": { "name": "Descripción" }, "recurring_frequency": { "name": "Frecuencia Recurrente", "state": { "none": "Ninguna", "daily": "Diaria", "weekly": "Semanal", "biweekly": "—", "monthly": "Mensual", "custom": "Personalizada" } }, "applicable_days": { "name": "Applicable Days", "state": { "mon": "Lunes", "tue": "Martes", "wed": "Miércoles", "thu": "Jueves", "fri": "Viernes", "sat": "Sábado", "sun": "Domingo" } }, "due_date": { "name": "Fecha de Vencimiento" }, "default_points": { "name": "Puntos Predeterminados" }, "partial_allowed": { "name": "Permitido Parcialmente", "state": { "true": "Sí", "false": "No" } }, "allow_multiple_claims_per_day": { "name": "Permitir Múltiples Reclamaciones por Día", "state": { "true": "Sí", "false": "No" } }, "assigned_kids": { "name": "Niños Asignados" }, "chore_approvals_today": { "name": "Aprobaciones de la Tarea Hoy" }, "labels": { "name": "Etiquetas" } } }, "reward_status_sensor": { "name": "{kid_name} - Estado de Recompensa - {reward_name}", "state": { "not_claimed": "No Reclamada", "approved": "Aprobada", "claimed": "Reclamada", "unknown": "Desconocida", "none": "Ninguna" }, "state_attributes": { "kid_name": { "name": "Nombre del Niño/a" }, "reward_name": { "name": "Nombre de la Recompensa" }, "reward_cost": { "name": "Costo de la Recompensa" }, "description": { "name": "Descripción" }, "labels": { "name": "Etiquetas" } } }, "chore_claims_sensor": { "name": "{kid_name} - Reclamaciones - {chore_name}" }, "chore_approvals_sensor": { "name": "{kid_name} - Aprobaciones - {chore_name}" }, "penalty_applies_sensor": { "name": "{kid_name} - Penalizaciones Aplicadas - {penalty_name}", "state_attributes": { "kid_name": { "name": "Nombre del Niño/a" }, "penalty_name": { "name": "Nombre de la Penalización" }, "descriptionn": { "name": "Descripción" }, "penalty_points": { "name": "Puntos de Penalización" }, "labels": { "name": "Etiquetas" } } }, "bonus_applies_sensor": { "name": "{kid_name} - Bonus Applies - {bonus_name}", "state_attributes": { "kid_name": { "name": "Nombre del Niño/a" }, "bonus_name": { "name": "Nombre de la Bonificación" }, "description": { "name": "Descripción" }, "bonus_points": { "name": "Puntos de Bonificación" }, "labels": { "name": "Etiquetas" } } }, "kid_points_earned_daily_sensor": { "name": "{kid_name} - Puntos Ganados - Diariamente" }, "kid_points_earned_weekly_sensor": { "name": "{kid_name} - Puntos Ganados - Semanalmente" }, "kid_points_earned_monthly_sensor": { "name": "{kid_name} - Puntos Ganados - Mensualmente" }, "achievement_state_sensor": { "name": "Logro - {achievement_name}", "state_attributes": { "achievement_name": { "name": "Nombre del Logro" }, "description": { "name": "Descripción" }, "assigned_kids": { "name": "Niños Asignados" }, "type": { "name": "Tipo", "state": { "chore_total": "Tareas Totales", "chore_streak": "Racha Tareas", "daily_minimum": "Mínimo Diario" } }, "associated_chore": { "name": "Tarea Asociada" }, "critera": { "name": "Criterios" }, "target_value": { "name": "Objetivo" }, "reward_points": { "name": "Puntos de Recompensa" }, "kids_earned": { "name": "Ganado por" }, "labels": { "name": "Etiquetas" } } }, "challenge_state_sensor": { "name": "Desafío - {challenge_name}", "state_attributes": { "challenge_name": { "name": "Nombre del Desafío" }, "description": { "name": "Descripción" }, "assigned_kids": { "name": "Niños Asignados" }, "type": { "name": "Tipo", "state": { "total_within_window": "Total en Periodo", "daily_minimum": "Minimo Diario" } }, "associated_chore": { "name": "Tarea Asociada" }, "critera": { "name": "Criterios" }, "target_value": { "name": "Objetivo" }, "reward_points": { "name": "Puntos de Recompensa" }, "start_date": { "name": "Fecha de Inicio" }, "end_date": { "name": "Fecha de Finalización" }, "kids_earned": { "name": "Ganado por" }, "labels": { "name": "Etiquetas" } } }, "achievement_progress_sensor": { "name": "{kid_name} - Progreso - {achievement_name}", "state_attributes": { "achievement_name": { "name": "Nombre del Logro" }, "description": { "name": "Descripción" }, "assigned_kids": { "name": "Niños Asignados" }, "type": { "name": "Tipo", "state": { "chore_total": "Tareas Totales", "chore_streak": "Racha Tareas", "daily_minimum": "Mínimo Diario" } }, "associated_chore": { "name": "Tarea Asociada" }, "critera": { "name": "Criterios" }, "target_value": { "name": "Objetivo" }, "reward_points": { "name": "Puntos de Recompensa" }, "raw_progress": { "name": "Progreso" }, "awarded": { "name": "Otorgado", "state": { "true": "Sí", "false": "No" } }, "labels": { "name": "Etiquetas" } } }, "challenge_progress_sensor": { "name": "{kid_name} - Progreso - {challenge_name}", "state_attributes": { "challenge_name": { "name": "Nombre del Desafío" }, "description": { "name": "Descripción" }, "assigned_kids": { "name": "Niños Asignados" }, "type": { "name": "Tipo", "state": { "total_within_window": "Total en Periodo", "daily_minimum": "Minimo Diario" } }, "associated_chore": { "name": "Tarea Asociada" }, "critera": { "name": "Criterios" }, "target_value": { "name": "Objetivo" }, "reward_points": { "name": "Puntos de Recompensa" }, "start_date": { "name": "Fecha de Inicio" }, "end_date": { "name": "Fecha de Finalización" }, "raw_progress": { "name": "Progreso" }, "awarded": { "name": "Otorgado", "state": { "true": "Sí", "false": "No" } }, "labels": { "name": "Etiquetas" } } }, "kid_highest_streak_sensor": { "name": "{kid_name} - Mayor Racha", "state_attributes": { "streaks_by_achievement": { "name": "Rachas por Logro" } } }, "chore_streak_sensor": { "name": "{kid_name} - Racha - {chore_name}", "state_attributes": { "last_date": { "name": "Última Fecha" }, "raw_streak": { "name": "Racha Actual" } } } }, "button": { "claim_chore_button": { "name": "{kid_name} - Reclamar Tarea - {chore_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "approve_chore_button": { "name": "{kid_name} - Aprobar Tarea - {chore_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "disapprove_chore_button": { "name": "{kid_name} - Desaprobar Tarea - {chore_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "claim_reward_button": { "name": "{kid_name} - Reclamar Recompensa - {reward_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "approve_reward_button": { "name": "{kid_name} - Aprobar Recompensa - {reward_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "disapprove_reward_button": { "name": "{kid_name} - Desaprobar Recompensa - {reward_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "penalty_button": { "name": "{kid_name} - Aplicar Penalización - {penalty_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "bonus_button": { "name": "{kid_name} - Aplicar Bonificación - {bonus_name}", "state_attributes": { "labels": { "name": "Etiquetas" } } }, "manual_adjustment_button": { "name": "{kid_name} {sign_label} {points_label}" } } } } ================================================ FILE: hacs.json ================================================ { "name": "KidsChores", "homeassistant": "2024.12", "hacs": "1.33.0", "render_readme": true }